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.
This commit is contained in:
parent
22b9c570ba
commit
be92bc0835
17 changed files with 529 additions and 377 deletions
42
swarm-controller-api/src/forge.rs
Normal file
42
swarm-controller-api/src/forge.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//! `POST /api/forge/users/{name}/admin`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// What `POST /api/forge/users/{name}/admin` found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ForgeAdminChange {
|
||||
Promoted,
|
||||
AlreadyAdmin,
|
||||
}
|
||||
|
||||
/// Success body of `POST /api/forge/users/{name}/admin`.
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct MakeForgeAdminResponse {
|
||||
pub change: ForgeAdminChange,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ForgeAdminChange, MakeForgeAdminResponse};
|
||||
|
||||
#[test]
|
||||
fn both_changes_round_trip() {
|
||||
for (change, wire) in [
|
||||
(ForgeAdminChange::Promoted, r#"{"change":"promoted"}"#),
|
||||
(
|
||||
ForgeAdminChange::AlreadyAdmin,
|
||||
r#"{"change":"already_admin"}"#,
|
||||
),
|
||||
] {
|
||||
let resp = MakeForgeAdminResponse { change };
|
||||
assert_eq!(serde_json::to_string(&resp).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MakeForgeAdminResponse>(wire).unwrap(),
|
||||
resp
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
swarm-controller-api/src/lib.rs
Normal file
8
swarm-controller-api/src/lib.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
//! Wire types for swarm-controller's HTTP API.
|
||||
//!
|
||||
//! `swarm-controller` serves these bodies and `swarmctl` sends and reads
|
||||
//! them, so the shapes cannot drift between the two. Serde only; with the
|
||||
//! `schema` feature each type also derives `utoipa::ToSchema`.
|
||||
|
||||
pub mod forge;
|
||||
pub mod schedules;
|
||||
218
swarm-controller-api/src/schedules.rs
Normal file
218
swarm-controller-api/src/schedules.rs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
//! `/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}"#,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue