From 71d360c9d25c1c42091c74fcf4dccd71d895ab18 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 7 Nov 2026 03:58:39 +0100 Subject: [PATCH] swarmctl: agent set-state declares an agent's wanted state Wraps PUT /api/hives/{hive}/agents/{agent}/state, the route the swarm UI's state menu uses. States come from swarm_wire::wanted::AgentState; destroyed needs --yes. Closes #6159 --- Cargo.lock | 1 + docs/2-handbook/reference/swarmctl-cli.md | 29 +++++++++ swarmctl/Cargo.toml | 2 + swarmctl/README.md | 13 ++++ swarmctl/src/agent.rs | 73 ++++++++++++++++++++++- swarmctl/src/main.rs | 70 ++++++++++++++++++++++ 6 files changed, 187 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 78a64609..5c9674e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5366,6 +5366,7 @@ dependencies = [ "serde_norway", "sha2 0.11.1", "swarm-controller-api", + "swarm-wire", "tokio", "url", ] diff --git a/docs/2-handbook/reference/swarmctl-cli.md b/docs/2-handbook/reference/swarmctl-cli.md index 3f038c66..148dfc33 100644 --- a/docs/2-handbook/reference/swarmctl-cli.md +++ b/docs/2-handbook/reference/swarmctl-cli.md @@ -8,6 +8,7 @@ This document contains the help content for the `swarmctl` command-line program. * [`swarmctl agent`↴](#swarmctl-agent) * [`swarmctl agent create`↴](#swarmctl-agent-create) * [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token) +* [`swarmctl agent set-state`↴](#swarmctl-agent-set-state) * [`swarmctl agent inference`↴](#swarmctl-agent-inference) * [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get) * [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set) @@ -72,6 +73,7 @@ Manage agents across the swarm * `create` — Queue creation of a new agent on a hive in this swarm * `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale +* `set-state` — Declare the state a hive converges an agent to: `up`, `offline`, `paused` or `destroyed` * `inference` — Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models * `permissions` — Show or change the permissions the swarm grants an agent * `agents-md` — Show an agent's AGENTS.md, or edit it with a diff @@ -132,6 +134,33 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo +## `swarmctl agent set-state` + +Declare the state a hive converges an agent to: `up`, `offline`, `paused` or `destroyed`. + +Writes the swarm's declaration for the agent, the one the swarm UI's state menu writes, and returns once the controller has published it; the hive converges on its own clock. The swarm must already know the agent on `--hive`: this never creates one. + +`destroyed` can't be undone, so it needs `--yes`. + +No approval gate guards this: the caller is root on the controller's host, or logged in through the swarm domain's SSO gate. + +**Usage:** `swarmctl agent set-state [OPTIONS] --hive ` + +###### **Arguments:** + +* `` — Agent name +* `` — The state to declare: `up`, `offline`, `paused` or `destroyed` + +###### **Options:** + +* `--hive ` — The hive that runs the agent +* `--yes` — Confirm `destroyed`, which can't be undone +* `--controller-socket ` — swarm-controller's unix socket. + + Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`. + + + ## `swarmctl agent inference` Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models. diff --git a/swarmctl/Cargo.toml b/swarmctl/Cargo.toml index f89a8913..a59f9731 100644 --- a/swarmctl/Cargo.toml +++ b/swarmctl/Cargo.toml @@ -36,6 +36,8 @@ serde_norway = "0.9.42" sha2.workspace = true # The request and response bodies, shared with swarm-controller. swarm-controller-api.workspace = true +# `AgentState`, the values `agent set-state` takes. +swarm-wire.workspace = true tokio.workspace = true url.workspace = true diff --git a/swarmctl/README.md b/swarmctl/README.md index f0e7be31..56315b74 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -133,6 +133,19 @@ agent's forge token and mints it if it's missing or stale. The controller already does this for every agent with a store identity at start and every five minutes; this verb skips the wait. Queues and returns. +## `agent set-state` + +```console +# swarmctl agent set-state ruth --hive alpha paused +declared agent "ruth" paused on hive "alpha"; the hive converges on its own clock +``` + +`PUT /api/hives/{hive}/agents/{agent}/state` on the swarm-controller, the route +the swarm UI's state menu uses. The values are `swarm_wire::wanted::AgentState`'s: +`up`, `offline`, `paused`, `destroyed`. It never creates an agent: the controller +refuses an agent the swarm hasn't declared on that hive. `destroyed` can't be +undone, so it needs `--yes`. + ## `forge make-admin` ```console diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index ee3337c0..8482b22f 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -112,6 +112,51 @@ pub(crate) fn mint_forge_token(target: &Target, name: &str) -> Result<()> { Ok(()) } +/// Parse a state as the wire enum spells it, so the accepted values are +/// `swarm_wire::wanted::AgentState`'s and the error lists them. +pub(crate) fn parse_state(value: &str) -> Result { + serde_json::from_value(serde_json::Value::String(value.to_owned())).map_err(|e| e.to_string()) +} + +/// Refuse `destroyed` unless the operator confirmed it. +fn check_confirmed(state: swarm_wire::wanted::AgentState, yes: bool) -> Result<()> { + if state == swarm_wire::wanted::AgentState::Destroyed && !yes { + bail!("`destroyed` can't be undone: pass --yes to declare it"); + } + Ok(()) +} + +/// Run `swarmctl agent set-state`: `PUT /api/hives/{hive}/agents/{agent}/state`. +pub(crate) fn set_state( + target: &Target, + agent: &str, + hive: &str, + state: swarm_wire::wanted::AgentState, + yes: bool, +) -> Result<()> { + check_confirmed(state, yes)?; + let agent = parse_ident(agent, "agent name")?; + let hive = parse_ident(hive, "hive")?; + let wire: &'static str = state.into(); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("starting a tokio runtime for the controller request")?; + rt.block_on(send::<_, serde_json::Value>( + target, + hyper::Method::PUT, + &format!("/api/hives/{hive}/agents/{agent}/state"), + Some(&serde_json::json!({ "state": wire })), + "set-state", + ))?; + + println!( + "declared agent {agent:?} {wire} on hive {hive:?}; the hive converges on its own clock" + ); + Ok(()) +} + /// One `POST /api/agents` round trip at the controller. async fn post_create( target: &Target, @@ -280,7 +325,33 @@ fn connect_hint(socket: &Path) -> String { #[cfg(test)] mod tests { - use super::{describe_error, parse_ident}; + use super::{check_confirmed, describe_error, parse_ident, parse_state}; + use swarm_wire::wanted::AgentState; + + /// The accepted values are the wire enum's: each spelling it writes + /// parses back, and an unknown one names the valid ones. + #[test] + fn states_parse_through_the_wire_enum() { + for state in [ + AgentState::Up, + AgentState::Offline, + AgentState::Paused, + AgentState::Destroyed, + ] { + let wire: &'static str = state.into(); + assert_eq!(parse_state(wire), Ok(state)); + } + let err = parse_state("sleeping").expect_err("unknown state"); + assert!(err.contains("paused"), "{err}"); + } + + #[test] + fn destroyed_needs_yes() { + assert!(check_confirmed(AgentState::Destroyed, false).is_err()); + assert!(check_confirmed(AgentState::Destroyed, true).is_ok()); + // Control: the other states don't ask. + assert!(check_confirmed(AgentState::Paused, false).is_ok()); + } /// The whole point of parsing problem+json: the 400 for an unknown /// hive carries the roster of hives that would have worked, and that diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 4df2383c..8049a40a 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -279,6 +279,19 @@ enum AgentVerb { /// Queues and returns, the same way `agent create` does — watch the /// swarm UI's job view for the outcome. MintForgeToken(AgentMintForgeTokenArgs), + /// Declare the state a hive converges an agent to: `up`, `offline`, + /// `paused` or `destroyed`. + /// + /// Writes the swarm's declaration for the agent, the one the swarm UI's + /// state menu writes, and returns once the controller has published it; + /// the hive converges on its own clock. The swarm must already know the + /// agent on `--hive`: this never creates one. + /// + /// `destroyed` can't be undone, so it needs `--yes`. + /// + /// No approval gate guards this: the caller is root on the controller's + /// host, or logged in through the swarm domain's SSO gate. + SetState(AgentSetStateArgs), /// Show the model, effort, subagent models and subagent cap the swarm sets /// for an agent, or change the model, effort and subagent models. /// @@ -415,6 +428,23 @@ struct InferenceSetArgs { socket: ControllerSocketArg, } +#[derive(Args)] +struct AgentSetStateArgs { + /// Agent name. + agent: String, + /// The hive that runs the agent. + #[arg(long, value_name = "HIVE")] + hive: String, + /// The state to declare: `up`, `offline`, `paused` or `destroyed`. + #[arg(value_name = "STATE", value_parser = agent::parse_state)] + state: swarm_wire::wanted::AgentState, + /// Confirm `destroyed`, which can't be undone. + #[arg(long)] + yes: bool, + #[command(flatten)] + socket: ControllerSocketArg, +} + #[derive(Args)] struct AgentCreateArgs { /// Name for the new agent: 1–63 characters of `[a-z0-9-]`. @@ -690,6 +720,12 @@ fn main() -> Result<()> { let target = remote.target(args.controller_socket)?; agent::mint_forge_token(&target, &args.name) } + Verb::Agent { + command: AgentVerb::SetState(args), + } => { + let target = args.socket.resolve(&remote)?; + agent::set_state(&target, &args.agent, &args.hive, args.state, args.yes) + } Verb::Agent { command: AgentVerb::Inference { command }, } => inference(&remote, command), @@ -1265,6 +1301,40 @@ fn open_new_nofollow(tmp: &Path) -> io::Result { mod tests { use super::*; + #[test] + fn set_state_takes_the_wire_enum_values() { + let cli = Cli::try_parse_from([ + "swarmctl", + "agent", + "set-state", + "iris", + "--hive", + "alpha", + "paused", + ]) + .expect("parses"); + let Verb::Agent { + command: AgentVerb::SetState(args), + } = cli.command + else { + panic!("parsed as another verb"); + }; + assert_eq!(args.state, swarm_wire::wanted::AgentState::Paused); + assert!(!args.yes); + assert!( + Cli::try_parse_from([ + "swarmctl", + "agent", + "set-state", + "iris", + "--hive", + "alpha", + "sleeping", + ]) + .is_err() + ); + } + /// Root on the controller host, with no `--swarm` and no login: the verbs /// still go over the unix socket, unchanged by the remote path. #[test]