diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 5da6dcb0..be1e3ede 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -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 a slow one, and a failed refresh keeps the rows already on screen under an 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 `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 diff --git a/frontend/packages/swarm-ui/src/agent/roster/WantedMenu.tsx b/frontend/packages/swarm-ui/src/agent/roster/WantedMenu.tsx index 4b491514..02b0d1b9 100644 --- a/frontend/packages/swarm-ui/src/agent/roster/WantedMenu.tsx +++ b/frontend/packages/swarm-ui/src/agent/roster/WantedMenu.tsx @@ -1,13 +1,8 @@ // — the "wanted" state control: one badge showing the // current declaration, opening a `Dropdown` with the four explicit -// states — replaces the old toggle-badge-plus-separate-destroy-badge -// pair. Explicit options also fix a real bug the toggle had: for an -// undeclared row, the toggle inferred a target as "the opposite of -// `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"). +// states. An agent with no declaration has none to change: its badge is +// disabled and points at creating it at swarm level, because the state +// route refuses an agent the swarm never declared on that hive. // Presentational only, same split as `StatusChips`'s // `StatusMenu`: the caller owns what each selection actually does. // @@ -66,6 +61,8 @@ export function WantedMenu({ const [open, setOpen] = useState(false); const anchorRef = useRef(null); const destroyed = row.wanted === "destroyed"; + const undeclared = !row.wanted; + const canDeclare = !!row.hive && !destroyed && !undeclared; const tone: BadgeTone = row.wanted === "up" ? "positive" @@ -87,20 +84,20 @@ export function WantedMenu({
setOpen((o) => !o) : undefined} + // `destroyed` and an undeclared row block opening the menu at + // all — a UX-only guard, the server enforces both (see + // `declareState`'s caller). + onClick={canDeclare ? () => setOpen((o) => !o) : undefined} expanded={open} - disabled={pending || !row.hive || destroyed} + disabled={pending || !canDeclare} title={ destroyed ? `${row.name} is destroyed — redeploy via "+ agent" to bring it back` - : row.hive - ? `declare a new state for ${row.name}` - : "no hive on record for this agent — nothing to declare against" + : !row.hive + ? "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}` } /> ), (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 = 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), @@ -165,17 +166,17 @@ pub(crate) async fn set_agent_state( .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? .into_string(); - // Only a first declaration of the name on this hive is checked, against - // what `create_agent` refuses: an agent already declared here changes - // state whatever its name. An undeclared name that passes is accepted: - // 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. + // A placing state is accepted only for an agent the swarm already declared + // or queued on this hive; anything else is refused, so this route never + // creates an agent. // // 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. let _gate = if places_agent(req.state) { 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 detail = format!( "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(), hive_types::reserved_names_raw().as_deref(), || in_roster(state.auth.as_deref(), &agent), - || queued_placements_now(&state), + queued, || declarations_elsewhere(&state, &agent, &hive), ) .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` -/// refuses: 400 for a new name that breaks a naming rule, 409 for a name the -/// swarm has placed on another hive. An agent `here`, the hive's current -/// declaration, already declares in a placing state is placed on this hive, +/// Refuse a placement of `agent` on `hive` that the swarm never made there: +/// 400 for a new name that breaks a naming rule, 409 for a name the swarm has +/// placed on another hive, 404 for a name it has neither declared nor queued +/// 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. /// /// The roster is read only for a name that breaks a rule. Unlike in @@ -244,7 +246,7 @@ async fn refuse_placement( here: Option<&swarm_wire::wanted::HiveWanted>, reserved: Option<&str>, read_roster: impl FnOnce() -> R, - snapshot_queued: impl FnOnce() -> Vec<(String, String)>, + queued: Vec<(String, String)>, read_declared: impl FnOnce() -> D, ) -> Result<(), problem_details::ProblemDetails> where @@ -262,6 +264,7 @@ where if declared_here { 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 // carry warnings. let mut warnings = Vec::new(); @@ -278,7 +281,7 @@ where name_verdict(agent, &broken, Ok(existing), &mut warnings) .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() { let detail = format!( "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)); } + 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(()) } @@ -1853,7 +1867,7 @@ mod tests { here.as_ref(), Some("admin"), || std::future::ready(roster), - Vec::new, + Vec::new(), || std::future::ready(Ok(declared)), ) .await @@ -1935,9 +1949,17 @@ mod tests { Some(axum::http::StatusCode::SERVICE_UNAVAILABLE), "{err:?}" ); - refuse_on_pr1ma_with("atlas", Vec::new(), unreadable()) - .await - .expect("an ordinary name does not depend on the roster"); + refuse_on_pr1ma_with( + "atlas", + 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 @@ -1989,13 +2011,77 @@ mod tests { assert!(format!("{err:?}").contains("sec0nd"), "{err:?}"); } - /// An agent that runs on `pr1ma` but was never declared anywhere, and is - /// not in the roster, is one the swarm adopts by declaring it. + /// A name the swarm never declared or queued on this hive is refused with + /// 404, naming the fix. #[tokio::test] - async fn adopting_an_undeclared_agent_on_its_own_hive_is_accepted() { - refuse_on_pr1ma("legacy", Vec::new()) + async fn an_undeclared_agent_is_refused() { + let err = refuse_on_pr1ma("legacy", Vec::new()) .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]