1pub mod redact;
32
33use std::fmt;
34
35use jiff::Timestamp;
36use serde::{Deserialize, Serialize};
37
38use crate::agent::AgentName;
39use crate::flight::{ItineraryId, RunId};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub enum Blocker {
48 Access,
50 Tooling,
52 Ambiguity,
54 Environment,
56 Decision,
58 Other,
60}
61
62impl Blocker {
63 pub const ALL: [Self; 6] = [
65 Self::Access,
66 Self::Tooling,
67 Self::Ambiguity,
68 Self::Environment,
69 Self::Decision,
70 Self::Other,
71 ];
72
73 #[must_use]
75 pub fn slug(self) -> &'static str {
76 match self {
77 Self::Access => "access",
78 Self::Tooling => "tooling",
79 Self::Ambiguity => "ambiguity",
80 Self::Environment => "environment",
81 Self::Decision => "decision",
82 Self::Other => "other",
83 }
84 }
85
86 #[must_use]
88 pub fn from_slug(slug: &str) -> Option<Self> {
89 Self::ALL.into_iter().find(|kind| kind.slug() == slug)
90 }
91
92 #[must_use]
94 pub fn describe(self) -> &'static str {
95 match self {
96 Self::Access => "a credential or permission was missing, expired or refused",
97 Self::Tooling => "a command, tool or service was unavailable or failed",
98 Self::Ambiguity => "the instructions or the work item did not say enough",
99 Self::Environment => "the workspace was not in a usable state",
100 Self::Decision => "a choice that is not yours to make",
101 Self::Other => "something else",
102 }
103 }
104}
105
106impl fmt::Display for Blocker {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 f.write_str(self.slug())
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
114pub struct HelpRequest {
115 pub agent: AgentName,
117 pub run: RunId,
119 pub itinerary: ItineraryId,
121 pub blocker: Blocker,
123 pub summary: String,
125 pub detail: String,
127 pub fatal: bool,
132 pub at: Timestamp,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub resolved_at: Option<Timestamp>,
137}
138
139impl HelpRequest {
140 #[must_use]
142 pub fn new(
143 agent: AgentName,
144 run: RunId,
145 itinerary: ItineraryId,
146 blocker: Blocker,
147 summary: impl Into<String>,
148 detail: impl Into<String>,
149 at: Timestamp,
150 ) -> Self {
151 Self {
152 agent,
153 run,
154 itinerary,
155 blocker,
156 summary: redact::detail(&summary.into()),
157 detail: redact::detail(&detail.into()),
158 fatal: false,
159 at,
160 resolved_at: None,
161 }
162 }
163
164 #[must_use]
166 pub fn fatal(mut self) -> Self {
167 self.fatal = true;
168 self
169 }
170
171 #[must_use]
173 pub fn resolved(mut self, at: Timestamp) -> Self {
174 self.resolved_at = Some(at);
175 self
176 }
177
178 #[must_use]
180 pub fn is_open(&self) -> bool {
181 self.resolved_at.is_none()
182 }
183
184 #[must_use]
189 pub fn is_same_ask_as(&self, other: &Self) -> bool {
190 self.agent == other.agent
191 && self.blocker == other.blocker
192 && crate::learning::says_the_same_thing(&self.summary, &other.summary)
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 fn at(rfc3339: &str) -> Timestamp {
201 rfc3339.parse().expect("valid timestamp")
202 }
203
204 fn request(agent: &str, blocker: Blocker, summary: &str) -> HelpRequest {
205 HelpRequest::new(
206 agent.into(),
207 RunId::generate(),
208 ItineraryId::generate(),
209 blocker,
210 summary,
211 "tried X, got Y, need Z",
212 at("2026-09-16T10:00:00Z"),
213 )
214 }
215
216 #[test]
217 fn a_request_starts_open_and_not_fatal() {
218 let asked = request("publisher", Blocker::Access, "the ADO token is expired");
219
220 assert!(asked.is_open());
221 assert!(
222 !asked.fatal,
223 "an agent can be limited without being stopped, and the default should not overstate"
224 );
225 }
226
227 #[test]
228 fn a_blocked_run_and_a_limited_one_are_distinguishable() {
229 let stopped = request("publisher", Blocker::Access, "no token at all").fatal();
232 let limited = request(
233 "reviewer",
234 Blocker::Tooling,
235 "could not read one linked file",
236 );
237
238 assert!(stopped.fatal);
239 assert!(!limited.fatal);
240 }
241
242 #[test]
243 fn resolving_closes_it() {
244 let asked = request("publisher", Blocker::Access, "token expired")
245 .resolved(at("2026-09-16T11:00:00Z"));
246
247 assert!(!asked.is_open());
248 assert_eq!(asked.resolved_at, Some(at("2026-09-16T11:00:00Z")));
249 }
250
251 #[test]
252 fn the_same_blocker_asked_twice_is_recognised() {
253 let first = request("publisher", Blocker::Access, "The ADO token has expired");
256 let again = request("publisher", Blocker::Access, "the ADO token has expired.");
257
258 assert!(first.is_same_ask_as(&again));
259 }
260
261 #[test]
262 fn a_different_agent_or_category_is_a_different_ask() {
263 let publisher = request("publisher", Blocker::Access, "the ADO token is expired");
264
265 assert!(!publisher.is_same_ask_as(&request(
266 "developer",
267 Blocker::Access,
268 "the ADO token is expired"
269 )));
270 assert!(!publisher.is_same_ask_as(&request(
271 "publisher",
272 Blocker::Tooling,
273 "the ADO token is expired"
274 )));
275 }
276
277 #[test]
278 fn categories_round_trip_and_describe_themselves() {
279 for blocker in Blocker::ALL {
280 assert_eq!(Blocker::from_slug(blocker.slug()), Some(blocker));
281 assert!(!blocker.describe().is_empty());
282 }
283
284 assert_eq!(Blocker::from_slug("vibes"), None);
285 }
286
287 #[test]
288 fn a_request_serialises_to_one_readable_line() {
289 let line = serde_json::to_string(&request("publisher", Blocker::Access, "token expired"))
290 .expect("serialises");
291
292 assert!(!line.contains('\n'));
293 assert!(line.contains(r#""blocker":"access""#), "{line}");
294 assert!(!line.contains("resolved_at"), "absent, not null: {line}");
295 }
296}