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