1use 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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub hitl: Option<FleetHitl>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub requires_programs: Vec<ProgramDep>,
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct FleetHitl {
51 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub mode: Option<crate::hitl::Unanswered>,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub auto_approve_tiers: Vec<crate::hitl::RiskTier>,
67}
68
69impl FleetHitl {
70 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 #[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
120pub 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
130pub const CHANNEL_PREFIX: &str = "fleet-";
132
133pub 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#[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 pub fn is_terminal(&self) -> bool {
163 matches!(
164 self,
165 JobStatus::Done | JobStatus::Failed | JobStatus::Canceled
166 )
167 }
168
169 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct Job {
194 pub id: String,
195 pub text: String,
196 pub source: String,
198 pub status: JobStatus,
199 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 #[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 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 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")); }
234
235 #[test]
236 fn fleet_name_from_channel_id_extracts_and_validates() {
237 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 assert_eq!(fleet_name_from_channel_id("dev"), None);
246 assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
247 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); }
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 #[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 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 #[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 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 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}