Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller-api/src/schedules.rs
atlas be92bc0835 share the forge and schedule wire types through swarm-controller-api
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.
2026-10-17 23:27:14 +02:00

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}"#,
);
}
}