Skip to main content

agentmux_srv/backend/storage/
agent_groups.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Saved fleet-control target groups — see `db_agent_groups` in
5//! migrations.rs (`OBJECT_SCHEMA_VERSION` v24) and
6//! docs/specs/SPEC_MULTI_AGENT_FLEET_CONTROL_2026_08_20.md.
7//!
8//! A group is a durable, named list of block ids a user has saved for
9//! reuse as a bulk-action target set — the Ansible-inventory-group lesson
10//! from that spec's research: a saved group, not raw ephemeral selection,
11//! is the abstraction that scales as a fleet grows. `member_ids` is stored
12//! as a JSON array; the frontend resolves a group to concrete ids before
13//! calling `fleet.broadcast`/`fleet.bulk-stop` so membership can't drift
14//! between confirming a bulk action and running it.
15
16use rusqlite::params;
17
18use super::error::StoreError;
19use super::store::Store;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct AgentGroup {
23    pub id: String,
24    pub name: String,
25    pub member_ids: Vec<String>,
26    pub created_at: i64,
27}
28
29impl Store {
30    pub fn agent_group_create(
31        &self,
32        id: &str,
33        name: &str,
34        member_ids: &[String],
35        created_at: i64,
36    ) -> Result<(), StoreError> {
37        let member_ids_json = serde_json::to_string(member_ids)?;
38        let conn = self.conn.lock().unwrap();
39        conn.execute(
40            "INSERT INTO db_agent_groups (id, name, member_ids, created_at) VALUES (?1, ?2, ?3, ?4)",
41            params![id, name, member_ids_json, created_at],
42        )?;
43        Ok(())
44    }
45
46    pub fn agent_group_list(&self) -> Result<Vec<AgentGroup>, StoreError> {
47        let conn = self.conn.lock().unwrap();
48        let mut stmt = conn.prepare(
49            "SELECT id, name, member_ids, created_at FROM db_agent_groups ORDER BY created_at ASC",
50        )?;
51        let iter = stmt.query_map([], map_row)?;
52        iter.collect::<Result<Vec<_>, _>>().map_err(Into::into)
53    }
54
55    pub fn agent_group_get(&self, id: &str) -> Result<Option<AgentGroup>, StoreError> {
56        let conn = self.conn.lock().unwrap();
57        let mut stmt = conn.prepare(
58            "SELECT id, name, member_ids, created_at FROM db_agent_groups WHERE id = ?1",
59        )?;
60        let mut rows = stmt.query_map(params![id], map_row)?;
61        match rows.next() {
62            Some(r) => Ok(Some(r?)),
63            None => Ok(None),
64        }
65    }
66
67    /// Partial update: `None` fields are left unchanged. Returns `false` if
68    /// no row matched `id` (caller should surface a not-found error).
69    pub fn agent_group_update(
70        &self,
71        id: &str,
72        name: Option<&str>,
73        member_ids: Option<&[String]>,
74    ) -> Result<bool, StoreError> {
75        let conn = self.conn.lock().unwrap();
76        if let Some(name) = name {
77            conn.execute(
78                "UPDATE db_agent_groups SET name = ?2 WHERE id = ?1",
79                params![id, name],
80            )?;
81        }
82        if let Some(member_ids) = member_ids {
83            let member_ids_json = serde_json::to_string(member_ids)?;
84            conn.execute(
85                "UPDATE db_agent_groups SET member_ids = ?2 WHERE id = ?1",
86                params![id, member_ids_json],
87            )?;
88        }
89        let exists: bool = conn
90            .query_row("SELECT 1 FROM db_agent_groups WHERE id = ?1", params![id], |_| Ok(()))
91            .is_ok();
92        Ok(exists)
93    }
94
95    pub fn agent_group_delete(&self, id: &str) -> Result<bool, StoreError> {
96        let conn = self.conn.lock().unwrap();
97        let affected = conn.execute("DELETE FROM db_agent_groups WHERE id = ?1", params![id])?;
98        Ok(affected > 0)
99    }
100}
101
102fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AgentGroup> {
103    let member_ids_json: String = row.get(2)?;
104    let member_ids: Vec<String> = serde_json::from_str(&member_ids_json).unwrap_or_default();
105    Ok(AgentGroup {
106        id: row.get(0)?,
107        name: row.get(1)?,
108        member_ids,
109        created_at: row.get(3)?,
110    })
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn object_store() -> Store {
118        Store::open(std::path::Path::new(":memory:")).unwrap()
119    }
120
121    #[test]
122    fn create_and_list_roundtrips() {
123        let store = object_store();
124        store
125            .agent_group_create("g1", "backend", &["block-a".to_string(), "block-b".to_string()], 1000)
126            .unwrap();
127        let groups = store.agent_group_list().unwrap();
128        assert_eq!(groups.len(), 1);
129        assert_eq!(groups[0].id, "g1");
130        assert_eq!(groups[0].name, "backend");
131        assert_eq!(groups[0].member_ids, vec!["block-a".to_string(), "block-b".to_string()]);
132        assert_eq!(groups[0].created_at, 1000);
133    }
134
135    #[test]
136    fn get_returns_none_for_missing_id() {
137        let store = object_store();
138        assert!(store.agent_group_get("nope").unwrap().is_none());
139    }
140
141    #[test]
142    fn update_name_only_leaves_members_untouched() {
143        let store = object_store();
144        store.agent_group_create("g1", "old-name", &["block-a".to_string()], 1000).unwrap();
145        let updated = store.agent_group_update("g1", Some("new-name"), None).unwrap();
146        assert!(updated);
147        let g = store.agent_group_get("g1").unwrap().unwrap();
148        assert_eq!(g.name, "new-name");
149        assert_eq!(g.member_ids, vec!["block-a".to_string()]);
150    }
151
152    #[test]
153    fn update_members_only_leaves_name_untouched() {
154        let store = object_store();
155        store.agent_group_create("g1", "name", &["block-a".to_string()], 1000).unwrap();
156        let updated = store
157            .agent_group_update("g1", None, Some(&["block-b".to_string(), "block-c".to_string()]))
158            .unwrap();
159        assert!(updated);
160        let g = store.agent_group_get("g1").unwrap().unwrap();
161        assert_eq!(g.name, "name");
162        assert_eq!(g.member_ids, vec!["block-b".to_string(), "block-c".to_string()]);
163    }
164
165    #[test]
166    fn update_returns_false_for_missing_id() {
167        let store = object_store();
168        assert!(!store.agent_group_update("nope", Some("x"), None).unwrap());
169    }
170
171    #[test]
172    fn delete_removes_the_row_and_returns_true() {
173        let store = object_store();
174        store.agent_group_create("g1", "name", &[], 1000).unwrap();
175        assert!(store.agent_group_delete("g1").unwrap());
176        assert!(store.agent_group_get("g1").unwrap().is_none());
177    }
178
179    #[test]
180    fn delete_returns_false_for_missing_id() {
181        let store = object_store();
182        assert!(!store.agent_group_delete("nope").unwrap());
183    }
184
185    #[test]
186    fn list_orders_by_created_at() {
187        let store = object_store();
188        store.agent_group_create("g2", "second", &[], 2000).unwrap();
189        store.agent_group_create("g1", "first", &[], 1000).unwrap();
190        let groups = store.agent_group_list().unwrap();
191        assert_eq!(groups.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), vec!["g1", "g2"]);
192    }
193}