1#[derive(Debug, Clone, Copy)]
7pub struct Gates {
8 pub paused: bool,
9 pub agent_configured: bool,
10 pub hours_open: bool,
11 pub at_capacity: bool,
12 pub has_queued_activation: bool,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Ineligible {
17 Failed { attempts: i64 },
18 Held,
19 Blocked,
20 Claimed { run: String },
21 NoAgentConfigured,
22 Paused,
23 OutsideRunningHours,
24 AtCapacity,
25 NoActivation,
26}
27
28impl Ineligible {
29 pub fn describe(&self) -> String {
30 match self {
31 Self::Failed { attempts } => {
32 format!("failed after {attempts} attempt(s); requeue with `sloop retry`")
33 }
34 Self::Held => "held by operator; release with `sloop ready`".into(),
35 Self::Blocked => "blocked".into(),
36 Self::Claimed { run } => format!("claimed by run {run}"),
37 Self::NoAgentConfigured => "no agent targets configured".into(),
38 Self::Paused => "scheduler is paused; resume with `sloop resume`".into(),
39 Self::OutsideRunningHours => "outside configured running hours".into(),
40 Self::AtCapacity => "all agent slots are busy".into(),
41 Self::NoActivation => "ready but no queued activation; enqueue with `sloop run`".into(),
42 }
43 }
44}
45
46pub fn ticket_ineligibility(
49 state: &str,
50 attempts: i64,
51 active_run: Option<&str>,
52 gates: &Gates,
53) -> Option<Ineligible> {
54 match state {
55 "failed" => return Some(Ineligible::Failed { attempts }),
56 "held" => return Some(Ineligible::Held),
57 "blocked" => return Some(Ineligible::Blocked),
58 "claimed" => {
59 return Some(Ineligible::Claimed {
60 run: active_run.unwrap_or("?").to_owned(),
61 });
62 }
63 "merged" | "needs_review" => return None,
64 _ => {}
65 }
66 if !gates.agent_configured {
67 Some(Ineligible::NoAgentConfigured)
68 } else if gates.paused {
69 Some(Ineligible::Paused)
70 } else if !gates.hours_open {
71 Some(Ineligible::OutsideRunningHours)
72 } else if gates.at_capacity {
73 Some(Ineligible::AtCapacity)
74 } else if !gates.has_queued_activation {
75 Some(Ineligible::NoActivation)
76 } else {
77 None
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 fn open_gates() -> Gates {
86 Gates {
87 paused: false,
88 agent_configured: true,
89 hours_open: true,
90 at_capacity: false,
91 has_queued_activation: true,
92 }
93 }
94
95 #[test]
96 fn ticket_states_explain_themselves_before_global_gates() {
97 let mut gates = open_gates();
98 gates.paused = true; assert!(matches!(
100 ticket_ineligibility("failed", 2, None, &gates),
101 Some(Ineligible::Failed { attempts: 2 })
102 ));
103 assert!(matches!(
104 ticket_ineligibility("held", 0, None, &gates),
105 Some(Ineligible::Held)
106 ));
107 assert!(matches!(
108 ticket_ineligibility("blocked", 0, None, &gates),
109 Some(Ineligible::Blocked)
110 ));
111 let claimed = ticket_ineligibility("claimed", 1, Some("R4"), &gates);
112 assert!(matches!(claimed, Some(Ineligible::Claimed { ref run }) if run == "R4"));
113 }
114
115 #[test]
116 fn terminal_states_need_no_reason() {
117 let gates = open_gates();
118 assert!(ticket_ineligibility("merged", 1, None, &gates).is_none());
119 assert!(ticket_ineligibility("needs_review", 1, None, &gates).is_none());
120 }
121
122 #[test]
123 fn global_gates_apply_to_ready_tickets_in_priority_order() {
124 let mut gates = open_gates();
125 gates.agent_configured = false;
126 gates.paused = true;
127 assert!(matches!(
128 ticket_ineligibility("ready", 0, None, &gates),
129 Some(Ineligible::NoAgentConfigured)
130 ));
131
132 let mut gates = open_gates();
133 gates.paused = true;
134 assert!(matches!(
135 ticket_ineligibility("ready", 0, None, &gates),
136 Some(Ineligible::Paused)
137 ));
138
139 let mut gates = open_gates();
140 gates.hours_open = false;
141 assert!(matches!(
142 ticket_ineligibility("ready", 0, None, &gates),
143 Some(Ineligible::OutsideRunningHours)
144 ));
145
146 let mut gates = open_gates();
147 gates.at_capacity = true;
148 assert!(matches!(
149 ticket_ineligibility("ready", 0, None, &gates),
150 Some(Ineligible::AtCapacity)
151 ));
152
153 let mut gates = open_gates();
154 gates.has_queued_activation = false;
155 assert!(matches!(
156 ticket_ineligibility("ready", 0, None, &gates),
157 Some(Ineligible::NoActivation)
158 ));
159 }
160
161 #[test]
162 fn a_dispatchable_ready_ticket_has_no_reason() {
163 assert!(ticket_ineligibility("ready", 0, None, &open_gates()).is_none());
164 }
165
166 #[test]
167 fn descriptions_are_actionable() {
168 assert_eq!(
169 Ineligible::Failed { attempts: 2 }.describe(),
170 "failed after 2 attempt(s); requeue with `sloop retry`"
171 );
172 assert_eq!(
173 Ineligible::NoActivation.describe(),
174 "ready but no queued activation; enqueue with `sloop run`"
175 );
176 }
177}