1use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24#[serde(rename_all = "snake_case")]
25pub enum BlockingStatus {
26 Waiting,
27 Blocked,
28 Stalled,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum BlockingReason {
34 Idle {
35 include_draft: bool,
36 },
37 DependencyBlocked {
38 blocked_tasks: usize,
39 },
40 ScheduleBlocked {
41 blocked_tasks: usize,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 next_runnable_at: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 seconds_until_next_runnable: Option<i64>,
46 },
47 LockBlocked {
48 #[serde(skip_serializing_if = "Option::is_none")]
49 lock_path: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 owner: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 owner_pid: Option<u32>,
54 },
55 CiBlocked {
56 #[serde(skip_serializing_if = "Option::is_none")]
57 exit_code: Option<i32>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pattern: Option<String>,
60 },
61 RunnerRecovery {
62 scope: String,
63 reason: String,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 task_id: Option<String>,
66 },
67 OperatorRecovery {
68 scope: String,
69 reason: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 suggested_command: Option<String>,
72 },
73 MixedQueue {
74 dependency_blocked: usize,
75 schedule_blocked: usize,
76 status_filtered: usize,
77 },
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(deny_unknown_fields)]
82pub struct BlockingState {
83 pub status: BlockingStatus,
84 pub reason: BlockingReason,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub task_id: Option<String>,
87 pub message: String,
88 pub detail: String,
89}
90
91impl BlockingState {
92 pub fn new(
93 status: BlockingStatus,
94 reason: BlockingReason,
95 task_id: Option<String>,
96 message: impl Into<String>,
97 detail: impl Into<String>,
98 ) -> Self {
99 Self {
100 status,
101 reason,
102 task_id,
103 message: message.into(),
104 detail: detail.into(),
105 }
106 }
107
108 pub fn idle(include_draft: bool) -> Self {
109 let message = if include_draft {
110 "Ralph is idle: no todo or draft tasks are available."
111 } else {
112 "Ralph is idle: no todo tasks are available."
113 };
114 let detail = if include_draft {
115 "The queue currently has no runnable todo or draft candidates; Ralph is waiting for new work."
116 } else {
117 "The queue currently has no runnable todo candidates; Ralph is waiting for new work."
118 };
119 Self::new(
120 BlockingStatus::Waiting,
121 BlockingReason::Idle { include_draft },
122 None,
123 message,
124 detail,
125 )
126 }
127
128 pub fn dependency_blocked(blocked_tasks: usize) -> Self {
129 Self::new(
130 BlockingStatus::Blocked,
131 BlockingReason::DependencyBlocked { blocked_tasks },
132 None,
133 "Ralph is blocked by unfinished dependencies.",
134 format!("{blocked_tasks} candidate task(s) are waiting on dependency completion."),
135 )
136 }
137
138 pub fn schedule_blocked(
139 blocked_tasks: usize,
140 next_runnable_at: Option<String>,
141 seconds_until_next_runnable: Option<i64>,
142 ) -> Self {
143 let detail = match (&next_runnable_at, seconds_until_next_runnable) {
144 (Some(next_at), Some(seconds)) => format!(
145 "{blocked_tasks} candidate task(s) are scheduled for the future. The next one becomes runnable at {next_at} ({seconds}s remaining)."
146 ),
147 (Some(next_at), None) => format!(
148 "{blocked_tasks} candidate task(s) are scheduled for the future. The next known scheduled time is {next_at}."
149 ),
150 _ => {
151 format!("{blocked_tasks} candidate task(s) are scheduled for the future.")
152 }
153 };
154 Self::new(
155 BlockingStatus::Waiting,
156 BlockingReason::ScheduleBlocked {
157 blocked_tasks,
158 next_runnable_at,
159 seconds_until_next_runnable,
160 },
161 None,
162 "Ralph is waiting for scheduled work to become runnable.",
163 detail,
164 )
165 }
166
167 pub fn mixed_queue(
168 dependency_blocked: usize,
169 schedule_blocked: usize,
170 status_filtered: usize,
171 ) -> Self {
172 Self::new(
173 BlockingStatus::Blocked,
174 BlockingReason::MixedQueue {
175 dependency_blocked,
176 schedule_blocked,
177 status_filtered,
178 },
179 None,
180 "Ralph is blocked by a mix of dependency and schedule gates.",
181 format!(
182 "candidate blockers: dependencies={dependency_blocked}, schedule={schedule_blocked}, status_or_flags={status_filtered}."
183 ),
184 )
185 }
186
187 pub fn lock_blocked(
188 lock_path: Option<String>,
189 owner: Option<String>,
190 owner_pid: Option<u32>,
191 ) -> Self {
192 let detail = match (&owner, owner_pid, &lock_path) {
193 (Some(owner), Some(owner_pid), Some(lock_path)) => format!(
194 "Another Ralph process ({owner}, pid {owner_pid}) owns the queue lock at {lock_path}."
195 ),
196 (Some(owner), Some(owner_pid), None) => {
197 format!("Another Ralph process ({owner}, pid {owner_pid}) owns the queue lock.")
198 }
199 (_, _, Some(lock_path)) => {
200 format!("Another Ralph process owns the queue lock at {lock_path}.")
201 }
202 _ => "Another Ralph process currently owns the queue lock.".to_string(),
203 };
204 Self::new(
205 BlockingStatus::Stalled,
206 BlockingReason::LockBlocked {
207 lock_path,
208 owner,
209 owner_pid,
210 },
211 None,
212 "Ralph is stalled on queue lock contention.",
213 detail,
214 )
215 }
216
217 pub fn ci_blocked(exit_code: Option<i32>, pattern: Option<String>) -> Self {
218 let detail = match (&pattern, exit_code) {
219 (Some(pattern), Some(exit_code)) => {
220 format!("CI gate failed with exit code {exit_code}. Detected pattern: {pattern}.")
221 }
222 (Some(pattern), None) => format!("CI gate failed. Detected pattern: {pattern}."),
223 (None, Some(exit_code)) => {
224 format!("CI gate failed with exit code {exit_code}.")
225 }
226 (None, None) => "CI gate failed without a classified pattern.".to_string(),
227 };
228 Self::new(
229 BlockingStatus::Stalled,
230 BlockingReason::CiBlocked { exit_code, pattern },
231 None,
232 "Ralph is stalled on CI gate failure.",
233 detail,
234 )
235 }
236
237 pub fn runner_recovery(
238 scope: impl Into<String>,
239 reason: impl Into<String>,
240 task_id: Option<String>,
241 message: impl Into<String>,
242 detail: impl Into<String>,
243 ) -> Self {
244 Self::new(
245 BlockingStatus::Stalled,
246 BlockingReason::RunnerRecovery {
247 scope: scope.into(),
248 reason: reason.into(),
249 task_id: task_id.clone(),
250 },
251 task_id,
252 message,
253 detail,
254 )
255 }
256
257 pub fn operator_recovery(
258 status: BlockingStatus,
259 scope: impl Into<String>,
260 reason: impl Into<String>,
261 task_id: Option<String>,
262 message: impl Into<String>,
263 detail: impl Into<String>,
264 suggested_command: Option<String>,
265 ) -> Self {
266 Self::new(
267 status,
268 BlockingReason::OperatorRecovery {
269 scope: scope.into(),
270 reason: reason.into(),
271 suggested_command,
272 },
273 task_id,
274 message,
275 detail,
276 )
277 }
278}