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