diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 62e230f4..19dd5673 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -21,6 +21,8 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl agents restart`↴](#hivectl-agents-restart) * [`hivectl agents restart-all`↴](#hivectl-agents-restart-all) * [`hivectl choom`↴](#hivectl-choom) +* [`hivectl stop`↴](#hivectl-stop) +* [`hivectl start`↴](#hivectl-start) ## `hivectl` @@ -35,6 +37,8 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that * `gateway` — Gateway htpasswd user management. Add, remove, or list users in an htpasswd file used by the gateway's HTTP Basic auth (`services.hyperhive.gateway.auth`). Credentials are stored as `BCrypt` hashes — no extra service or PAM required * `agents` — Agent container management. Requires the hive-c0re daemon to be running (connects to the host admin socket) * `choom` — Open an interactive Claude session inside an agent container +* `stop` — Stop containers hive-wide in one operator action. Bare `hivectl stop` stops **everything** — all sub-agents plus the ci, forge, gateway, and matrix infra containers. Narrow it with scope flags: `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / `--matrix` (named infra), and `--agent ` (repeatable) for specific sub-agents. Flags are additive (e.g. `--agents --matrix`). Requires the hive-c0re daemon (connects to the host admin socket). hive-c0re itself is never stopped — it services the request +* `start` — Start containers hive-wide — the inverse of `hivectl stop`. Bare `hivectl start` starts everything back up; the same scope flags as `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). Requires the hive-c0re daemon @@ -297,6 +301,49 @@ Pass `--fresh` to start a new Claude session instead of continuing the most rece +## `hivectl stop` + +Stop containers hive-wide in one operator action. Bare `hivectl stop` stops **everything** — all sub-agents plus the ci, forge, gateway, and matrix infra containers. Narrow it with scope flags: `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / `--matrix` (named infra), and `--agent ` (repeatable) for specific sub-agents. Flags are additive (e.g. `--agents --matrix`). Requires the hive-c0re daemon (connects to the host admin socket). hive-c0re itself is never stopped — it services the request + +**Usage:** `hivectl stop [OPTIONS]` + +###### **Options:** + +* `--agents` — All sub-agent containers +* `--agent ` — A specific sub-agent by name. Repeatable: `--agent a --agent b` +* `--ci` — The CI runner container (`hive-ci`) +* `--forge` — The forge container (`hive-forge`) +* `--gateway` — The gateway container (`hive-gateway`) +* `--matrix` — The matrix container (`hive-matrix`) +* `--graceful` — Gracefully quiesce each agent (finish the current turn, drain the inbox) before stopping, instead of a hard stop. + + NOTE: not yet effective — the per-agent quiesce is still being implemented (see the graceful-agent-stop tracker), so today this falls through to a hard stop. The flag is accepted now so the wire/CLI shape is stable when the quiesce lands. +* `--socket ` — Path to the hive-c0re host admin socket + + Default value: `/run/hyperhive/host.sock` + + + +## `hivectl start` + +Start containers hive-wide — the inverse of `hivectl stop`. Bare `hivectl start` starts everything back up; the same scope flags as `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). Requires the hive-c0re daemon + +**Usage:** `hivectl start [OPTIONS]` + +###### **Options:** + +* `--agents` — All sub-agent containers +* `--agent ` — A specific sub-agent by name. Repeatable: `--agent a --agent b` +* `--ci` — The CI runner container (`hive-ci`) +* `--forge` — The forge container (`hive-forge`) +* `--gateway` — The gateway container (`hive-gateway`) +* `--matrix` — The matrix container (`hive-matrix`) +* `--socket ` — Path to the hive-c0re host admin socket + + Default value: `/run/hyperhive/host.sock` + + +
diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 68d00ccd..2808fe70 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -518,10 +518,10 @@ async fn handle_start_child(coord: &Arc, agent: &str, name: &str) - async fn handle_restart_child(coord: &Arc, agent: &str, name: &str) -> AgentResponse { // Infra-container restart: an agent holding the `infra_admin` // capability can restart a hive infrastructure container (hive-ci / - // hive-gateway / hive-forge) by passing its name to the same restart - // tool. These names are never agent children, so this branch is - // disjoint from the child-restart path below. - if hive_sh4re::priv_proto::RESTARTABLE_INFRA_CONTAINERS.contains(&name) { + // hive-gateway / hive-forge / hive-matrix) by passing its name to the + // same restart tool. These names are never agent children, so this + // branch is disjoint from the child-restart path below. + if hive_sh4re::priv_proto::SIBLING_CONTAINERS.contains(&name) { return handle_restart_infra(coord, agent, name).await; } if let Some(err) = require_child(agent, name, "restart") { @@ -541,7 +541,7 @@ async fn handle_restart_child(coord: &Arc, agent: &str, name: &str) /// Restart a hive infrastructure container on behalf of an agent that /// holds the `infra_admin` capability. The container name is already -/// known to be in `RESTARTABLE_INFRA_CONTAINERS`; this gates on the +/// known to be in `SIBLING_CONTAINERS`; this gates on the /// capability and routes the systemctl restart through hive-priv (which /// re-validates the name root-side). Direct, not approval-gated. async fn handle_restart_infra( diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 9374c004..1fe74ed1 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -19,7 +19,7 @@ use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use hive_c0re::coordinator::Coordinator; #[derive(Parser)] @@ -100,6 +100,41 @@ enum Cmd { #[arg(long)] fresh: bool, }, + /// Stop containers hive-wide in one operator action. Bare `hivectl + /// stop` stops **everything** — all sub-agents plus the ci, forge, + /// gateway, and matrix infra containers. Narrow it with scope flags: + /// `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / + /// `--matrix` (named infra), and `--agent ` (repeatable) for + /// specific sub-agents. Flags are additive (e.g. `--agents --matrix`). + /// Requires the hive-c0re daemon (connects to the host admin socket). + /// hive-c0re itself is never stopped — it services the request. + Stop { + #[command(flatten)] + scope: ScopeArgs, + /// Gracefully quiesce each agent (finish the current turn, drain + /// the inbox) before stopping, instead of a hard stop. + /// + /// NOTE: not yet effective — the per-agent quiesce is still being + /// implemented (see the graceful-agent-stop tracker), so today + /// this falls through to a hard stop. The flag is accepted now so + /// the wire/CLI shape is stable when the quiesce lands. + #[arg(long)] + graceful: bool, + /// Path to the hive-c0re host admin socket. + #[arg(long, default_value = DEFAULT_HOST_SOCKET)] + socket: PathBuf, + }, + /// Start containers hive-wide — the inverse of `hivectl stop`. Bare + /// `hivectl start` starts everything back up; the same scope flags as + /// `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, + /// `--matrix`, `--agent `). Requires the hive-c0re daemon. + Start { + #[command(flatten)] + scope: ScopeArgs, + /// Path to the hive-c0re host admin socket. + #[arg(long, default_value = DEFAULT_HOST_SOCKET)] + socket: PathBuf, + }, /// Emit the full CLI reference as `CommonMark` to stdout. /// /// Hidden tooling command (not part of day-to-day operator admin): @@ -111,6 +146,49 @@ enum Cmd { MarkdownDocs, } +/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag +/// set the verb targets **everything** (all sub-agents + every controllable +/// infra container). Setting any flag restricts to the selected classes, +/// additively. +// One bool per selectable container class, each mapping 1:1 to a clap flag; +// orthogonal toggles, not a state machine — hence the bools allow (mirrors +// `hive_sh4re::LifecycleScope`). +#[allow(clippy::struct_excessive_bools)] +#[derive(Args)] +struct ScopeArgs { + /// All sub-agent containers. + #[arg(long)] + agents: bool, + /// A specific sub-agent by name. Repeatable: `--agent a --agent b`. + #[arg(long = "agent", value_name = "NAME")] + agent: Vec, + /// The CI runner container (`hive-ci`). + #[arg(long)] + ci: bool, + /// The forge container (`hive-forge`). + #[arg(long)] + forge: bool, + /// The gateway container (`hive-gateway`). + #[arg(long)] + gateway: bool, + /// The matrix container (`hive-matrix`). + #[arg(long)] + matrix: bool, +} + +impl ScopeArgs { + fn to_scope(&self) -> hive_sh4re::LifecycleScope { + hive_sh4re::LifecycleScope { + agents: self.agents, + agent_names: self.agent.clone(), + ci: self.ci, + forge: self.forge, + gateway: self.gateway, + matrix: self.matrix, + } + } +} + #[derive(Subcommand)] enum ForgeCmd { /// Create or refresh the Forgejo account + token for ``. @@ -348,6 +426,12 @@ async fn main() -> Result<()> { AgentsCmd::Restart { name, socket } => agents_restart(&socket, &name).await, AgentsCmd::RestartAll { socket } => agents_restart_all(&socket).await, }, + Cmd::Stop { + scope, + graceful, + socket, + } => stop(&socket, scope.to_scope(), graceful).await, + Cmd::Start { scope, socket } => start(&socket, scope.to_scope()).await, Cmd::Choom { name, fresh } => choom(&name, fresh), Cmd::MarkdownDocs => { print!("{}", clap_markdown::help_markdown::()); @@ -804,6 +888,43 @@ async fn agents_restart_all(socket: &Path) -> Result<()> { Ok(()) } +async fn stop(socket: &Path, scope: hive_sh4re::LifecycleScope, graceful: bool) -> Result<()> { + let resp = + hive_c0re::client::request(socket, hive_sh4re::HostRequest::Stop { scope, graceful }) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + render_lifecycle(&resp, "stopped") +} + +async fn start(socket: &Path, scope: hive_sh4re::LifecycleScope) -> Result<()> { + let resp = hive_c0re::client::request(socket, hive_sh4re::HostRequest::Start { scope }) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + render_lifecycle(&resp, "started") +} + +/// Render a hive-wide stop/start response: one `: ` line per +/// touched container, then surface any aggregated per-target failure as a +/// non-zero exit. `verb` is the past-tense word printed per item +/// (`stopped` / `started`). +fn render_lifecycle(resp: &hive_sh4re::HostResponse, verb: &str) -> Result<()> { + let items = resp.agents.as_deref().unwrap_or(&[]); + if items.is_empty() { + println!("{verb}: nothing matched the requested scope"); + } else { + for item in items { + println!("{verb}: {item}"); + } + } + if !resp.ok { + bail!( + "{verb}: {}", + resp.error.as_deref().unwrap_or("unknown error") + ); + } + Ok(()) +} + /// Reject usernames containing `:` (field separator) or control chars /// that would corrupt the htpasswd file format. fn validate_htpasswd_username(username: &str) -> Result<()> { diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 6f38a131..b6a25b5f 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -8,8 +8,8 @@ use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - BindMount, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, - PrivStream, + BindMount, InfraAction, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, + PrivResponse, PrivStream, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -283,14 +283,24 @@ pub async fn restart_matrix_daemon(agent_name: &str) -> Result<()> { .await?) } -/// Restart a hive infrastructure container (hive-ci / hive-gateway / -/// hive-forge) on the host via `systemctl restart -/// container@.service`. hive-priv re-validates `container` -/// against its root-side allowlist; callers must already have checked -/// the requesting agent holds the `infra_admin` capability. +/// Restart a hive infrastructure container on the host (thin wrapper over +/// [`control_infra_container`] with `action = Restart`). hive-priv +/// re-validates `container` against its root-side allowlist; callers must +/// already have checked the requesting agent holds the `infra_admin` +/// capability. pub async fn restart_infra_container(container: &str) -> Result<()> { - ok(call(&PrivRequest::RestartInfraContainer { + control_infra_container(container, InfraAction::Restart).await +} + +/// Start / stop / restart a hive infrastructure container (`hive-ci`, +/// `hive-gateway`, `hive-forge`, `hive-matrix`) on the host via `systemctl +/// container@.service`. hive-priv re-validates +/// `container` against its root-side allowlist (`SIBLING_CONTAINERS`). Used +/// by the hive-wide `hivectl stop` / `hivectl start` flow. +pub async fn control_infra_container(container: &str, action: InfraAction) -> Result<()> { + ok(call(&PrivRequest::ControlInfraContainer { container: container.to_owned(), + action, }) .await?) } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 69e643d6..2e2a041a 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -2,7 +2,8 @@ use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; -use hive_sh4re::{HostRequest, HostResponse}; +use hive_sh4re::priv_proto::InfraAction; +use hive_sh4re::{HostRequest, HostResponse, LifecycleScope}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; @@ -93,6 +94,20 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostResponse::success() } HostRequest::RestartAll => handle_restart_all().await?, + HostRequest::Stop { scope, graceful } => { + // Resolve the scope to explicit container names at the entry + // point, then operate on names — never pass the bare "all + // agents" flag deeper (it'd force every consumer, incl. the + // graceful-stop queue, to re-expand it). + let agents = scoped_agents(scope).await?; + let infra = scoped_infra(scope); + handle_stop(&agents, &infra, *graceful).await? + } + HostRequest::Start { scope } => { + let agents = scoped_agents(scope).await?; + let infra = scoped_infra(scope); + handle_start(&agents, &infra).await? + } HostRequest::Destroy { name, purge } => { actions::destroy(&coord, name, *purge).await?; HostResponse::success() @@ -199,6 +214,136 @@ async fn handle_restart_all() -> Result { } } +/// Stop the given `agents` (resolved logical names) then `infra` containers +/// (`hivectl stop`). Agents go down before infra so they're not mid-request +/// against a forge/matrix that's already gone. Per-target failures are +/// aggregated rather than aborting on the first error, mirroring +/// `handle_restart_all`. Callers resolve the [`LifecycleScope`] to these +/// explicit name lists up front — this never sees the "all" flag. +async fn handle_stop(agents: &[String], infra: &[&str], graceful: bool) -> Result { + tracing::info!(?agents, ?infra, graceful, "stop"); + let mut ok_items: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + + for agent in agents { + // TODO(graceful agent stop): when `graceful`, run the per-agent + // quiesce (turn-end → drain → reject new messages) before the kill. + // The flag is threaded through the wire now; the quiesce itself + // lands with the graceful-agent-stop work. + let _ = graceful; + match lifecycle::kill(agent).await { + Ok(()) => ok_items.push(agent.clone()), + Err(e) => { + tracing::warn!(%agent, error = ?e, "stop: agent kill failed"); + errors.push(format!("{agent}: {e:#}")); + } + } + } + + for &container in infra { + match crate::priv_client::control_infra_container(container, InfraAction::Stop).await { + Ok(()) => ok_items.push(container.to_owned()), + Err(e) => { + tracing::warn!(%container, error = ?e, "stop: infra stop failed"); + errors.push(format!("{container}: {e:#}")); + } + } + } + + Ok(finish_lifecycle(ok_items, &errors)) +} + +/// Start the given `infra` containers then `agents` (`hivectl start`) — the +/// inverse of [`handle_stop`]. Infra comes up before agents so the agents +/// find forge/matrix/gateway ready. Per-target failures aggregated. Callers +/// resolve the [`LifecycleScope`] to these explicit name lists up front. +async fn handle_start(agents: &[String], infra: &[&str]) -> Result { + tracing::info!(?agents, ?infra, "start"); + let mut ok_items: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + + for &container in infra { + match crate::priv_client::control_infra_container(container, InfraAction::Start).await { + Ok(()) => ok_items.push(container.to_owned()), + Err(e) => { + tracing::warn!(%container, error = ?e, "start: infra start failed"); + errors.push(format!("{container}: {e:#}")); + } + } + } + + for agent in agents { + match lifecycle::start(agent).await { + Ok(()) => ok_items.push(agent.clone()), + Err(e) => { + tracing::warn!(%agent, error = ?e, "start: agent start failed"); + errors.push(format!("{agent}: {e:#}")); + } + } + } + + Ok(finish_lifecycle(ok_items, &errors)) +} + +/// Resolve which sub-agent logical names a scope targets: every live +/// container (from `lifecycle::list`) when `agents` is set or the scope is +/// "everything", plus any explicit `agent_names`. Returns de-duplicated +/// logical names with the `h-` container prefix stripped. +async fn scoped_agents(scope: &LifecycleScope) -> Result> { + use std::collections::BTreeSet; + let mut set: BTreeSet = BTreeSet::new(); + if scope.agents || scope.is_everything() { + for c in lifecycle::list().await? { + let logical = c + .strip_prefix(lifecycle::AGENT_PREFIX) + .unwrap_or(&c) + .to_owned(); + set.insert(logical); + } + } + for n in &scope.agent_names { + set.insert(n.clone()); + } + Ok(set.into_iter().collect()) +} + +/// Resolve which infra container names a scope targets. An "everything" +/// scope (no flags set) selects all controllable infra; otherwise each set +/// flag maps to its container. Fixed order for deterministic output. +fn scoped_infra(scope: &LifecycleScope) -> Vec<&'static str> { + let everything = scope.is_everything(); + let mut out = Vec::new(); + if everything || scope.ci { + out.push("hive-ci"); + } + if everything || scope.forge { + out.push("hive-forge"); + } + if everything || scope.gateway { + out.push("hive-gateway"); + } + if everything || scope.matrix { + out.push("hive-matrix"); + } + out +} + +/// Build the aggregated lifecycle response: `ok` with the touched names when +/// every target succeeded, otherwise `ok: false` with the joined errors and +/// the partial success list (matches `handle_restart_all`). +fn finish_lifecycle(ok_items: Vec, errors: &[String]) -> HostResponse { + if errors.is_empty() { + HostResponse::list(ok_items) + } else { + HostResponse { + ok: false, + error: Some(errors.join("; ")), + agents: Some(ok_items), + approvals: None, + } + } +} + /// Rebuild `name`'s container, notifying the manager of the outcome /// (success or failure) and kicking the agent's next turn on success. async fn handle_rebuild(coord: &Arc, name: &str) -> Result { diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 55c69f94..47e9cce1 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -21,9 +21,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, JournalQuery, MANAGER_NAME, META_DIR, + AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, InfraAction, JournalQuery, MANAGER_NAME, META_DIR, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, - RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS, + SIBLING_CONTAINERS, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::unix::OwnedWriteHalf; @@ -265,9 +265,10 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, restart_matrix_daemon(agent_name).await } - PrivRequest::RestartInfraContainer { ref container } => { - restart_infra_container(container).await - } + PrivRequest::ControlInfraContainer { + ref container, + action, + } => control_infra_container(container, action).await, } } @@ -394,29 +395,32 @@ async fn restart_matrix_daemon(agent_name: &str) -> Result<(String, String)> { )) } -/// `RestartInfraContainer` — restart a hive infrastructure container on -/// the host via `systemctl restart container@.service`. The -/// `container` is validated against `RESTARTABLE_INFRA_CONTAINERS` here, -/// root-side, so this is the authoritative allowlist even though -/// hive-c0re also gates on the caller's `infra_admin` capability. -async fn restart_infra_container(container: &str) -> Result<(String, String)> { - if !RESTARTABLE_INFRA_CONTAINERS.contains(&container) { - bail!("container {container:?} is not a restartable hive infra container"); +/// `ControlInfraContainer` — start/stop/restart a hive infrastructure +/// container via `systemctl container@.service`. The +/// `container` is validated against `SIBLING_CONTAINERS` here, root-side; +/// this is the authoritative allowlist (hive-c0re is never in it, so a +/// stop can't sever the daemon socket the request arrived on). Serves both +/// the hive-wide `hivectl stop`/`start` flow and an `infra_admin` agent's +/// `restart` (action = Restart). +async fn control_infra_container(container: &str, action: InfraAction) -> Result<(String, String)> { + if !SIBLING_CONTAINERS.contains(&container) { + bail!("container {container:?} is not a controllable hive infra container"); } + let verb = action.systemctl_verb(); let unit = format!("container@{container}.service"); let out = Command::new("systemctl") - .args(["restart", &unit]) + .args([verb, &unit]) .output() .await - .with_context(|| format!("systemctl restart {unit}"))?; + .with_context(|| format!("systemctl {verb} {unit}"))?; if !out.status.success() { bail!( - "systemctl restart {unit} exited {}: {}", + "systemctl {verb} {unit} exited {}: {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } - tracing::info!(target: "infra-restart", "restarted {unit}"); + tracing::info!(target: "infra-control", "{verb} {unit}"); Ok(( String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned(), diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 7308761e..ba3789a7 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -58,6 +58,68 @@ pub enum HostRequest { child: String, new_parent: Option, }, + /// Stop managed containers hive-wide in one operator action + /// (`hivectl stop`): agents plus the selected infra containers. `scope` + /// selects which classes; an all-false scope means **everything** (the + /// bare `hivectl stop`). `graceful` runs the per-agent quiesce (graceful + /// agent stop, issue tracker `graceful agent stop`) instead of a hard + /// stop. Agents stop via the lifecycle path; infra containers via the + /// host `container@` units. + Stop { + #[serde(default)] + scope: LifecycleScope, + #[serde(default)] + graceful: bool, + }, + /// Start managed containers hive-wide — the inverse of `Stop` + /// (`hivectl start`). Same `scope` semantics (all-false = everything); + /// no graceful flag (start is unconditional). + Start { + #[serde(default)] + scope: LifecycleScope, + }, +} + +/// Selects which container classes a hive-wide [`HostRequest::Stop`] / +/// [`HostRequest::Start`] touches. An all-false scope means **everything** +/// (the bare `hivectl stop` / `start`); set individual fields to restrict +/// (e.g. only `agents` → just the sub-agent containers). `agents` covers +/// every managed sub-agent container; the rest are the named infra +/// containers (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). +// +// A flat bag of independent flag toggles — one bool per selectable +// container class — is exactly the right shape here: each maps 1:1 to a +// `hivectl` `--ci` / `--forge` / `--gateway` / `--matrix` flag, and they're +// orthogonal (any subset is valid), so a state machine or two-variant enums +// would only obscure the mapping. Hence the `struct_excessive_bools` allow. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LifecycleScope { + /// All sub-agent containers (`--agents`). + #[serde(default)] + pub agents: bool, + /// Specific sub-agents by logical name (`--agent `, repeatable). + /// Additive with the rest of the scope; redundant when `agents` is set + /// (which already covers every sub-agent). + #[serde(default)] + pub agent_names: Vec, + #[serde(default)] + pub ci: bool, + #[serde(default)] + pub forge: bool, + #[serde(default)] + pub gateway: bool, + #[serde(default)] + pub matrix: bool, +} + +impl LifecycleScope { + /// True when nothing is explicitly selected — interpreted as "all + /// classes" (the bare `hivectl stop` / `start` with no scope flags). + pub fn is_everything(&self) -> bool { + !(self.agents || self.ci || self.forge || self.gateway || self.matrix) + && self.agent_names.is_empty() + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index b23fca72..1b4a3e38 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -15,17 +15,36 @@ pub const MANAGER_NAME: &str = "ruth"; /// Sub-agent container prefix. System container name = `h-`. pub const AGENT_PREFIX: &str = "h-"; -/// Sibling service containers managed by hive-c0re. +/// Sibling service containers managed by hive-c0re. This doubles as the +/// authoritative allowlist for infra lifecycle ops +/// ([`PrivRequest::ControlInfraContainer`]): any of these four may be +/// started / stopped / restarted (by the hive-wide `hivectl stop`/`start` +/// flow or an `infra_admin` agent's `restart`). `hive-c0re` is deliberately +/// absent — stopping it would sever the very socket the request arrived on. +/// hive-priv re-validates against this list root-side, so it's authoritative +/// regardless of what the caller sends. pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway", "hive-ci"]; -/// Infra containers an agent holding the `infra_admin` capability may -/// restart via the `restart` MCP tool. A deliberate subset of -/// [`SIBLING_CONTAINERS`]: hive-matrix is excluded (kicking the matrix -/// backend mid-sync is its own concern) and hive-c0re is excluded -/// entirely (a self-restart would sever the very socket the request -/// arrived on). hive-priv re-validates against this list root-side, so -/// it is the authoritative allowlist regardless of what the caller sends. -pub const RESTARTABLE_INFRA_CONTAINERS: &[&str] = &["hive-ci", "hive-gateway", "hive-forge"]; +/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly +/// to `systemctl container@.service`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InfraAction { + Start, + Stop, + Restart, +} + +impl InfraAction { + /// The `systemctl` subcommand this action maps to. + pub fn systemctl_verb(self) -> &'static str { + match self { + InfraAction::Start => "start", + InfraAction::Stop => "stop", + InfraAction::Restart => "restart", + } + } +} /// Host path of the meta flake. The flake ref for agent `` is /// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire. @@ -305,16 +324,17 @@ pub enum PrivRequest { agent_name: String, }, - /// Restart a hive infrastructure container on the host via - /// `systemctl restart container@.service`. hive-priv - /// validates `container` against [`RESTARTABLE_INFRA_CONTAINERS`] - /// before acting — the root-side allowlist is authoritative. Used - /// by hive-c0re to service a `restart` request from an agent that - /// holds the `infra_admin` capability. - RestartInfraContainer { + /// Start / stop / restart a hive infrastructure container on the host + /// via `systemctl container@.service`. hive-priv + /// validates `container` against [`SIBLING_CONTAINERS`] root-side (the + /// authoritative allowlist; `hive-c0re` is never in it). Serves both the + /// hive-wide `hivectl stop` / `hivectl start` flow and an `infra_admin` + /// agent's `restart` (with `action = Restart`). + ControlInfraContainer { /// Infra container name (e.g. `hive-ci`); must be in - /// [`RESTARTABLE_INFRA_CONTAINERS`]. + /// [`SIBLING_CONTAINERS`]. container: String, + action: InfraAction, }, } @@ -373,21 +393,15 @@ pub enum PrivEvent { #[cfg(test)] mod tests { - use super::{RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS}; + use super::SIBLING_CONTAINERS; #[test] - fn restartable_infra_is_a_safe_subset_of_siblings() { - // Every restartable infra container must be a known sibling. - for c in RESTARTABLE_INFRA_CONTAINERS { - assert!( - SIBLING_CONTAINERS.contains(c), - "{c} is not a managed sibling container" - ); - } - // hive-matrix and hive-c0re are deliberately excluded: kicking the - // matrix backend mid-sync is its own concern, and a self-restart of - // c0re would sever the request socket. - assert!(!RESTARTABLE_INFRA_CONTAINERS.contains(&"hive-matrix")); - assert!(!RESTARTABLE_INFRA_CONTAINERS.contains(&"hive-c0re")); + fn infra_control_allowlist_excludes_c0re_includes_matrix() { + // SIBLING_CONTAINERS is the authoritative allowlist for infra + // lifecycle ops. hive-c0re must NEVER be in it — stopping the daemon + // would sever the socket the request arrived on. + assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re")); + // hive-matrix IS controllable (operator can stop/start/restart it). + assert!(SIBLING_CONTAINERS.contains(&"hive-matrix")); } }