destroy verb: CLI + admin socket + dashboard button; purges state + approvals

This commit is contained in:
müde 2026-05-15 02:57:22 +02:00
parent c7b50aa5b7
commit b711296460
8 changed files with 92 additions and 4 deletions

View file

@ -3,11 +3,11 @@
//! `&Coordinator` and the request parameters; callers stitch the response //! `&Coordinator` and the request parameters; callers stitch the response
//! shape they want (HTTP redirect vs JSON). //! 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 hive_sh4re::{ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER};
use crate::coordinator::Coordinator; 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 /// 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 /// 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<()> { pub fn deny(coord: &Coordinator, id: i64) -> Result<()> {
let approval = coord.approvals.get(id)?; let approval = coord.approvals.get(id)?;
coord.approvals.mark_denied(id)?; coord.approvals.mark_denied(id)?;

View file

@ -132,6 +132,18 @@ impl Approvals {
)?; )?;
Ok(()) 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<usize> {
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<Approval> { fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {

View file

@ -80,10 +80,15 @@ impl Coordinator {
Self::manager_dir().join("mcp.sock") 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 /// Manager-editable proposed config repo. Bind-mounted into the manager
/// container as `/agents/<name>/config/`. /// container as `/agents/<name>/config/`.
pub fn agent_proposed_dir(name: &str) -> PathBuf { 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. /// Authoritative applied config repo. Hive-c0re-only.

View file

@ -41,6 +41,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/", get(index)) .route("/", get(index))
.route("/approve/{id}", post(post_approve)) .route("/approve/{id}", post(post_approve))
.route("/deny/{id}", post(post_deny)) .route("/deny/{id}", post(post_deny))
.route("/destroy/{name}", post(post_destroy))
.route("/send", post(post_send)) .route("/send", post(post_send))
.route("/messages/stream", get(messages_stream)) .route("/messages/stream", get(messages_stream))
.with_state(AppState { coord }); .with_state(AppState { coord });
@ -124,6 +125,16 @@ async fn post_deny(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -
} }
} }
async fn post_destroy(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> 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 { fn error_response(message: &str) -> Response {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@ -154,7 +165,7 @@ fn render_containers(containers: &[String], hostname: &str) -> String {
let port = lifecycle::agent_web_port(name); let port = lifecycle::agent_web_port(name);
let _ = writeln!( let _ = writeln!(
out, out,
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span> <span class=\"meta\">{container} :{port}</span></li>", "<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span> <span class=\"meta\">{container} :{port}</span>\n <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" onsubmit=\"return confirm('destroy {name}? this wipes the agent\\'s state.');\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</li>",
); );
} }
} }
@ -410,6 +421,7 @@ const STYLE: &str = r#"
} }
.approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; } .approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; }
.approvals form.inline { display: inline; margin-left: 0.4em; } .approvals form.inline { display: inline; margin-left: 0.4em; }
ul form.inline { display: inline-block; }
.btn { .btn {
font-family: inherit; font-family: inherit;
font-weight: bold; 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:hover { background: rgba(255,255,255,0.05); text-shadow: 0 0 12px currentColor; }
.btn-approve { color: var(--green); border-color: var(--green); } .btn-approve { color: var(--green); border-color: var(--green); }
.btn-deny { color: var(--red); border-color: var(--red); } .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); } .btn-talk { color: var(--cyan); border-color: var(--cyan); }
.talkform { .talkform {
display: flex; display: flex;

View file

@ -85,6 +85,25 @@ pub async fn kill(name: &str) -> Result<()> {
run(&["stop", &container]).await 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/<C>
// and /etc/nixos-containers/<C>.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( pub async fn rebuild(
name: &str, name: &str,
hyperhive_flake: &str, hyperhive_flake: &str,

View file

@ -48,6 +48,8 @@ enum Cmd {
Spawn { name: String }, Spawn { name: String },
/// Stop a managed container (graceful). /// Stop a managed container (graceful).
Kill { name: String }, 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. /// Apply pending config to a managed container.
Rebuild { name: String }, Rebuild { name: String },
/// List managed containers. /// List managed containers.
@ -92,6 +94,9 @@ async fn main() -> Result<()> {
Cmd::Kill { name } => { Cmd::Kill { name } => {
render(client::request(&cli.socket, HostRequest::Kill { name }).await?) render(client::request(&cli.socket, HostRequest::Kill { name }).await?)
} }
Cmd::Destroy { name } => {
render(client::request(&cli.socket, HostRequest::Destroy { name }).await?)
}
Cmd::Rebuild { name } => { Cmd::Rebuild { name } => {
render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?) render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?)
} }

View file

@ -85,6 +85,10 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse {
coord.unregister_agent(name); coord.unregister_agent(name);
HostResponse::success() HostResponse::success()
} }
HostRequest::Destroy { name } => {
actions::destroy(coord, name).await?;
HostResponse::success()
}
HostRequest::Rebuild { name } => { HostRequest::Rebuild { name } => {
tracing::info!(%name, "rebuild"); tracing::info!(%name, "rebuild");
let agent_dir = coord.register_agent(name)?; let agent_dir = coord.register_agent(name)?;

View file

@ -16,6 +16,9 @@ pub enum HostRequest {
Spawn { name: String }, Spawn { name: String },
/// Stop a managed container (graceful). /// Stop a managed container (graceful).
Kill { name: String }, 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. /// Apply pending config to a managed container.
Rebuild { name: String }, Rebuild { name: String },
/// List managed containers. /// List managed containers.