swarmctl hand-mirrored the controller's forge make-admin response and the /api/schedules bodies. Both now import them from a new serde-only crate, swarm-controller-api, the way hivectl and hive-c0re share hive-host-sock. ToSchema sits behind its schema feature, which only the controller turns on. The wire format is unchanged: round-trip tests in the new crate pin each type against the JSON the controller and swarmctl produced, and the controller's OpenAPI document for these routes is byte-identical. Files: swarm-controller-api/ is the new crate; swarm-controller and swarmctl (src, Cargo.toml, Cargo.lock, root Cargo.toml) switch to it; CLAUDE.md lists the crate and which swarmctl shapes are still local mirrors; swarmctl/README.md says the forge and schedule bodies come from the crate, so none of its lines still call them mirrored. Part of #5472.
218 lines
7.8 KiB
Rust
218 lines
7.8 KiB
Rust
//! `/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<String>,
|
|
/// 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<u64>,
|
|
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<i64>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// 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<i64>,
|
|
/// Seconds between fires, at least 1. Absent for a one-shot.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub interval_seconds: Option<u64>,
|
|
#[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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub body: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub anchor_unix: Option<i64>,
|
|
/// 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<u64>))]
|
|
pub interval_seconds: IntervalEdit,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub paused: Option<bool>,
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<u64>),
|
|
}
|
|
|
|
impl IntervalEdit {
|
|
#[must_use]
|
|
pub fn is_keep(&self) -> bool {
|
|
matches!(self, Self::Keep)
|
|
}
|
|
}
|
|
|
|
impl Serialize for IntervalEdit {
|
|
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
|
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: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
Option::<u64>::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<T>(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::<T>(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::<AddScheduleRequest>(
|
|
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}"#,
|
|
);
|
|
}
|
|
}
|