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:
parent
06b303aff2
commit
71d360c9d2
6 changed files with 187 additions and 1 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -5366,6 +5366,7 @@ dependencies = [
|
||||||
"serde_norway",
|
"serde_norway",
|
||||||
"sha2 0.11.1",
|
"sha2 0.11.1",
|
||||||
"swarm-controller-api",
|
"swarm-controller-api",
|
||||||
|
"swarm-wire",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ This document contains the help content for the `swarmctl` command-line program.
|
||||||
* [`swarmctl agent`↴](#swarmctl-agent)
|
* [`swarmctl agent`↴](#swarmctl-agent)
|
||||||
* [`swarmctl agent create`↴](#swarmctl-agent-create)
|
* [`swarmctl agent create`↴](#swarmctl-agent-create)
|
||||||
* [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token)
|
* [`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`↴](#swarmctl-agent-inference)
|
||||||
* [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get)
|
* [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get)
|
||||||
* [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set)
|
* [`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
|
* `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
|
* `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
|
* `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
|
* `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
|
* `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 <HIVE> <AGENT> <STATE>`
|
||||||
|
|
||||||
|
###### **Arguments:**
|
||||||
|
|
||||||
|
* `<AGENT>` — Agent name
|
||||||
|
* `<STATE>` — The state to declare: `up`, `offline`, `paused` or `destroyed`
|
||||||
|
|
||||||
|
###### **Options:**
|
||||||
|
|
||||||
|
* `--hive <HIVE>` — The hive that runs the agent
|
||||||
|
* `--yes` — Confirm `destroyed`, which can't be undone
|
||||||
|
* `--controller-socket <PATH>` — 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`
|
## `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.
|
Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models.
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ serde_norway = "0.9.42"
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
# The request and response bodies, shared with swarm-controller.
|
# The request and response bodies, shared with swarm-controller.
|
||||||
swarm-controller-api.workspace = true
|
swarm-controller-api.workspace = true
|
||||||
|
# `AgentState`, the values `agent set-state` takes.
|
||||||
|
swarm-wire.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
already does this for every agent with a store identity at start and every
|
||||||
five minutes; this verb skips the wait. Queues and returns.
|
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`
|
## `forge make-admin`
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,51 @@ pub(crate) fn mint_forge_token(target: &Target, name: &str) -> Result<()> {
|
||||||
Ok(())
|
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.
|
/// One `POST /api/agents` round trip at the controller.
|
||||||
async fn post_create(
|
async fn post_create(
|
||||||
target: &Target,
|
target: &Target,
|
||||||
|
|
@ -280,7 +325,33 @@ fn connect_hint(socket: &Path) -> String {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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
|
/// The whole point of parsing problem+json: the 400 for an unknown
|
||||||
/// hive carries the roster of hives that would have worked, and that
|
/// hive carries the roster of hives that would have worked, and that
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,19 @@ enum AgentVerb {
|
||||||
/// Queues and returns, the same way `agent create` does — watch the
|
/// Queues and returns, the same way `agent create` does — watch the
|
||||||
/// swarm UI's job view for the outcome.
|
/// swarm UI's job view for the outcome.
|
||||||
MintForgeToken(AgentMintForgeTokenArgs),
|
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
|
/// Show the model, effort, subagent models and subagent cap the swarm sets
|
||||||
/// for an agent, or change the model, effort and subagent models.
|
/// for an agent, or change the model, effort and subagent models.
|
||||||
///
|
///
|
||||||
|
|
@ -415,6 +428,23 @@ struct InferenceSetArgs {
|
||||||
socket: ControllerSocketArg,
|
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)]
|
#[derive(Args)]
|
||||||
struct AgentCreateArgs {
|
struct AgentCreateArgs {
|
||||||
/// Name for the new agent: 1–63 characters of `[a-z0-9-]`.
|
/// 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)?;
|
let target = remote.target(args.controller_socket)?;
|
||||||
agent::mint_forge_token(&target, &args.name)
|
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 {
|
Verb::Agent {
|
||||||
command: AgentVerb::Inference { command },
|
command: AgentVerb::Inference { command },
|
||||||
} => inference(&remote, command),
|
} => inference(&remote, command),
|
||||||
|
|
@ -1265,6 +1301,40 @@ fn open_new_nofollow(tmp: &Path) -> io::Result<File> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// Root on the controller host, with no `--swarm` and no login: the verbs
|
||||||
/// still go over the unix socket, unchanged by the remote path.
|
/// still go over the unix socket, unchanged by the remote path.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue