Skip to main content

team_core/
registry.rs

1//! Durable, system-wide record of which teams are up and where they live.
2//!
3//! teamctl's other notions of identity are all keyed on *name*, never on
4//! *path*: the session UUID, the tmux session name, the mailbox row key,
5//! the `applied.json` key. So nothing could durably answer "what teams are
6//! running across this machine, and in which directories?" — the question
7//! behind system-wide `teamctl ps`, orphan reaping, and the same-name guard.
8//!
9//! This registry is that durable record. It lives at
10//! `~/.config/teamctl/teams.json` (the same config dir the `context` store
11//! uses) and is keyed on the compound `(project_id, root)` — one entry per
12//! project per absolute team root, so two installs of the same template in
13//! different folders are distinct rows rather than a collision.
14//!
15//! `up` upserts this team's entries (see `crates/teamctl/src/cmd/up.rs`),
16//! `down` clears them (`down.rs`). Writes are atomic (temp + rename) so a
17//! reader never observes a torn file. Atomicity is per-write, not a
18//! cross-process lock: two `up`s of *different* teams racing the
19//! load-modify-save could still drop one update — acceptable for a
20//! self-healing side store (the next `up` re-records it), and called out
21//! here rather than over-claimed.
22//!
23//! The store *records* the path alongside the name; it deliberately does
24//! NOT make the session UUID or tmux name itself path-aware (a larger
25//! identity change, out of scope).
26
27use std::fs;
28use std::path::{Path, PathBuf};
29
30use anyhow::{Context, Result};
31use serde::{Deserialize, Serialize};
32
33/// File name under the config dir.
34const FILE: &str = "teams.json";
35
36/// One running team: a single `(project_id, root)` pair plus the metadata
37/// `ps` / reaping / the same-name guard need. Field names serialize verbatim
38/// (snake_case), matching every other persisted teamctl store.
39#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct TeamEntry {
41    /// Project id as declared in the compose (bare, e.g. `main`).
42    #[serde(default)]
43    pub project_id: String,
44    /// Absolute, canonicalized team root (the directory `up` recorded —
45    /// the same value tagged onto the tmux session as `@teamctl-root`).
46    #[serde(default)]
47    pub root: PathBuf,
48    /// Supervisor tmux prefix in effect when the team came up.
49    #[serde(default)]
50    pub tmux_prefix: String,
51    /// Sorted agent names belonging to this project.
52    #[serde(default)]
53    pub agents: Vec<String>,
54    /// RFC3339 timestamp of when this entry was first recorded by `up`.
55    /// Preserved across an idempotent re-`up` so uptime doesn't reset.
56    #[serde(default)]
57    pub started_at: String,
58}
59
60/// The whole store. `#[serde(default)]` per field keeps old files
61/// forward-compatible as the shape grows, matching `ContextStore`.
62#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Registry {
64    #[serde(default)]
65    pub teams: Vec<TeamEntry>,
66}
67
68/// `~/.config/teamctl` — derived from `$HOME` (`$USERPROFILE` fallback for
69/// Windows). `None` when neither is set, so callers warn-and-skip rather
70/// than guess a path. Mirrors the CLI `context` store's resolver and
71/// `session::claude_home`; deliberately NOT `dirs::config_dir()`, which on
72/// macOS resolves to `~/Library/Application Support` and would break the
73/// literal `~/.config/teamctl` path the rest of the CLI uses.
74pub fn config_dir() -> Option<PathBuf> {
75    std::env::var_os("HOME")
76        .or_else(|| std::env::var_os("USERPROFILE"))
77        .map(|home| PathBuf::from(home).join(".config/teamctl"))
78}
79
80/// RFC3339 (seconds, `Z`) — the serialized-timestamp idiom shared with
81/// `snapshot::applied_at` / `attachments`. Co-located here because this
82/// module owns the `started_at` field's format contract.
83pub fn now_rfc3339() -> String {
84    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
85}
86
87/// Load the registry from `dir/teams.json`. A missing file is an empty
88/// registry; a corrupt one degrades to empty rather than erroring (the
89/// next `up` overwrites it) — the same tolerance `ContextStore::load` and
90/// `snapshot::read` apply. Only a genuine read error (e.g. permissions) is
91/// propagated. `dir` is the config dir (see [`config_dir`]); it is taken as
92/// a parameter so tests can point at a tempdir without touching `$HOME`.
93pub fn load(dir: &Path) -> Result<Registry> {
94    let path = dir.join(FILE);
95    if !path.exists() {
96        return Ok(Registry::default());
97    }
98    let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
99    Ok(serde_json::from_str(&raw).unwrap_or_default())
100}
101
102/// Upsert one team entry. Convenience over [`upsert_many`].
103pub fn upsert(dir: &Path, entry: TeamEntry) -> Result<()> {
104    upsert_many(dir, vec![entry])
105}
106
107/// Upsert a batch in a single load-modify-save (one atomic write for the
108/// whole `up`). Each entry replaces any existing row with the same
109/// `(project_id, root)` key, preserving that row's original `started_at`;
110/// new keys are appended. Entries stay sorted by `(root, project_id)` for
111/// stable on-disk output.
112pub fn upsert_many(dir: &Path, entries: Vec<TeamEntry>) -> Result<()> {
113    if entries.is_empty() {
114        return Ok(());
115    }
116    let mut reg = load(dir)?;
117    for mut entry in entries {
118        if let Some(existing) = reg
119            .teams
120            .iter_mut()
121            .find(|t| t.project_id == entry.project_id && t.root == entry.root)
122        {
123            if !existing.started_at.is_empty() {
124                entry.started_at = existing.started_at.clone();
125            }
126            *existing = entry;
127        } else {
128            reg.teams.push(entry);
129        }
130    }
131    reg.teams.sort_by(|a, b| {
132        a.root
133            .cmp(&b.root)
134            .then_with(|| a.project_id.cmp(&b.project_id))
135    });
136    save(dir, &reg)
137}
138
139/// Clear entries for `root`. `project = None` drops every entry at that
140/// root (a whole-team `down`); `project = Some(id)` drops only that
141/// project's entry (a project-scoped `down` on a multi-project root, so
142/// sibling projects stay registered). A no-op write is skipped.
143pub fn clear(dir: &Path, root: &Path, project: Option<&str>) -> Result<()> {
144    let mut reg = load(dir)?;
145    let before = reg.teams.len();
146    reg.teams
147        .retain(|t| t.root != root || project.is_some_and(|p| t.project_id != p));
148    if reg.teams.len() != before {
149        save(dir, &reg)?;
150    }
151    Ok(())
152}
153
154/// `true` when this entry's recorded root no longer holds a
155/// `team-compose.yaml` — a stale row whose team config was moved or
156/// deleted. `up` always records the `.team` directory as `root`, so the
157/// `<root>/team-compose.yaml` check is the live one for registry entries;
158/// the `<root>/.team/team-compose.yaml` fallback is kept only for parity
159/// with the shared `sessions::root_has_compose` rule (which also accepts a
160/// root that points at the bare project dir). `path_exists` is injected so
161/// the classifier is pure and unit-testable; production passes
162/// `|p| p.exists()`.
163pub fn is_orphan(entry: &TeamEntry, path_exists: &impl Fn(&Path) -> bool) -> bool {
164    !(path_exists(&entry.root.join("team-compose.yaml"))
165        || path_exists(&entry.root.join(".team").join("team-compose.yaml")))
166}
167
168/// One recorded `(project, agent)` running under a root, with its tmux
169/// session name reconstructed from the prefix the team was brought up with.
170/// The unit `down`/`reload` orphan-reaping operates on.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct RosterEntry {
173    pub project_id: String,
174    pub agent: String,
175    pub tmux_session: String,
176}
177
178impl RosterEntry {
179    /// `<project>:<agent>` — matches `compose.agents().id()`.
180    pub fn id(&self) -> String {
181        format!("{}:{}", self.project_id, self.agent)
182    }
183}
184
185impl Registry {
186    /// The recorded roster for `root`: one [`RosterEntry`] per agent of
187    /// every team registered at that root. The tmux session is rebuilt from
188    /// the team's recorded `tmux_prefix` (`{prefix}{project}-{agent}`, the
189    /// supervisor's naming formula) so a reaped session is targeted with the
190    /// name it was started under — correct even if the live compose's prefix
191    /// has since drifted.
192    pub fn roster_for_root(&self, root: &Path) -> Vec<RosterEntry> {
193        self.teams
194            .iter()
195            .filter(|t| t.root == root)
196            .flat_map(|t| {
197                t.agents.iter().map(move |agent| RosterEntry {
198                    project_id: t.project_id.clone(),
199                    agent: agent.clone(),
200                    tmux_session: format!("{}{}-{}", t.tmux_prefix, t.project_id, agent),
201                })
202            })
203            .collect()
204    }
205}
206
207/// The reap set: recorded roster agents that are no longer desired.
208/// `desired` is the current compose's agent ids (`<project>:<agent>`).
209/// `scoped` limits the reap to one project (a `--project` down/reload);
210/// `per_agent` (a `--agent` selector active) disables reaping entirely — a
211/// partial teardown leaves the rest of the team registered and running, so
212/// nothing there is an orphan. Pure; mirrors `down::registry_clear_scope`'s
213/// scope contract so reaping is never broader than the invocation.
214pub fn reap_targets(
215    roster: &[RosterEntry],
216    desired: &std::collections::HashSet<String>,
217    scoped: Option<&str>,
218    per_agent: bool,
219) -> Vec<RosterEntry> {
220    if per_agent {
221        return Vec::new();
222    }
223    roster
224        .iter()
225        .filter(|e| scoped.is_none_or(|p| e.project_id == p))
226        .filter(|e| !desired.contains(&e.id()))
227        .cloned()
228        .collect()
229}
230
231/// Load the registry at `dir` and return the orphan roster for `root` —
232/// recorded agents no longer in `desired`. The down/reload reap entry point:
233/// composes [`load`] + [`Registry::roster_for_root`] + [`reap_targets`] so the
234/// whole on-disk → orphans chain is one tested call. `dir` is taken as a
235/// parameter so tests point at a tempdir without touching `$HOME`. A missing
236/// store loads as empty (no orphans); only a genuine read error propagates.
237pub fn orphans_for_root(
238    dir: &Path,
239    root: &Path,
240    desired: &std::collections::HashSet<String>,
241    scoped: Option<&str>,
242    per_agent: bool,
243) -> Result<Vec<RosterEntry>> {
244    let reg = load(dir)?;
245    Ok(reap_targets(
246        &reg.roster_for_root(root),
247        desired,
248        scoped,
249        per_agent,
250    ))
251}
252
253/// If another team is registered with the same `project_id` but a DIFFERENT
254/// root that still holds a compose (not an orphan), return that root — the
255/// same-name-different-folder collision `up` must refuse. It's the cluster's
256/// root cause: identity is name-keyed, not path-keyed, so two such teams
257/// alias each other's sessions, tmux names, and mailbox keys. The team's own
258/// root is never a conflict (a re-up of the same `(project_id, root)` is
259/// fine). `path_exists` is injected so the liveness check is pure and
260/// unit-testable; production passes `|p| p.exists()`.
261pub fn same_name_other_root(
262    reg: &Registry,
263    project_id: &str,
264    root: &Path,
265    path_exists: &impl Fn(&Path) -> bool,
266) -> Option<PathBuf> {
267    reg.teams
268        .iter()
269        .find(|t| t.project_id == project_id && t.root != root && !is_orphan(t, path_exists))
270        .map(|t| t.root.clone())
271}
272
273/// Atomic write: serialize to a pid-scoped sibling temp file, then rename
274/// over the target so a concurrent reader sees either the old or the new
275/// file, never a partial one. The pid in the temp name keeps two processes
276/// from clobbering each other's temp. Mirrors the `attachments` /
277/// `~/.claude.json` temp+rename idiom (no shared helper exists to reuse).
278fn save(dir: &Path, reg: &Registry) -> Result<()> {
279    fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
280    let path = dir.join(FILE);
281    let tmp = dir.join(format!("{FILE}.{}.tmp", std::process::id()));
282    let body = serde_json::to_string_pretty(reg)?;
283    fs::write(&tmp, body).with_context(|| format!("write {}", tmp.display()))?;
284    fs::rename(&tmp, &path).with_context(|| format!("rename into {}", path.display()))?;
285    Ok(())
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::collections::HashSet;
292
293    fn entry(project: &str, root: &str, agents: &[&str], started: &str) -> TeamEntry {
294        TeamEntry {
295            project_id: project.into(),
296            root: PathBuf::from(root),
297            tmux_prefix: "t-".into(),
298            agents: agents.iter().map(|s| (*s).to_string()).collect(),
299            started_at: started.into(),
300        }
301    }
302
303    #[test]
304    fn load_missing_file_is_empty() {
305        let dir = tempfile::tempdir().unwrap();
306        let reg = load(dir.path()).unwrap();
307        assert!(reg.teams.is_empty());
308    }
309
310    #[test]
311    fn upsert_then_load_roundtrips() {
312        let dir = tempfile::tempdir().unwrap();
313        upsert(
314            dir.path(),
315            entry(
316                "main",
317                "/r/a/.team",
318                &["compass", "scout"],
319                "2026-06-13T00:00:00Z",
320            ),
321        )
322        .unwrap();
323        let reg = load(dir.path()).unwrap();
324        assert_eq!(reg.teams.len(), 1);
325        let t = &reg.teams[0];
326        assert_eq!(t.project_id, "main");
327        assert_eq!(t.root, PathBuf::from("/r/a/.team"));
328        assert_eq!(t.tmux_prefix, "t-");
329        assert_eq!(t.agents, vec!["compass", "scout"]);
330        assert_eq!(t.started_at, "2026-06-13T00:00:00Z");
331    }
332
333    #[test]
334    fn upsert_same_key_replaces_and_preserves_started_at() {
335        let dir = tempfile::tempdir().unwrap();
336        upsert(dir.path(), entry("main", "/r/a/.team", &["compass"], "T0")).unwrap();
337        // Re-up with a different roster + a newer (would-be) start time.
338        upsert(
339            dir.path(),
340            entry("main", "/r/a/.team", &["compass", "scribe"], "T1"),
341        )
342        .unwrap();
343        let reg = load(dir.path()).unwrap();
344        assert_eq!(reg.teams.len(), 1, "same (project,root) is one row");
345        assert_eq!(
346            reg.teams[0].agents,
347            vec!["compass", "scribe"],
348            "roster refreshed"
349        );
350        assert_eq!(
351            reg.teams[0].started_at, "T0",
352            "uptime preserved across re-up"
353        );
354    }
355
356    #[test]
357    fn distinct_keys_coexist_and_sort() {
358        let dir = tempfile::tempdir().unwrap();
359        // Same project id, different roots → two rows (the path-keyed fix).
360        // Different project, shared root → two rows.
361        upsert_many(
362            dir.path(),
363            vec![
364                entry("main", "/r/b/.team", &["x"], "T0"),
365                entry("main", "/r/a/.team", &["x"], "T0"),
366                entry("ops", "/r/a/.team", &["y"], "T0"),
367            ],
368        )
369        .unwrap();
370        let reg = load(dir.path()).unwrap();
371        assert_eq!(reg.teams.len(), 3);
372        // Sorted by (root, project_id).
373        assert_eq!(
374            reg.teams
375                .iter()
376                .map(|t| (t.root.to_string_lossy().into_owned(), t.project_id.clone()))
377                .collect::<Vec<_>>(),
378            vec![
379                ("/r/a/.team".into(), "main".into()),
380                ("/r/a/.team".into(), "ops".into()),
381                ("/r/b/.team".into(), "main".into()),
382            ]
383        );
384    }
385
386    #[test]
387    fn clear_whole_root_drops_all_its_entries() {
388        let dir = tempfile::tempdir().unwrap();
389        upsert_many(
390            dir.path(),
391            vec![
392                entry("main", "/r/a/.team", &["x"], "T0"),
393                entry("ops", "/r/a/.team", &["y"], "T0"),
394                entry("main", "/r/b/.team", &["z"], "T0"),
395            ],
396        )
397        .unwrap();
398        clear(dir.path(), Path::new("/r/a/.team"), None).unwrap();
399        let reg = load(dir.path()).unwrap();
400        assert_eq!(reg.teams.len(), 1);
401        assert_eq!(reg.teams[0].root, PathBuf::from("/r/b/.team"));
402    }
403
404    #[test]
405    fn clear_scoped_project_keeps_sibling_at_same_root() {
406        let dir = tempfile::tempdir().unwrap();
407        upsert_many(
408            dir.path(),
409            vec![
410                entry("main", "/r/a/.team", &["x"], "T0"),
411                entry("ops", "/r/a/.team", &["y"], "T0"),
412            ],
413        )
414        .unwrap();
415        clear(dir.path(), Path::new("/r/a/.team"), Some("main")).unwrap();
416        let reg = load(dir.path()).unwrap();
417        assert_eq!(reg.teams.len(), 1, "only the scoped project is dropped");
418        assert_eq!(reg.teams[0].project_id, "ops");
419    }
420
421    #[test]
422    fn save_is_atomic_and_leaves_no_temp() {
423        let dir = tempfile::tempdir().unwrap();
424        upsert(dir.path(), entry("main", "/r/a/.team", &["x"], "T0")).unwrap();
425        let leftovers: Vec<_> = fs::read_dir(dir.path())
426            .unwrap()
427            .filter_map(|e| e.ok())
428            .map(|e| e.file_name().to_string_lossy().into_owned())
429            .collect();
430        assert!(leftovers.contains(&"teams.json".to_string()));
431        assert!(
432            !leftovers.iter().any(|n| n.ends_with(".tmp")),
433            "no temp file should survive a successful rename: {leftovers:?}"
434        );
435    }
436
437    #[test]
438    fn load_corrupt_file_degrades_to_empty() {
439        let dir = tempfile::tempdir().unwrap();
440        fs::create_dir_all(dir.path()).unwrap();
441        fs::write(dir.path().join(FILE), b"{ this is not json").unwrap();
442        let reg = load(dir.path()).unwrap();
443        assert!(
444            reg.teams.is_empty(),
445            "corrupt store reads as empty, not an error"
446        );
447    }
448
449    #[test]
450    fn is_orphan_tracks_presence_of_compose() {
451        let live = entry("main", "/r/live/.team", &["x"], "T0");
452        let gone = entry("main", "/r/gone/.team", &["x"], "T0");
453        // Modern shape: root IS the .team dir, compose directly inside it.
454        let extant: HashSet<PathBuf> = [PathBuf::from("/r/live/.team/team-compose.yaml")].into();
455        let exists = |p: &Path| extant.contains(p);
456        assert!(!is_orphan(&live, &exists), "live root keeps its compose");
457        assert!(is_orphan(&gone, &exists), "missing compose ⇒ orphan");
458    }
459
460    #[test]
461    fn is_orphan_falls_back_to_bare_root_layout_for_sessions_parity() {
462        // Defensive fallback only: `up` records the `.team` dir as `root`,
463        // so this bare-project-dir shape isn't a path `up` writes — it's
464        // here for parity with `sessions::root_has_compose`, which accepts
465        // a root pointing at the project dir with the compose under .team/.
466        let e = entry("main", "/r/proj", &["x"], "T0");
467        let extant: HashSet<PathBuf> = [PathBuf::from("/r/proj/.team/team-compose.yaml")].into();
468        let exists = |p: &Path| extant.contains(p);
469        assert!(!is_orphan(&e, &exists));
470    }
471
472    fn rentry(project: &str, agent: &str, session: &str) -> RosterEntry {
473        RosterEntry {
474            project_id: project.into(),
475            agent: agent.into(),
476            tmux_session: session.into(),
477        }
478    }
479
480    #[test]
481    fn roster_for_root_rebuilds_sessions_from_recorded_prefix() {
482        // entry() records tmux_prefix "t-"; the roster rebuilds each agent's
483        // session as `{prefix}{project}-{agent}`, only for the asked root.
484        let mut reg = Registry::default();
485        reg.teams
486            .push(entry("main", "/r/a/.team", &["compass", "scout"], "T0"));
487        reg.teams.push(entry("ops", "/r/b/.team", &["otto"], "T0"));
488        assert_eq!(
489            reg.roster_for_root(Path::new("/r/a/.team")),
490            vec![
491                rentry("main", "compass", "t-main-compass"),
492                rentry("main", "scout", "t-main-scout"),
493            ]
494        );
495    }
496
497    #[test]
498    fn reap_targets_drops_removed_keeps_current() {
499        // Roster has compass+scout; the current compose (desired) keeps only
500        // compass → scout is the orphan, compass is never reaped.
501        let roster = vec![
502            rentry("main", "compass", "t-main-compass"),
503            rentry("main", "scout", "t-main-scout"),
504        ];
505        let desired: HashSet<String> = ["main:compass".to_string()].into_iter().collect();
506        assert_eq!(
507            reap_targets(&roster, &desired, None, false),
508            vec![rentry("main", "scout", "t-main-scout")]
509        );
510    }
511
512    #[test]
513    fn reap_targets_skips_entirely_on_per_agent_teardown() {
514        // scout would be an orphan (empty desired), but a `--agent` teardown
515        // is partial → reap nothing.
516        let roster = vec![rentry("main", "scout", "t-main-scout")];
517        assert!(reap_targets(&roster, &HashSet::new(), None, true).is_empty());
518    }
519
520    #[test]
521    fn reap_targets_honors_project_scope() {
522        // Two projects share a root; a `--project main` reap touches only
523        // main's orphans, never ops'.
524        let roster = vec![
525            rentry("main", "scout", "t-main-scout"),
526            rentry("ops", "otto", "t-ops-otto"),
527        ];
528        assert_eq!(
529            reap_targets(&roster, &HashSet::new(), Some("main"), false),
530            vec![rentry("main", "scout", "t-main-scout")]
531        );
532    }
533
534    #[test]
535    fn orphans_for_root_loads_and_diffs_against_desired() {
536        // End-to-end over a real on-disk store (what `down`/`reload` reap
537        // through): a team recorded up at this root with compass+scout; the
538        // current compose keeps only compass → scout is the orphan. compass
539        // (still desired) is never returned — no false positive.
540        let dir = tempfile::tempdir().unwrap();
541        upsert(
542            dir.path(),
543            entry("main", "/r/a/.team", &["compass", "scout"], "T0"),
544        )
545        .unwrap();
546        let desired: HashSet<String> = ["main:compass".to_string()].into_iter().collect();
547        assert_eq!(
548            orphans_for_root(dir.path(), Path::new("/r/a/.team"), &desired, None, false).unwrap(),
549            vec![rentry("main", "scout", "t-main-scout")]
550        );
551
552        // A missing store loads as empty → no orphans, not an error: the
553        // applied.json-absent reload path stays safe even with no registry.
554        let empty = tempfile::tempdir().unwrap();
555        assert!(
556            orphans_for_root(empty.path(), Path::new("/r/a/.team"), &desired, None, false)
557                .unwrap()
558                .is_empty()
559        );
560    }
561
562    #[test]
563    fn same_name_other_root_flags_only_a_live_different_folder() {
564        // `main` is registered up at /r/a/.team.
565        let mut reg = Registry::default();
566        reg.teams.push(entry("main", "/r/a/.team", &["x"], "T0"));
567        let a_live = |p: &Path| p == Path::new("/r/a/.team/team-compose.yaml");
568
569        // Launching `main` from a DIFFERENT folder, while /r/a is still live →
570        // the conflict, naming the other root.
571        assert_eq!(
572            same_name_other_root(&reg, "main", Path::new("/r/b/.team"), &a_live),
573            Some(PathBuf::from("/r/a/.team"))
574        );
575        // Re-up of the SAME (project, root) → not a conflict.
576        assert_eq!(
577            same_name_other_root(&reg, "main", Path::new("/r/a/.team"), &a_live),
578            None
579        );
580        // A DIFFERENT project id at another folder → not a conflict.
581        assert_eq!(
582            same_name_other_root(&reg, "ops", Path::new("/r/b/.team"), &a_live),
583            None
584        );
585        // The other folder is gone (orphan, no compose) → stale, don't block.
586        let none_live = |_: &Path| false;
587        assert_eq!(
588            same_name_other_root(&reg, "main", Path::new("/r/b/.team"), &none_live),
589            None
590        );
591    }
592}