use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ResumeWait {
Event(String),
Timeout {
duration_minutes: i64,
on_timeout: String,
},
Trigger,
}
impl ResumeWait {
fn token(&self) -> String {
match self {
Self::Event(topic) => format!("on_event:{topic}"),
Self::Timeout {
duration_minutes,
on_timeout,
} => format!("timeout:{duration_minutes}m/{on_timeout}"),
Self::Trigger => "trigger".to_string(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AgentTerminalSuspension {
pub reason: String,
pub initiator: String,
pub waits: Vec<ResumeWait>,
}
impl AgentTerminalSuspension {
pub fn from_status_value(value: Option<&serde_json::Value>) -> Option<Self> {
let value = value?;
let reason = value.get("reason").and_then(serde_json::Value::as_str)?;
let reason = reason.trim();
if reason.is_empty() {
return None;
}
let initiator = value
.get("initiator")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
Some(Self {
reason: reason.to_string(),
initiator,
waits: waits_from_conditions(value.get("conditions")),
})
}
pub fn detail(&self) -> Option<String> {
if self.waits.is_empty() {
return None;
}
let tokens: Vec<String> = self.waits.iter().map(ResumeWait::token).collect();
Some(format!("resume_when={}", tokens.join(",")))
}
}
fn waits_from_conditions(conditions: Option<&serde_json::Value>) -> Vec<ResumeWait> {
let Some(conditions) = conditions.filter(|value| value.is_object()) else {
return Vec::new();
};
let mut waits = Vec::new();
if conditions
.get("trigger")
.is_some_and(serde_json::Value::is_object)
{
waits.push(ResumeWait::Trigger);
}
if let Some(timeout) = conditions.get("timeout").filter(|value| value.is_object()) {
if let Some(duration_minutes) = timeout
.get("duration_minutes")
.and_then(serde_json::Value::as_i64)
{
waits.push(ResumeWait::Timeout {
duration_minutes,
on_timeout: timeout
.get("on_timeout")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
});
}
}
if let Some(topic) = conditions
.get("on_event")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|topic| !topic.is_empty())
{
waits.push(ResumeWait::Event(topic.to_string()));
}
waits
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_trust_denial_suspension_carries_its_reason_and_the_event_it_waits_on() {
let suspension = AgentTerminalSuspension::from_status_value(Some(&serde_json::json!({
"reason": "Workspace is untrusted, so the edit was refused",
"initiator": "self",
"conditions": {"on_event": "workspace_trusted"},
})))
.expect("a suspension record with a reason parses");
assert_eq!(
suspension.reason,
"Workspace is untrusted, so the edit was refused"
);
assert_eq!(
suspension.waits,
vec![ResumeWait::Event("workspace_trusted".to_string())]
);
assert_eq!(
suspension.detail().as_deref(),
Some("resume_when=on_event:workspace_trusted")
);
}
#[test]
fn a_record_without_a_reason_is_not_a_cause() {
assert!(AgentTerminalSuspension::from_status_value(None).is_none());
assert!(
AgentTerminalSuspension::from_status_value(Some(&serde_json::json!({
"initiator": "self"
})))
.is_none()
);
assert!(
AgentTerminalSuspension::from_status_value(Some(&serde_json::json!({
"reason": " "
})))
.is_none()
);
}
#[test]
fn no_declared_condition_leaves_detail_absent_rather_than_empty() {
let suspension = AgentTerminalSuspension::from_status_value(Some(&serde_json::json!({
"reason": "waiting on the operator",
"initiator": "self",
"conditions": serde_json::Value::Null,
})))
.expect("a reason alone is a cause");
assert!(suspension.waits.is_empty());
assert_eq!(suspension.detail(), None);
}
#[test]
fn every_declared_condition_reaches_the_detail_token() {
let suspension = AgentTerminalSuspension::from_status_value(Some(&serde_json::json!({
"reason": "parked",
"initiator": "self",
"conditions": {
"trigger": {"kind": "cron"},
"timeout": {"duration_minutes": 5, "on_timeout": "resume"},
"on_event": "workspace_trusted",
},
})))
.expect("a full condition set parses");
assert_eq!(
suspension.detail().as_deref(),
Some("resume_when=trigger,timeout:5m/resume,on_event:workspace_trusted")
);
}
}