Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: state route refuses an agent the swarm has not declared on that hive

A placing state is accepted only for an agent declared or queued on the hive; anything else gets 404 telling the caller to create it at swarm level. swarm-ui's wanted badge no longer offers a first declaration for an undeclared row.
This commit is contained in:
atlas 2026-10-25 15:58:24 +01:00
commit 716290e4d2
3 changed files with 131 additions and 40 deletions

View file

@ -33,6 +33,14 @@ Pages that refresh on a timer share one polling rule: the page sends no new
request while the last one is still running, so an older answer never overtakes request while the last one is still running, so an older answer never overtakes
a slow one, and a failed refresh keeps the rows already on screen under an a slow one, and a failed refresh keeps the rows already on screen under an
error. error.
The **wanted** badge changes the state of an agent the swarm has declared. For an
agent the swarm never declared on its hive, such as one only its hive knows
about, the badge reads **no declaration** and does not open: create the agent
with `swarmctl agent create` or **+ agent**, which declares it `paused`. The API
answers `PUT /api/hives/{hive}/agents/{agent}/state` for such an agent with
404 and says to create it at swarm level.
An agent created here or with `swarmctl agent create` starts `paused`; set it An agent created here or with `swarmctl agent create` starts `paused`; set it
`up` from its card. A card shows **login needed** when the agent's own `up` from its card. A card shows **login needed** when the agent's own
turn-state header says its claude has no session, and a **log in** button turn-state header says its claude has no session, and a **log in** button

View file

