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 = "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 #[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
67pub 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
77pub const CHANNEL_PREFIX: &str = "fleet-";
79
80pub 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#[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 pub fn is_terminal(&self) -> bool {
105 matches!(
106 self,
107 JobStatus::Done | JobStatus::Failed | JobStatus::Canceled
108 )
109 }
110
111 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct Job {
135 pub id: String,
136 pub text: String,
137 pub source: String,
139 pub status: JobStatus,
140 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 #[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 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 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")); }
175
176 #[test]
177 fn fleet_name_from_channel_id_extracts_and_validates() {
178 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 assert_eq!(fleet_name_from_channel_id("dev"), None);
187 assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
188 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); }
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 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 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}