refactor(#2352): extract standalone hivectl crate, hive-c0re daemon-only

This commit is contained in:
damocles 2026-07-15 22:36:13 +02:00
commit cc67a05974
13 changed files with 236 additions and 147 deletions

View file

@ -25,13 +25,19 @@ hand-maintained per-file tree drifts out of sync with the code.
### Rust workspace (`Cargo.toml` members)
- **`hive-c0re/`** — host daemon (runs as the unprivileged `hive-core`
user) plus two operator CLIs. `src/main.rs` is the `hive-c0re` binary
(serve / spawn / kill / rebuild / approve / destroy / periodic vacuum
loops); `src/bin/hivectl.rs` is the ad-hoc operator admin CLI. Owns
the sqlite broker, approval + question + reminder + schedule queues,
the meta flake, lifecycle (`nixos-container` shellouts), gateway /
forge / matrix provisioning, per-container stats, and the axum
operator dashboard (`dashboard.rs`). Largest crate.
user). `src/main.rs` is the `hive-c0re` binary — **daemon-only**
(`serve` + the periodic vacuum/sweep loops); the operator CLI lives in
the separate `hivectl` crate, which talks to the daemon over the host
admin socket. Owns the sqlite broker, approval + question + reminder +
schedule queues, the meta flake, lifecycle (`nixos-container`
shellouts), gateway / forge / matrix provisioning, per-container stats,
and the axum operator dashboard (`dashboard.rs`). Largest crate.
- **`hivectl/`** — standalone operator CLI (`hivectl` binary). Talks to
the `hive-c0re` daemon over the host admin socket (`hive-host-sock`
wire types) — does NOT link `hive-c0re`. Verbs: `agents <spawn|kill|
destroy|rebuild|restart|list|set-parent|…>`, `approvals <pending|
approve|deny>`, `forge`/`matrix`/`github`/`gateway` provisioning,
`choom`, `stop`/`start`, `wg`/`peer-config`.
- **`hive-ag3nt/`** — in-container harness; three sibling binaries for
every agent (`hive-agent` serve loop, `hive-agent-mcp`,
`hive-agent-wake`). Turn-loop *policy* layer (`turn.rs`) over the `hive-claude`

17
Cargo.lock generated
View file

@ -1665,6 +1665,23 @@ dependencies = [
"serde_json",
]
[[package]]
name = "hivectl"
version = "0.1.0"
dependencies = [
"anyhow",
"bcrypt",
"clap",
"clap-markdown",
"clap_complete",
"hive-host-sock",
"hive-sh4re",
"indicatif",
"serde_json",
"tokio",
"tracing-subscriber",
]
[[package]]
name = "hkdf"
version = "0.12.4"

View file

@ -14,6 +14,7 @@ members = [
"hive-priv",
"hive-priv-sock",
"hive-sh4re",
"hivectl",
]
[workspace.package]

View file

@ -26,7 +26,7 @@ above was spawned by `alice`), and the operator can reparent any agent. The
bootstrap container (`ruth`) is just another root. Re-parenting is
operator-driven:
- CLI: `hive-c0re set-parent <child> --parent <new>` (or `--root` to
- CLI: `hivectl agents set-parent <child> --parent <new>` (or `--root` to
promote). Exactly one of `--parent` / `--root` is required.
- Dashboard: `POST /api/topology/set-parent` (form fields `child`,
optional `new_parent` — absent / empty ⇒ promote to root).

View file

@ -37,7 +37,7 @@ request.
3. The operator reviews the PR **on the forge** (native diff, threaded
comments, CI status) and sees a matching card on the dashboard with a
"review PR on forge" deep link. They click ◆ APPR0VE (or
`hive-c0re approve <id>` on the CLI) once satisfied.
`hivectl approvals approve <id>` on the CLI) once satisfied.
4. On approve, `run_merge_config_pr`:
- re-reads the live PR head and **aborts if it drifted** from the
reviewed `fetched_sha` (the submitter must push again, which queues
@ -110,7 +110,7 @@ kind-specific payload carrier.
- `Spawn` — direct container creation from the agent's config repo.
`commit_ref` is empty. Submitted via `HostRequest::RequestSpawn`
(operator-gated, the `◆ R3QU3ST SP4WN` dashboard button +
`hive-c0re request-spawn` CLI). The host-level `HostRequest::Spawn`
`hivectl agents request-spawn` CLI). The host-level `HostRequest::Spawn`
variant bypasses the approval queue entirely — privileged-context use
only (operator on the host shell, test scripts, one-off recoveries).
This is the **canonical first-spawn**: a new agent's `InitConfig`

View file

@ -304,7 +304,7 @@ step would re-fire).
`R3V1V3` queues a Spawn approval that reuses the kept state on
approve (no re-login).
- `PURG3` (opt-in via the dashboard button or
`hive-c0re destroy --purge <name>`) — DESTR0Y plus wipes
`hivectl agents destroy --purge <name>`) — DESTR0Y plus wipes
`/var/lib/hyperhive/{agents,applied}/<name>/`. Config history,
claude creds, /state/ notes, and the harness dir are all gone.
No undo.

View file

@ -1,15 +1,15 @@
//! `hive-c0re` library — module surface shared by the `hive-c0re`
//! daemon binary and the `hivectl` operator CLI.
//! `hive-c0re` library — the coordinator daemon's module surface
//! (coordinator, broker, axum dashboard, admin/manager/agent unix
//! sockets, background sweepers). Consumed by the `hive-c0re` daemon
//! binary (`src/main.rs`).
//!
//! `hive-c0re` (daemon) keeps the systemd service shape it always had:
//! coordinator, broker, axum dashboard, admin/manager/agent unix
//! sockets, background sweepers. `hivectl` (sibling bin under
//! `src/bin/hivectl.rs`) reuses a thin subset (`forge`, `matrix`,
//! `lifecycle`) to expose host-side administration verbs — manually
//! provisioning forge / matrix users for an agent, etc.
//! The operator CLI lives in the **standalone `hivectl` crate**, which
//! talks to the daemon over the host admin socket (`hive-host-sock` wire
//! types) rather than linking this crate — so `hive-c0re` is daemon-only,
//! not a library shared with a CLI.
//!
//! Every module is re-exported `pub` so anything in the crate is
//! addressable from either binary; the lib doesn't have a curated
//! addressable from the daemon binary; the lib doesn't have a curated
//! surface beyond "this is where the modules live".
//!
//! Cohesive clusters live in directory submodules (`stores`, `stats`,
@ -19,7 +19,6 @@
pub mod actions;
pub mod agent_config;
pub mod client;
pub mod container_view;
pub mod coordinator;
pub mod dashboard;

View file

