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:
parent
bd54372fcc
commit
716290e4d2
3 changed files with 131 additions and 40 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,7 +1949,15 @@ 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(
|
||||||
|
"atlas",
|
||||||
|
vec![declaring(
|
||||||
|
"pr1ma",
|
||||||
|
"atlas",
|
||||||
|
swarm_wire::wanted::AgentState::Up,
|
||||||
|
)],
|
||||||
|
unreadable(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("an ordinary name does not depend on the roster");
|
.expect("an ordinary name does not depend on the roster");
|
||||||
}
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue