Skip to main content

batty_cli/team/
config_diff.rs

1//! Diff two team configurations to produce a topology change set.
2//!
3//! Used by both `batty scale` (CLI-side) and daemon hot-reload to determine
4//! which agents need to be added, removed, or left unchanged.
5
6use std::collections::HashSet;
7
8use super::config::TeamConfig;
9use super::hierarchy::{self, MemberInstance};
10
11/// A single member that changed between two configurations.
12#[derive(Debug, Clone)]
13pub struct MemberChange {
14    pub name: String,
15    pub member: MemberInstance,
16}
17
18/// The result of diffing two resolved team topologies.
19#[derive(Debug, Clone)]
20pub struct TopologyDiff {
21    /// Members present in new config but not in old.
22    pub added: Vec<MemberChange>,
23    /// Members present in old config but not in new.
24    pub removed: Vec<MemberChange>,
25    /// Members present in both configs (by name).
26    pub unchanged: Vec<String>,
27}
28
29impl TopologyDiff {
30    /// True when no members were added or removed.
31    pub fn is_empty(&self) -> bool {
32        self.added.is_empty() && self.removed.is_empty()
33    }
34
35    /// Total number of changes.
36    pub fn change_count(&self) -> usize {
37        self.added.len() + self.removed.len()
38    }
39}
40
41/// Compute the topology diff between two team configurations.
42///
43/// Resolves both configs into member instance lists and compares by name.
44pub fn diff_configs(old: &TeamConfig, new: &TeamConfig) -> anyhow::Result<TopologyDiff> {
45    let old_members = hierarchy::resolve_hierarchy(old)?;
46    let new_members = hierarchy::resolve_hierarchy(new)?;
47    Ok(diff_members(&old_members, &new_members))
48}
49
50/// Compute the topology diff between two resolved member lists.
51pub fn diff_members(old: &[MemberInstance], new: &[MemberInstance]) -> TopologyDiff {
52    let old_names: HashSet<&str> = old.iter().map(|m| m.name.as_str()).collect();
53    let new_names: HashSet<&str> = new.iter().map(|m| m.name.as_str()).collect();
54
55    let added: Vec<MemberChange> = new
56        .iter()
57        .filter(|m| !old_names.contains(m.name.as_str()))
58        .map(|m| MemberChange {
59            name: m.name.clone(),
60            member: m.clone(),
61        })
62        .collect();
63
64    let removed: Vec<MemberChange> = old
65        .iter()
66        .filter(|m| !new_names.contains(m.name.as_str()))
67        .map(|m| MemberChange {
68            name: m.name.clone(),
69            member: m.clone(),
70        })
71        .collect();
72
73    let unchanged: Vec<String> = old
74        .iter()
75        .filter(|m| new_names.contains(m.name.as_str()))
76        .map(|m| m.name.clone())
77        .collect();
78
79    TopologyDiff {
80        added,
81        removed,
82        unchanged,
83    }
84}
85
86// ---------------------------------------------------------------------------
87// Tests
88// ---------------------------------------------------------------------------
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::team::config::{RoleDef, RoleType};
94
95    fn minimal_config(engineer_instances: u32, manager_instances: u32) -> TeamConfig {
96        TeamConfig {
97            name: "test".into(),
98            agent: None,
99            workflow_mode: Default::default(),
100            board: Default::default(),
101            standup: Default::default(),
102            automation: Default::default(),
103            automation_sender: None,
104            external_senders: vec![],
105            orchestrator_pane: false,
106            orchestrator_position: Default::default(),
107            layout: None,
108            workflow_policy: Default::default(),
109            cost: Default::default(),
110            grafana: Default::default(),
111            use_shim: true,
112            use_sdk_mode: false,
113            auto_respawn_on_crash: false,
114            shim_health_check_interval_secs: 60,
115            shim_health_timeout_secs: 120,
116            shim_shutdown_timeout_secs: 30,
117            shim_working_state_timeout_secs: 1800,
118            pending_queue_max_age_secs: 600,
119            event_log_max_bytes: 10 * 1024 * 1024,
120            retro_min_duration_secs: 60,
121            roles: vec![
122                RoleDef {
123                    name: "architect".into(),
124                    role_type: RoleType::Architect,
125                    agent: None,
126                    auth_mode: None,
127                    auth_env: vec![],
128                    instances: 1,
129                    prompt: None,
130                    talks_to: vec![],
131                    channel: None,
132                    channel_config: None,
133                    nudge_interval_secs: None,
134                    receives_standup: None,
135                    standup_interval_secs: None,
136                    owns: vec![],
137                    barrier_group: None,
138                    use_worktrees: false,
139                    ..Default::default()
140                },
141                RoleDef {
142                    name: "manager".into(),
143                    role_type: RoleType::Manager,
144                    agent: None,
145                    auth_mode: None,
146                    auth_env: vec![],
147                    instances: manager_instances,
148                    prompt: None,
149                    talks_to: vec![],
150                    channel: None,
151                    channel_config: None,
152                    nudge_interval_secs: None,
153                    receives_standup: None,
154                    standup_interval_secs: None,
155                    owns: vec![],
156                    barrier_group: None,
157                    use_worktrees: false,
158                    ..Default::default()
159                },
160                RoleDef {
161                    name: "engineer".into(),
162                    role_type: RoleType::Engineer,
163                    agent: None,
164                    auth_mode: None,
165                    auth_env: vec![],
166                    instances: engineer_instances,
167                    prompt: None,
168                    talks_to: vec![],
169                    channel: None,
170                    channel_config: None,
171                    nudge_interval_secs: None,
172                    receives_standup: None,
173                    standup_interval_secs: None,
174                    owns: vec![],
175                    barrier_group: None,
176                    use_worktrees: true,
177                    ..Default::default()
178                },
179            ],
180        }
181    }
182
183    #[test]
184    fn no_change_produces_empty_diff() {
185        let config = minimal_config(3, 1);
186        let diff = diff_configs(&config, &config).unwrap();
187        assert!(diff.is_empty());
188        assert_eq!(diff.change_count(), 0);
189        // architect + manager + 3 engineers = 5 unchanged
190        assert_eq!(diff.unchanged.len(), 5);
191    }
192
193    #[test]
194    fn scale_up_engineers_shows_added() {
195        let old = minimal_config(2, 1);
196        let new = minimal_config(4, 1);
197        let diff = diff_configs(&old, &new).unwrap();
198        assert_eq!(diff.added.len(), 2);
199        assert!(diff.removed.is_empty());
200        // Check the added names
201        let added_names: HashSet<&str> = diff.added.iter().map(|c| c.name.as_str()).collect();
202        assert!(added_names.contains("eng-1-3"));
203        assert!(added_names.contains("eng-1-4"));
204    }
205
206    #[test]
207    fn scale_down_engineers_shows_removed() {
208        let old = minimal_config(4, 1);
209        let new = minimal_config(2, 1);
210        let diff = diff_configs(&old, &new).unwrap();
211        assert!(diff.added.is_empty());
212        assert_eq!(diff.removed.len(), 2);
213        let removed_names: HashSet<&str> = diff.removed.iter().map(|c| c.name.as_str()).collect();
214        assert!(removed_names.contains("eng-1-3"));
215        assert!(removed_names.contains("eng-1-4"));
216    }
217
218    #[test]
219    fn add_manager_shows_added_manager_and_engineers() {
220        let old = minimal_config(2, 1);
221        let new = minimal_config(2, 2);
222        let diff = diff_configs(&old, &new).unwrap();
223        // Adding a second manager creates manager-2 + eng-2-1, eng-2-2
224        // And renames architect→architect, manager→manager-1, manager-2 new
225        // Actually hierarchy naming: with instances=2, managers become manager-1, manager-2
226        // With instances=1, manager stays as "manager"
227        // So old has: architect, manager, eng-1-1, eng-1-2
228        // New has: architect, manager-1, manager-2, eng-1-1, eng-1-2, eng-2-1, eng-2-2
229        // Diff: removed manager, added manager-1, manager-2, eng-2-1, eng-2-2
230        assert!(!diff.added.is_empty());
231        let added_names: HashSet<&str> = diff.added.iter().map(|c| c.name.as_str()).collect();
232        assert!(added_names.contains("manager-2"));
233    }
234
235    #[test]
236    fn diff_members_direct() {
237        let old = vec![
238            MemberInstance {
239                name: "architect".into(),
240                role_name: "architect".into(),
241                role_type: RoleType::Architect,
242                agent: Some("claude".into()),
243                prompt: None,
244                reports_to: None,
245                use_worktrees: false,
246                ..Default::default()
247            },
248            MemberInstance {
249                name: "eng-1-1".into(),
250                role_name: "engineer".into(),
251                role_type: RoleType::Engineer,
252                agent: Some("claude".into()),
253                prompt: None,
254                reports_to: Some("manager".into()),
255                use_worktrees: true,
256                ..Default::default()
257            },
258        ];
259        let new = vec![
260            MemberInstance {
261                name: "architect".into(),
262                role_name: "architect".into(),
263                role_type: RoleType::Architect,
264                agent: Some("claude".into()),
265                prompt: None,
266                reports_to: None,
267                use_worktrees: false,
268                ..Default::default()
269            },
270            MemberInstance {
271                name: "eng-1-1".into(),
272                role_name: "engineer".into(),
273                role_type: RoleType::Engineer,
274                agent: Some("claude".into()),
275                prompt: None,
276                reports_to: Some("manager".into()),
277                use_worktrees: true,
278                ..Default::default()
279            },
280            MemberInstance {
281                name: "eng-1-2".into(),
282                role_name: "engineer".into(),
283                role_type: RoleType::Engineer,
284                agent: Some("claude".into()),
285                prompt: None,
286                reports_to: Some("manager".into()),
287                use_worktrees: true,
288                ..Default::default()
289            },
290        ];
291        let diff = diff_members(&old, &new);
292        assert_eq!(diff.added.len(), 1);
293        assert_eq!(diff.added[0].name, "eng-1-2");
294        assert!(diff.removed.is_empty());
295        assert_eq!(diff.unchanged.len(), 2);
296    }
297}