1use serde::{Deserialize, Serialize};
4
5use crate::deps::ProgramDep;
6use crate::parallel::ParallelConfig;
7
8pub const CONCIERGE_AGENT: &str = "mur";
9
10pub const RUN_ID_ENV: &str = "MUR_RUN_ID";
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct Fleet {
18 pub name: String,
19 #[serde(default)]
20 pub display_name: String,
21 #[serde(default)]
22 pub goal: String,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub router: Option<String>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub team_id: Option<String>,
30 #[serde(default)]
31 pub members: Vec<String>,
32 pub channel_id: String,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
34 pub rules: Vec<String>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub skills: Vec<String>,
37 #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
38 pub loop_cfg: Option<FleetLoop>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub parallel: Option<ParallelConfig>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub hitl: Option<FleetHitl>,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub requires_programs: Vec<ProgramDep>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub limits: Option<crate::limits::Limits>,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub needs: Vec<String>,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct FleetHitl {
69 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub mode: Option<crate::hitl::Unanswered>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub auto_approve_tiers: Vec<crate::hitl::RiskTier>,
85}
86
87impl FleetHitl {
88 pub fn validate(&self) -> Result<(), String> {
94 let bad: Vec<String> = self
95 .auto_approve_tiers
96 .iter()
97 .filter(|t| !crate::hitl::tier_may_be_granted(**t))
98 .map(|t| format!("{t:?}").to_lowercase())
99 .collect();
100 if bad.is_empty() {
101 return Ok(());
102 }
103 Err(format!(
104 "hitl.auto_approve_tiers may not include {} — standing approval is capped at `write`. \
105 Those actions have to be approved per-run (`mur channel approve`), because their cost \
106 cannot be undone by noticing afterwards.",
107 bad.join(", ")
108 ))
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct FleetLoop {
114 #[serde(default = "default_trigger")]
115 pub trigger: String,
116 #[serde(default)]
119 pub max_iterations: u32,
120 #[serde(default)]
121 pub budget_usd: f64,
122 #[serde(default)]
123 pub deadline: String,
124 #[serde(default)]
125 pub done_when: String,
126}
127
128fn default_trigger() -> String {
129 "manual".to_string()
130}
131
132impl Fleet {
133 pub fn limits_or_legacy(&self) -> Option<crate::limits::Limits> {
138 if let Some(l) = &self.limits {
139 return Some(l.clone());
140 }
141 let lc = self.loop_cfg.as_ref()?;
142 let deadline = (!lc.deadline.trim().is_empty()).then(|| lc.deadline.trim().to_string());
143 let cost_usd = (lc.budget_usd > 0.0).then_some(lc.budget_usd);
144 if deadline.is_none() && cost_usd.is_none() {
145 return None;
146 }
147 Some(crate::limits::Limits {
148 deadline,
149 stuck: None,
150 cost_usd,
151 })
152 }
153
154 pub fn router_or_concierge(&self) -> &str {
155 self.router.as_deref().unwrap_or(CONCIERGE_AGENT)
156 }
157}
158
159pub fn valid_fleet_name(name: &str) -> bool {
162 !name.is_empty()
163 && name.len() <= 64
164 && name
165 .chars()
166 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
167}
168
169pub const CHANNEL_PREFIX: &str = "fleet-";
171
172pub fn fleet_name_from_channel_id(channel_id: &str) -> Option<&str> {
179 let name = channel_id.strip_prefix(CHANNEL_PREFIX)?;
180 valid_fleet_name(name).then_some(name)
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "lowercase")]
190pub enum JobStatus {
191 Queued,
192 Running,
193 Blocked,
194 Done,
195 Failed,
196 Canceled,
197}
198
199impl JobStatus {
200 pub fn is_terminal(&self) -> bool {
202 matches!(
203 self,
204 JobStatus::Done | JobStatus::Failed | JobStatus::Canceled
205 )
206 }
207
208 pub fn as_str(&self) -> &'static str {
212 match self {
213 JobStatus::Queued => "queued",
214 JobStatus::Running => "running",
215 JobStatus::Blocked => "blocked",
216 JobStatus::Done => "done",
217 JobStatus::Failed => "failed",
218 JobStatus::Canceled => "canceled",
219 }
220 }
221}
222
223impl std::fmt::Display for JobStatus {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 f.write_str(self.as_str())
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct Job {
233 pub id: String,
234 pub text: String,
235 pub source: String,
237 pub status: JobStatus,
238 pub created_at: String,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub started_at: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub finished_at: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub run_id: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub result: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub error: Option<String>,
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn env_name_is_stable() {
259 assert_eq!(RUN_ID_ENV, "MUR_RUN_ID");
263 }
264
265 #[test]
266 fn valid_fleet_name_accepts_and_rejects() {
267 assert!(valid_fleet_name("dev"));
269 assert!(valid_fleet_name("dev-team"));
270 assert!(valid_fleet_name("dev_1"));
271 assert!(valid_fleet_name("ab12"));
272 assert!(!valid_fleet_name("")); assert!(!valid_fleet_name("../x")); assert!(!valid_fleet_name("a/b")); assert!(!valid_fleet_name("a\\b")); assert!(!valid_fleet_name("Dev")); assert!(!valid_fleet_name("a b")); assert!(!valid_fleet_name(".hidden")); }
281
282 #[test]
283 fn fleet_name_from_channel_id_extracts_and_validates() {
284 assert_eq!(fleet_name_from_channel_id("fleet-dev"), Some("dev"));
286 assert_eq!(
287 fleet_name_from_channel_id("fleet-my-squad"),
288 Some("my-squad")
289 );
290 assert_eq!(fleet_name_from_channel_id("fleet-ab12"), Some("ab12"));
291 assert_eq!(fleet_name_from_channel_id("dev"), None);
293 assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
294 assert_eq!(fleet_name_from_channel_id("fleet-"), None); assert_eq!(fleet_name_from_channel_id("fleet-../etc"), None); assert_eq!(fleet_name_from_channel_id("fleet-a/b"), None); assert_eq!(fleet_name_from_channel_id("fleet-Dev"), None); }
300
301 #[test]
302 fn fleet_minimal_yaml_deserializes_with_defaults() {
303 let f: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
304 assert_eq!(f.name, "dev");
305 assert_eq!(f.channel_id, "fleet-dev");
306 assert!(f.members.is_empty());
307 assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
308 assert!(f.loop_cfg.is_none());
309 }
310
311 #[test]
315 fn fleet_hitl_mode_parses_and_defaults_to_absent() {
316 let bare: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
317 assert!(bare.hitl.is_none(), "no policy stated ⇒ auto");
318
319 let declared: Fleet =
320 serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\nhitl:\n mode: deny\n")
321 .unwrap();
322 assert_eq!(
323 declared.hitl.as_ref().and_then(|h| h.mode),
324 Some(crate::hitl::Unanswered::Deny)
325 );
326
327 for (yaml, want) in [
328 ("defer", crate::hitl::Unanswered::Defer),
329 ("wait", crate::hitl::Unanswered::Wait),
330 ("deny", crate::hitl::Unanswered::Deny),
331 ] {
332 let f: Fleet = serde_yaml::from_str(&format!(
333 "name: dev\nchannel_id: fleet-dev\nhitl:\n mode: {yaml}\n"
334 ))
335 .unwrap();
336 assert_eq!(f.hitl.and_then(|h| h.mode), Some(want));
337 }
338
339 assert!(
341 serde_yaml::from_str::<Fleet>(
342 "name: dev\nchannel_id: fleet-dev\nhitl:\n mode: yolo\n"
343 )
344 .is_err()
345 );
346 }
347
348 #[test]
353 fn auto_approve_tiers_parse_and_validate_against_the_ceiling() {
354 let f: Fleet = serde_yaml::from_str(
355 "name: dev\nchannel_id: fleet-dev\nhitl:\n auto_approve_tiers: [read, write]\n",
356 )
357 .unwrap();
358 let hitl = f.hitl.as_ref().unwrap();
359 assert_eq!(
360 hitl.auto_approve_tiers,
361 vec![crate::hitl::RiskTier::Read, crate::hitl::RiskTier::Write]
362 );
363 assert!(hitl.validate().is_ok());
364
365 let f: Fleet = serde_yaml::from_str(
366 "name: dev\nchannel_id: fleet-dev\nhitl:\n auto_approve_tiers: [write, destructive]\n",
367 )
368 .unwrap();
369 let err = f.hitl.as_ref().unwrap().validate().unwrap_err();
370 assert!(err.contains("destructive"), "{err}");
371 assert!(err.contains("write"), "the ceiling must be named: {err}");
372 }
373
374 #[test]
375 fn fleet_yaml_roundtrip_and_router_default() {
376 let f = Fleet {
377 name: "dev".into(),
378 display_name: "Dev Team".into(),
379 goal: "ship it".into(),
380 router: None,
381 team_id: None,
382 members: vec!["pm".into(), "qa".into()],
383 channel_id: "fleet-dev".into(),
384 rules: vec![],
385 skills: vec![],
386 loop_cfg: None,
387 parallel: None,
388 hitl: None,
389 requires_programs: vec![],
390 limits: None,
391 needs: vec![],
392 };
393 assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
394 let yaml = serde_yaml::to_string(&f).unwrap();
395 let back: Fleet = serde_yaml::from_str(&yaml).unwrap();
396 assert_eq!(back, f);
397 let with_loop: Fleet = serde_yaml::from_str(
399 "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",
400 ).unwrap();
401 assert_eq!(with_loop.loop_cfg.unwrap().max_iterations, 3);
402 }
403
404 #[test]
405 fn minimal_loop_block_deserializes_with_defaults() {
406 let f: Fleet = serde_yaml::from_str(
409 "name: dev\nchannel_id: fleet-dev\nloop:\n trigger: \"interval:1h\"\n",
410 )
411 .unwrap();
412 let l = f.loop_cfg.unwrap();
413 assert_eq!(l.trigger, "interval:1h");
414 assert_eq!(l.max_iterations, 0);
415 assert_eq!(l.budget_usd, 0.0);
416 }
417
418 #[test]
419 fn job_status_serde_is_lowercase_and_terminal_predicate() {
420 assert_eq!(
421 serde_yaml::to_string(&JobStatus::Queued).unwrap().trim(),
422 "queued"
423 );
424 assert_eq!(
425 serde_yaml::to_string(&JobStatus::Done).unwrap().trim(),
426 "done"
427 );
428 assert!(!JobStatus::Queued.is_terminal());
429 assert!(!JobStatus::Running.is_terminal());
430 assert!(JobStatus::Done.is_terminal());
431 assert!(JobStatus::Failed.is_terminal());
432 assert!(JobStatus::Canceled.is_terminal());
433 }
434
435 #[test]
436 fn job_status_as_str_and_display_match_serde_for_all_variants() {
437 for s in [
438 JobStatus::Queued,
439 JobStatus::Running,
440 JobStatus::Done,
441 JobStatus::Failed,
442 JobStatus::Canceled,
443 ] {
444 let serde = serde_yaml::to_string(&s).unwrap();
445 assert_eq!(
446 serde.trim(),
447 s.as_str(),
448 "as_str must match serde for {s:?}"
449 );
450 assert_eq!(s.to_string(), s.as_str(), "Display must delegate to as_str");
451 }
452 }
453
454 #[test]
455 fn job_yaml_roundtrip_with_optional_fields_skipped() {
456 let j = Job {
457 id: "0190f3a2-0000-7000-8000-000000000000".into(),
458 text: "ship it".into(),
459 source: "cli".into(),
460 status: JobStatus::Queued,
461 created_at: "2026-06-24T00:00:00Z".into(),
462 started_at: None,
463 finished_at: None,
464 run_id: None,
465 result: None,
466 error: None,
467 };
468 let yaml = serde_yaml::to_string(&j).unwrap();
469 assert!(
470 !yaml.contains("started_at"),
471 "None optionals must be skipped: {yaml}"
472 );
473 let back: Job = serde_yaml::from_str(&yaml).unwrap();
474 assert_eq!(back, j);
475 }
476}
477
478#[cfg(test)]
479mod limits_tests {
480 use super::*;
481
482 fn fleet(loop_cfg: Option<FleetLoop>, limits: Option<crate::limits::Limits>) -> Fleet {
483 Fleet {
484 name: "dev".into(),
485 display_name: String::new(),
486 goal: "g".into(),
487 router: None,
488 team_id: None,
489 members: vec![],
490 channel_id: "fleet-dev".into(),
491 rules: vec![],
492 skills: vec![],
493 loop_cfg,
494 parallel: None,
495 hitl: None,
496 requires_programs: vec![],
497 limits,
498 needs: vec![],
499 }
500 }
501
502 #[test]
506 fn legacy_loop_fields_are_read_as_limits_only_when_the_block_is_absent() {
507 let lc = FleetLoop {
508 trigger: "manual".into(),
509 max_iterations: 8,
510 budget_usd: 5.0,
511 deadline: "2h".into(),
512 done_when: String::new(),
513 };
514 let legacy = fleet(Some(lc.clone()), None)
515 .limits_or_legacy()
516 .expect("derived");
517 assert_eq!(legacy.deadline.as_deref(), Some("2h"));
518 assert_eq!(legacy.cost_usd, Some(5.0));
519 assert_eq!(legacy.stuck, None, "the old loop had no stuck setting");
520
521 let zero = FleetLoop {
523 budget_usd: 0.0,
524 deadline: String::new(),
525 ..lc.clone()
526 };
527 assert_eq!(fleet(Some(zero), None).limits_or_legacy(), None);
528
529 let explicit = fleet(Some(lc), Some(crate::limits::Limits::default())).limits_or_legacy();
531 assert_eq!(explicit, Some(crate::limits::Limits::default()));
532
533 let yaml = serde_yaml_ng::to_string(&fleet(None, None)).unwrap();
535 assert!(!yaml.contains("limits"), "{yaml}");
536 }
537
538 #[test]
541 fn needs_round_trip_and_stay_absent_when_empty() {
542 let mut f = fleet(None, None);
543 assert!(!serde_yaml_ng::to_string(&f).unwrap().contains("needs"));
544 f.needs = vec!["write_file".into(), "bash".into()];
545 let yaml = serde_yaml_ng::to_string(&f).unwrap();
546 assert!(yaml.contains("needs:"), "{yaml}");
547 let back: Fleet = serde_yaml_ng::from_str(&yaml).unwrap();
548 assert_eq!(
549 back.needs,
550 vec!["write_file".to_string(), "bash".to_string()]
551 );
552 }
553}