@ -1,13 +1,8 @@
// <WantedMenu> — the "wanted" state control: one badge showing the // <WantedMenu> — the "wanted" state control: one badge showing the
// current declaration, opening a `Dropdown` with the four explicit // current declaration, opening a `Dropdown` with the four explicit
// states — replaces the old toggle-badge-plus-separate-destroy-badge // states. An agent with no declaration has none to change: its badge is
// pair. Explicit options also fix a real bug the toggle had: for an // disabled and points at creating it at swarm level, because the state
// undeclared row, the toggle inferred a target as "the opposite of // route refuses an agent the swarm never declared on that hive.
// `snapshot.running`", so a click on an undeclared-but-running agent
// silently declared it *offline* rather than making its real state
// explicit — a dropdown just lets "up" be picked directly regardless
// of what's inferred, no flip-to-the-opposite-of-a-guess involved
// (mara: "when no state is declared, i want to set it to online").
// Presentational only, same split as `StatusChips`'s // Presentational only, same split as `StatusChips`'s
// `StatusMenu`: the caller owns what each selection actually does. // `StatusMenu`: the caller owns what each selection actually does.
// //
@ -66,6 +61,8 @@ export function WantedMenu({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null); const anchorRef = useRef<HTMLDivElement>(null);
const destroyed = row.wanted === "destroyed"; const destroyed = row.wanted === "destroyed";
const undeclared = !row.wanted;
const canDeclare = !!row.hive && !destroyed && !undeclared;
const tone: BadgeTone = const tone: BadgeTone =
row.wanted === "up" row.wanted === "up"
? "positive" ? "positive"
@ -87,20 +84,20 @@ export function WantedMenu({
<div class="ui-dropdown-anchor" ref={anchorRef}> <div class="ui-dropdown-anchor" ref={anchorRef}>
<Badge <Badge
tone={tone} tone={tone}
value={pending ? "…" : (row.wanted ?? "no declaration")} // `destroyed` and an undeclared row block opening the menu at
// `destroyed` blocks opening the menu at all — same UX-only // all — a UX-only guard, the server enforces both (see
// guard as before (real enforcement is server-side, see // `declareState`'s caller).
// `declareState`'s caller), just applied to the trigger instead onClick={canDeclare ? () => setOpen((o) => !o) : undefined}
// of a `disabled` toggle badge.
onClick={row.hive && !destroyed ? () => setOpen((o) => !o) : undefined}
expanded={open} expanded={open}
disabled={pending || !row.hive || destroyed} disabled={pending || !canDeclare}
title={ title={
destroyed destroyed
? `${row.name} is destroyed — redeploy via "+ agent" to bring it back` ? `${row.name} is destroyed — redeploy via "+ agent" to bring it back`
: row.hive : !row.hive
? `declare a new state for ${row.name}` ? "no hive on record for this agent — nothing to declare against"
: "no hive on record for this agent — nothing to declare against" : undeclared
? `${row.name} is not declared at swarm level — create it there`
: `declare a new state for ${row.name}`
} }
/> />
<Dropdown <Dropdown

View file

@ -148,6 +148,7 @@ fn declaration_target(
responses( responses(
(status = 200, description = "the declaration as now published", body = Vec<AgentDeclaration>), (status = 200, description = "the declaration as now published", body = Vec<AgentDeclaration>),
(status = 400, description = "a name is not an identifier, the hive is not in this swarm, the state is unknown, or `agent` is new and reserved or refused by the forge (problem+json)", body = String), (status = 400, description = "a name is not an identifier, the hive is not in this swarm, the state is unknown, or `agent` is new and reserved or refused by the forge (problem+json)", body = String),
(status = 404, description = "the swarm has neither declared nor queued `agent` on this hive; create it at swarm level first (problem+json)", body = String),
(status = 409, description = "the swarm has already placed `agent` on a different hive (problem+json)", body = String), (status = 409, description = "the swarm has already placed `agent` on a different hive (problem+json)", body = String),
(status = 503, description = "no swarm queue is wired up, or it is not connected, or `agent` is new here and breaks a naming rule but whether it already exists could not be checked (problem+json)", body = String), (status = 503, description = "no swarm queue is wired up, or it is not connected, or `agent` is new here and breaks a naming rule but whether it already exists could not be checked (problem+json)", body = String),
(status = 500, description = "the declaration could not be published, or another hive's wanted state could not be read (problem+json)", body = String), (status = 500, description = "the declaration could not be published, or another hive's wanted state could not be read (problem+json)", body = String),
@ -165,17 +166,17 @@ pub(crate) async fn set_agent_state(
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string(); .into_string();
// Only a first declaration of the name on this hive is checked, against // A placing state is accepted only for an agent the swarm already declared
// what `create_agent` refuses: an agent already declared here changes // or queued on this hive; anything else is refused, so this route never
// state whatever its name. An undeclared name that passes is accepted: // creates an agent.
// that is how the swarm adopts an agent that predates swarm-level
// creation. Deliberate, pending the operator's decision on whether a name
// the swarm never placed should be refused here instead.
// //
// The gate is held from the checks through the write. A destroy places // The gate is held from the checks through the write. A destroy places
// nothing, so it is not checked and does not wait on creations. // nothing, so it is not checked and does not wait on creations.
let _gate = if places_agent(req.state) { let _gate = if places_agent(req.state) {
let gate = state.create_gate.lock().await; let gate = state.create_gate.lock().await;
// Before the declaration is read, so a creation finishing in between
// is in the snapshot or in the declaration.
let queued = queued_placements_now(&state);
let here = writer.view(&hive).await.map_err(|e| { let here = writer.view(&hive).await.map_err(|e| {
let detail = format!( let detail = format!(
"cannot tell whether {agent:?} is already declared on hive {hive:?}, so it was \ "cannot tell whether {agent:?} is already declared on hive {hive:?}, so it was \
@ -189,7 +190,7 @@ pub(crate) async fn set_agent_state(
here.as_ref(), here.as_ref(),
hive_types::reserved_names_raw().as_deref(), hive_types::reserved_names_raw().as_deref(),
|| in_roster(state.auth.as_deref(), &agent), || in_roster(state.auth.as_deref(), &agent),
|| queued_placements_now(&state), queued,
|| declarations_elsewhere(&state, &agent, &hive), || declarations_elsewhere(&state, &agent, &hive),
) )
.await?; .await?;
@ -229,10 +230,11 @@ pub(crate) fn places_agent(state: swarm_wire::wanted::AgentState) -> bool {
} }
} }
/// Refuse a first placement of `agent` on `hive` that `create_agent` /// Refuse a placement of `agent` on `hive` that the swarm never made there:
/// refuses: 400 for a new name that breaks a naming rule, 409 for a name the /// 400 for a new name that breaks a naming rule, 409 for a name the swarm has
/// swarm has placed on another hive. An agent `here`, the hive's current /// placed on another hive, 404 for a name it has neither declared nor queued
/// declaration, already declares in a placing state is placed on this hive, /// on this hive. An agent `here`, the hive's current declaration, already
/// declares in a placing state, or one in `queued`, is placed on this hive,
/// and passes. /// and passes.
/// ///
/// The roster is read only for a name that breaks a rule. Unlike in /// The roster is read only for a name that breaks a rule. Unlike in
@ -244,7 +246,7 @@ async fn refuse_placement<R, D>(
here: Option<&swarm_wire::wanted::HiveWanted>, here: Option<&swarm_wire::wanted::HiveWanted>,
reserved: Option<&str>, reserved: Option<&str>,
read_roster: impl FnOnce() -> R, read_roster: impl FnOnce() -> R,
snapshot_queued: impl FnOnce() -> Vec<(String, String)>, queued: Vec<(String, String)>,
read_declared: impl FnOnce() -> D, read_declared: impl FnOnce() -> D,
) -> Result<(), problem_details::ProblemDetails> ) -> Result<(), problem_details::ProblemDetails>
where where
@ -262,6 +264,7 @@ where
if declared_here { if declared_here {
return Ok(()); return Ok(());
} }
let queued_here = queued.iter().any(|(h, a)| h == hive && a == agent);
// Both calls log what they push here; this response has nowhere to // Both calls log what they push here; this response has nowhere to
// carry warnings. // carry warnings.
let mut warnings = Vec::new(); let mut warnings = Vec::new();
@ -278,7 +281,7 @@ where
name_verdict(agent, &broken, Ok(existing), &mut warnings) name_verdict(agent, &broken, Ok(existing), &mut warnings)
.map_err(|detail| error_problem(axum::http::StatusCode::BAD_REQUEST, &detail))?; .map_err(|detail| error_problem(axum::http::StatusCode::BAD_REQUEST, &detail))?;
} }
let elsewhere = placements_elsewhere(agent, hive, snapshot_queued, read_declared).await?; let elsewhere = placements_elsewhere(agent, hive, || queued, read_declared).await?;
if !elsewhere.is_empty() { if !elsewhere.is_empty() {
let detail = format!( let detail = format!(
"agent {agent:?} is placed on hive {}, not {hive:?} — agent names are unique across \ "agent {agent:?} is placed on hive {}, not {hive:?} — agent names are unique across \
@ -287,6 +290,17 @@ where
); );
return Err(error_problem(axum::http::StatusCode::CONFLICT, &detail)); return Err(error_problem(axum::http::StatusCode::CONFLICT, &detail));
} }
if !queued_here {
tracing::warn!(
hive,
agent,
"refused a state for an agent the swarm has not declared on this hive"
);
let detail = format!(
"agent {agent:?} is not declared at swarm level on hive {hive:?}; create it there"
);
return Err(error_problem(axum::http::StatusCode::NOT_FOUND, &detail));
}
Ok(()) Ok(())
} }
@ -1853,7 +1867,7 @@ mod tests {
here.as_ref(), here.as_ref(),
Some("admin"), Some("admin"),
|| std::future::ready(roster), || std::future::ready(roster),
Vec::new, Vec::new(),
|| std::future::ready(Ok(declared)), || std::future::ready(Ok(declared)),
) )
.await .await
@ -1935,9 +1949,17 @@ mod tests {
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE), Some(axum::http::StatusCode::SERVICE_UNAVAILABLE),
"{err:?}" "{err:?}"
); );
refuse_on_pr1ma_with("atlas", Vec::new(), unreadable()) refuse_on_pr1ma_with(
.await "atlas",
.expect("an ordinary name does not depend on the roster"); vec![declaring(
"pr1ma",
"atlas",
swarm_wire::wanted::AgentState::Up,
)],
unreadable(),
)
.await
.expect("an ordinary name does not depend on the roster");
} }
/// A destroyed agent is gone from its hive, so declaring it again is a /// A destroyed agent is gone from its hive, so declaring it again is a
@ -1989,13 +2011,77 @@ mod tests {
assert!(format!("{err:?}").contains("sec0nd"), "{err:?}"); assert!(format!("{err:?}").contains("sec0nd"), "{err:?}");
} }
/// An agent that runs on `pr1ma` but was never declared anywhere, and is /// A name the swarm never declared or queued on this hive is refused with
/// not in the roster, is one the swarm adopts by declaring it. /// 404, naming the fix.
#[tokio::test] #[tokio::test]
async fn adopting_an_undeclared_agent_on_its_own_hive_is_accepted() { async fn an_undeclared_agent_is_refused() {
refuse_on_pr1ma("legacy", Vec::new()) let err = refuse_on_pr1ma("legacy", Vec::new())
.await .await
.expect("an undeclared agent must still be adoptable"); .expect_err("an agent the swarm never declared must be refused");
assert_eq!(
err.status,
Some(axum::http::StatusCode::NOT_FOUND),
"{err:?}"
);
assert!(format!("{err:?}").contains("create it there"), "{err:?}");
}
/// An agent declared `Destroyed` here is gone from the hive, so it is
/// undeclared again.
#[tokio::test]
async fn a_destroyed_agent_is_refused_like_an_undeclared_one() {
use swarm_wire::wanted::AgentState;
let err = refuse_on_pr1ma(
"atlas",
vec![declaring("pr1ma", "atlas", AgentState::Destroyed)],
)
.await
.expect_err("a destroyed agent must be created again, not declared");
assert_eq!(
err.status,
Some(axum::http::StatusCode::NOT_FOUND),
"{err:?}"
);
}
/// Presence control for the two refusals above: a creation accepted but
/// not yet written passes.
#[tokio::test]
async fn an_agent_queued_for_this_hive_is_accepted() {
let queued = vec![("pr1ma".to_owned(), "atlas".to_owned())];
super::refuse_placement(
"atlas",
"pr1ma",
None,
Some("admin"),
|| std::future::ready(Ok(false)),
queued,
|| std::future::ready(Ok(Vec::new())),
)
.await
.expect("a queued creation is declared");
}
/// A creation queued for another hive does not declare the agent here.
#[tokio::test]
async fn an_agent_queued_for_another_hive_is_refused() {
let queued = vec![("sec0nd".to_owned(), "atlas".to_owned())];
let err = super::refuse_placement(
"atlas",
"pr1ma",
None,
Some("admin"),
|| std::future::ready(Ok(false)),
queued,
|| std::future::ready(Ok(Vec::new())),
)
.await
.expect_err("queued elsewhere is placed elsewhere");
assert_eq!(
err.status,
Some(axum::http::StatusCode::CONFLICT),
"{err:?}"
);
} }
#[tokio::test] #[tokio::test]