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
|
|
@ -89,6 +89,8 @@ serde_json.workspace = true
|
|||
serde_norway = "0.9.42"
|
||||
strum.workspace = true
|
||||
swarm-authelia-bridge-sock.workspace = true
|
||||
# `schema`: the forge and schedule routes describe these bodies in the spec.
|
||||
swarm-controller-api = { workspace = true, features = ["schema"] }
|
||||
# The queue connect (token mint + auth callback + reconnect) is shared with
|
||||
# every other participant - a hive publishing its own status runs the same
|
||||
# code with a different client id. Two copies of credential handling is one
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ use backon::{ExponentialBuilder, Retryable as _};
|
|||
use hive_jobq_wire::GraphWire as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_authelia_bridge_sock::BridgeResponse;
|
||||
use swarm_controller_api::forge::{ForgeAdminChange, MakeForgeAdminResponse};
|
||||
use utoipa::{OpenApi, ToSchema};
|
||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||
|
||||
|
|
@ -3122,20 +3123,6 @@ async fn mint_agent_forge_token(
|
|||
Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() }))
|
||||
}
|
||||
|
||||
/// What `POST /api/forge/users/{name}/admin` found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ForgeAdminChange {
|
||||
Promoted,
|
||||
AlreadyAdmin,
|
||||
}
|
||||
|
||||
/// Success body of `POST /api/forge/users/{name}/admin`.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
struct MakeForgeAdminResponse {
|
||||
change: ForgeAdminChange,
|
||||
}
|
||||
|
||||
/// Make an existing human forge account a site admin. Idempotent.
|
||||
///
|
||||
/// Never creates the account: a human's is made by their first SSO login to
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ use axum::Json;
|
|||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use problem_details::ProblemDetails;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_controller_api::schedules::{
|
||||
AddScheduleRequest, EditScheduleRequest, IntervalEdit, ScheduleView,
|
||||
};
|
||||
use swarm_queue_client::agent_schedules::{self, AgentSchedules, Schedule};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::agent_renewal::unix_now;
|
||||
use crate::wanted::WantedWriter;
|
||||
|
|
@ -168,6 +170,20 @@ impl Definition {
|
|||
let interval = i64::try_from(self.interval_seconds?).ok()?;
|
||||
Some(self.for_agent().latest_slot(now)? + interval)
|
||||
}
|
||||
|
||||
/// This schedule as the API shows it, with its next fire after `now`.
|
||||
fn view(&self, now: i64) -> ScheduleView {
|
||||
ScheduleView {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
agents: self.agents.iter().cloned().collect(),
|
||||
body: self.body.clone(),
|
||||
anchor_unix: self.anchor_unix,
|
||||
interval_seconds: self.interval_seconds,
|
||||
paused: self.paused,
|
||||
next_fire_unix: self.next_fire(now),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `agent`'s key: the schedules that name it, in definition order, then the
|
||||
|
|
@ -190,95 +206,6 @@ fn projection(definitions: &Definitions, agent: &str, current: &AgentSchedules)
|
|||
}
|
||||
}
|
||||
|
||||
/// One schedule as the API shows it.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ScheduleView {
|
||||
id: String,
|
||||
/// The operator's title for it.
|
||||
name: String,
|
||||
/// The agents it fires in, sorted.
|
||||
agents: Vec<String>,
|
||||
/// The prompt, delivered to each agent verbatim.
|
||||
body: String,
|
||||
/// The first fire time, unix seconds.
|
||||
anchor_unix: i64,
|
||||
/// Seconds between fires; `null` for a one-shot.
|
||||
interval_seconds: Option<u64>,
|
||||
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.
|
||||
next_fire_unix: Option<i64>,
|
||||
}
|
||||
|
||||
impl ScheduleView {
|
||||
fn new(d: &Definition, now: i64) -> Self {
|
||||
Self {
|
||||
id: d.id.clone(),
|
||||
name: d.name.clone(),
|
||||
agents: d.agents.iter().cloned().collect(),
|
||||
body: d.body.clone(),
|
||||
anchor_unix: d.anchor_unix,
|
||||
interval_seconds: d.interval_seconds,
|
||||
paused: d.paused,
|
||||
next_fire_unix: d.next_fire(now),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new schedule.
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct AddScheduleRequest {
|
||||
/// The title. Must not be empty.
|
||||
name: String,
|
||||
/// The agents to fire in, at least one. Each must be declared on a hive.
|
||||
agents: Vec<String>,
|
||||
/// The prompt, delivered verbatim. Must not be empty.
|
||||
body: String,
|
||||
/// The first fire time, unix seconds. Defaults to now.
|
||||
anchor_unix: Option<i64>,
|
||||
/// Seconds between fires, at least 1. Absent for a one-shot.
|
||||
interval_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
paused: bool,
|
||||
}
|
||||
|
||||
/// A partial change to one schedule; an absent field is left as it is.
|
||||
#[derive(Debug, Clone, Default, Deserialize, ToSchema)]
|
||||
pub struct EditScheduleRequest {
|
||||
name: Option<String>,
|
||||
body: Option<String>,
|
||||
anchor_unix: Option<i64>,
|
||||
/// A number sets the interval; `null` makes the schedule a one-shot.
|
||||
#[serde(default)]
|
||||
#[schema(value_type = Option<u64>)]
|
||||
interval_seconds: IntervalEdit,
|
||||
paused: Option<bool>,
|
||||
/// Agents to add. Each must be declared on a hive; one already targeted
|
||||
/// is left as it is.
|
||||
#[serde(default)]
|
||||
add_agents: Vec<String>,
|
||||
/// Agents to remove. Each must be a current target, and at least one
|
||||
/// target must remain.
|
||||
#[serde(default)]
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
enum IntervalEdit {
|
||||
#[default]
|
||||
Keep,
|
||||
Set(Option<u64>),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for IntervalEdit {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Option::<u64>::deserialize(d).map(Self::Set)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a change was refused before it was written.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Refusal {
|
||||
|
|
@ -322,89 +249,85 @@ fn check_agents(names: &[String], live: &BTreeSet<String>) -> Result<BTreeSet<St
|
|||
Ok(agents)
|
||||
}
|
||||
|
||||
impl AddScheduleRequest {
|
||||
fn into_definition(
|
||||
self,
|
||||
id: String,
|
||||
now: i64,
|
||||
live: &BTreeSet<String>,
|
||||
) -> Result<Definition, Refusal> {
|
||||
check_text(&self.name, "name")?;
|
||||
check_text(&self.body, "body")?;
|
||||
check_interval(self.interval_seconds)?;
|
||||
let agents = check_agents(&self.agents, live)?;
|
||||
if agents.is_empty() {
|
||||
return Err(invalid("a schedule needs at least one agent"));
|
||||
}
|
||||
Ok(Definition {
|
||||
id,
|
||||
name: self.name,
|
||||
agents,
|
||||
body: self.body,
|
||||
anchor_unix: self.anchor_unix.unwrap_or(now),
|
||||
interval_seconds: self.interval_seconds,
|
||||
paused: self.paused,
|
||||
})
|
||||
fn new_definition(
|
||||
req: AddScheduleRequest,
|
||||
id: String,
|
||||
now: i64,
|
||||
live: &BTreeSet<String>,
|
||||
) -> Result<Definition, Refusal> {
|
||||
check_text(&req.name, "name")?;
|
||||
check_text(&req.body, "body")?;
|
||||
check_interval(req.interval_seconds)?;
|
||||
let agents = check_agents(&req.agents, live)?;
|
||||
if agents.is_empty() {
|
||||
return Err(invalid("a schedule needs at least one agent"));
|
||||
}
|
||||
Ok(Definition {
|
||||
id,
|
||||
name: req.name,
|
||||
agents,
|
||||
body: req.body,
|
||||
anchor_unix: req.anchor_unix.unwrap_or(now),
|
||||
interval_seconds: req.interval_seconds,
|
||||
paused: req.paused,
|
||||
})
|
||||
}
|
||||
|
||||
impl EditScheduleRequest {
|
||||
/// Apply the edit to schedule `id`, returning it as changed and every
|
||||
/// agent it named before or after.
|
||||
fn apply(
|
||||
self,
|
||||
definitions: &mut Definitions,
|
||||
id: &str,
|
||||
live: &BTreeSet<String>,
|
||||
) -> Result<(Definition, BTreeSet<String>), Refusal> {
|
||||
if let Some(name) = &self.name {
|
||||
check_text(name, "name")?;
|
||||
}
|
||||
if let Some(body) = &self.body {
|
||||
check_text(body, "body")?;
|
||||
}
|
||||
if let IntervalEdit::Set(interval) = self.interval_seconds {
|
||||
check_interval(interval)?;
|
||||
}
|
||||
let added = check_agents(&self.add_agents, live)?;
|
||||
let d = definitions
|
||||
.schedules
|
||||
.iter_mut()
|
||||
.find(|d| d.id == id)
|
||||
.ok_or_else(|| Refusal::NoSuchSchedule(id.to_owned()))?;
|
||||
let mut agents = d.agents.clone();
|
||||
agents.extend(added);
|
||||
for name in &self.remove_agents {
|
||||
if !agents.remove(name) {
|
||||
return Err(Refusal::Invalid(format!(
|
||||
"agent {name:?} is not a target of schedule {id:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if agents.is_empty() {
|
||||
return Err(invalid(
|
||||
"this would leave the schedule with no agents; remove the schedule instead",
|
||||
));
|
||||
}
|
||||
let touched = d.agents.union(&agents).cloned().collect();
|
||||
d.agents = agents;
|
||||
if let Some(name) = self.name {
|
||||
d.name = name;
|
||||
}
|
||||
if let Some(body) = self.body {
|
||||
d.body = body;
|
||||
}
|
||||
if let Some(anchor) = self.anchor_unix {
|
||||
d.anchor_unix = anchor;
|
||||
}
|
||||
if let IntervalEdit::Set(interval) = self.interval_seconds {
|
||||
d.interval_seconds = interval;
|
||||
}
|
||||
if let Some(paused) = self.paused {
|
||||
d.paused = paused;
|
||||
}
|
||||
Ok((d.clone(), touched))
|
||||
/// Apply the edit to schedule `id`, returning it as changed and every
|
||||
/// agent it named before or after.
|
||||
fn apply_edit(
|
||||
edit: EditScheduleRequest,
|
||||
definitions: &mut Definitions,
|
||||
id: &str,
|
||||
live: &BTreeSet<String>,
|
||||
) -> Result<(Definition, BTreeSet<String>), Refusal> {
|
||||
if let Some(name) = &edit.name {
|
||||
check_text(name, "name")?;
|
||||
}
|
||||
if let Some(body) = &edit.body {
|
||||
check_text(body, "body")?;
|
||||
}
|
||||
if let IntervalEdit::Set(interval) = edit.interval_seconds {
|
||||
check_interval(interval)?;
|
||||
}
|
||||
let added = check_agents(&edit.add_agents, live)?;
|
||||
let d = definitions
|
||||
.schedules
|
||||
.iter_mut()
|
||||
.find(|d| d.id == id)
|
||||
.ok_or_else(|| Refusal::NoSuchSchedule(id.to_owned()))?;
|
||||
let mut agents = d.agents.clone();
|
||||
agents.extend(added);
|
||||
for name in &edit.remove_agents {
|
||||
if !agents.remove(name) {
|
||||
return Err(Refusal::Invalid(format!(
|
||||
"agent {name:?} is not a target of schedule {id:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if agents.is_empty() {
|
||||
return Err(invalid(
|
||||
"this would leave the schedule with no agents; remove the schedule instead",
|
||||
));
|
||||
}
|
||||
let touched = d.agents.union(&agents).cloned().collect();
|
||||
d.agents = agents;
|
||||
if let Some(name) = edit.name {
|
||||
d.name = name;
|
||||
}
|
||||
if let Some(body) = edit.body {
|
||||
d.body = body;
|
||||
}
|
||||
if let Some(anchor) = edit.anchor_unix {
|
||||
d.anchor_unix = anchor;
|
||||
}
|
||||
if let IntervalEdit::Set(interval) = edit.interval_seconds {
|
||||
d.interval_seconds = interval;
|
||||
}
|
||||
if let Some(paused) = edit.paused {
|
||||
d.paused = paused;
|
||||
}
|
||||
Ok((d.clone(), touched))
|
||||
}
|
||||
|
||||
/// Remove schedule `id`, returning the agents it named.
|
||||
|
|
@ -757,12 +680,7 @@ pub(crate) async fn list_schedules(
|
|||
all.schedules
|
||||
.sort_by(|a, b| (&a.name, &a.id).cmp(&(&b.name, &b.id)));
|
||||
let now = unix_now();
|
||||
Ok(Json(
|
||||
all.schedules
|
||||
.iter()
|
||||
.map(|d| ScheduleView::new(d, now))
|
||||
.collect(),
|
||||
))
|
||||
Ok(Json(all.schedules.iter().map(|d| d.view(now)).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
|
@ -794,7 +712,7 @@ pub(crate) async fn add_schedule(
|
|||
.map_err(|e| problem(e.into()))?;
|
||||
let added = commit(&store, |all| {
|
||||
let id = mint_id(all)?;
|
||||
let d = req.clone().into_definition(id, now, &live)?;
|
||||
let d = new_definition(req.clone(), id, now, &live)?;
|
||||
all.schedules.push(d.clone());
|
||||
Ok(d)
|
||||
})
|
||||
|
|
@ -802,7 +720,7 @@ pub(crate) async fn add_schedule(
|
|||
.map_err(problem)?;
|
||||
tracing::info!(id = %added.id, agents = ?added.agents, "added a schedule");
|
||||
project_all(&client, &store, &added.agents).await;
|
||||
Ok((StatusCode::CREATED, Json(ScheduleView::new(&added, now))))
|
||||
Ok((StatusCode::CREATED, Json(added.view(now))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
|
@ -834,12 +752,12 @@ pub(crate) async fn edit_schedule(
|
|||
let store = open_or_create_definitions(&client)
|
||||
.await
|
||||
.map_err(|e| problem(e.into()))?;
|
||||
let (edited, touched) = commit(&store, |all| Ok(req.clone().apply(all, &id, &live)?))
|
||||
let (edited, touched) = commit(&store, |all| Ok(apply_edit(req.clone(), all, &id, &live)?))
|
||||
.await
|
||||
.map_err(problem)?;
|
||||
tracing::info!(%id, "edited a schedule");
|
||||
project_all(&client, &store, &touched).await;
|
||||
Ok(Json(ScheduleView::new(&edited, unix_now())))
|
||||
Ok(Json(edited.view(unix_now())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
|
@ -934,9 +852,13 @@ mod tests {
|
|||
#[test]
|
||||
fn a_text_edit_leaves_the_cadence_and_id_alone() {
|
||||
let mut defs = all();
|
||||
let (out, touched) = edit(r#"{"body":"retro","name":"retro"}"#)
|
||||
.apply(&mut defs, "abc", &live())
|
||||
.unwrap();
|
||||
let (out, touched) = apply_edit(
|
||||
edit(r#"{"body":"retro","name":"retro"}"#),
|
||||
&mut defs,
|
||||
"abc",
|
||||
&live(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(out.id.as_str(), out.body.as_str(), out.name.as_str()),
|
||||
("abc", "retro", "retro")
|
||||
|
|
@ -950,9 +872,13 @@ mod tests {
|
|||
#[test]
|
||||
fn the_agent_set_changes_without_a_new_id() {
|
||||
let mut defs = all();
|
||||
let (out, touched) = edit(r#"{"add_agents":["scribe"],"remove_agents":["iris"]}"#)
|
||||
.apply(&mut defs, "abc", &live())
|
||||
.unwrap();
|
||||
let (out, touched) = apply_edit(
|
||||
edit(r#"{"add_agents":["scribe"],"remove_agents":["iris"]}"#),
|
||||
&mut defs,
|
||||
"abc",
|
||||
&live(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out.id, "abc");
|
||||
assert_eq!(out.agents, ["atlas", "scribe"].map(str::to_owned).into());
|
||||
assert_eq!(
|
||||
|
|
@ -974,7 +900,10 @@ mod tests {
|
|||
(r#"{"body":"x"}"#, "nope"),
|
||||
] {
|
||||
let mut defs = all();
|
||||
assert!(edit(json).apply(&mut defs, id, &live()).is_err(), "{json}");
|
||||
assert!(
|
||||
apply_edit(edit(json), &mut defs, id, &live()).is_err(),
|
||||
"{json}"
|
||||
);
|
||||
assert_eq!(defs, all(), "{json}");
|
||||
}
|
||||
}
|
||||
|
|
@ -982,14 +911,16 @@ mod tests {
|
|||
#[test]
|
||||
fn a_null_interval_makes_a_one_shot_and_an_absent_one_keeps_it() {
|
||||
let mut defs = all();
|
||||
let (kept, _) = edit(r#"{"paused":true}"#)
|
||||
.apply(&mut defs, "abc", &live())
|
||||
.unwrap();
|
||||
let (kept, _) = apply_edit(edit(r#"{"paused":true}"#), &mut defs, "abc", &live()).unwrap();
|
||||
assert_eq!(kept.interval_seconds, Some(3600));
|
||||
assert!(kept.paused);
|
||||
let (once, _) = edit(r#"{"interval_seconds":null}"#)
|
||||
.apply(&mut defs, "abc", &live())
|
||||
.unwrap();
|
||||
let (once, _) = apply_edit(
|
||||
edit(r#"{"interval_seconds":null}"#),
|
||||
&mut defs,
|
||||
"abc",
|
||||
&live(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(once.interval_seconds, None);
|
||||
}
|
||||
|
||||
|
|
@ -1008,9 +939,12 @@ mod tests {
|
|||
#[test]
|
||||
fn an_add_needs_a_name_and_declared_agents_and_anchors_at_now() {
|
||||
let add = |json: &str| {
|
||||
serde_json::from_str::<AddScheduleRequest>(json)
|
||||
.unwrap()
|
||||
.into_definition("id".to_owned(), 42, &live())
|
||||
new_definition(
|
||||
serde_json::from_str::<AddScheduleRequest>(json).unwrap(),
|
||||
"id".to_owned(),
|
||||
42,
|
||||
&live(),
|
||||
)
|
||||
};
|
||||
let d = add(r#"{"name":"n","agents":["iris","atlas","iris"],"body":"hi"}"#).unwrap();
|
||||
assert_eq!(
|
||||
|
|
@ -1043,7 +977,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn a_view_shows_the_wire_shape() {
|
||||
let v = serde_json::to_value(ScheduleView::new(&standup(), 1500)).unwrap();
|
||||
let v = serde_json::to_value(standup().view(1500)).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
serde_json::json!({
|
||||
|
|
|
|||
Loading…
Reference in a new issue