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