@ -1,9 +1,8 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context as _, Result, bail};
use anyhow::{Context as _, Result};
use clap::{Parser, Subcommand};
use hive_host_sock::{HostRequest, HostResponse};
// Every module hangs off the `hive_c0re` library (see `src/lib.rs`).
// The daemon and the `hivectl` sibling binary share the same module
@ -12,7 +11,7 @@ use hive_host_sock::{HostRequest, HostResponse};
// explicit (any new daemon entry point reads off the next add).
use hive_c0re::coordinator::{Coordinator, HiveEnv, ServeConfig};
use hive_c0re::{
agent_sockets, auto_update, broker, client, crash_watch, dashboard, dashboard_events, forge,
agent_sockets, auto_update, broker, crash_watch, dashboard, dashboard_events, forge,
host_stats, job_queue, knowledge, matrix, mcp_sockets, migrate, reminder_scheduler,
scheduled_prompts_worker, server, socket_server, sweep_health, warnings,
};
@ -91,52 +90,6 @@ enum Cmd {
#[arg(long)]
build_slots: Option<usize>,
},
/// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses
/// the approval queue — use only as an operator on the host. For
/// approval-gated spawns, use `request-spawn` instead.
Spawn { name: String },
/// Queue a spawn request as an approval. The container is created on
/// `approve <id>` (CLI) or the dashboard's APPR0VE button.
RequestSpawn { name: String },
/// Stop a managed container (graceful).
Kill { name: String },
/// Tear down a sub-agent container. Container is removed; persistent
/// state (config repos + Claude credentials) is kept by default. Pass
/// `--purge` to also wipe the agent's state dirs (config + creds +
/// notes). No undo.
Destroy {
name: String,
#[arg(long)]
purge: bool,
},
/// Apply pending config to a managed container.
Rebuild { name: String },
/// List managed containers.
List,
/// List pending approval requests submitted by the manager.
Pending,
/// Approve a pending request by id; the action runs immediately.
Approve { id: i64 },
/// Deny a pending request by id.
Deny { id: i64 },
/// Move an agent in the topology tree. Set `--parent` to a new
/// parent agent name; pass `--root` to promote the agent to root
/// (no parent). Refuses cycles and unknown agents. The manager
/// is reparentable like any other agent — its privileges come
/// from the privileged MCP socket, not its tree position.
SetParent {
child: String,
/// New parent agent name. Mutually exclusive with `--root`.
/// Exactly one of `--parent` / `--root` is required — clap
/// rejects both-absent calls so a fat-fingered
/// `hive-c0re set-parent alice` doesn't silently promote
/// alice to root.
#[arg(long, conflicts_with = "root", required_unless_present = "root")]
parent: Option<String>,
/// Promote `child` to root (no parent).
#[arg(long)]
root: bool,
},
}
#[tokio::main]
@ -206,37 +159,6 @@ async fn main() -> Result<()> {
}
cmd_serve(sc.env, sc.model_prices, sc.build_slots, db, &cli.socket).await
}
Cmd::Spawn { name } => {
render(client::request(&cli.socket, HostRequest::Spawn { name }).await?)
}
Cmd::RequestSpawn { name } => {
render(client::request(&cli.socket, HostRequest::RequestSpawn { name }).await?)
}
Cmd::Kill { name } => {
render(client::request(&cli.socket, HostRequest::Kill { name }).await?)
}
Cmd::Destroy { name, purge } => {
render(client::request(&cli.socket, HostRequest::Destroy { name, purge }).await?)
}
Cmd::Rebuild { name } => {
render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?)
}
Cmd::List => render(client::request(&cli.socket, HostRequest::List).await?),
Cmd::Pending => render(client::request(&cli.socket, HostRequest::Pending).await?),
Cmd::Approve { id } => {
render(client::request(&cli.socket, HostRequest::Approve { id }).await?)
}
Cmd::Deny { id } => render(client::request(&cli.socket, HostRequest::Deny { id }).await?),
Cmd::SetParent {
child,
parent,
root,
} => {
let new_parent = if root { None } else { parent };
render(
client::request(&cli.socket, HostRequest::SetParent { child, new_parent }).await?,
)
}
}
}
@ -648,11 +570,3 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
}
});
}
fn render(resp: HostResponse) -> Result<()> {
println!("{}", serde_json::to_string_pretty(&resp)?);
if !resp.ok {
bail!(resp.error.unwrap_or_else(|| "request failed".to_owned()));
}
Ok(())
}

24
hivectl/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "hivectl"
edition.workspace = true
version.workspace = true
[lints]
workspace = true
[[bin]]
name = "hivectl"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
bcrypt.workspace = true
clap.workspace = true
clap_complete.workspace = true
clap-markdown = "0.1"
hive-host-sock.workspace = true
hive-sh4re.workspace = true
indicatif.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing-subscriber.workspace = true

View file

