use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use team_core::compose::Compose;
use team_core::supervisor::{AgentSpec, AgentState, Supervisor, TmuxSupervisor};
use super::agent_filter::AgentSelector;
pub fn run(root: &Path, project: Option<&str>, sel: &AgentSelector) -> Result<()> {
let compose = super::load(root)?;
let errs = team_core::validate::validate(&compose);
if !errs.is_empty() {
for e in &errs {
eprintln!("error: {e}");
}
bail!("{} validation error(s) — fix before down", errs.len());
}
let scoped = project
.map(|name| super::project_filter::resolve(&compose, name))
.transpose()?;
let targets = match scoped.as_deref() {
Some(id) => super::agent_filter::resolve(&compose, id, sel)?,
None => None,
};
let mut touched = 0usize;
let sup = TmuxSupervisor;
for h in compose.agents() {
if scoped.as_deref().is_some_and(|id| id != h.project) {
continue;
}
if targets.as_ref().is_some_and(|t| !t.contains(h.agent)) {
continue;
}
let spec = AgentSpec::from_handle(h, &compose.root, &compose.global.supervisor.tmux_prefix);
sup.down(&spec)?;
println!("down · {}", h.id());
touched += 1;
}
for spec in super::bot::bot_specs(&compose) {
let split = spec.manager.split_once(':');
if scoped
.as_deref()
.is_some_and(|id| split.map(|(p, _)| p) != Some(id))
{
continue;
}
if let Some(t) = &targets {
if !t.contains(split.map(|(_, a)| a).unwrap_or("")) {
continue;
}
}
super::bot::down_one(&spec);
println!("down · bot {}", spec.session);
touched += 1;
}
touched += reap_orphans(&compose, &sup, scoped.as_deref(), targets.is_some());
if let (Some(id), 0) = (scoped.as_deref(), touched) {
println!("no agents in scope for project {id}.");
}
if let Some(project) = registry_clear_scope(targets.is_some(), scoped.as_deref()) {
if let Some(dir) = team_core::registry::config_dir() {
if let Err(e) = team_core::registry::clear(&dir, &compose.root, project) {
eprintln!("warn · teams registry: {e:#}");
}
}
}
super::caffeinate::stop_if_last();
Ok(())
}
fn registry_clear_scope(per_agent: bool, scoped: Option<&str>) -> Option<Option<&str>> {
if per_agent {
None
} else {
Some(scoped)
}
}
fn reap_orphans(
compose: &Compose,
sup: &TmuxSupervisor,
scoped: Option<&str>,
per_agent: bool,
) -> usize {
let Some(dir) = team_core::registry::config_dir() else {
return 0;
};
let desired: HashSet<String> = compose.agents().map(|h| h.id()).collect();
let orphans = match team_core::registry::orphans_for_root(
&dir,
&compose.root,
&desired,
scoped,
per_agent,
) {
Ok(o) => o,
Err(e) => {
eprintln!("warn · teams registry: {e:#}");
return 0;
}
};
let mut reaped = 0;
for orphan in orphans {
let spec = orphan_spec(compose, &orphan);
match sup.state(&spec) {
Ok(AgentState::Running) => match sup.down(&spec) {
Ok(()) => {
println!("reaped · {}", orphan.id());
reaped += 1;
}
Err(e) => eprintln!("warn · reap {}: {e:#}", orphan.id()),
},
Ok(_) => {}
Err(e) => eprintln!("warn · reap {} (state): {e:#}", orphan.id()),
}
}
reaped
}
fn orphan_spec(compose: &Compose, e: &team_core::registry::RosterEntry) -> AgentSpec {
AgentSpec {
project: e.project_id.clone(),
agent: e.agent.clone(),
tmux_session: e.tmux_session.clone(),
wrapper: super::agent_wrapper(&compose.root),
cwd: compose.root.clone(),
env_file: PathBuf::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn per_agent_down_leaves_registry_untouched() {
assert_eq!(registry_clear_scope(true, None), None);
assert_eq!(registry_clear_scope(true, Some("main")), None);
}
#[test]
fn whole_team_down_clears_every_entry_for_the_root() {
assert_eq!(registry_clear_scope(false, None), Some(None));
}
#[test]
fn project_scoped_down_clears_only_that_project() {
assert_eq!(
registry_clear_scope(false, Some("main")),
Some(Some("main"))
);
}
}