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