pub mod redact;
use std::fmt;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use crate::agent::AgentName;
use crate::flight::{ItineraryId, RunId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Blocker {
Access,
Tooling,
Ambiguity,
Environment,
Decision,
Other,
}
impl Blocker {
pub const ALL: [Self; 6] = [
Self::Access,
Self::Tooling,
Self::Ambiguity,
Self::Environment,
Self::Decision,
Self::Other,
];
#[must_use]
pub fn slug(self) -> &'static str {
match self {
Self::Access => "access",
Self::Tooling => "tooling",
Self::Ambiguity => "ambiguity",
Self::Environment => "environment",
Self::Decision => "decision",
Self::Other => "other",
}
}
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|kind| kind.slug() == slug)
}
#[must_use]
pub fn describe(self) -> &'static str {
match self {
Self::Access => "a credential or permission was missing, expired or refused",
Self::Tooling => "a command, tool or service was unavailable or failed",
Self::Ambiguity => "the instructions or the work item did not say enough",
Self::Environment => "the workspace was not in a usable state",
Self::Decision => "a choice that is not yours to make",
Self::Other => "something else",
}
}
}
impl fmt::Display for Blocker {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.slug())
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct HelpRequest {
pub agent: AgentName,
pub run: RunId,
pub itinerary: ItineraryId,
pub blocker: Blocker,
pub summary: String,
pub detail: String,
pub fatal: bool,
pub at: Timestamp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_at: Option<Timestamp>,
}
impl HelpRequest {
#[must_use]
pub fn new(
agent: AgentName,
run: RunId,
itinerary: ItineraryId,
blocker: Blocker,
summary: impl Into<String>,
detail: impl Into<String>,
at: Timestamp,
) -> Self {
Self {
agent,
run,
itinerary,
blocker,
summary: redact::detail(&summary.into()),
detail: redact::detail(&detail.into()),
fatal: false,
at,
resolved_at: None,
}
}
#[must_use]
pub fn fatal(mut self) -> Self {
self.fatal = true;
self
}
#[must_use]
pub fn resolved(mut self, at: Timestamp) -> Self {
self.resolved_at = Some(at);
self
}
#[must_use]
pub fn is_open(&self) -> bool {
self.resolved_at.is_none()
}
#[must_use]
pub fn is_same_ask_as(&self, other: &Self) -> bool {
self.agent == other.agent
&& self.blocker == other.blocker
&& crate::learning::says_the_same_thing(&self.summary, &other.summary)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(rfc3339: &str) -> Timestamp {
rfc3339.parse().expect("valid timestamp")
}
fn request(agent: &str, blocker: Blocker, summary: &str) -> HelpRequest {
HelpRequest::new(
agent.into(),
RunId::generate(),
ItineraryId::generate(),
blocker,
summary,
"tried X, got Y, need Z",
at("2026-09-16T10:00:00Z"),
)
}
#[test]
fn a_request_starts_open_and_not_fatal() {
let asked = request("publisher", Blocker::Access, "the ADO token is expired");
assert!(asked.is_open());
assert!(
!asked.fatal,
"an agent can be limited without being stopped, and the default should not overstate"
);
}
#[test]
fn a_blocked_run_and_a_limited_one_are_distinguishable() {
let stopped = request("publisher", Blocker::Access, "no token at all").fatal();
let limited = request(
"reviewer",
Blocker::Tooling,
"could not read one linked file",
);
assert!(stopped.fatal);
assert!(!limited.fatal);
}
#[test]
fn resolving_closes_it() {
let asked = request("publisher", Blocker::Access, "token expired")
.resolved(at("2026-09-16T11:00:00Z"));
assert!(!asked.is_open());
assert_eq!(asked.resolved_at, Some(at("2026-09-16T11:00:00Z")));
}
#[test]
fn the_same_blocker_asked_twice_is_recognised() {
let first = request("publisher", Blocker::Access, "The ADO token has expired");
let again = request("publisher", Blocker::Access, "the ADO token has expired.");
assert!(first.is_same_ask_as(&again));
}
#[test]
fn a_different_agent_or_category_is_a_different_ask() {
let publisher = request("publisher", Blocker::Access, "the ADO token is expired");
assert!(!publisher.is_same_ask_as(&request(
"developer",
Blocker::Access,
"the ADO token is expired"
)));
assert!(!publisher.is_same_ask_as(&request(
"publisher",
Blocker::Tooling,
"the ADO token is expired"
)));
}
#[test]
fn categories_round_trip_and_describe_themselves() {
for blocker in Blocker::ALL {
assert_eq!(Blocker::from_slug(blocker.slug()), Some(blocker));
assert!(!blocker.describe().is_empty());
}
assert_eq!(Blocker::from_slug("vibes"), None);
}
#[test]
fn a_request_serialises_to_one_readable_line() {
let line = serde_json::to_string(&request("publisher", Blocker::Access, "token expired"))
.expect("serialises");
assert!(!line.contains('\n'));
assert!(line.contains(r#""blocker":"access""#), "{line}");
assert!(!line.contains("resolved_at"), "absent, not null: {line}");
}
}