Skip to main content

batty_cli/team/
scale.rs

1//! CLI-side scale command: mutates team.yaml to change instance counts
2//! or add/remove manager roles. The daemon detects the config change via
3//! hot-reload and reconciles the running topology.
4
5use std::path::Path;
6
7use anyhow::{Context, Result, bail};
8use regex::Regex;
9
10use super::config::{RoleType, TeamConfig};
11use super::config_diff;
12use super::hierarchy;
13use super::team_config_path;
14use crate::cli::ScaleCommand;
15
16/// Run a `batty scale` subcommand.
17pub fn run(project_root: &Path, command: ScaleCommand) -> Result<()> {
18    let config_path = team_config_path(project_root);
19    if !config_path.exists() {
20        bail!("No team config found. Run `batty init` first.");
21    }
22
23    match command {
24        ScaleCommand::Engineers { count } => scale_engineers(project_root, count),
25        ScaleCommand::AddManager { name } => add_manager(project_root, &name),
26        ScaleCommand::RemoveManager { name } => remove_manager(project_root, &name),
27        ScaleCommand::Status => show_topology(project_root),
28    }
29}
30
31/// Update the engineer role's instance count in team.yaml.
32fn scale_engineers(project_root: &Path, count: u32) -> Result<()> {
33    if count == 0 {
34        bail!("Engineer count must be at least 1.");
35    }
36
37    let config_path = team_config_path(project_root);
38    let config = TeamConfig::load(&config_path)?;
39
40    // Find the engineer role
41    let eng_role = config
42        .roles
43        .iter()
44        .find(|r| r.role_type == RoleType::Engineer)
45        .context("No engineer role found in team config")?;
46
47    let old_count = eng_role.instances;
48    if old_count == count {
49        println!("Engineers already at {count} instances. No change needed.");
50        return Ok(());
51    }
52
53    // Read raw YAML and update the engineer instances field
54    let content = std::fs::read_to_string(&config_path).context("failed to read team.yaml")?;
55    let updated = update_role_instances(&content, &eng_role.name, count)?;
56    std::fs::write(&config_path, &updated).context("failed to write updated team.yaml")?;
57
58    // Compute diff for display
59    let new_config = TeamConfig::load(&config_path)?;
60    let diff = config_diff::diff_configs(&config, &new_config)?;
61
62    if count > old_count {
63        println!(
64            "Scaled engineers from {} to {} (+{} agents).",
65            old_count,
66            count,
67            diff.added.len()
68        );
69    } else {
70        println!(
71            "Scaled engineers from {} to {} (-{} agents).",
72            old_count,
73            count,
74            diff.removed.len()
75        );
76    }
77    println!("Daemon will detect the change and reconcile topology.");
78    Ok(())
79}
80
81/// Add a new manager role to team.yaml.
82fn add_manager(project_root: &Path, name: &str) -> Result<()> {
83    let config_path = team_config_path(project_root);
84    let config = TeamConfig::load(&config_path)?;
85
86    // Check name doesn't conflict with existing roles
87    if config.roles.iter().any(|r| r.name == name) {
88        bail!("Role '{name}' already exists in team config.");
89    }
90
91    // Find existing manager for defaults
92    let existing_mgr = config
93        .roles
94        .iter()
95        .find(|r| r.role_type == RoleType::Manager);
96    let agent = existing_mgr
97        .and_then(|m| m.agent.clone())
98        .unwrap_or_else(|| "claude".to_string());
99    let prompt = existing_mgr
100        .and_then(|m| m.prompt.clone())
101        .unwrap_or_else(|| "batty_manager.md".to_string());
102
103    // Append new manager role to the YAML
104    let content = std::fs::read_to_string(&config_path).context("failed to read team.yaml")?;
105
106    let role_block = format!(
107        "\n  - name: {name}\n    role_type: manager\n    agent: {agent}\n    instances: 1\n    prompt: {prompt}\n    talks_to: [architect, engineer]\n"
108    );
109
110    let updated = content.trim_end().to_string() + &role_block;
111    std::fs::write(&config_path, &updated).context("failed to write updated team.yaml")?;
112
113    println!("Added manager role '{name}'. Daemon will spawn the new agent.");
114    Ok(())
115}
116
117/// Remove a manager role from team.yaml.
118fn remove_manager(project_root: &Path, name: &str) -> Result<()> {
119    let config_path = team_config_path(project_root);
120    let config = TeamConfig::load(&config_path)?;
121
122    let role = config
123        .roles
124        .iter()
125        .find(|r| r.name == name)
126        .context(format!("Role '{name}' not found in team config"))?;
127
128    if role.role_type != RoleType::Manager {
129        bail!(
130            "Role '{name}' is not a manager (it's a {:?}).",
131            role.role_type
132        );
133    }
134
135    // Count managers — must keep at least one
136    let manager_count = config
137        .roles
138        .iter()
139        .filter(|r| r.role_type == RoleType::Manager)
140        .count();
141    if manager_count <= 1 {
142        bail!("Cannot remove the last manager. At least one manager is required.");
143    }
144
145    // Remove the role block from YAML
146    let content = std::fs::read_to_string(&config_path).context("failed to read team.yaml")?;
147    let updated = remove_role_block(&content, name)?;
148    std::fs::write(&config_path, &updated).context("failed to write updated team.yaml")?;
149
150    println!("Removed manager role '{name}'. Daemon will gracefully shut down the agent.");
151    Ok(())
152}
153
154/// Show current topology.
155fn show_topology(project_root: &Path) -> Result<()> {
156    let config_path = team_config_path(project_root);
157    let config = TeamConfig::load(&config_path)?;
158    let members = hierarchy::resolve_hierarchy(&config)?;
159
160    println!("Team: {}", config.name);
161    println!();
162
163    for role in &config.roles {
164        let type_str = match role.role_type {
165            RoleType::User => "user",
166            RoleType::Architect => "architect",
167            RoleType::Manager => "manager",
168            RoleType::Engineer => "engineer",
169        };
170        let member_names: Vec<&str> = members
171            .iter()
172            .filter(|m| m.role_name == role.name)
173            .map(|m| m.name.as_str())
174            .collect();
175        println!(
176            "  {:12} {:10} instances={:<3} members=[{}]",
177            role.name,
178            type_str,
179            role.instances,
180            member_names.join(", ")
181        );
182    }
183
184    let agent_count = members
185        .iter()
186        .filter(|m| m.role_type != RoleType::User)
187        .count();
188    println!();
189    println!("Total agent members: {agent_count}");
190    Ok(())
191}
192
193// ---------------------------------------------------------------------------
194// YAML manipulation helpers
195// ---------------------------------------------------------------------------
196
197/// Update the `instances:` field for a role identified by name in raw YAML.
198///
199/// This preserves comments and formatting by doing a targeted regex replacement.
200fn update_role_instances(yaml: &str, role_name: &str, new_count: u32) -> Result<String> {
201    // Strategy: find the role block starting with `- name: <role_name>`, then
202    // update the `instances:` line within it (before the next `- name:` or EOF).
203    let lines: Vec<&str> = yaml.lines().collect();
204    let mut result = Vec::new();
205    let mut in_target_role = false;
206    let mut updated = false;
207    let instances_re = Regex::new(r"^(\s+instances:\s*)\d+").unwrap();
208
209    for line in &lines {
210        // Detect role block boundaries
211        let trimmed = line.trim();
212        if trimmed.starts_with("- name:") {
213            let name_val = trimmed.strip_prefix("- name:").unwrap_or("").trim();
214            in_target_role = name_val == role_name;
215        }
216
217        if in_target_role && !updated {
218            if let Some(caps) = instances_re.captures(line) {
219                let prefix = caps.get(1).unwrap().as_str();
220                result.push(format!("{prefix}{new_count}"));
221                updated = true;
222                continue;
223            }
224        }
225
226        result.push(line.to_string());
227    }
228
229    if !updated {
230        bail!("Could not find instances field for role '{role_name}' in team.yaml");
231    }
232
233    Ok(result.join("\n") + "\n")
234}
235
236/// Remove a role block (from `- name: <name>` to the next `- name:` or EOF).
237fn remove_role_block(yaml: &str, role_name: &str) -> Result<String> {
238    let lines: Vec<&str> = yaml.lines().collect();
239    let mut result = Vec::new();
240    let mut skipping = false;
241    let mut found = false;
242
243    for line in &lines {
244        let trimmed = line.trim();
245        if trimmed.starts_with("- name:") {
246            let name_val = trimmed.strip_prefix("- name:").unwrap_or("").trim();
247            if name_val == role_name {
248                skipping = true;
249                found = true;
250                continue;
251            }
252            skipping = false;
253        }
254
255        if !skipping {
256            result.push(*line);
257        }
258    }
259
260    if !found {
261        bail!("Could not find role '{role_name}' in team.yaml");
262    }
263
264    Ok(result.join("\n") + "\n")
265}
266
267// ---------------------------------------------------------------------------
268// Tests
269// ---------------------------------------------------------------------------
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    const SAMPLE_YAML: &str = r#"name: test-team
276
277roles:
278  - name: architect
279    role_type: architect
280    agent: claude
281    instances: 1
282
283  - name: manager
284    role_type: manager
285    agent: claude
286    instances: 1
287    prompt: batty_manager.md
288    talks_to: [architect, engineer]
289
290  - name: secondary-mgr
291    role_type: manager
292    agent: claude
293    instances: 1
294    prompt: batty_manager.md
295
296  - name: engineer
297    role_type: engineer
298    agent: claude
299    instances: 3
300    prompt: batty_engineer.md
301    talks_to: [manager]
302    use_worktrees: true
303"#;
304
305    #[test]
306    fn update_instances_for_engineer() {
307        let result = update_role_instances(SAMPLE_YAML, "engineer", 6).unwrap();
308        assert!(result.contains("instances: 6"));
309        // Architect still has 1
310        let config: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
311        let roles = config["roles"].as_sequence().unwrap();
312        let eng = roles
313            .iter()
314            .find(|r| r["name"].as_str() == Some("engineer"))
315            .unwrap();
316        assert_eq!(eng["instances"].as_u64(), Some(6));
317        // Manager still 1
318        let mgr = roles
319            .iter()
320            .find(|r| r["name"].as_str() == Some("manager"))
321            .unwrap();
322        assert_eq!(mgr["instances"].as_u64(), Some(1));
323    }
324
325    #[test]
326    fn update_instances_for_manager() {
327        let result = update_role_instances(SAMPLE_YAML, "manager", 3).unwrap();
328        let config: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
329        let roles = config["roles"].as_sequence().unwrap();
330        let mgr = roles
331            .iter()
332            .find(|r| r["name"].as_str() == Some("manager"))
333            .unwrap();
334        assert_eq!(mgr["instances"].as_u64(), Some(3));
335    }
336
337    #[test]
338    fn update_instances_missing_role_errors() {
339        let result = update_role_instances(SAMPLE_YAML, "nonexistent", 5);
340        assert!(result.is_err());
341    }
342
343    #[test]
344    fn remove_role_block_removes_manager() {
345        let result = remove_role_block(SAMPLE_YAML, "secondary-mgr").unwrap();
346        assert!(!result.contains("secondary-mgr"));
347        // Other roles still present
348        assert!(result.contains("architect"));
349        assert!(result.contains("manager"));
350        assert!(result.contains("engineer"));
351        // Parses as valid YAML
352        let config: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
353        let roles = config["roles"].as_sequence().unwrap();
354        assert_eq!(roles.len(), 3);
355    }
356
357    #[test]
358    fn remove_role_block_missing_errors() {
359        let result = remove_role_block(SAMPLE_YAML, "nonexistent");
360        assert!(result.is_err());
361    }
362
363    #[test]
364    fn remove_last_role_block_works() {
365        // Removing the last role (engineer) should work
366        let result = remove_role_block(SAMPLE_YAML, "engineer").unwrap();
367        assert!(!result.contains("- name: engineer"));
368        // The word "engineer" may still appear in talks_to of other roles
369        let config: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
370        let roles = config["roles"].as_sequence().unwrap();
371        assert_eq!(roles.len(), 3);
372    }
373}