//! `/api/schedules`: the operator's scheduled prompts. use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// One schedule as the API shows it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct ScheduleView { pub id: String, /// The operator's title for it. pub name: String, /// The agents it fires in, sorted. pub agents: Vec, /// The prompt, delivered to each agent verbatim. pub body: String, /// The first fire time, unix seconds. pub anchor_unix: i64, /// Seconds between fires; `null` for a one-shot. pub interval_seconds: Option, pub paused: bool, /// The next fire time on the schedule's grid, unix seconds, computed when /// the response is built; `null` when paused or a one-shot whose time /// has passed. Says nothing about whether an agent fired an earlier one. pub next_fire_unix: Option, } /// A new schedule. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct AddScheduleRequest { /// The title. Must not be empty. pub name: String, /// The agents to fire in, at least one. Each must be declared on a hive. pub agents: Vec, /// The prompt, delivered verbatim. Must not be empty. pub body: String, /// The first fire time, unix seconds. Defaults to now. #[serde(skip_serializing_if = "Option::is_none")] pub anchor_unix: Option, /// Seconds between fires, at least 1. Absent for a one-shot. #[serde(skip_serializing_if = "Option::is_none")] pub interval_seconds: Option, #[serde(default)] pub paused: bool, } /// A partial change to one schedule; an absent field is left as it is. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct EditScheduleRequest { #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, #[serde(skip_serializing_if = "Option::is_none")] pub anchor_unix: Option, /// A number sets the interval; `null` makes the schedule a one-shot. #[serde(default, skip_serializing_if = "IntervalEdit::is_keep")] #[cfg_attr(feature = "schema", schema(value_type = Option))] pub interval_seconds: IntervalEdit, #[serde(skip_serializing_if = "Option::is_none")] pub paused: Option, /// Agents to add. Each must be declared on a hive; one already targeted /// is left as it is. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub add_agents: Vec, /// Agents to remove. Each must be a current target, and at least one /// target must remain. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub remove_agents: Vec, } /// What an edit does to the interval: an absent field keeps it, `null` makes /// a one-shot, a number sets it. `Keep` is never serialized; the field is /// left out instead. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum IntervalEdit { #[default] Keep, Set(Option), } impl IntervalEdit { #[must_use] pub fn is_keep(&self) -> bool { matches!(self, Self::Keep) } } impl Serialize for IntervalEdit { fn serialize(&self, s: S) -> Result { match self { Self::Keep | Self::Set(None) => s.serialize_none(), Self::Set(Some(seconds)) => s.serialize_u64(*seconds), } } } impl<'de> Deserialize<'de> for IntervalEdit { fn deserialize>(d: D) -> Result { Option::::deserialize(d).map(Self::Set) } } #[cfg(test)] mod tests { use super::{AddScheduleRequest, EditScheduleRequest, IntervalEdit, ScheduleView}; /// Serialize `value`, check it against `wire`, and decode `wire` back. fn round_trip(value: &T, wire: &str) where T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, { assert_eq!(serde_json::to_string(value).unwrap(), wire); assert_eq!(&serde_json::from_str::(wire).unwrap(), value); } #[test] fn a_view_round_trips() { round_trip( &ScheduleView { id: "abc".to_owned(), name: "standup".to_owned(), agents: vec!["atlas".to_owned(), "iris".to_owned()], body: "post the standup".to_owned(), anchor_unix: 1000, interval_seconds: Some(3600), paused: false, next_fire_unix: Some(4600), }, r#"{"id":"abc","name":"standup","agents":["atlas","iris"],"body":"post the standup","anchor_unix":1000,"interval_seconds":3600,"paused":false,"next_fire_unix":4600}"#, ); round_trip( &ScheduleView { id: "abc".to_owned(), name: "once".to_owned(), agents: vec!["atlas".to_owned()], body: "hi".to_owned(), anchor_unix: 0, interval_seconds: None, paused: true, next_fire_unix: None, }, r#"{"id":"abc","name":"once","agents":["atlas"],"body":"hi","anchor_unix":0,"interval_seconds":null,"paused":true,"next_fire_unix":null}"#, ); } /// An absent anchor or interval is left out, `paused` is always sent, and /// a body without `paused` decodes as not paused. #[test] fn an_add_round_trips() { let mut add = AddScheduleRequest { name: "n".to_owned(), agents: vec!["iris".to_owned()], body: "hi".to_owned(), anchor_unix: Some(5), interval_seconds: Some(60), paused: true, }; round_trip( &add, r#"{"name":"n","agents":["iris"],"body":"hi","anchor_unix":5,"interval_seconds":60,"paused":true}"#, ); add.anchor_unix = None; add.interval_seconds = None; add.paused = false; round_trip( &add, r#"{"name":"n","agents":["iris"],"body":"hi","paused":false}"#, ); assert_eq!( serde_json::from_str::( r#"{"name":"n","agents":["iris"],"body":"hi"}"# ) .unwrap(), add ); } /// An edit sends only what it changes: `Keep` and empty agent lists are /// left out, and a one-shot is an explicit `null`. #[test] fn an_edit_round_trips() { round_trip(&EditScheduleRequest::default(), "{}"); round_trip( &EditScheduleRequest { name: Some("hi".to_owned()), ..EditScheduleRequest::default() }, r#"{"name":"hi"}"#, ); round_trip( &EditScheduleRequest { interval_seconds: IntervalEdit::Set(None), ..EditScheduleRequest::default() }, r#"{"interval_seconds":null}"#, ); round_trip( &EditScheduleRequest { anchor_unix: Some(5), interval_seconds: IntervalEdit::Set(Some(60)), add_agents: vec!["iris".to_owned()], remove_agents: vec!["atlas".to_owned()], ..EditScheduleRequest::default() }, r#"{"anchor_unix":5,"interval_seconds":60,"add_agents":["iris"],"remove_agents":["atlas"]}"#, ); round_trip( &EditScheduleRequest { body: Some("b".to_owned()), paused: Some(true), ..EditScheduleRequest::default() }, r#"{"body":"b","paused":true}"#, ); } }