diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index bef28f6..1d5683f 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -3,11 +3,11 @@ //! `&Coordinator` and the request parameters; callers stitch the response //! shape they want (HTTP redirect vs JSON). -use anyhow::Result; +use anyhow::{Result, bail}; use hive_sh4re::{ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER}; use crate::coordinator::Coordinator; -use crate::lifecycle; +use crate::lifecycle::{self, MANAGER_NAME}; /// Approve a pending request: read the agent.nix at the approval's commit from /// the proposed repo, copy into the applied repo, commit there, and rebuild @@ -64,6 +64,33 @@ pub async fn approve(coord: &Coordinator, id: i64) -> Result<()> { } } +/// Fully tear down a sub-agent. Refuses the manager (declarative; would fight +/// with the host's nixos config). +pub async fn destroy(coord: &Coordinator, name: &str) -> Result<()> { + if name == MANAGER_NAME || name == MANAGER_AGENT { + bail!("refusing to destroy the manager ({name})"); + } + tracing::info!(%name, "destroy"); + lifecycle::destroy(name).await?; + coord.unregister_agent(name); + let runtime = Coordinator::agent_dir(name); + if runtime.exists() { + let _ = std::fs::remove_dir_all(&runtime); + } + let state = Coordinator::agent_state_root(name); + if state.exists() { + let _ = std::fs::remove_dir_all(&state); + } + let applied = Coordinator::agent_applied_dir(name); + if applied.exists() { + let _ = std::fs::remove_dir_all(&applied); + } + let _ = coord + .approvals + .fail_pending_for_agent(name, "agent destroyed"); + Ok(()) +} + pub fn deny(coord: &Coordinator, id: i64) -> Result<()> { let approval = coord.approvals.get(id)?; coord.approvals.mark_denied(id)?; diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index 8f6e66a..ea87320 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -132,6 +132,18 @@ impl Approvals { )?; Ok(()) } + + /// Mark every pending approval for `agent` as failed (returns rows affected). + /// Used by `destroy` to clear the queue of an agent that no longer exists. + pub fn fail_pending_for_agent(&self, agent: &str, note: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let n = conn.execute( + "UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2 + WHERE agent = ?3 AND status = 'pending'", + params![now_unix(), note, agent], + )?; + Ok(n) + } } fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 5f7a029..12a5056 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -80,10 +80,15 @@ impl Coordinator { Self::manager_dir().join("mcp.sock") } + /// Per-agent state root (parent of `config/`, future `prompts/`, etc.). + pub fn agent_state_root(name: &str) -> PathBuf { + PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}")) + } + /// Manager-editable proposed config repo. Bind-mounted into the manager /// container as `/agents//config/`. pub fn agent_proposed_dir(name: &str) -> PathBuf { - PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}/config")) + Self::agent_state_root(name).join("config") } /// Authoritative applied config repo. Hive-c0re-only. diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index b09bd5e..aeebd62 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -41,6 +41,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/", get(index)) .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) + .route("/destroy/{name}", post(post_destroy)) .route("/send", post(post_send)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); @@ -124,6 +125,16 @@ async fn post_deny(State(state): State, AxumPath(id): AxumPath) - } } +async fn post_destroy( + State(state): State, + AxumPath(name): AxumPath, +) -> Response { + match actions::destroy(&state.coord, &name).await { + Ok(()) => Redirect::to("/").into_response(), + Err(e) => error_response(&format!("destroy {name} failed: {e:#}")), + } +} + fn error_response(message: &str) -> Response { ( StatusCode::INTERNAL_SERVER_ERROR, @@ -154,7 +165,7 @@ fn render_containers(containers: &[String], hostname: &str) -> String { let port = lifecycle::agent_web_port(name); let _ = writeln!( out, - "
  • ▒░▒░░ {name} ag3nt {container} :{port}
  • ", + "
  • ▒░▒░░ {name} ag3nt {container} :{port}\n
    \n
  • ", ); } } @@ -410,6 +421,7 @@ const STYLE: &str = r#" } .approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; } .approvals form.inline { display: inline; margin-left: 0.4em; } + ul form.inline { display: inline-block; } .btn { font-family: inherit; font-weight: bold; @@ -424,6 +436,7 @@ const STYLE: &str = r#" .btn:hover { background: rgba(255,255,255,0.05); text-shadow: 0 0 12px currentColor; } .btn-approve { color: var(--green); border-color: var(--green); } .btn-deny { color: var(--red); border-color: var(--red); } + .btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-talk { color: var(--cyan); border-color: var(--cyan); } .talkform { display: flex; diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index dcce16c..7577bb4 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -85,6 +85,25 @@ pub async fn kill(name: &str) -> Result<()> { run(&["stop", &container]).await } +/// Fully tear down a sub-agent's container: stop + remove via `nixos-container +/// destroy`, then clean our own systemd drop-in. Leaves it to the caller to +/// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir. +pub async fn destroy(name: &str) -> Result<()> { + validate(name)?; + let container = container_name(name); + // nixos-container destroy handles stop + removal of /var/lib/nixos-containers/ + // and /etc/nixos-containers/.conf. Tolerate "no such container". + if let Err(e) = run(&["destroy", &container]).await { + tracing::warn!(error = ?e, "nixos-container destroy returned an error; continuing cleanup"); + } + let dropin_dir = format!("/run/systemd/system/container@{container}.service.d"); + if std::path::Path::new(&dropin_dir).exists() { + std::fs::remove_dir_all(&dropin_dir) + .with_context(|| format!("remove {dropin_dir}"))?; + } + Ok(()) +} + pub async fn rebuild( name: &str, hyperhive_flake: &str, diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 9e3e977..9491c0a 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -48,6 +48,8 @@ enum Cmd { Spawn { name: String }, /// Stop a managed container (graceful). Kill { name: String }, + /// Fully tear down a sub-agent (state + applied repo + drop-in wiped). + Destroy { name: String }, /// Apply pending config to a managed container. Rebuild { name: String }, /// List managed containers. @@ -92,6 +94,9 @@ async fn main() -> Result<()> { Cmd::Kill { name } => { render(client::request(&cli.socket, HostRequest::Kill { name }).await?) } + Cmd::Destroy { name } => { + render(client::request(&cli.socket, HostRequest::Destroy { name }).await?) + } Cmd::Rebuild { name } => { render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?) } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index db382a7..447f94c 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -85,6 +85,10 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { coord.unregister_agent(name); HostResponse::success() } + HostRequest::Destroy { name } => { + actions::destroy(coord, name).await?; + HostResponse::success() + } HostRequest::Rebuild { name } => { tracing::info!(%name, "rebuild"); let agent_dir = coord.register_agent(name)?; diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index c5848cc..d4d5650 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -16,6 +16,9 @@ pub enum HostRequest { Spawn { name: String }, /// Stop a managed container (graceful). Kill { name: String }, + /// Fully tear down a sub-agent: stop, wipe state + applied repo, drop the + /// systemd drop-in, purge pending approvals. Manager not destroyable. + Destroy { name: String }, /// Apply pending config to a managed container. Rebuild { name: String }, /// List managed containers.