1use serde::{Deserialize, Serialize};
4
5use crate::parallel::ParallelConfig;
6
7pub const CONCIERGE_AGENT: &str = "mur";
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub struct Fleet {
11 pub name: String,
12 #[serde(default)]
13 pub display_name: String,
14 #[serde(default)]
15 pub goal: String,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub router: Option<String>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub team_id: Option<String>,
23 #[serde(default)]
24 pub members: Vec<String>,
25 pub channel_id: String,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub rules: Vec<String>,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub skills: Vec<String>,
30 #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
31 pub loop_cfg: Option<FleetLoop>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub parallel: Option<ParallelConfig>,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct FleetLoop {
38 #[serde(default = "default_trigger")]
39 pub trigger: String,
40 #[serde(default)]
43 pub max_iterations: u32,
44 #[serde(default)]
45 pub budget_usd: f64,
46 #[serde(default)]
47 pub deadline: String,
48 #[serde(default)]
49 pub done_when: String,
50}
51
52fn default_trigger() -> String {
53 "manual".to_string()
54}
55
56impl Fleet {
57 pub fn router_or_concierge(&self) -> &str {
58 self.router.as_deref().unwrap_or(CONCIERGE_AGENT)
59 }
60}
61
62pub fn valid_fleet_name(name: &str) -> bool {
65 !name.is_empty()
66 && name.len() <= 64
67 && name
68 .chars()
69 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
70}
71
72pub const CHANNEL_PREFIX: &str = "fleet-";
74
75pub fn fleet_name_from_channel_id(channel_id: &str) -> Option<&str> {
82 let name = channel_id.strip_prefix(CHANNEL_PREFIX)?;
83 valid_fleet_name(name).then_some(name)
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "lowercase")]
89pub enum JobStatus {
90 Queued,
91 Running,
92 Done,
93 Failed,
94 Canceled,
95}
96
97impl JobStatus {
98 pub fn is_terminal(&self) -> bool {
100 matches!(
101 self,
102 JobStatus::Done | JobStatus::Failed | JobStatus::Canceled
103 )
104 }
105
106 pub fn as_str(&self) -> &'static str {
110 match self {
111 JobStatus::Queued => "queued",
112 JobStatus::Running => "running",
113 JobStatus::Done => "done",
114 JobStatus::Failed => "failed",
115 JobStatus::Canceled => "canceled",
116 }
117 }
118}
119
120impl std::fmt::Display for JobStatus {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.write_str(self.as_str())
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Job {
130 pub id: String,
131 pub text: String,
132 pub source: String,
134 pub status: JobStatus,
135 pub created_at: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub started_at: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub finished_at: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub run_id: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub result: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub error: Option<String>,
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn valid_fleet_name_accepts_and_rejects() {
156 assert!(valid_fleet_name("dev"));
158 assert!(valid_fleet_name("dev-team"));
159 assert!(valid_fleet_name("dev_1"));
160 assert!(valid_fleet_name("ab12"));
161 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")); }
170
171 #[test]
172 fn fleet_name_from_channel_id_extracts_and_validates() {
173 assert_eq!(fleet_name_from_channel_id("fleet-dev"), Some("dev"));
175 assert_eq!(
176 fleet_name_from_channel_id("fleet-my-squad"),
177 Some("my-squad")
178 );
179 assert_eq!(fleet_name_from_channel_id("fleet-ab12"), Some("ab12"));
180 assert_eq!(fleet_name_from_channel_id("dev"), None);
182 assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
183 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); }
189
190 #[test]
191 fn fleet_minimal_yaml_deserializes_with_defaults() {
192 let f: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
193 assert_eq!(f.name, "dev");
194 assert_eq!(f.channel_id, "fleet-dev");
195 assert!(f.members.is_empty());
196 assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
197 assert!(f.loop_cfg.is_none());
198 }
199
200 #[test]
201 fn fleet_yaml_roundtrip_and_router_default() {
202 let f = Fleet {
203 name: "dev".into(),
204 display_name: "Dev Team".into(),
205 goal: "ship it".into(),
206 router: None,
207 team_id: None,
208 members: vec!["pm".into(), "qa".into()],
209 channel_id: "fleet-dev".into(),
210 rules: vec![],
211 skills: vec![],
212 loop_cfg: None,
213 parallel: None,
214 };
215 assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
216 let yaml = serde_yaml::to_string(&f).unwrap();
217 let back: Fleet = serde_yaml::from_str(&yaml).unwrap();
218 assert_eq!(back, f);
219 let with_loop: Fleet = serde_yaml::from_str(
221 "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",
222 ).unwrap();
223 assert_eq!(with_loop.loop_cfg.unwrap().max_iterations, 3);
224 }
225
226 #[test]
227 fn minimal_loop_block_deserializes_with_defaults() {
228 let f: Fleet = serde_yaml::from_str(
231 "name: dev\nchannel_id: fleet-dev\nloop:\n trigger: \"interval:1h\"\n",
232 )
233 .unwrap();
234 let l = f.loop_cfg.unwrap();
235 assert_eq!(l.trigger, "interval:1h");
236 assert_eq!(l.max_iterations, 0);
237 assert_eq!(l.budget_usd, 0.0);
238 }
239
240 #[test]
241 fn job_status_serde_is_lowercase_and_terminal_predicate() {
242 assert_eq!(
243 serde_yaml::to_string(&JobStatus::Queued).unwrap().trim(),
244 "queued"
245 );
246 assert_eq!(
247 serde_yaml::to_string(&JobStatus::Done).unwrap().trim(),
248 "done"
249 );
250 assert!(!JobStatus::Queued.is_terminal());
251 assert!(!JobStatus::Running.is_terminal());
252 assert!(JobStatus::Done.is_terminal());
253 assert!(JobStatus::Failed.is_terminal());
254 assert!(JobStatus::Canceled.is_terminal());
255 }
256
257 #[test]
258 fn job_status_as_str_and_display_match_serde_for_all_variants() {
259 for s in [
260 JobStatus::Queued,
261 JobStatus::Running,
262 JobStatus::Done,
263 JobStatus::Failed,
264 JobStatus::Canceled,
265 ] {
266 let serde = serde_yaml::to_string(&s).unwrap();
267 assert_eq!(
268 serde.trim(),
269 s.as_str(),
270 "as_str must match serde for {s:?}"
271 );
272 assert_eq!(s.to_string(), s.as_str(), "Display must delegate to as_str");
273 }
274 }
275
276 #[test]
277 fn job_yaml_roundtrip_with_optional_fields_skipped() {
278 let j = Job {
279 id: "0190f3a2-0000-7000-8000-000000000000".into(),
280 text: "ship it".into(),
281 source: "cli".into(),
282 status: JobStatus::Queued,
283 created_at: "2026-06-24T00:00:00Z".into(),
284 started_at: None,
285 finished_at: None,
286 run_id: None,
287 result: None,
288 error: None,
289 };
290 let yaml = serde_yaml::to_string(&j).unwrap();
291 assert!(
292 !yaml.contains("started_at"),
293 "None optionals must be skipped: {yaml}"
294 );
295 let back: Job = serde_yaml::from_str(&yaml).unwrap();
296 assert_eq!(back, j);
297 }
298}