Skip to main content

agent_teams/team/
mod.rs

1//! Team management: trait definition and file-system implementation.
2//!
3//! The `TeamManager` trait abstracts team CRUD operations, while
4//! `FileTeamManager` persists teams as JSON files under a base directory
5//! (defaulting to `~/.claude/teams`), matching the Claude Code layout.
6
7use std::path::PathBuf;
8
9use async_trait::async_trait;
10use tracing::{debug, info};
11
12use crate::error::{Error, Result};
13use crate::models::team::{MemberUnion, TeamConfig};
14use crate::util::atomic_write::atomic_write_json;
15use crate::util::file_lock::FileLock;
16use crate::util::validate_name;
17
18// ---------------------------------------------------------------------------
19// Trait
20// ---------------------------------------------------------------------------
21
22/// Manages team lifecycle: create, delete, read config, and member mutations.
23///
24/// Methods are `async` for forward-compatibility with remote/database backends,
25/// even though the file-system implementation is synchronous under the hood.
26#[async_trait]
27pub trait TeamManager: Send + Sync {
28    /// Create a new team with the given name and optional description.
29    ///
30    /// Returns `TeamAlreadyExists` if the team directory already exists.
31    async fn create_team(&self, name: &str, description: Option<&str>) -> Result<TeamConfig>;
32
33    /// Delete a team by name.
34    ///
35    /// Returns `TeamNotFound` if the team does not exist, and
36    /// `TeamHasActiveMembers` if any teammates are still present.
37    async fn delete_team(&self, name: &str) -> Result<()>;
38
39    /// Read the team config from disk.
40    async fn read_config(&self, name: &str) -> Result<TeamConfig>;
41
42    /// Add a member (lead or teammate) to a team.
43    ///
44    /// Returns `MemberAlreadyExists` if a member with the same name is present.
45    async fn add_member(&self, team: &str, member: MemberUnion) -> Result<()>;
46
47    /// Remove a member by name from a team.
48    ///
49    /// Returns `MemberNotFound` if no member with that name exists.
50    async fn remove_member(&self, team: &str, member_name: &str) -> Result<()>;
51
52    /// List all team names (directory names under the base dir).
53    async fn list_teams(&self) -> Result<Vec<String>>;
54}
55
56// ---------------------------------------------------------------------------
57// FileTeamManager
58// ---------------------------------------------------------------------------
59
60/// File-system backed `TeamManager`.
61///
62/// Directory layout (mirrors Claude Code):
63/// ```text
64/// {base_dir}/
65///   {team-name}/
66///     config.json        # TeamConfig
67///     config.json.lock   # flock guard
68///     inboxes/           # per-agent message inboxes
69///   ...
70/// {tasks_base}/          # ~/.claude/tasks/{team-name}/  (task storage)
71/// ```
72pub struct FileTeamManager {
73    base_dir: PathBuf,
74}
75
76impl FileTeamManager {
77    /// Create a `FileTeamManager` rooted at the given `base_dir`.
78    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
79        Self {
80            base_dir: base_dir.into(),
81        }
82    }
83
84    /// Convenience path: `{base_dir}/{name}`
85    fn team_dir(&self, name: &str) -> PathBuf {
86        self.base_dir.join(name)
87    }
88
89    /// Convenience path: `{base_dir}/{name}/config.json`
90    fn config_path(&self, name: &str) -> PathBuf {
91        self.team_dir(name).join("config.json")
92    }
93
94    /// Convenience path: `{base_dir}/{name}/config.json.lock`
95    fn lock_path(&self, name: &str) -> PathBuf {
96        self.team_dir(name).join("config.json.lock")
97    }
98
99    /// The tasks directory for a team, derived from `base_dir`.
100    ///
101    /// If `base_dir` is `~/.claude/teams`, tasks go to `~/.claude/tasks/{name}`.
102    /// If `base_dir` is `/tmp/foo/teams`, tasks go to `/tmp/foo/tasks/{name}`.
103    fn tasks_dir(&self, name: &str) -> PathBuf {
104        // Derive tasks base from teams base: sibling `tasks/` directory
105        let tasks_base = self.base_dir.parent()
106            .map(|p| p.join("tasks"))
107            .unwrap_or_else(|| self.base_dir.join("tasks"));
108        tasks_base.join(name)
109    }
110
111    /// Read + deserialize config.json (no lock -- caller should hold one if mutating).
112    fn read_config_sync(config_path: &std::path::Path, name: &str) -> Result<TeamConfig> {
113        if !config_path.exists() {
114            return Err(Error::TeamNotFound {
115                name: name.to_string(),
116            });
117        }
118        let data = std::fs::read_to_string(config_path)?;
119        let config: TeamConfig = serde_json::from_str(&data)?;
120        Ok(config)
121    }
122}
123
124impl Default for FileTeamManager {
125    /// Default base directory: `~/.claude/teams`
126    ///
127    /// # Panics
128    ///
129    /// Panics if the home directory cannot be determined.
130    fn default() -> Self {
131        let base = dirs::home_dir()
132            .expect("could not determine home directory")
133            .join(".claude")
134            .join("teams");
135        Self::new(base)
136    }
137}
138
139#[async_trait]
140impl TeamManager for FileTeamManager {
141    async fn create_team(&self, name: &str, description: Option<&str>) -> Result<TeamConfig> {
142        validate_name(name)?;
143
144        let base_dir = self.base_dir.clone();
145        let team_dir = self.team_dir(name);
146        let config_path = self.config_path(name);
147        let tasks_dir = self.tasks_dir(name);
148        let name = name.to_string();
149        let description = description.map(String::from);
150
151        tokio::task::spawn_blocking(move || {
152            // Ensure the parent base directory exists
153            std::fs::create_dir_all(&base_dir)?;
154
155            // Atomically create the team directory -- fails if it already exists,
156            // avoiding the TOCTOU race of `exists()` + `create_dir_all()`.
157            match std::fs::create_dir(&team_dir) {
158                Ok(()) => {}
159                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
160                    return Err(Error::TeamAlreadyExists {
161                        name: name.clone(),
162                    });
163                }
164                Err(e) => return Err(Error::Io(e)),
165            }
166
167            // Create subdirectories (team dir already exists so these are safe)
168            std::fs::create_dir_all(team_dir.join("inboxes"))?;
169
170            // Create tasks directory alongside teams
171            std::fs::create_dir_all(&tasks_dir)?;
172            debug!(path = %tasks_dir.display(), "created tasks directory");
173
174            let config = TeamConfig {
175                team_name: name.clone(),
176                description,
177                created_at: None,
178                lead_agent_id: None,
179                lead_session_id: None,
180                members: Vec::new(),
181            };
182
183            atomic_write_json(&config_path, &config)?;
184            info!(team = %name, "created team");
185
186            Ok(config)
187        })
188        .await
189        .map_err(|e| Error::JoinError(format!("{e}")))?
190    }
191
192    async fn delete_team(&self, name: &str) -> Result<()> {
193        validate_name(name)?;
194
195        let team_dir = self.team_dir(name);
196        let lock_path = self.lock_path(name);
197        let config_path = self.config_path(name);
198        let tasks_dir = self.tasks_dir(name);
199        let name = name.to_string();
200
201        tokio::task::spawn_blocking(move || {
202            if !team_dir.exists() {
203                return Err(Error::TeamNotFound {
204                    name: name.clone(),
205                });
206            }
207
208            // Hold the config lock through both the check AND the deletion
209            // to prevent a concurrent `add_member` from sneaking in (TOCTOU fix).
210            let _lock = FileLock::acquire(&lock_path)?;
211
212            let config = Self::read_config_sync(&config_path, &name)?;
213            let has_teammates = config.members.iter().any(|m| m.is_teammate());
214            if has_teammates {
215                return Err(Error::TeamHasActiveMembers {
216                    name: name.clone(),
217                });
218            }
219
220            // Lock is held -- safe to delete now
221            std::fs::remove_dir_all(&team_dir)?;
222            info!(team = %name, "deleted team directory");
223
224            // Also remove tasks directory if it exists
225            if tasks_dir.exists() {
226                std::fs::remove_dir_all(&tasks_dir)?;
227                debug!(team = %name, "deleted tasks directory");
228            }
229
230            Ok(())
231        })
232        .await
233        .map_err(|e| Error::JoinError(format!("{e}")))?
234    }
235
236    async fn read_config(&self, name: &str) -> Result<TeamConfig> {
237        validate_name(name)?;
238
239        let config_path = self.config_path(name);
240        let name = name.to_string();
241
242        tokio::task::spawn_blocking(move || Self::read_config_sync(&config_path, &name))
243            .await
244            .map_err(|e| Error::JoinError(format!("{e}")))?
245    }
246
247    async fn add_member(&self, team: &str, member: MemberUnion) -> Result<()> {
248        validate_name(team)?;
249
250        let team_dir = self.team_dir(team);
251        let lock_path = self.lock_path(team);
252        let config_path = self.config_path(team);
253        let team = team.to_string();
254        let member_name = member.name().to_string();
255
256        tokio::task::spawn_blocking(move || {
257            if !team_dir.exists() {
258                return Err(Error::TeamNotFound {
259                    name: team.clone(),
260                });
261            }
262
263            let _lock = FileLock::acquire(&lock_path)?;
264            debug!(team = %team, "acquired config lock");
265
266            let mut config = Self::read_config_sync(&config_path, &team)?;
267
268            if config.members.iter().any(|m| m.name() == member_name) {
269                return Err(Error::MemberAlreadyExists {
270                    team: team.clone(),
271                    member: member_name.clone(),
272                });
273            }
274            config.members.push(member);
275            info!(team = %team, member = %member_name, "added member");
276
277            atomic_write_json(&config_path, &config)?;
278            Ok(())
279        })
280        .await
281        .map_err(|e| Error::JoinError(format!("{e}")))?
282    }
283
284    async fn remove_member(&self, team: &str, member_name: &str) -> Result<()> {
285        validate_name(team)?;
286
287        let team_dir = self.team_dir(team);
288        let lock_path = self.lock_path(team);
289        let config_path = self.config_path(team);
290        let team = team.to_string();
291        let member_name = member_name.to_string();
292
293        tokio::task::spawn_blocking(move || {
294            if !team_dir.exists() {
295                return Err(Error::TeamNotFound {
296                    name: team.clone(),
297                });
298            }
299
300            let _lock = FileLock::acquire(&lock_path)?;
301            debug!(team = %team, "acquired config lock");
302
303            let mut config = Self::read_config_sync(&config_path, &team)?;
304
305            let idx = config
306                .members
307                .iter()
308                .position(|m| m.name() == member_name)
309                .ok_or_else(|| Error::MemberNotFound {
310                    team: team.clone(),
311                    member: member_name.clone(),
312                })?;
313            config.members.remove(idx);
314            info!(team = %team, member = %member_name, "removed member");
315
316            atomic_write_json(&config_path, &config)?;
317            Ok(())
318        })
319        .await
320        .map_err(|e| Error::JoinError(format!("{e}")))?
321    }
322
323    async fn list_teams(&self) -> Result<Vec<String>> {
324        let base_dir = self.base_dir.clone();
325
326        tokio::task::spawn_blocking(move || {
327            if !base_dir.exists() {
328                return Ok(Vec::new());
329            }
330
331            let mut teams = Vec::new();
332            for entry in std::fs::read_dir(&base_dir)? {
333                let entry = entry?;
334                if entry.file_type()?.is_dir() {
335                    // Only include directories that contain a config.json
336                    let config_path = entry.path().join("config.json");
337                    if config_path.exists()
338                        && let Some(name) = entry.file_name().to_str()
339                    {
340                        teams.push(name.to_string());
341                    }
342                }
343            }
344            teams.sort();
345            Ok(teams)
346        })
347        .await
348        .map_err(|e| Error::JoinError(format!("{e}")))?
349    }
350}
351
352// ---------------------------------------------------------------------------
353// Tests
354// ---------------------------------------------------------------------------
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::models::team::{LeadMember, TeammateMember};
360    use std::path::Path;
361
362    fn make_manager(dir: &Path) -> FileTeamManager {
363        FileTeamManager::new(dir)
364    }
365
366    fn lead(name: &str) -> MemberUnion {
367        MemberUnion::Lead(LeadMember {
368            name: name.into(),
369            agent_id: format!("{name}-id"),
370            agent_type: "team-lead".into(),
371            model: None,
372            joined_at: None,
373            tmux_pane_id: None,
374            cwd: None,
375            subscriptions: None,
376        })
377    }
378
379    fn teammate(name: &str) -> MemberUnion {
380        MemberUnion::Teammate(TeammateMember {
381            name: name.into(),
382            agent_id: format!("{name}-id"),
383            agent_type: "general-purpose".into(),
384            prompt: format!("You are {name}."),
385            model: None,
386            color: None,
387            plan_mode_required: None,
388            joined_at: None,
389            tmux_pane_id: None,
390            cwd: None,
391            subscriptions: None,
392            backend_type: None,
393        })
394    }
395
396    #[tokio::test]
397    async fn create_and_read_team() {
398        let tmp = tempfile::tempdir().unwrap();
399        let mgr = make_manager(tmp.path());
400
401        let config = mgr.create_team("alpha", Some("first team")).await.unwrap();
402        assert_eq!(config.team_name, "alpha");
403        assert_eq!(config.description.as_deref(), Some("first team"));
404        assert!(config.members.is_empty());
405
406        // Config persisted to disk
407        let read = mgr.read_config("alpha").await.unwrap();
408        assert_eq!(read.team_name, "alpha");
409
410        // Directory structure
411        assert!(tmp.path().join("alpha/inboxes").is_dir());
412    }
413
414    #[tokio::test]
415    async fn create_duplicate_team_fails() {
416        let tmp = tempfile::tempdir().unwrap();
417        let mgr = make_manager(tmp.path());
418
419        mgr.create_team("dup", None).await.unwrap();
420        let err = mgr.create_team("dup", None).await.unwrap_err();
421        assert!(matches!(err, Error::TeamAlreadyExists { .. }));
422    }
423
424    #[tokio::test]
425    async fn add_and_remove_member() {
426        let tmp = tempfile::tempdir().unwrap();
427        let mgr = make_manager(tmp.path());
428
429        mgr.create_team("beta", None).await.unwrap();
430
431        mgr.add_member("beta", lead("boss")).await.unwrap();
432        mgr.add_member("beta", teammate("worker")).await.unwrap();
433
434        let config = mgr.read_config("beta").await.unwrap();
435        assert_eq!(config.members.len(), 2);
436        assert_eq!(config.members[0].name(), "boss");
437        assert_eq!(config.members[1].name(), "worker");
438
439        // Remove the teammate
440        mgr.remove_member("beta", "worker").await.unwrap();
441        let config = mgr.read_config("beta").await.unwrap();
442        assert_eq!(config.members.len(), 1);
443    }
444
445    #[tokio::test]
446    async fn add_duplicate_member_fails() {
447        let tmp = tempfile::tempdir().unwrap();
448        let mgr = make_manager(tmp.path());
449
450        mgr.create_team("gamma", None).await.unwrap();
451        mgr.add_member("gamma", lead("lead")).await.unwrap();
452
453        let err = mgr.add_member("gamma", lead("lead")).await.unwrap_err();
454        assert!(matches!(err, Error::MemberAlreadyExists { .. }));
455    }
456
457    #[tokio::test]
458    async fn remove_nonexistent_member_fails() {
459        let tmp = tempfile::tempdir().unwrap();
460        let mgr = make_manager(tmp.path());
461
462        mgr.create_team("delta", None).await.unwrap();
463
464        let err = mgr.remove_member("delta", "ghost").await.unwrap_err();
465        assert!(matches!(err, Error::MemberNotFound { .. }));
466    }
467
468    #[tokio::test]
469    async fn delete_team_with_no_teammates() {
470        let tmp = tempfile::tempdir().unwrap();
471        let mgr = make_manager(tmp.path());
472
473        mgr.create_team("ephemeral", None).await.unwrap();
474        mgr.add_member("ephemeral", lead("lead")).await.unwrap();
475
476        mgr.delete_team("ephemeral").await.unwrap();
477        assert!(!tmp.path().join("ephemeral").exists());
478    }
479
480    #[tokio::test]
481    async fn delete_team_with_teammates_fails() {
482        let tmp = tempfile::tempdir().unwrap();
483        let mgr = make_manager(tmp.path());
484
485        mgr.create_team("sticky", None).await.unwrap();
486        mgr.add_member("sticky", teammate("worker")).await.unwrap();
487
488        let err = mgr.delete_team("sticky").await.unwrap_err();
489        assert!(matches!(err, Error::TeamHasActiveMembers { .. }));
490    }
491
492    #[tokio::test]
493    async fn delete_nonexistent_team_fails() {
494        let tmp = tempfile::tempdir().unwrap();
495        let mgr = make_manager(tmp.path());
496
497        let err = mgr.delete_team("nope").await.unwrap_err();
498        assert!(matches!(err, Error::TeamNotFound { .. }));
499    }
500
501    #[tokio::test]
502    async fn list_teams_sorted() {
503        let tmp = tempfile::tempdir().unwrap();
504        let mgr = make_manager(tmp.path());
505
506        mgr.create_team("zulu", None).await.unwrap();
507        mgr.create_team("alpha", None).await.unwrap();
508        mgr.create_team("mike", None).await.unwrap();
509
510        let teams = mgr.list_teams().await.unwrap();
511        assert_eq!(teams, vec!["alpha", "mike", "zulu"]);
512    }
513
514    #[tokio::test]
515    async fn list_teams_empty_base_dir() {
516        let tmp = tempfile::tempdir().unwrap();
517        // Point to a non-existent subdirectory
518        let mgr = FileTeamManager::new(tmp.path().join("nonexistent"));
519        let teams = mgr.list_teams().await.unwrap();
520        assert!(teams.is_empty());
521    }
522
523    #[tokio::test]
524    async fn read_config_nonexistent_team_fails() {
525        let tmp = tempfile::tempdir().unwrap();
526        let mgr = make_manager(tmp.path());
527
528        let err = mgr.read_config("ghost").await.unwrap_err();
529        assert!(matches!(err, Error::TeamNotFound { .. }));
530    }
531
532    #[tokio::test]
533    async fn operations_on_nonexistent_team_fail() {
534        let tmp = tempfile::tempdir().unwrap();
535        let mgr = make_manager(tmp.path());
536
537        let err = mgr.add_member("nope", lead("a")).await.unwrap_err();
538        assert!(matches!(err, Error::TeamNotFound { .. }));
539
540        let err = mgr.remove_member("nope", "a").await.unwrap_err();
541        assert!(matches!(err, Error::TeamNotFound { .. }));
542    }
543
544    #[tokio::test]
545    async fn path_traversal_rejected() {
546        let tmp = tempfile::tempdir().unwrap();
547        let mgr = make_manager(tmp.path());
548
549        let err = mgr.create_team("../escape", None).await.unwrap_err();
550        assert!(matches!(err, Error::InvalidName { .. }));
551
552        let err = mgr.create_team("..", None).await.unwrap_err();
553        assert!(matches!(err, Error::InvalidName { .. }));
554
555        let err = mgr.create_team(".", None).await.unwrap_err();
556        assert!(matches!(err, Error::InvalidName { .. }));
557
558        let err = mgr.create_team("", None).await.unwrap_err();
559        assert!(matches!(err, Error::InvalidName { .. }));
560
561        let err = mgr.read_config("foo/bar").await.unwrap_err();
562        assert!(matches!(err, Error::InvalidName { .. }));
563
564        let err = mgr.delete_team("a\\b").await.unwrap_err();
565        assert!(matches!(err, Error::InvalidName { .. }));
566    }
567}