Watch
0
0
Fork
You've already forked hyperhive
0

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
This commit is contained in:
atlas 2026-11-07 03:58:39 +01:00 • committed by mara
commit 71d360c9d2
4 changed files with 187 additions and 1 deletions

View file

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

View file

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

View file

@ -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<swarm_wire::wanted::AgentState, String> {
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

View file

@ -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<File> {
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]