reap: cascade orgs → companies → teams → agents
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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user