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
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT team_id FROM company_teams WHERE company_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "team_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "640d23c926f2354e6b22fc7a406635e1019efaf74b0a0e95a0c92c7997b9c005"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT company_id FROM org_companies WHERE org_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "company_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "938e0bc9ff77600bf956618c78192dcca85cf2580bb496606178a59d7939bdcc"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT claw_id FROM team_members WHERE team_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "claw_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "c2cdec949bd2aa1500a79a3c6018b0a3afb8230b407c64be54c89932050d9683"
}
+78 -8
View File
@@ -7,6 +7,7 @@ use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::convert::Infallible; use std::convert::Infallible;
use uuid::Uuid;
use crate::runtime_provision::provider_alias_for; use crate::runtime_provision::provider_alias_for;
use crate::{ApiError, AppState, Authed}; use crate::{ApiError, AppState, Authed};
@@ -981,26 +982,74 @@ pub async fn delete(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[derive(Deserialize)] #[derive(Deserialize, Default)]
pub struct BatchDeleteRequest { pub struct BatchDeleteRequest {
#[serde(default)]
pub ids: Vec<AgentId>, 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 /// `POST /api/claws/batch-delete` (SSE) — HARD-purge every selected agent,
/// every attached resource: deprovision the ZeroClaw runtime, tear down the /// team, company, or org. For groups, the backend walks the tree (org →
/// sandbox container, unlink the `.brain`/`.onion` files, then transactionally /// companies → teams → agents) and reaps every unique agent underneath:
/// purge all DB rows (`agents::hard_purge`). Streams per-agent/per-resource /// deprovision the ZeroClaw runtime, tear down the sandbox container,
/// progress; the agents vanish from the roster (hard delete) on refresh. /// 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( pub async fn batch_delete(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
Json(body): Json<BatchDeleteRequest>, Json(body): Json<BatchDeleteRequest>,
) -> impl axum::response::IntoResponse { ) -> impl axum::response::IntoResponse {
let stream = async_stream::stream! { 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 provisioner = crate::runtime_provision::RuntimeProvisioner::from_env();
let mut done = 0usize; let mut done = 0usize;
for id in body.ids { for id in agent_ids {
let base = 100 * done / total; let base = 100 * done / total;
let agent = match workspace_agent(&state, &user, id).await { let agent = match workspace_agent(&state, &user, id).await {
Ok(a) => a, 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}")})); } 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"})); yield sse(json!({"stage":"done","pct":100,"label":"Done"}));
}; };
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new()) Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())
+12
View File
@@ -194,3 +194,15 @@ pub async fn teams_for_company(
}) })
.collect()) .collect())
} }
/// Distinct team ids bound to a company via `company_teams`. The
/// cascade-reap path drills through here to collect agents.
pub async fn teams_of_company(pool: &PgPool, company_id: Uuid) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT DISTINCT team_id FROM company_teams WHERE company_id = $1",
company_id,
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.team_id).collect())
}
+12
View File
@@ -164,3 +164,15 @@ pub async fn delete_org(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> R
} }
Ok(()) Ok(())
} }
/// Distinct company ids bound to an org via `org_companies`. The
/// cascade-reap path drills through here to collect teams → agents.
pub async fn companies_of_org(pool: &PgPool, org_id: Uuid) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT DISTINCT company_id FROM org_companies WHERE org_id = $1",
org_id,
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.company_id).collect())
}
+12
View File
@@ -190,6 +190,18 @@ pub async fn get_team(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Res
}) })
} }
/// Distinct agent ids bound to a team via `team_members`. Used by the
/// cascade-reap path so deleting a team also purges the agents inside it.
pub async fn agents_of_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT DISTINCT claw_id FROM team_members WHERE team_id = $1",
team_id,
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.claw_id).collect())
}
/// The node→claw bindings for a team. /// The node→claw bindings for a team.
pub async fn members_for_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<TeamMember>, DbError> { pub async fn members_for_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<TeamMember>, DbError> {
let rows = sqlx::query!( let rows = sqlx::query!(
@@ -656,7 +656,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
) : ( ) : (
<> <>
{/* The collapsible org → company → team → agent tree (World) or the flat agents list. */} {/* The collapsible org → company → team → agent tree (World) or the flat agents list. */}
<StructureTree roots={treeRoots} activeId={treeActiveId} autoExpand={treeAutoExpand} onSelectNode={onTreeSelect} selectMode={selectMode} selectLevel={isClaw ? "claw" : "*"} selectedIds={selectedAgents} onToggleSelect={(id) => setSelectedAgents((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); return next; } const lv = nodeLevel.get(id); const curLv = prev.size ? nodeLevel.get([...prev][0]) : lv; if (lv !== curLv) return new Set([id]); next.add(id); return next; })} /> <StructureTree roots={treeRoots} activeId={treeActiveId} autoExpand={treeAutoExpand} onSelectNode={onTreeSelect} selectMode={selectMode} selectLevel={"*"} selectedIds={selectedAgents} onToggleSelect={(id) => setSelectedAgents((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); return next; } const lv = nodeLevel.get(id); const curLv = prev.size ? nodeLevel.get([...prev][0]) : lv; if (lv !== curLv) return new Set([id]); next.add(id); return next; })} />
{selectMode ? ( {selectMode ? (
<div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontFamily: mono, fontSize: 11, color: selectedItems.length ? "#ff8a7a" : "#6a6a72" }}>{selectedItems.length} selected{selectedItems.length ? ` · ${reapKind}` : ""}</div> <div style={{ fontFamily: mono, fontSize: 11, color: selectedItems.length ? "#ff8a7a" : "#6a6a72" }}>{selectedItems.length} selected{selectedItems.length ? ` · ${reapKind}` : ""}</div>
@@ -1,9 +1,10 @@
"use client"; "use client";
// Streams the deletion of selected entities. Agents hard-purge with full reaping // Streams the cascade-deletion of selected entities. Every kind — agents,
// (POST /api/claws/batch-delete, SSE: runtime/containers/files/DB). Teams, // teams, companies, orgs — routes through the same SSE endpoint
// companies and orgs are structural deletes (DELETE /api/<kind>/{id} per item — // (POST /api/claws/batch-delete). Server expands groups to their descendant
// the children survive, just ungrouped). On done, the sidebar refreshes. // agents, hard-reaps each (runtime/containers/files/DB), then deletes the
// group rows themselves. No orphans left behind.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
@@ -15,7 +16,7 @@ type Item = { id: string; name: string };
type Line = { stage: string; label: string }; type Line = { stage: string; label: string };
const NOUN: Record<ReapKind, string> = { agents: "agent", teams: "team", companies: "company", orgs: "organization" }; const NOUN: Record<ReapKind, string> = { agents: "agent", teams: "team", companies: "company", orgs: "organization" };
const ENDPOINT: Record<Exclude<ReapKind, "agents">, string> = { teams: "teams", companies: "companies", orgs: "orgs" }; const BODY_KEY: Record<ReapKind, string> = { agents: "ids", teams: "teams", companies: "companies", orgs: "orgs" };
function lineColor(stage: string): string { function lineColor(stage: string): string {
if (stage === "removed" || stage === "done") return "#7fd0a0"; if (stage === "removed" || stage === "done") return "#7fd0a0";
@@ -41,42 +42,30 @@ export function ReapProgressModal({ items, kind, onClose, onDone }: { items: Ite
started.current = true; started.current = true;
(async () => { (async () => {
try { try {
if (kind === "agents") { // One SSE stream regardless of kind. The backend expands
// Full reap via the streaming batch endpoint. // teams/companies/orgs to the agents inside them, reaps each, then
const res = await fetch("/api/claws/batch-delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: items.map((i) => i.id) }) }); // deletes the group rows.
if (!res.ok || !res.body) { setError(`Request failed (${res.status})`); setFinished(true); return; } const body: Record<string, string[]> = { [BODY_KEY[kind]]: items.map((i) => i.id) };
const reader = res.body.getReader(); const res = await fetch("/api/claws/batch-delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const dec = new TextDecoder(); if (!res.ok || !res.body) { setError(`Request failed (${res.status})`); setFinished(true); return; }
let buf = ""; const reader = res.body.getReader();
for (;;) { const dec = new TextDecoder();
const { done, value } = await reader.read(); let buf = "";
if (done) break; for (;;) {
buf += dec.decode(value, { stream: true }); const { done, value } = await reader.read();
let i; if (done) break;
while ((i = buf.indexOf("\n\n")) >= 0) { buf += dec.decode(value, { stream: true });
const frame = buf.slice(0, i); buf = buf.slice(i + 2); let i;
const l = frame.split("\n").find((x) => x.startsWith("data:")); while ((i = buf.indexOf("\n\n")) >= 0) {
if (!l) continue; const frame = buf.slice(0, i); buf = buf.slice(i + 2);
try { const l = frame.split("\n").find((x) => x.startsWith("data:"));
const e = JSON.parse(l.slice(5).trim()) as { stage: string; label?: string; pct?: number }; if (!l) continue;
if (typeof e.pct === "number") setPct(e.pct);
if (e.label) setLines((p) => [...p, { stage: e.stage, label: e.label as string }]);
if (e.stage === "done") setFinished(true);
} catch { /* skip */ }
}
}
} else {
// Structural delete, one request per item.
const ep = ENDPOINT[kind];
for (let i = 0; i < items.length; i++) {
const it = items[i];
setLines((p) => [...p, { stage: "start", label: `Removing ${it.name}…` }]);
try { try {
const res = await fetch(`/api/${ep}/${it.id}`, { method: "DELETE" }); const e = JSON.parse(l.slice(5).trim()) as { stage: string; label?: string; pct?: number };
if (res.ok) setLines((p) => [...p, { stage: "removed", label: `✓ ${it.name} removed` }]); if (typeof e.pct === "number") setPct(e.pct);
else setLines((p) => [...p, { stage: "error", label: `${it.name}: failed (${res.status})` }]); if (e.label) setLines((p) => [...p, { stage: e.stage, label: e.label as string }]);
} catch { setLines((p) => [...p, { stage: "error", label: `${it.name}: network error` }]); } if (e.stage === "done") setFinished(true);
setPct(Math.round((100 * (i + 1)) / Math.max(1, items.length))); } catch { /* skip */ }
} }
} }
setFinished(true); setFinished(true);
@@ -90,8 +79,8 @@ export function ReapProgressModal({ items, kind, onClose, onDone }: { items: Ite
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 11, padding: "16px 20px", borderBottom: "1px solid rgba(255,255,255,.07)" }}> <div style={{ flex: "none", display: "flex", alignItems: "center", gap: 11, padding: "16px 20px", borderBottom: "1px solid rgba(255,255,255,.07)" }}>
<span style={{ width: 34, height: 34, flex: "none", borderRadius: 9, background: "rgba(255,111,97,.12)", border: "1px solid rgba(255,111,97,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#ff6f61" }}><Trash2 size={16} /></span> <span style={{ width: 34, height: 34, flex: "none", borderRadius: 9, background: "rgba(255,111,97,.12)", border: "1px solid rgba(255,111,97,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#ff6f61" }}><Trash2 size={16} /></span>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>{finished ? "Removal complete" : `Removing ${count} ${NOUN[kind]}${count === 1 ? "" : "s"}…`}</div> <div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>{finished ? "Removal complete" : `Reaping ${count} ${NOUN[kind]}${count === 1 ? "" : "s"}…`}</div>
<div style={{ fontSize: 12, color: "#8a8a92", marginTop: 2 }}>{kind === "agents" ? "Reaping runtime, containers, files & data — this is permanent." : "Removing the grouping — the items inside survive, just ungrouped."}</div> <div style={{ fontSize: 12, color: "#8a8a92", marginTop: 2 }}>{kind === "agents" ? "Reaping runtime, containers, files & data — this is permanent." : `Cascading through every agent inside — runtime, containers, files & data all reaped. Permanent.`}</div>
</div> </div>
</div> </div>