@ -42,10 +42,9 @@ async fn wait_for_dags_plain(socket: &Path, ids: Vec<u64>) -> Result<()> {
let mut failed: Vec<String> = Vec::new();
while !pending.is_empty() {
for id in pending.clone() {
let resp =
hive_c0re::client::request(socket, hive_host_sock::HostRequest::QueueDag { id })
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
let resp = crate::client::request(socket, hive_host_sock::HostRequest::QueueDag { id })
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
let dags = resp.dags.unwrap_or_default();
if dags.is_empty() {
// Evicted from the queue's history tail — it finished a
@ -116,10 +115,9 @@ async fn wait_for_dags_animated(socket: &Path, ids: Vec<u64>) -> Result<()> {
while !pending.is_empty() {
let now = now_unix();
for id in pending.clone() {
let resp =
hive_c0re::client::request(socket, hive_host_sock::HostRequest::QueueDag { id })
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
let resp = crate::client::request(socket, hive_host_sock::HostRequest::QueueDag { id })
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
let dags = resp.dags.unwrap_or_default();
if dags.is_empty() {
mp.println(format!("job #{id}: gone from queue history"))

View file

@ -20,12 +20,13 @@ use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use clap::{Args, Parser, Subcommand};
use hive_host_sock::HostRequest;
/// The host admin socket client (`request`), split out so it lives with
/// hivectl rather than in the daemon crate.
mod client;
/// Rebuild-queue DAG progress rendering (`wait_for_dags` + the spinner /
/// plain renderers), split out to keep this file manageable. `#[path]` keeps
/// the file under `bin/hivectl/` (a subdir cargo won't treat as its own
/// binary) rather than the sibling `bin/dag_progress.rs` a bare `mod` maps to.
#[path = "hivectl/dag_progress.rs"]
/// plain renderers), split out to keep this file manageable.
mod dag_progress;
use dag_progress::wait_for_dags;
@ -94,6 +95,13 @@ enum Cmd {
#[command(subcommand)]
cmd: AgentsCmd,
},
/// Operator approval queue: list pending requests, approve / deny them.
/// Requires the hive-c0re daemon to be running (connects to the host
/// admin socket).
Approvals {
#[command(subcommand)]
cmd: ApprovalsCmd,
},
/// WireGuard inter-hive mesh setup helpers (`services.hyperhive.swarm`).
///
/// One-time-setup convenience so nobody has to remember the `wg` dance:
@ -456,8 +464,8 @@ enum GithubCmd {
// Default htpasswd file path — the host-side location of the gateway's
// credential store, pre-created by a tmpfiles rule when
// `services.hyperhive.gateway.auth.enable = true`. Literal lives in
// `hive_c0re::paths`.
use hive_c0re::paths::GATEWAY_HTPASSWD as DEFAULT_HTPASSWD_FILE;
// `hive_host_sock`.
use hive_host_sock::GATEWAY_HTPASSWD as DEFAULT_HTPASSWD_FILE;
#[derive(Subcommand)]
enum GatewayCmd {
@ -564,9 +572,9 @@ enum QuotaCmd {
}
// Default host admin socket path. Shared with `hive-c0re`'s `main.rs`
// default via `hive_c0re::paths::HOST_SOCKET` — the daemon binds there
// default via `hive_host_sock::HOST_SOCKET` — the daemon binds there
// and `hivectl agents` connects to it.
use hive_c0re::paths::HOST_SOCKET as DEFAULT_HOST_SOCKET;
use hive_host_sock::HOST_SOCKET as DEFAULT_HOST_SOCKET;
#[derive(Subcommand)]
enum AgentsCmd {
@ -600,6 +608,70 @@ enum AgentsCmd {
#[arg(long)]
no_wait: bool,
},
/// Spawn a new agent container directly (`h-<name>`). Bypasses the
/// approval queue — operator-on-the-host only. For approval-gated
/// spawns, use `request-spawn`.
Spawn {
/// Agent name (e.g. `iris`).
name: String,
},
/// Queue a spawn request as an approval. The container is created on
/// `hivectl approvals approve <id>` (or the dashboard APPR0VE button).
RequestSpawn {
/// Agent name.
name: String,
},
/// Stop a managed container (graceful).
Kill {
/// Agent name.
name: String,
},
/// Tear down a sub-agent container. The container is removed; persistent
/// state (config repos + Claude credentials) is kept by default. Pass
/// `--purge` to also wipe the agent's state dirs (config + creds +
/// notes). No undo.
Destroy {
/// Agent name.
name: String,
#[arg(long)]
purge: bool,
},
/// Apply pending config to a managed container.
Rebuild {
/// Agent name.
name: String,
},
/// Move an agent in the topology tree. Set `--parent` to a new parent
/// agent name, or pass `--root` to promote the agent to root (no
/// parent). Exactly one is required. Refuses cycles and unknown agents.
SetParent {
/// Agent to move.
child: String,
/// New parent agent name. Mutually exclusive with `--root`.
#[arg(long, conflicts_with = "root", required_unless_present = "root")]
parent: Option<String>,
/// Promote `child` to root (no parent).
#[arg(long)]
root: bool,
},
}
/// Operator approval queue: list pending requests and approve / deny them.
/// Requires the hive-c0re daemon (connects to the host admin socket).
#[derive(Subcommand)]
enum ApprovalsCmd {
/// List pending approval requests submitted by agents.
Pending,
/// Approve a pending request by id; the action runs immediately.
Approve {
/// Approval id (from `hivectl approvals pending`).
id: i64,
},
/// Deny a pending request by id.
Deny {
/// Approval id.
id: i64,
},
}
#[derive(Subcommand)]
@ -707,11 +779,8 @@ async fn main() -> Result<()> {
GatewayCmd::DeleteUser { file, username } => gateway_delete_user(&file, &username),
GatewayCmd::ListUsers { file } => gateway_list_users(&file),
},
Cmd::Agents { cmd } => match cmd {
AgentsCmd::List { json } => agents_list(&socket, json).await,
AgentsCmd::Restart { name, no_wait } => agents_restart(&socket, &name, no_wait).await,
AgentsCmd::RestartAll { no_wait } => agents_restart_all(&socket, no_wait).await,
},
Cmd::Agents { cmd } => run_agents(&socket, cmd).await,
Cmd::Approvals { cmd } => run_approvals(&socket, cmd).await,
Cmd::Wg { cmd } => match cmd {
WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await,
WgCmd::Peer {
@ -848,7 +917,7 @@ async fn query_hive_domain(socket: &Path) -> Option<String> {
/// Best-effort query for this hive's domain + browser-facing web URLs
/// (`HostRequest::Urls`). `None` when the daemon is unreachable.
async fn query_hive_urls(socket: &Path) -> Option<hive_host_sock::HiveUrls> {
hive_c0re::client::request(socket, hive_host_sock::HostRequest::Urls)
crate::client::request(socket, hive_host_sock::HostRequest::Urls)
.await
.ok()
.and_then(|r| r.urls)
@ -1036,7 +1105,7 @@ async fn quota_enable(socket: &Path) -> Result<()> {
/// The daemon resolves the agent set + reads each subvolume's usage (it
/// holds the privileged helper); the client just formats the returned rows.
async fn quota_show(socket: &Path, name: Option<&str>) -> Result<()> {
let resp = hive_c0re::client::request(
let resp = crate::client::request(
socket,
hive_host_sock::HostRequest::QuotaShow {
name: name.map(str::to_owned),
@ -1146,7 +1215,7 @@ fn human_bytes(n: u64) -> String {
/// "needs root" error fixes that first-run footgun, where running a
/// privileged verb without sudo reported as a missing agent.
fn agent_exists(name: &str) -> Result<bool> {
let root = hive_c0re::paths::agent_state_dir(name);
let root = hive_host_sock::agent_state_dir(name);
match root.try_exists() {
Ok(found) => Ok(found),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => bail!(
@ -1170,10 +1239,10 @@ fn choom(name: &str, resume_session: Option<&str>) -> Result<()> {
if !agent_exists(name)? {
bail!(
"no such agent: '{name}' (no state dir under {}/)",
hive_c0re::paths::AGENTS_ROOT
hive_host_sock::AGENTS_ROOT
);
}
let container = hive_c0re::lifecycle::container_name(name);
let container = hive_host_sock::container_name(name);
// Enter as the agent's unix user (== agent name) so claude reads the
// right `$HOME/.claude`.
let target = format!("{name}@{container}");
@ -1291,7 +1360,7 @@ async fn daemon_request(
req: hive_host_sock::HostRequest,
label: &str,
) -> Result<()> {
let resp = hive_c0re::client::request(socket, req)
let resp = crate::client::request(socket, req)
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
if !resp.ok {
@ -1337,7 +1406,7 @@ fn resolve_password(password: Option<&str>, password_stdin: bool) -> Result<Opti
/// admin tokens and the matrix creds dir, so hivectl no longer links the
/// matrix machinery — it just forwards the request and renders the reply.
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
let resp = hive_c0re::client::request(socket, req)
let resp = crate::client::request(socket, req)
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
if !resp.ok {
@ -1509,7 +1578,7 @@ fn gateway_list_users(file: &Path) -> Result<()> {
// ---------------------------------------------------------------------------
async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()> {
let resp = hive_c0re::client::request(
let resp = crate::client::request(
socket,
hive_host_sock::HostRequest::Restart {
name: name.to_owned(),
@ -1533,7 +1602,7 @@ async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()>
/// or the raw JSON rows with `--json`. Reuses the dashboard's
/// `ContainerView` aggregation, so the CLI and the web UI never drift.
async fn agents_list(socket: &Path, json: bool) -> Result<()> {
let resp = hive_c0re::client::request(socket, hive_host_sock::HostRequest::AgentStatus)
let resp = crate::client::request(socket, hive_host_sock::HostRequest::AgentStatus)
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
if !resp.ok {
@ -1605,7 +1674,7 @@ async fn agents_list(socket: &Path, json: bool) -> Result<()> {
}
async fn agents_restart_all(socket: &Path, no_wait: bool) -> Result<()> {
let resp = hive_c0re::client::request(socket, hive_host_sock::HostRequest::RestartAll)
let resp = crate::client::request(socket, hive_host_sock::HostRequest::RestartAll)
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
let agents = resp.agents.as_deref().unwrap_or(&[]);
@ -1631,7 +1700,7 @@ async fn stop(
graceful: bool,
no_wait: bool,
) -> Result<()> {
let resp = hive_c0re::client::request(
let resp = crate::client::request(
socket,
hive_host_sock::HostRequest::Stop { scope, graceful },
)
@ -1646,7 +1715,7 @@ async fn stop(
}
async fn start(socket: &Path, scope: hive_host_sock::LifecycleScope, no_wait: bool) -> Result<()> {
let resp = hive_c0re::client::request(socket, hive_host_sock::HostRequest::Start { scope })
let resp = crate::client::request(socket, hive_host_sock::HostRequest::Start { scope })
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
let rendered = render_lifecycle(&resp, "start queued");
@ -1672,7 +1741,7 @@ async fn restart(
scope: hive_host_sock::LifecycleScope,
graceful: bool,
) -> Result<()> {
let resp = hive_c0re::client::request(
let resp = crate::client::request(
socket,
hive_host_sock::HostRequest::RestartScoped { scope, graceful },
)
@ -1737,7 +1806,7 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
}
println!("stopping {name} (releasing its state bind-mount)…");
let stop_resp = hive_c0re::client::request(
let stop_resp = crate::client::request(
socket,
hive_host_sock::HostRequest::Stop {
scope: single_agent_scope(name),
@ -1774,7 +1843,7 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
// start-side failure (incl. the IPC call itself erroring) can't mask the
// migration outcome below.
println!("starting {name}");
let start_result = hive_c0re::client::request(
let start_result = crate::client::request(
socket,
hive_host_sock::HostRequest::Start {
scope: single_agent_scope(name),
@ -1899,6 +1968,67 @@ async fn subvol_snapshot_send(
/// 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`).
/// Dispatch `hivectl agents <verb>` — container lifecycle over the host
/// admin socket.
async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> {
match cmd {
AgentsCmd::List { json } => agents_list(socket, json).await,
AgentsCmd::Restart { name, no_wait } => agents_restart(socket, &name, no_wait).await,
AgentsCmd::RestartAll { no_wait } => agents_restart_all(socket, no_wait).await,
AgentsCmd::Spawn { name } => {
render(crate::client::request(socket, HostRequest::Spawn { name }).await?)
}
AgentsCmd::RequestSpawn { name } => {
render(crate::client::request(socket, HostRequest::RequestSpawn { name }).await?)
}
AgentsCmd::Kill { name } => {
render(crate::client::request(socket, HostRequest::Kill { name }).await?)
}
AgentsCmd::Destroy { name, purge } => {
render(crate::client::request(socket, HostRequest::Destroy { name, purge }).await?)
}
AgentsCmd::Rebuild { name } => {
render(crate::client::request(socket, HostRequest::Rebuild { name }).await?)
}
AgentsCmd::SetParent {
child,
parent,
root,
} => {
let new_parent = if root { None } else { parent };
render(
crate::client::request(socket, HostRequest::SetParent { child, new_parent })
.await?,
)
}
}
}
/// Dispatch `hivectl approvals <verb>` — the operator approval queue.
async fn run_approvals(socket: &Path, cmd: ApprovalsCmd) -> Result<()> {
match cmd {
ApprovalsCmd::Pending => {
render(crate::client::request(socket, HostRequest::Pending).await?)
}
ApprovalsCmd::Approve { id } => {
render(crate::client::request(socket, HostRequest::Approve { id }).await?)
}
ApprovalsCmd::Deny { id } => {
render(crate::client::request(socket, HostRequest::Deny { id }).await?)
}
}
}
/// Pretty-print a `HostResponse` as JSON and bail on failure. Used by the
/// agent-lifecycle + approval verbs that just relay a daemon result verbatim.
fn render(resp: hive_host_sock::HostResponse) -> Result<()> {
println!("{}", serde_json::to_string_pretty(&resp)?);
if !resp.ok {
bail!(resp.error.unwrap_or_else(|| "request failed".to_owned()));
}
Ok(())
}
fn render_lifecycle(resp: &hive_host_sock::HostResponse, verb: &str) -> Result<()> {
let items = resp.agents.as_deref().unwrap_or(&[]);
if items.is_empty() {

View file

@ -23,8 +23,8 @@
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";
description = ''
hyperhive workspace package. Provides `/bin/hive-c0re`
(coordinator daemon + admin-socket CLI) and `/bin/hivectl`
(operator-facing host CLI for ad-hoc administration). Wired to
(coordinator daemon) and `/bin/hivectl` (operator-facing host
CLI for ad-hoc administration + the host admin socket). Wired to
this flake's `packages.<system>.default` by
`nixosModules.default` (via `lib.mkDefault`, so setting it here
wins).