Skip to main content

mur_common/
fleet.rs

1//! Fleet — a named squad of agents working a shared goal over one channel.
2
3use serde::{Deserialize, Serialize};
4
5pub const CONCIERGE_AGENT: &str = "mur";
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub struct Fleet {
9    pub name: String,
10    #[serde(default)]
11    pub display_name: String,
12    #[serde(default)]
13    pub goal: String,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub router: Option<String>,
16    #[serde(default)]
17    pub members: Vec<String>,
18    pub channel_id: String,
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub rules: Vec<String>,
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub skills: Vec<String>,
23    #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
24    pub loop_cfg: Option<FleetLoop>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct FleetLoop {
29    #[serde(default = "default_trigger")]
30    pub trigger: String,
31    // default 0 → resolvers fall back (cap → DEFAULT_MAX_ITERATIONS, budget → no cap),
32    // so a minimal `loop:` block (e.g. just `trigger:`) deserializes.
33    #[serde(default)]
34    pub max_iterations: u32,
35    #[serde(default)]
36    pub budget_usd: f64,
37    #[serde(default)]
38    pub deadline: String,
39    #[serde(default)]
40    pub done_when: String,
41}
42
43fn default_trigger() -> String {
44    "manual".to_string()
45}
46
47impl Fleet {
48    pub fn router_or_concierge(&self) -> &str {
49        self.router.as_deref().unwrap_or(CONCIERGE_AGENT)
50    }
51}
52
53/// A fleet name must be a filesystem-safe lowercase slug (it becomes a directory
54/// `~/.mur/fleets/<name>` and a channel id `fleet-<name>`).
55pub fn valid_fleet_name(name: &str) -> bool {
56    !name.is_empty()
57        && name.len() <= 64
58        && name
59            .chars()
60            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
61}
62
63/// Channel-id prefix for a fleet's shared channel (`fleet-<name>`).
64pub const CHANNEL_PREFIX: &str = "fleet-";
65
66/// Derive the fleet name from a channel id of the form `fleet-<name>`.
67///
68/// Returns `None` for non-fleet channels, and also for a `fleet-`-prefixed id
69/// whose remainder isn't a valid fleet name — so a crafted channel id can't
70/// smuggle a path-traversal segment or otherwise masquerade as a fleet to pull
71/// in fleet-scoped skills.
72pub fn fleet_name_from_channel_id(channel_id: &str) -> Option<&str> {
73    let name = channel_id.strip_prefix(CHANNEL_PREFIX)?;
74    valid_fleet_name(name).then_some(name)
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn valid_fleet_name_accepts_and_rejects() {
83        // accepted
84        assert!(valid_fleet_name("dev"));
85        assert!(valid_fleet_name("dev-team"));
86        assert!(valid_fleet_name("dev_1"));
87        assert!(valid_fleet_name("ab12"));
88        // rejected
89        assert!(!valid_fleet_name("")); // empty
90        assert!(!valid_fleet_name("../x")); // path traversal
91        assert!(!valid_fleet_name("a/b")); // slash
92        assert!(!valid_fleet_name("a\\b")); // backslash
93        assert!(!valid_fleet_name("Dev")); // uppercase
94        assert!(!valid_fleet_name("a b")); // space
95        assert!(!valid_fleet_name(".hidden")); // dot
96    }
97
98    #[test]
99    fn fleet_name_from_channel_id_extracts_and_validates() {
100        // valid fleet channels
101        assert_eq!(fleet_name_from_channel_id("fleet-dev"), Some("dev"));
102        assert_eq!(
103            fleet_name_from_channel_id("fleet-my-squad"),
104            Some("my-squad")
105        );
106        assert_eq!(fleet_name_from_channel_id("fleet-ab12"), Some("ab12"));
107        // not a fleet channel
108        assert_eq!(fleet_name_from_channel_id("dev"), None);
109        assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
110        // prefixed but invalid remainder → rejected (no masquerading)
111        assert_eq!(fleet_name_from_channel_id("fleet-"), None); // empty
112        assert_eq!(fleet_name_from_channel_id("fleet-../etc"), None); // traversal
113        assert_eq!(fleet_name_from_channel_id("fleet-a/b"), None); // slash
114        assert_eq!(fleet_name_from_channel_id("fleet-Dev"), None); // uppercase
115    }
116
117    #[test]
118    fn fleet_minimal_yaml_deserializes_with_defaults() {
119        let f: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
120        assert_eq!(f.name, "dev");
121        assert_eq!(f.channel_id, "fleet-dev");
122        assert!(f.members.is_empty());
123        assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
124        assert!(f.loop_cfg.is_none());
125    }
126
127    #[test]
128    fn fleet_yaml_roundtrip_and_router_default() {
129        let f = Fleet {
130            name: "dev".into(),
131            display_name: "Dev Team".into(),
132            goal: "ship it".into(),
133            router: None,
134            members: vec!["pm".into(), "qa".into()],
135            channel_id: "fleet-dev".into(),
136            rules: vec![],
137            skills: vec![],
138            loop_cfg: None,
139        };
140        assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
141        let yaml = serde_yaml::to_string(&f).unwrap();
142        let back: Fleet = serde_yaml::from_str(&yaml).unwrap();
143        assert_eq!(back, f);
144        // `loop:` key (not `loop_cfg`) when present
145        let with_loop: Fleet = serde_yaml::from_str(
146            "name: dev\ndisplay_name: Dev\ngoal: test\nchannel_id: fleet-dev\nrules: []\nskills: []\nmembers: []\nloop:\n  trigger: manual\n  max_iterations: 3\n  budget_usd: 1.0\n  deadline: '2026-12-31'\n  done_when: 'all_tasks_done'\n",
147        ).unwrap();
148        assert_eq!(with_loop.loop_cfg.unwrap().max_iterations, 3);
149    }
150
151    #[test]
152    fn minimal_loop_block_deserializes_with_defaults() {
153        // A `loop:` block with only a trigger must not fail (max_iterations /
154        // budget_usd default to 0 → resolvers fall back).
155        let f: Fleet = serde_yaml::from_str(
156            "name: dev\nchannel_id: fleet-dev\nloop:\n  trigger: \"interval:1h\"\n",
157        )
158        .unwrap();
159        let l = f.loop_cfg.unwrap();
160        assert_eq!(l.trigger, "interval:1h");
161        assert_eq!(l.max_iterations, 0);
162        assert_eq!(l.budget_usd, 0.0);
163    }
164}