use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::ids::{ActivityId, WorkflowId};
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(tag = "priority")]
pub enum InjectPriority {
Normal,
Interrupt,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(tag = "decision")]
pub enum ApprovalDecision {
Approve,
Deny,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
#[serde(tag = "kind")]
pub enum InterventionKind {
InjectMessage {
text: String,
priority: InjectPriority,
},
Cancel {
reason: String,
},
PauseResume {
paused: bool,
},
UpdateBudget {
max_tokens: Option<u64>,
max_turns: Option<u32>,
},
RespondToApproval {
call_id: String,
decision: ApprovalDecision,
note: Option<String>,
},
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct InterventionCommand {
pub workflow_id: WorkflowId,
pub activity_id: ActivityId,
pub attempt: u32,
pub issued_by: Option<String>,
pub issued_at: DateTime<Utc>,
pub kind: InterventionKind,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
#[serde(tag = "outcome")]
pub enum InterventionOutcome {
Applied,
CapabilityNotSupported {
primitive: InterventionPrimitive,
},
StaleTarget {
detail: String,
},
}
impl InterventionOutcome {
#[must_use]
pub const fn is_applied(&self) -> bool {
matches!(self, Self::Applied)
}
#[must_use]
pub const fn capability_not_supported(primitive: InterventionPrimitive) -> Self {
Self::CapabilityNotSupported { primitive }
}
#[must_use]
pub fn stale_target(detail: impl Into<String>) -> Self {
Self::StaleTarget {
detail: detail.into(),
}
}
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[serde(tag = "primitive")]
pub enum InterventionPrimitive {
InjectMessage,
Cancel,
PauseResume,
UpdateBudget,
RespondToApproval,
}
impl InterventionKind {
#[must_use]
pub const fn primitive(&self) -> InterventionPrimitive {
match self {
Self::InjectMessage { .. } => InterventionPrimitive::InjectMessage,
Self::Cancel { .. } => InterventionPrimitive::Cancel,
Self::PauseResume { .. } => InterventionPrimitive::PauseResume,
Self::UpdateBudget { .. } => InterventionPrimitive::UpdateBudget,
Self::RespondToApproval { .. } => InterventionPrimitive::RespondToApproval,
}
}
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Default)]
pub struct InterventionCapabilities {
pub supported: Vec<InterventionPrimitive>,
}
impl InterventionCapabilities {
#[must_use]
pub fn none() -> Self {
Self::default()
}
pub fn from_primitives(primitives: impl IntoIterator<Item = InterventionPrimitive>) -> Self {
Self {
supported: primitives.into_iter().collect(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.supported.is_empty()
}
#[must_use]
pub fn supports_primitive(&self, primitive: InterventionPrimitive) -> bool {
self.supported.contains(&primitive)
}
#[must_use]
pub fn supports(&self, kind: &InterventionKind) -> bool {
self.supports_primitive(kind.primitive())
}
}
#[cfg(test)]
mod tests {
use chrono::{DateTime, Utc};
use serde::de::DeserializeOwned;
use super::{
ApprovalDecision, InjectPriority, InterventionCapabilities, InterventionCommand,
InterventionKind, InterventionOutcome, InterventionPrimitive, WorkflowId,
};
use crate::ids::ActivityId;
fn fixed_time() -> DateTime<Utc> {
DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default()
}
fn round_trip<T>(value: &T) -> Result<T, serde_json::Error>
where
T: DeserializeOwned + serde::Serialize,
{
let json = serde_json::to_string(value)?;
serde_json::from_str::<T>(&json)
}
fn command(kind: InterventionKind) -> InterventionCommand {
InterventionCommand {
workflow_id: WorkflowId::new(uuid::Uuid::nil()),
activity_id: ActivityId::from_sequence_position(3),
attempt: 1,
issued_by: Some("operator@example.com".to_owned()),
issued_at: fixed_time(),
kind,
}
}
#[test]
fn every_intervention_variant_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let kinds = vec![
InterventionKind::InjectMessage {
text: "use the other module".to_owned(),
priority: InjectPriority::Interrupt,
},
InterventionKind::InjectMessage {
text: "some context".to_owned(),
priority: InjectPriority::Normal,
},
InterventionKind::Cancel {
reason: "operator abort".to_owned(),
},
InterventionKind::PauseResume { paused: true },
InterventionKind::PauseResume { paused: false },
InterventionKind::UpdateBudget {
max_tokens: Some(10_000),
max_turns: None,
},
InterventionKind::RespondToApproval {
call_id: "call-9".to_owned(),
decision: ApprovalDecision::Approve,
note: Some("looks fine".to_owned()),
},
InterventionKind::RespondToApproval {
call_id: "call-10".to_owned(),
decision: ApprovalDecision::Deny,
note: None,
},
];
for kind in kinds {
let cmd = command(kind);
let decoded = round_trip(&cmd)?;
assert_eq!(cmd, decoded);
}
Ok(())
}
#[test]
fn command_without_auth_subject_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = command(InterventionKind::Cancel {
reason: "shutdown".to_owned(),
});
cmd.issued_by = None;
let decoded = round_trip(&cmd)?;
assert_eq!(decoded.issued_by, None);
assert_eq!(cmd, decoded);
Ok(())
}
#[test]
fn empty_capability_set_is_first_class() -> Result<(), Box<dyn std::error::Error>> {
let observability_only = InterventionCapabilities::none();
assert!(observability_only.is_empty());
assert_eq!(observability_only, InterventionCapabilities::default());
let cancel = InterventionKind::Cancel {
reason: "x".to_owned(),
};
assert!(!observability_only.supports(&cancel));
let decoded = round_trip(&observability_only)?;
assert_eq!(observability_only, decoded);
Ok(())
}
#[test]
fn capabilities_gate_on_advertised_primitives() {
let caps = InterventionCapabilities::from_primitives([
InterventionPrimitive::InjectMessage,
InterventionPrimitive::Cancel,
]);
assert!(!caps.is_empty());
assert!(caps.supports(&InterventionKind::InjectMessage {
text: "hi".to_owned(),
priority: InjectPriority::Normal,
}));
assert!(caps.supports(&InterventionKind::Cancel {
reason: "stop".to_owned(),
}));
assert!(!caps.supports(&InterventionKind::PauseResume { paused: true }));
assert!(!caps.supports(&InterventionKind::UpdateBudget {
max_tokens: None,
max_turns: None,
}));
}
#[test]
fn every_outcome_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let outcomes = vec![
InterventionOutcome::Applied,
InterventionOutcome::capability_not_supported(InterventionPrimitive::PauseResume),
InterventionOutcome::stale_target("attempt 2 superseded"),
];
for outcome in outcomes {
let decoded = round_trip(&outcome)?;
assert_eq!(outcome, decoded);
}
assert!(InterventionOutcome::Applied.is_applied());
assert!(!InterventionOutcome::stale_target("gone").is_applied());
assert!(
!InterventionOutcome::capability_not_supported(InterventionPrimitive::Cancel)
.is_applied()
);
Ok(())
}
}