destroy --purge: also wipe agent state dirs

new --purge flag on the destroy verb (cli + admin socket + dashboard).
default destroy still keeps /var/lib/hyperhive/{agents,applied}/<name>/
so recreating with the same name reuses prior config + creds.
with --purge, both dirs go too (config history, claude creds, /state/
notes). no undo. dashboard adds a separate PURG3 button with an
explicit confirmation copy; the existing DESTR0Y button keeps the
soft semantics.

claude.md dashboard-action-surface section updated; todo entry
dropped.
This commit is contained in:
müde 2026-05-15 19:29:14 +02:00
parent 8d3df656de
commit 48ebfefd1a
8 changed files with 78 additions and 28 deletions

View file

@ -132,22 +132,40 @@ fn finish_approval(
/// kept, so recreating an agent of the same name reuses prior config + creds
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
/// is cleared because its contents (the mcp socket) don't survive restarts
/// anyway. A future `--purge` path can wipe state when the operator opts in.
/// anyway. With `purge=true` the persistent trees are also wiped — config
/// history, claude creds, notes — there is no undo.
/// Refuses the manager (declarative; would fight with the host's nixos config).
pub async fn destroy(coord: &Coordinator, name: &str) -> Result<()> {
pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> {
if name == MANAGER_NAME || name == MANAGER_AGENT {
bail!("refusing to destroy the manager ({name})");
}
tracing::info!(%name, "destroy");
tracing::info!(%name, purge, "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 _ = coord
.approvals
.fail_pending_for_agent(name, "agent destroyed");
if purge {
for dir in [
Coordinator::agent_state_root(name),
Coordinator::agent_applied_dir(name),
] {
if dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&dir)
{
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
}
}
}
let _ = coord.approvals.fail_pending_for_agent(
name,
if purge {
"agent purged"
} else {
"agent destroyed"
},
);
coord.notify_manager(&HelperEvent::Destroyed {
agent: name.to_owned(),
});

View file

@ -424,8 +424,20 @@ fn strip_container_prefix(name: &str) -> String {
.to_owned()
}
async fn post_destroy(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
match actions::destroy(&state.coord, &name).await {
#[derive(Deserialize, Default)]
struct DestroyForm {
#[serde(default)]
purge: Option<String>,
}
async fn post_destroy(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
Form(form): Form<DestroyForm>,
) -> Response {
// Checkbox semantics: any non-empty value (axum sends "on") = purge.
let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty());
match actions::destroy(&state.coord, &name, purge).await {
Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
}

View file

@ -56,8 +56,14 @@ enum Cmd {
/// 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.
Destroy { name: String },
/// 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.
@ -121,8 +127,8 @@ 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::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?)

View file

@ -115,8 +115,8 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
});
HostResponse::success()
}
HostRequest::Destroy { name } => {
actions::destroy(&coord, name).await?;
HostRequest::Destroy { name, purge } => {
actions::destroy(&coord, name, *purge).await?;
HostResponse::success()
}
HostRequest::Rebuild { name } => {