reap: cascade orgs → companies → teams → agents
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 3m56s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 4m4s

Selecting a team/company/org in the Agents sidebar used to only
delete the grouping row; the agents inside survived, ungrouped.
Not what the user wanted, and it left a trail of orphaned runtime
state (containers, .brain files, DB rows) behind.

Backend — POST /api/claws/batch-delete is now a universal cascade
reaper. Body accepts { ids?, teams?, companies?, orgs? } in any
combination. The server walks org → companies_of_org →
teams_of_company → agents_of_team, dedupes against explicit ids,
and hard-purges every unique agent (deprovision ZeroClaw runtime,
tear down sandbox container, unlink .brain/.onion files,
transactional agents::hard_purge). Group rows are deleted last; FK
cascades on team_members, company_teams, org_companies, and
loop_agents/teams/orgs clean up the join tables. Every stage
streams SSE.

Three new cm-db helpers wire the walk: agents_of_team,
teams_of_company, companies_of_org — all DISTINCT selects on the
existing join tables.

Frontend — Dashboard's Agents-tier StructureTree now sets
selectLevel="*" (was "claw"), so the Wrench → checkbox affordance
appears on org/company/team/agent nodes alike; the same-level
invariant in onToggleSelect still prevents mixed batches.
ReapProgressModal collapses to a single POST regardless of kind —
body key derived from kind — and its subtitle is honest:
"Cascading through every agent inside — permanent."
This commit is contained in:
Omar Sobh
2026-07-08 10:36:52 -07:00
parent 1a2baee74b
commit 984d9a1274
9 changed files with 212 additions and 51 deletions
+78 -8
View File
@@ -7,6 +7,7 @@ use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::convert::Infallible;
use uuid::Uuid;
use crate::runtime_provision::provider_alias_for;
use crate::{ApiError, AppState, Authed};
@@ -981,26 +982,74 @@ pub async fn delete(
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
#[derive(Deserialize, Default)]
pub struct BatchDeleteRequest {
#[serde(default)]
pub ids: Vec<AgentId>,
/// Cascade: teams/companies/orgs are expanded to the agents inside them,
/// every unique agent is hard-purged, then the group rows themselves are
/// deleted. FK cascades already remove the join tables; we still delete
/// the entity rows explicitly so `list_*` immediately reflects the reap.
#[serde(default)]
pub teams: Vec<Uuid>,
#[serde(default)]
pub companies: Vec<Uuid>,
#[serde(default)]
pub orgs: Vec<Uuid>,
}
/// `POST /api/claws/batch-delete` (SSE) — HARD-purge multiple agents and reap
/// every attached resource: deprovision the ZeroClaw runtime, tear down the
/// sandbox container, unlink the `.brain`/`.onion` files, then transactionally
/// purge all DB rows (`agents::hard_purge`). Streams per-agent/per-resource
/// progress; the agents vanish from the roster (hard delete) on refresh.
/// `POST /api/claws/batch-delete` (SSE) — HARD-purge every selected agent,
/// team, company, or org. For groups, the backend walks the tree (org →
/// companies → teams → agents) and reaps every unique agent underneath:
/// deprovision the ZeroClaw runtime, tear down the sandbox container,
/// unlink `.brain`/`.onion` files, then transactionally purge DB rows via
/// `agents::hard_purge`. After all agents are gone, the group rows
/// themselves are deleted (children FK-cascade). Streams per-stage progress.
pub async fn batch_delete(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<BatchDeleteRequest>,
) -> impl axum::response::IntoResponse {
let stream = async_stream::stream! {
let total = body.ids.len().max(1);
// Expand groups → collect a de-duplicated agent list. The group ids
// are retained so we can delete the entity rows after the reap.
use std::collections::HashSet;
let mut agent_ids: Vec<AgentId> = Vec::new();
let mut seen: HashSet<uuid::Uuid> = HashSet::new();
for a in &body.ids {
if seen.insert(a.as_uuid()) { agent_ids.push(*a); }
}
// Orgs → companies → teams → agents
let mut team_ids: HashSet<uuid::Uuid> = body.teams.iter().copied().collect();
let mut company_ids: HashSet<uuid::Uuid> = body.companies.iter().copied().collect();
for org_id in &body.orgs {
match cm_db::repo::orgs::companies_of_org(&state.pool, *org_id).await {
Ok(cs) => for c in cs { company_ids.insert(c); },
Err(e) => { yield sse(json!({"stage":"error","pct":0,"label":format!("org {org_id} expand failed: {e}")})); }
}
}
for company_id in &company_ids.clone() {
match cm_db::repo::companies::teams_of_company(&state.pool, *company_id).await {
Ok(ts) => for t in ts { team_ids.insert(t); },
Err(e) => { yield sse(json!({"stage":"error","pct":0,"label":format!("company {company_id} expand failed: {e}")})); }
}
}
for team_id in &team_ids {
match cm_db::repo::teams::agents_of_team(&state.pool, *team_id).await {
Ok(ags) => for a in ags {
if seen.insert(a) { agent_ids.push(AgentId::from(a)); }
},
Err(e) => { yield sse(json!({"stage":"error","pct":0,"label":format!("team {team_id} expand failed: {e}")})); }
}
}
let group_count = team_ids.len() + company_ids.len() + body.orgs.len();
if group_count > 0 {
yield sse(json!({"stage":"start","pct":0,"label":format!("Reaping {} agents from {} groups…", agent_ids.len(), group_count)}));
}
let total = (agent_ids.len() + group_count).max(1);
let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env();
let mut done = 0usize;
for id in body.ids {
for id in agent_ids {
let base = 100 * done / total;
let agent = match workspace_agent(&state, &user, id).await {
Ok(a) => a,
@@ -1039,6 +1088,27 @@ pub async fn batch_delete(
Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("{name}: purge failed: {e}")})); }
}
}
// Now that every descendant agent is gone, remove the group rows
// themselves. FK cascades on team_members / company_teams /
// org_companies clean up the join tables automatically.
for team_id in &team_ids {
match cm_db::repo::teams::delete_team(&state.pool, *team_id, user.workspace_id).await {
Ok(()) => { done += 1; yield sse(json!({"stage":"removed","pct":100 * done / total,"label":format!("✓ team {team_id} removed")})); }
Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("team {team_id} delete failed: {e}")})); }
}
}
for company_id in &company_ids {
match cm_db::repo::companies::delete_company(&state.pool, *company_id, user.workspace_id).await {
Ok(()) => { done += 1; yield sse(json!({"stage":"removed","pct":100 * done / total,"label":format!("✓ company {company_id} removed")})); }
Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("company {company_id} delete failed: {e}")})); }
}
}
for org_id in &body.orgs {
match cm_db::repo::orgs::delete_org(&state.pool, *org_id, user.workspace_id).await {
Ok(()) => { done += 1; yield sse(json!({"stage":"removed","pct":100 * done / total,"label":format!("✓ org {org_id} removed")})); }
Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("org {org_id} delete failed: {e}")})); }
}
}
yield sse(json!({"stage":"done","pct":100,"label":"Done"}));
};
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())