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    /// How this fleet handles risk-tiered approvals. Absent → auto: defer when
36    /// nothing can answer (no TTY), wait when a terminal is attached.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub hitl: Option<FleetHitl>,
39    /// External programs this artifact needs at runtime (portable-deps spec).
40    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub requires_programs: Vec<ProgramDep>,
43}
44
45/// Per-fleet approval policy. A floor, never a grant: every field here can only
46/// make an outcome stricter or change WHO waits — none of them approves
47/// anything, and there is deliberately no "auto-approve" knob (that is what
48/// `--yes` is, and it stays unreachable from unattended fleet paths).
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct FleetHitl {
51    /// What an unanswered Ask-tier gate does. Absent → auto: defer when no TTY
52    /// is attached, wait when one is. Set it explicitly when the TTY is a bad
53    /// proxy for "somebody is watching" — a monitored ops fleet wants `wait`
54    /// even headless; a fleet that must never reach for a human wants `deny`.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub mode: Option<crate::hitl::Unanswered>,
57    /// Risk tiers this fleet's owner has taken standing responsibility for:
58    /// the gate approves them without asking, and records the auto-approval on
59    /// the channel so the run is still auditable.
60    ///
61    /// An explicit LIST, not a ceiling — `RiskTier`'s ordering would make
62    /// `up_to: spend` quietly cover `network-egress` too, and a grant nobody
63    /// meant to write is the whole failure mode this feature has to avoid.
64    /// [`crate::hitl::tier_may_be_granted`] bounds what may appear here.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub auto_approve_tiers: Vec<crate::hitl::RiskTier>,
67}
68
69impl FleetHitl {
70    /// Reject a policy that grants more than config is allowed to grant.
71    ///
72    /// Loud at load time rather than silently ignored at the gate: a user who
73    /// wrote `destructive` here believes it took effect, and the gap between
74    /// that belief and the truth is where the damage lives.
75    pub fn validate(&self) -> Result<(), String> {
76        let bad: Vec<String> = self
77            .auto_approve_tiers
78            .iter()
79            .filter(|t| !crate::hitl::tier_may_be_granted(**t))
80            .map(|t| format!("{t:?}").to_lowercase())
81            .collect();
82        if bad.is_empty() {
83            return Ok(());
84        }
85        Err(format!(
86            "hitl.auto_approve_tiers may not include {} — standing approval is capped at `write`. \
87             Those actions have to be approved per-run (`mur channel approve`), because their cost \
88             cannot be undone by noticing afterwards.",
89            bad.join(", ")
90        ))
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct FleetLoop {
96    #[serde(default = "default_trigger")]
97    pub trigger: String,
98    // default 0 → resolvers fall back (cap → DEFAULT_MAX_ITERATIONS, budget → no cap),
99    // so a minimal `loop:` block (e.g. just `trigger:`) deserializes.
100    #[serde(default)]
101    pub max_iterations: u32,
102    #[serde(default)]
103    pub budget_usd: f64,
104    #[serde(default)]
105    pub deadline: String,
106    #[serde(default)]
107    pub done_when: String,
108}
109
110fn default_trigger() -> String {
111    "manual".to_string()
112}
113
114impl Fleet {
115    pub fn router_or_concierge(&self) -> &str {
116        self.router.as_deref().unwrap_or(CONCIERGE_AGENT)
117    }
118}
119
120/// A fleet name must be a filesystem-safe lowercase slug (it becomes a directory
121/// `~/.mur/fleets/<name>` and a channel id `fleet-<name>`).
122pub fn valid_fleet_name(name: &str) -> bool {
123    !name.is_empty()
124        && name.len() <= 64
125        && name
126            .chars()
127            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
128}
129
130/// Channel-id prefix for a fleet's shared channel (`fleet-<name>`).
131pub const CHANNEL_PREFIX: &str = "fleet-";
132
133/// Derive the fleet name from a channel id of the form `fleet-<name>`.
134///
135/// Returns `None` for non-fleet channels, and also for a `fleet-`-prefixed id
136/// whose remainder isn't a valid fleet name — so a crafted channel id can't
137/// smuggle a path-traversal segment or otherwise masquerade as a fleet to pull
138/// in fleet-scoped skills.
139pub fn fleet_name_from_channel_id(channel_id: &str) -> Option<&str> {
140    let name = channel_id.strip_prefix(CHANNEL_PREFIX)?;
141    valid_fleet_name(name).then_some(name)
142}
143
144/// Job status lifecycle: queued → running → {done, failed, canceled}, with
145/// `blocked` as a non-terminal detour: the run reached an action that needs a
146/// human and stopped there. An approval resumes it, so it is deliberately NOT
147/// terminal — reporting it as done would claim work nobody did, and reporting
148/// it as failed would claim a fault that did not occur.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum JobStatus {
152    Queued,
153    Running,
154    Blocked,
155    Done,
156    Failed,
157    Canceled,
158}
159
160impl JobStatus {
161    /// Returns true if the job has reached a terminal state.
162    pub fn is_terminal(&self) -> bool {
163        matches!(
164            self,
165            JobStatus::Done | JobStatus::Failed | JobStatus::Canceled
166        )
167    }
168
169    /// Lowercase name — matches the serde representation and the A2A `TaskState`
170    /// mapping. Use this for display instead of `{:?}`/`Debug`, which is not a
171    /// stable display contract.
172    pub fn as_str(&self) -> &'static str {
173        match self {
174            JobStatus::Queued => "queued",
175            JobStatus::Running => "running",
176            JobStatus::Blocked => "blocked",
177            JobStatus::Done => "done",
178            JobStatus::Failed => "failed",
179            JobStatus::Canceled => "canceled",
180        }
181    }
182}
183
184impl std::fmt::Display for JobStatus {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(self.as_str())
187    }
188}
189
190/// A job represents a unit of work submitted to a fleet for execution.
191/// The `id` is a UUIDv7 — time-sortable, so FIFO ordering is just a filename sort.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct Job {
194    pub id: String,
195    pub text: String,
196    /// "cli" | "a2a:<agent-id>" (a2a follow-on).
197    pub source: String,
198    pub status: JobStatus,
199    /// RFC3339 timestamps.
200    pub created_at: String,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub started_at: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub finished_at: Option<String>,
205    /// Channel run executed job (results live there).
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub run_id: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub result: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub error: Option<String>,
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn valid_fleet_name_accepts_and_rejects() {
220        // accepted
221        assert!(valid_fleet_name("dev"));
222        assert!(valid_fleet_name("dev-team"));
223        assert!(valid_fleet_name("dev_1"));
224        assert!(valid_fleet_name("ab12"));
225        // rejected
226        assert!(!valid_fleet_name("")); // empty
227        assert!(!valid_fleet_name("../x")); // path traversal
228        assert!(!valid_fleet_name("a/b")); // slash
229        assert!(!valid_fleet_name("a\\b")); // backslash
230        assert!(!valid_fleet_name("Dev")); // uppercase
231        assert!(!valid_fleet_name("a b")); // space
232        assert!(!valid_fleet_name(".hidden")); // dot
233    }
234
235    #[test]
236    fn fleet_name_from_channel_id_extracts_and_validates() {
237        // valid fleet channels
238        assert_eq!(fleet_name_from_channel_id("fleet-dev"), Some("dev"));
239        assert_eq!(
240            fleet_name_from_channel_id("fleet-my-squad"),
241            Some("my-squad")
242        );
243        assert_eq!(fleet_name_from_channel_id("fleet-ab12"), Some("ab12"));
244        // not a fleet channel
245        assert_eq!(fleet_name_from_channel_id("dev"), None);
246        assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
247        // prefixed but invalid remainder → rejected (no masquerading)
248        assert_eq!(fleet_name_from_channel_id("fleet-"), None); // empty
249        assert_eq!(fleet_name_from_channel_id("fleet-../etc"), None); // traversal
250        assert_eq!(fleet_name_from_channel_id("fleet-a/b"), None); // slash
251        assert_eq!(fleet_name_from_channel_id("fleet-Dev"), None); // uppercase
252    }
253
254    #[test]
255    fn fleet_minimal_yaml_deserializes_with_defaults() {
256        let f: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
257        assert_eq!(f.name, "dev");
258        assert_eq!(f.channel_id, "fleet-dev");
259        assert!(f.members.is_empty());
260        assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
261        assert!(f.loop_cfg.is_none());
262    }
263
264    /// `hitl.mode` must survive a round-trip and stay absent when unset — an
265    /// absent policy means "use the TTY-derived default", which is a different
266    /// statement from any of the three explicit modes.
267    #[test]
268    fn fleet_hitl_mode_parses_and_defaults_to_absent() {
269        let bare: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
270        assert!(bare.hitl.is_none(), "no policy stated ⇒ auto");
271
272        let declared: Fleet =
273            serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\nhitl:\n  mode: deny\n")
274                .unwrap();
275        assert_eq!(
276            declared.hitl.as_ref().and_then(|h| h.mode),
277            Some(crate::hitl::Unanswered::Deny)
278        );
279
280        for (yaml, want) in [
281            ("defer", crate::hitl::Unanswered::Defer),
282            ("wait", crate::hitl::Unanswered::Wait),
283            ("deny", crate::hitl::Unanswered::Deny),
284        ] {
285            let f: Fleet = serde_yaml::from_str(&format!(
286                "name: dev\nchannel_id: fleet-dev\nhitl:\n  mode: {yaml}\n"
287            ))
288            .unwrap();
289            assert_eq!(f.hitl.and_then(|h| h.mode), Some(want));
290        }
291
292        // An unknown mode is a typo, not a silent fallback to something looser.
293        assert!(
294            serde_yaml::from_str::<Fleet>(
295                "name: dev\nchannel_id: fleet-dev\nhitl:\n  mode: yolo\n"
296            )
297            .is_err()
298        );
299    }
300
301    /// `auto_approve_tiers` parses; `validate` accepts write-and-below and
302    /// refuses everything above — loudly, because a user who wrote
303    /// `destructive` believes it took effect, and the gap between that belief
304    /// and the truth is where the damage lives.
305    #[test]
306    fn auto_approve_tiers_parse_and_validate_against_the_ceiling() {
307        let f: Fleet = serde_yaml::from_str(
308            "name: dev\nchannel_id: fleet-dev\nhitl:\n  auto_approve_tiers: [read, write]\n",
309        )
310        .unwrap();
311        let hitl = f.hitl.as_ref().unwrap();
312        assert_eq!(
313            hitl.auto_approve_tiers,
314            vec![crate::hitl::RiskTier::Read, crate::hitl::RiskTier::Write]
315        );
316        assert!(hitl.validate().is_ok());
317
318        let f: Fleet = serde_yaml::from_str(
319            "name: dev\nchannel_id: fleet-dev\nhitl:\n  auto_approve_tiers: [write, destructive]\n",
320        )
321        .unwrap();
322        let err = f.hitl.as_ref().unwrap().validate().unwrap_err();
323        assert!(err.contains("destructive"), "{err}");
324        assert!(err.contains("write"), "the ceiling must be named: {err}");
325    }
326
327    #[test]
328    fn fleet_yaml_roundtrip_and_router_default() {
329        let f = Fleet {
330            name: "dev".into(),
331            display_name: "Dev Team".into(),
332            goal: "ship it".into(),
333            router: None,
334            team_id: None,
335            members: vec!["pm".into(), "qa".into()],
336            channel_id: "fleet-dev".into(),
337            rules: vec![],
338            skills: vec![],
339            loop_cfg: None,
340            parallel: None,
341            hitl: None,
342            requires_programs: vec![],
343        };
344        assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
345        let yaml = serde_yaml::to_string(&f).unwrap();
346        let back: Fleet = serde_yaml::from_str(&yaml).unwrap();
347        assert_eq!(back, f);
348        // `loop:` key (not `loop_cfg`) when present
349        let with_loop: Fleet = serde_yaml::from_str(
350            "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",
351        ).unwrap();
352        assert_eq!(with_loop.loop_cfg.unwrap().max_iterations, 3);
353    }
354
355    #[test]
356    fn minimal_loop_block_deserializes_with_defaults() {
357        // A `loop:` block with only a trigger must not fail (max_iterations /
358        // budget_usd default to 0 → resolvers fall back).
359        let f: Fleet = serde_yaml::from_str(
360            "name: dev\nchannel_id: fleet-dev\nloop:\n  trigger: \"interval:1h\"\n",
361        )
362        .unwrap();
363        let l = f.loop_cfg.unwrap();
364        assert_eq!(l.trigger, "interval:1h");
365        assert_eq!(l.max_iterations, 0);
366        assert_eq!(l.budget_usd, 0.0);
367    }
368
369    #[test]
370    fn job_status_serde_is_lowercase_and_terminal_predicate() {
371        assert_eq!(
372            serde_yaml::to_string(&JobStatus::Queued).unwrap().trim(),
373            "queued"
374        );
375        assert_eq!(
376            serde_yaml::to_string(&JobStatus::Done).unwrap().trim(),
377            "done"
378        );
379        assert!(!JobStatus::Queued.is_terminal());
380        assert!(!JobStatus::Running.is_terminal());
381        assert!(JobStatus::Done.is_terminal());
382        assert!(JobStatus::Failed.is_terminal());
383        assert!(JobStatus::Canceled.is_terminal());
384    }
385
386    #[test]
387    fn job_status_as_str_and_display_match_serde_for_all_variants() {
388        for s in [
389            JobStatus::Queued,
390            JobStatus::Running,
391            JobStatus::Done,
392            JobStatus::Failed,
393            JobStatus::Canceled,
394        ] {
395            let serde = serde_yaml::to_string(&s).unwrap();
396            assert_eq!(
397                serde.trim(),
398                s.as_str(),
399                "as_str must match serde for {s:?}"
400            );
401            assert_eq!(s.to_string(), s.as_str(), "Display must delegate to as_str");
402        }
403    }
404
405    #[test]
406    fn job_yaml_roundtrip_with_optional_fields_skipped() {
407        let j = Job {
408            id: "0190f3a2-0000-7000-8000-000000000000".into(),
409            text: "ship it".into(),
410            source: "cli".into(),
411            status: JobStatus::Queued,
412            created_at: "2026-06-24T00:00:00Z".into(),
413            started_at: None,
414            finished_at: None,
415            run_id: None,
416            result: None,
417            error: None,
418        };
419        let yaml = serde_yaml::to_string(&j).unwrap();
420        assert!(
421            !yaml.contains("started_at"),
422            "None optionals must be skipped: {yaml}"
423        );
424        let back: Job = serde_yaml::from_str(&yaml).unwrap();
425        assert_eq!(back, j);
426    }
427}