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    /// Team identifier for this fleet; set when the fleet is affiliated with a
17    /// MUR Server team. The fleet runner sets MUR_ACTIVE_TEAM from this value
18    /// before each member turn so team-scoped skills inject correctly.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub team_id: Option<String>,
21    #[serde(default)]
22    pub members: Vec<String>,
23    pub channel_id: String,
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub rules: Vec<String>,
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub skills: Vec<String>,
28    #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
29    pub loop_cfg: Option<FleetLoop>,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct FleetLoop {
34    #[serde(default = "default_trigger")]
35    pub trigger: String,
36    // default 0 → resolvers fall back (cap → DEFAULT_MAX_ITERATIONS, budget → no cap),
37    // so a minimal `loop:` block (e.g. just `trigger:`) deserializes.
38    #[serde(default)]
39    pub max_iterations: u32,
40    #[serde(default)]
41    pub budget_usd: f64,
42    #[serde(default)]
43    pub deadline: String,
44    #[serde(default)]
45    pub done_when: String,
46}
47
48fn default_trigger() -> String {
49    "manual".to_string()
50}
51
52impl Fleet {
53    pub fn router_or_concierge(&self) -> &str {
54        self.router.as_deref().unwrap_or(CONCIERGE_AGENT)
55    }
56}
57
58/// A fleet name must be a filesystem-safe lowercase slug (it becomes a directory
59/// `~/.mur/fleets/<name>` and a channel id `fleet-<name>`).
60pub fn valid_fleet_name(name: &str) -> bool {
61    !name.is_empty()
62        && name.len() <= 64
63        && name
64            .chars()
65            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
66}
67
68/// Channel-id prefix for a fleet's shared channel (`fleet-<name>`).
69pub const CHANNEL_PREFIX: &str = "fleet-";
70
71/// Derive the fleet name from a channel id of the form `fleet-<name>`.
72///
73/// Returns `None` for non-fleet channels, and also for a `fleet-`-prefixed id
74/// whose remainder isn't a valid fleet name — so a crafted channel id can't
75/// smuggle a path-traversal segment or otherwise masquerade as a fleet to pull
76/// in fleet-scoped skills.
77pub fn fleet_name_from_channel_id(channel_id: &str) -> Option<&str> {
78    let name = channel_id.strip_prefix(CHANNEL_PREFIX)?;
79    valid_fleet_name(name).then_some(name)
80}
81
82/// Job status lifecycle: queued → running → {done, failed, canceled}.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum JobStatus {
86    Queued,
87    Running,
88    Done,
89    Failed,
90    Canceled,
91}
92
93impl JobStatus {
94    /// Returns true if the job has reached a terminal state.
95    pub fn is_terminal(&self) -> bool {
96        matches!(
97            self,
98            JobStatus::Done | JobStatus::Failed | JobStatus::Canceled
99        )
100    }
101
102    /// Lowercase name — matches the serde representation and the A2A `TaskState`
103    /// mapping. Use this for display instead of `{:?}`/`Debug`, which is not a
104    /// stable display contract.
105    pub fn as_str(&self) -> &'static str {
106        match self {
107            JobStatus::Queued => "queued",
108            JobStatus::Running => "running",
109            JobStatus::Done => "done",
110            JobStatus::Failed => "failed",
111            JobStatus::Canceled => "canceled",
112        }
113    }
114}
115
116impl std::fmt::Display for JobStatus {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.write_str(self.as_str())
119    }
120}
121
122/// A job represents a unit of work submitted to a fleet for execution.
123/// The `id` is a UUIDv7 — time-sortable, so FIFO ordering is just a filename sort.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct Job {
126    pub id: String,
127    pub text: String,
128    /// "cli" | "a2a:<agent-id>" (a2a follow-on).
129    pub source: String,
130    pub status: JobStatus,
131    /// RFC3339 timestamps.
132    pub created_at: String,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub started_at: Option<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub finished_at: Option<String>,
137    /// Channel run executed job (results live there).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub run_id: Option<String>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub result: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub error: Option<String>,
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn valid_fleet_name_accepts_and_rejects() {
152        // accepted
153        assert!(valid_fleet_name("dev"));
154        assert!(valid_fleet_name("dev-team"));
155        assert!(valid_fleet_name("dev_1"));
156        assert!(valid_fleet_name("ab12"));
157        // rejected
158        assert!(!valid_fleet_name("")); // empty
159        assert!(!valid_fleet_name("../x")); // path traversal
160        assert!(!valid_fleet_name("a/b")); // slash
161        assert!(!valid_fleet_name("a\\b")); // backslash
162        assert!(!valid_fleet_name("Dev")); // uppercase
163        assert!(!valid_fleet_name("a b")); // space
164        assert!(!valid_fleet_name(".hidden")); // dot
165    }
166
167    #[test]
168    fn fleet_name_from_channel_id_extracts_and_validates() {
169        // valid fleet channels
170        assert_eq!(fleet_name_from_channel_id("fleet-dev"), Some("dev"));
171        assert_eq!(
172            fleet_name_from_channel_id("fleet-my-squad"),
173            Some("my-squad")
174        );
175        assert_eq!(fleet_name_from_channel_id("fleet-ab12"), Some("ab12"));
176        // not a fleet channel
177        assert_eq!(fleet_name_from_channel_id("dev"), None);
178        assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
179        // prefixed but invalid remainder → rejected (no masquerading)
180        assert_eq!(fleet_name_from_channel_id("fleet-"), None); // empty
181        assert_eq!(fleet_name_from_channel_id("fleet-../etc"), None); // traversal
182        assert_eq!(fleet_name_from_channel_id("fleet-a/b"), None); // slash
183        assert_eq!(fleet_name_from_channel_id("fleet-Dev"), None); // uppercase
184    }
185
186    #[test]
187    fn fleet_minimal_yaml_deserializes_with_defaults() {
188        let f: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
189        assert_eq!(f.name, "dev");
190        assert_eq!(f.channel_id, "fleet-dev");
191        assert!(f.members.is_empty());
192        assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
193        assert!(f.loop_cfg.is_none());
194    }
195
196    #[test]
197    fn fleet_yaml_roundtrip_and_router_default() {
198        let f = Fleet {
199            name: "dev".into(),
200            display_name: "Dev Team".into(),
201            goal: "ship it".into(),
202            router: None,
203            team_id: None,
204            members: vec!["pm".into(), "qa".into()],
205            channel_id: "fleet-dev".into(),
206            rules: vec![],
207            skills: vec![],
208            loop_cfg: None,
209        };
210        assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
211        let yaml = serde_yaml::to_string(&f).unwrap();
212        let back: Fleet = serde_yaml::from_str(&yaml).unwrap();
213        assert_eq!(back, f);
214        // `loop:` key (not `loop_cfg`) when present
215        let with_loop: Fleet = serde_yaml::from_str(
216            "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",
217        ).unwrap();
218        assert_eq!(with_loop.loop_cfg.unwrap().max_iterations, 3);
219    }
220
221    #[test]
222    fn minimal_loop_block_deserializes_with_defaults() {
223        // A `loop:` block with only a trigger must not fail (max_iterations /
224        // budget_usd default to 0 → resolvers fall back).
225        let f: Fleet = serde_yaml::from_str(
226            "name: dev\nchannel_id: fleet-dev\nloop:\n  trigger: \"interval:1h\"\n",
227        )
228        .unwrap();
229        let l = f.loop_cfg.unwrap();
230        assert_eq!(l.trigger, "interval:1h");
231        assert_eq!(l.max_iterations, 0);
232        assert_eq!(l.budget_usd, 0.0);
233    }
234
235    #[test]
236    fn job_status_serde_is_lowercase_and_terminal_predicate() {
237        assert_eq!(
238            serde_yaml::to_string(&JobStatus::Queued).unwrap().trim(),
239            "queued"
240        );
241        assert_eq!(
242            serde_yaml::to_string(&JobStatus::Done).unwrap().trim(),
243            "done"
244        );
245        assert!(!JobStatus::Queued.is_terminal());
246        assert!(!JobStatus::Running.is_terminal());
247        assert!(JobStatus::Done.is_terminal());
248        assert!(JobStatus::Failed.is_terminal());
249        assert!(JobStatus::Canceled.is_terminal());
250    }
251
252    #[test]
253    fn job_status_as_str_and_display_match_serde_for_all_variants() {
254        for s in [
255            JobStatus::Queued,
256            JobStatus::Running,
257            JobStatus::Done,
258            JobStatus::Failed,
259            JobStatus::Canceled,
260        ] {
261            let serde = serde_yaml::to_string(&s).unwrap();
262            assert_eq!(
263                serde.trim(),
264                s.as_str(),
265                "as_str must match serde for {s:?}"
266            );
267            assert_eq!(s.to_string(), s.as_str(), "Display must delegate to as_str");
268        }
269    }
270
271    #[test]
272    fn job_yaml_roundtrip_with_optional_fields_skipped() {
273        let j = Job {
274            id: "0190f3a2-0000-7000-8000-000000000000".into(),
275            text: "ship it".into(),
276            source: "cli".into(),
277            status: JobStatus::Queued,
278            created_at: "2026-06-24T00:00:00Z".into(),
279            started_at: None,
280            finished_at: None,
281            run_id: None,
282            result: None,
283            error: None,
284        };
285        let yaml = serde_yaml::to_string(&j).unwrap();
286        assert!(
287            !yaml.contains("started_at"),
288            "None optionals must be skipped: {yaml}"
289        );
290        let back: Job = serde_yaml::from_str(&yaml).unwrap();
291        assert_eq!(back, j);
292    }
293}