1#[derive(Debug, Clone, Copy)]
16pub struct Gates {
17 pub paused: bool,
18 pub draining: bool,
19 pub storage_writable: bool,
20 pub agent_configured: bool,
21 pub hours_open: bool,
22 pub at_capacity: bool,
23 pub has_queued_trigger: bool,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Ineligible {
28 Failed { attempts: i64 },
29 Held,
30 Blocked { blockers: Vec<String> },
31 Claimed { run: String },
32 NoAgentConfigured,
33 Paused,
34 Draining,
35 StorageFull,
36 OutsideRunningHours,
37 AtCapacity,
38 NoTrigger,
39}
40
41impl Ineligible {
42 pub fn describe(&self) -> String {
43 match self {
44 Self::Failed { attempts } => {
45 format!("failed after {attempts} attempt(s); requeue with `sloop retry`")
46 }
47 Self::Held => "held by operator; release with `sloop ready`".into(),
48 Self::Blocked { blockers } => {
49 format!("blocked by unmerged {}", blockers.join(", "))
50 }
51 Self::Claimed { run } => format!("claimed by run {run}"),
52 Self::NoAgentConfigured => "no agent targets configured".into(),
53 Self::Paused => "scheduler is paused; resume with `sloop resume`".into(),
54 Self::Draining => {
55 "scheduler is draining for restart; cancel with `sloop resume`".into()
56 }
57 Self::StorageFull => {
58 "database storage is full; free disk space to resume dispatch".into()
59 }
60 Self::OutsideRunningHours => "outside configured running hours".into(),
61 Self::AtCapacity => "all agent slots are busy".into(),
62 Self::NoTrigger => "ready but no queued trigger; enqueue with `sloop run`".into(),
63 }
64 }
65}
66
67pub fn display_state<'a>(state: &'a str, reason: Option<&Ineligible>) -> &'a str {
70 if matches!(reason, Some(Ineligible::Blocked { .. })) {
71 "blocked"
72 } else {
73 state
74 }
75}
76
77pub fn ticket_ineligibility(
80 state: &str,
81 attempts: i64,
82 active_run: Option<&str>,
83 unmerged_blockers: &[String],
84 gates: &Gates,
85) -> Option<Ineligible> {
86 match state {
87 "failed" => return Some(Ineligible::Failed { attempts }),
88 "held" => return Some(Ineligible::Held),
89 "claimed" => {
90 return Some(Ineligible::Claimed {
91 run: active_run.unwrap_or("?").to_owned(),
92 });
93 }
94 "merged" | "needs_review" => return None,
95 _ => {}
96 }
97 if !unmerged_blockers.is_empty() {
98 return Some(Ineligible::Blocked {
99 blockers: unmerged_blockers.to_vec(),
100 });
101 }
102 if !gates.agent_configured {
103 Some(Ineligible::NoAgentConfigured)
104 } else if gates.paused {
105 Some(Ineligible::Paused)
106 } else if gates.draining {
107 Some(Ineligible::Draining)
108 } else if !gates.storage_writable {
109 Some(Ineligible::StorageFull)
110 } else if !gates.hours_open {
111 Some(Ineligible::OutsideRunningHours)
112 } else if gates.at_capacity {
113 Some(Ineligible::AtCapacity)
114 } else if !gates.has_queued_trigger {
115 Some(Ineligible::NoTrigger)
116 } else {
117 None
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn open_gates() -> Gates {
126 Gates {
127 paused: false,
128 draining: false,
129 storage_writable: true,
130 agent_configured: true,
131 hours_open: true,
132 at_capacity: false,
133 has_queued_trigger: true,
134 }
135 }
136
137 fn ineligibility(
138 state: &str,
139 attempts: i64,
140 active_run: Option<&str>,
141 gates: &Gates,
142 ) -> Option<Ineligible> {
143 ticket_ineligibility(state, attempts, active_run, &[], gates)
144 }
145
146 #[test]
147 fn ticket_states_explain_themselves_before_global_gates() {
148 let mut gates = open_gates();
149 gates.paused = true; assert!(matches!(
151 ineligibility("failed", 2, None, &gates),
152 Some(Ineligible::Failed { attempts: 2 })
153 ));
154 assert!(matches!(
155 ineligibility("held", 0, None, &gates),
156 Some(Ineligible::Held)
157 ));
158 let claimed = ineligibility("claimed", 1, Some("R4"), &gates);
159 assert!(matches!(claimed, Some(Ineligible::Claimed { ref run }) if run == "R4"));
160 }
161
162 #[test]
163 fn blockers_are_named_before_global_gate_reasons() {
164 let mut gates = open_gates();
165 gates.paused = true;
166 let blockers = vec!["T1".into(), "T2".into()];
167 let reason = ticket_ineligibility("ready", 0, None, &blockers, &gates);
168
169 assert_eq!(
170 reason,
171 Some(Ineligible::Blocked {
172 blockers: blockers.clone()
173 })
174 );
175 assert_eq!(
176 reason.as_ref().unwrap().describe(),
177 "blocked by unmerged T1, T2"
178 );
179 assert_eq!(display_state("ready", reason.as_ref()), "blocked");
180 }
181
182 #[test]
183 fn terminal_states_need_no_reason() {
184 let gates = open_gates();
185 assert!(ineligibility("merged", 1, None, &gates).is_none());
186 assert!(ineligibility("needs_review", 1, None, &gates).is_none());
187 }
188
189 #[test]
190 fn global_gates_apply_to_ready_tickets_in_priority_order() {
191 let mut gates = open_gates();
192 gates.agent_configured = false;
193 gates.paused = true;
194 assert!(matches!(
195 ineligibility("ready", 0, None, &gates),
196 Some(Ineligible::NoAgentConfigured)
197 ));
198
199 let mut gates = open_gates();
200 gates.paused = true;
201 assert!(matches!(
202 ineligibility("ready", 0, None, &gates),
203 Some(Ineligible::Paused)
204 ));
205
206 let mut gates = open_gates();
207 gates.draining = true;
208 assert!(matches!(
209 ineligibility("ready", 0, None, &gates),
210 Some(Ineligible::Draining)
211 ));
212
213 let mut gates = open_gates();
214 gates.storage_writable = false;
215 assert!(matches!(
216 ineligibility("ready", 0, None, &gates),
217 Some(Ineligible::StorageFull)
218 ));
219
220 let mut gates = open_gates();
221 gates.hours_open = false;
222 assert!(matches!(
223 ineligibility("ready", 0, None, &gates),
224 Some(Ineligible::OutsideRunningHours)
225 ));
226
227 let mut gates = open_gates();
228 gates.at_capacity = true;
229 assert!(matches!(
230 ineligibility("ready", 0, None, &gates),
231 Some(Ineligible::AtCapacity)
232 ));
233
234 let mut gates = open_gates();
235 gates.has_queued_trigger = false;
236 assert!(matches!(
237 ineligibility("ready", 0, None, &gates),
238 Some(Ineligible::NoTrigger)
239 ));
240 }
241
242 #[test]
243 fn a_dispatchable_ready_ticket_has_no_reason() {
244 assert!(ineligibility("ready", 0, None, &open_gates()).is_none());
245 }
246
247 #[test]
248 fn descriptions_are_actionable() {
249 assert_eq!(
250 Ineligible::Failed { attempts: 2 }.describe(),
251 "failed after 2 attempt(s); requeue with `sloop retry`"
252 );
253 assert_eq!(
254 Ineligible::NoTrigger.describe(),
255 "ready but no queued trigger; enqueue with `sloop run`"
256 );
257 }
258}