Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-17 16:44:10 +02:00 • committed by mara
commit be92bc0835
17 changed files with 529 additions and 377 deletions

View file

@ -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

View file

@ -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!({