Large World graph, agent platform, brain stack & dashboard rebuild

Frontend
- Large World: collapse org/company/team tiers into one expandable React Flow
  hierarchy (WorldFlow) with per-click expand, persisted node positions, a
  compact tree sidebar, wrench multi-select delete across levels, and a sized
  right slide-out (phone/tablet/full) showing an agent summary + drill button.
- Agent page: GitHub-style animated contribution grid (VitalsCard), collapsible
  System Prompt + Personality cards, restructured anatomy cards, bigger avatar
  with name/title header row, Markdown/JSON-aware rendering, brain registry +
  history, avatar generate/upload.
- User-icon menu (Infrastructure/Brains/Tools/Profile/Credits) + ToolPanel;
  Master Planner deploy wizard (Specialists/Swarm/Scheduled/Triggered);
  Team Runs view; reap-progress modal; dashboard is the single live interface.

Backend
- cm-brain crate (.brain as the agent definition) + brain apply/history.
- Hard-purge reap (FK-ordered) + sandbox release + SSE batch-delete.
- Swarm self-verifying loop, mode-aware planner, web.search tool, webhooks
  (migration 0013), org/company/team delete endpoints, scheduler sweeps.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-22 23:21:54 -07:00
co-authored by Claude Opus 4.8
parent 9f266d5806
commit 34f744734b
123 changed files with 9591 additions and 1098 deletions
+664
View File
@@ -1,6 +1,8 @@
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json;
use std::convert::Infallible;
use cm_db::repo::audit::Actor;
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
use serde::{Deserialize, Serialize};
@@ -121,6 +123,605 @@ pub async fn compartments(
Ok(Json(out))
}
/// `GET /api/claws/{id}/brain` — the claw's `.brain` (cm-brain / ClawhDF5)
/// rendered for the anatomy cards: its six sections + recent memory + stats.
/// Best-effort: if the brain can't be opened, returns an empty (`exists:false`)
/// payload so the UI falls back to its other data sources.
#[derive(Serialize)]
pub struct BrainSkill {
pub name: String,
pub body: String,
}
#[derive(Serialize)]
pub struct BrainTool {
pub name: String,
pub state: String,
}
#[derive(Serialize, Default)]
pub struct BrainStats {
pub skills: usize,
pub tools: usize,
pub memories: usize,
}
#[derive(Serialize, Default)]
pub struct ClawBrainResponse {
/// Whether a `.brain` file already existed before this request.
pub exists: bool,
pub system_prompt: Option<String>,
pub personality: Option<String>,
pub skills: Vec<BrainSkill>,
pub tools: Vec<BrainTool>,
/// Recent conversational memory chunks, newest first.
pub memory: Vec<String>,
pub runtime: Option<Value>,
pub provenance: Option<Value>,
pub stats: BrainStats,
}
pub(crate) fn brain_dir() -> std::path::PathBuf {
std::env::var("CLAWMATES_BRAIN_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("clawmates-brains"))
}
/// Open (or first-create) the claw's brain and read it into a response. Seeds
/// the definition from Postgres on a fresh brain — mirrors the runtime's
/// first-touch seeding so the cards always have real data. Pure/sync.
fn load_brain(agent: &Agent, skills: &[(String, String)]) -> ClawBrainResponse {
use cm_brain::ClawBrain;
let path = brain_dir().join(format!("claw_{}.h5", agent.id));
let existed = path.exists();
let mut brain = match ClawBrain::open_or_create(&path, &agent.id.to_string()) {
Ok(b) => b,
Err(_) => return ClawBrainResponse::default(),
};
if brain.system_prompt().is_none() && !agent.system_prompt.trim().is_empty() {
let _ = brain.set_system_prompt(&agent.system_prompt);
for (name, body) in skills {
let _ = brain.set_skill(name, body);
}
}
let parse = |s: Option<String>| s.and_then(|t| serde_json::from_str::<Value>(&t).ok());
let skills_v: Vec<BrainSkill> = brain
.skills()
.into_iter()
.map(|(name, body)| BrainSkill { name, body })
.collect();
let tools_v: Vec<BrainTool> = brain
.tools()
.into_iter()
.map(|(name, state)| BrainTool { name, state })
.collect();
let memory: Vec<String> = brain
.recent_memory(12)
.into_iter()
.map(|(_, text)| text)
.collect();
let stats = BrainStats {
skills: skills_v.len(),
tools: tools_v.len(),
memories: brain.memory_count(),
};
ClawBrainResponse {
exists: existed,
system_prompt: brain.system_prompt(),
personality: brain.personality(),
skills: skills_v,
tools: tools_v,
memory,
runtime: parse(brain.runtime()),
provenance: parse(brain.provenance()),
stats,
}
}
pub async fn brain(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<ClawBrainResponse>, ApiError> {
let agent = workspace_agent(&state, &user, id).await?;
let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&state.pool, agent.id)
.await
.unwrap_or_default()
.into_iter()
.map(|s| (s.title, s.body))
.collect();
Ok(Json(load_brain(&agent, &skills)))
}
/// `POST /api/brainhub/enhance` — Opus-4.8 reviews a brain (prompt
/// effectiveness, exploitability, personality, tools/access), rewrites its
/// files, and commits a new version to ClawBrainHub. Streams progress (SSE).
#[derive(Deserialize)]
pub struct EnhanceRequest {
reference: String,
}
/// Extract a JSON object from an LLM response, tolerating prose wrappers, ```json
/// fences, and trailing commentary (balanced-brace scan from the first `{`).
pub(crate) fn extract_json(s: &str) -> Option<Value> {
let t = s.trim();
let t = t.strip_prefix("```json").or_else(|| t.strip_prefix("```")).unwrap_or(t);
let t = t.strip_suffix("```").unwrap_or(t).trim();
if let Ok(v) = serde_json::from_str::<Value>(t) {
return Some(v);
}
let bytes = t.as_bytes();
let start = t.find('{')?;
let (mut depth, mut in_str, mut esc) = (0i32, false, false);
for i in start..bytes.len() {
let c = bytes[i] as char;
if in_str {
if esc {
esc = false;
} else if c == '\\' {
esc = true;
} else if c == '"' {
in_str = false;
}
} else {
match c {
'"' => in_str = true,
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return serde_json::from_str(&t[start..=i]).ok();
}
}
_ => {}
}
}
}
None
}
pub(crate) const ENHANCE_SYSTEM: &str = "You are a rigorous AI-agent brain reviewer. You are given an agent's brain — \
its SYSTEM PROMPT, AGENTS.md (operating rules), PERSONA, and SKILLS. Audit it on four axes: \
(1) effectiveness — is the role clear, actionable, unambiguous; \
(2) exploitability — resistance to prompt injection / jailbreaks / data exfiltration / over-broad authority; \
(3) personality — consistency, tone, and an appropriate intensity/scale; \
(4) tools & access — are capabilities scoped and least-privilege. \
Then REWRITE each file to fix weaknesses and conform to production best practices (clear role, explicit \
guardrails and refusal boundaries, disciplined tool use, consistent persona). Preserve the agent's domain \
and intent; improve, don't replace its purpose. If a section is empty, create an appropriate one. \
USE WEB SEARCH to verify current real-world facts before writing — especially the LATEST STABLE versions \
of the languages, runtimes, toolchains, and key libraries this agent uses (your training data is stale; \
do not guess version numbers). Reflect the accurate current versions and any recent best-practice changes \
in the rewritten files. \
Respond with STRICT JSON ONLY, no prose or markdown, exactly this shape: \
{\"analysis\":{\"effectiveness\":{\"score\":0,\"notes\":\"\"},\"exploitability\":{\"score\":0,\"notes\":\"\"},\
\"personality\":{\"score\":0,\"notes\":\"\"},\"tools_access\":{\"score\":0,\"notes\":\"\"},\"summary\":\"\"},\
\"enhanced\":{\"system_prompt\":\"\",\"agent_md\":\"\",\"persona\":\"\",\"skills_md\":\"\"}} \
where scores are 0-10 and each enhanced file is the complete, ready-to-use replacement text.";
pub(crate) fn bump_version(v: &str) -> String {
let p: Vec<&str> = v.split('.').collect();
if p.len() == 3 {
if let Ok(patch) = p[2].parse::<u64>() {
return format!("{}.{}.{}", p[0], p[1], patch + 1);
}
}
format!("{v}-enhanced")
}
fn sse(v: Value) -> Result<Event, Infallible> {
Ok(Event::default().data(v.to_string()))
}
pub async fn enhance_brain(
State(state): State<AppState>,
Authed(_user): Authed,
Json(body): Json<EnhanceRequest>,
) -> impl axum::response::IntoResponse {
let reference = body.reference.trim().to_string();
let runtime = state.runtime.clone();
let stream = async_stream::stream! {
if reference.is_empty() || !reference.contains('/') {
yield sse(json!({"stage":"error","pct":100,"label":"Bad brain reference"}));
return;
}
yield sse(json!({"stage":"pull","pct":8,"label":"Pulling brain…"}));
let safe: String = reference.chars().map(|c| if c == '/' || c == ':' { '_' } else { c }).collect();
let path = brain_dir().join(format!("enhance_{safe}.h5"));
let _ = std::fs::remove_file(&path);
let pulled = match cm_brain::hub::pull(&reference, &path).await {
Ok(p) => p,
Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Pull failed: {e}")})); return; }
};
let (sp, agent_md, persona, skills) = match cm_brain::ClawBrain::open_or_create(&path, &reference) {
Ok(b) => (
b.system_prompt().unwrap_or_default(),
b.agent_md().unwrap_or_default(),
b.personality().unwrap_or_default(),
b.skills().into_iter().map(|(n, body)| format!("## {n}\n{body}")).collect::<Vec<_>>().join("\n\n"),
),
Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Open failed: {e}")})); return; }
};
yield sse(json!({"stage":"analyze","pct":28,"label":"Auditing with Claude Opus 4.8…"}));
let user_prompt = format!(
"BRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}"
);
let raw = match runtime.complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true).await {
Ok(t) => t,
Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Opus error: {e}")})); return; }
};
let v = match extract_json(&raw) {
Some(v) => v,
None => {
let head: String = raw.chars().take(220).collect();
let tail: String = { let n = raw.chars().count(); raw.chars().skip(n.saturating_sub(220)).collect() };
eprintln!("cm-api: enhance unparseable for {reference} (len={}): head={head:?} tail={tail:?}", raw.len());
yield sse(json!({"stage":"error","pct":100,"label":"Opus returned unparseable output"}));
return;
}
};
let analysis = v.get("analysis").cloned().unwrap_or(Value::Null);
let enh = v.get("enhanced").cloned().unwrap_or(Value::Null);
let field = |k: &str| enh.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let (e_sp, e_agent, e_persona, e_skills) =
(field("system_prompt"), field("agent_md"), field("persona"), field("skills_md"));
yield sse(json!({"stage":"write","pct":74,"label":"Applying enhancements…"}));
match cm_brain::ClawBrain::open_or_create(&path, &reference) {
Ok(mut b) => {
if !e_sp.trim().is_empty() { let _ = b.set_system_prompt(&e_sp); }
if !e_agent.trim().is_empty() { let _ = b.set_agent_md(&e_agent); }
if !e_persona.trim().is_empty() { let _ = b.set_personality(&e_persona); }
if !e_skills.trim().is_empty() { let _ = b.set_skills_md(&e_skills); }
}
Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Write failed: {e}")})); return; }
}
yield sse(json!({"stage":"push","pct":90,"label":"Committing new version to ClawBrainHub…"}));
let owner = cm_brain::hub::whoami().await.unwrap_or_else(|_| "me".to_string());
let on = pulled.meta.reference.rsplit_once(':').map(|(o, _)| o).unwrap_or(&pulled.meta.reference);
let name = on.rsplit_once('/').map(|(_, n)| n.to_string()).unwrap_or_else(|| on.to_string());
let cur_ver = pulled.meta.reference.rsplit_once(':').map(|(_, v)| v.to_string()).unwrap_or_else(|| "1.0.0".to_string());
let new_ref = format!("{owner}/{name}:{}", bump_version(&cur_ver));
match cm_brain::hub::push(&new_ref, &path, "Enhanced by Claude Opus 4.8", &[]).await {
Ok(()) => yield sse(json!({"stage":"done","pct":100,"label":"Committed new version","new_reference":new_ref,"analysis":analysis})),
Err(e) => yield sse(json!({"stage":"done","pct":100,"label":format!("Enhanced — push skipped ({e})"),"new_reference":Value::Null,"analysis":analysis})),
}
let _ = std::fs::remove_file(&path);
};
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())
}
/// Pull a registry brain, refine it once with Opus 4.8 (web-grounded, role-aware),
/// and publish a new version. Returns the new reference (or the original on push
/// failure). Used by the Master Planner scaffold.
pub(crate) async fn enhance_and_publish(
runtime: &cm_runtime::Runtime,
reference: &str,
role_context: &str,
) -> Result<String, String> {
let safe: String = reference.chars().map(|c| if c == '/' || c == ':' { '_' } else { c }).collect();
let path = brain_dir().join(format!("scaffold_{safe}.h5"));
let _ = std::fs::remove_file(&path);
let pulled = cm_brain::hub::pull(reference, &path).await.map_err(|e| e.to_string())?;
let (sp, agent_md, persona, skills) = {
let b = cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?;
(
b.system_prompt().unwrap_or_default(),
b.agent_md().unwrap_or_default(),
b.personality().unwrap_or_default(),
b.skills().into_iter().map(|(n, bd)| format!("## {n}\n{bd}")).collect::<Vec<_>>().join("\n\n"),
)
};
let user_prompt = format!(
"ROLE CONTEXT: {role_context}\n\nBRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}"
);
let raw = runtime.complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true).await?;
let v = extract_json(&raw).ok_or_else(|| "unparseable enhance output".to_string())?;
let enh = v.get("enhanced").cloned().unwrap_or(Value::Null);
let field = |k: &str| enh.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
{
let mut b = cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?;
if !field("system_prompt").trim().is_empty() { let _ = b.set_system_prompt(&field("system_prompt")); }
if !field("agent_md").trim().is_empty() { let _ = b.set_agent_md(&field("agent_md")); }
if !field("persona").trim().is_empty() { let _ = b.set_personality(&field("persona")); }
if !field("skills_md").trim().is_empty() { let _ = b.set_skills_md(&field("skills_md")); }
}
let owner = cm_brain::hub::whoami().await.unwrap_or_else(|_| "me".to_string());
let on = pulled.meta.reference.rsplit_once(':').map(|(o, _)| o).unwrap_or(&pulled.meta.reference);
let name = on.rsplit_once('/').map(|(_, n)| n.to_string()).unwrap_or_else(|| on.to_string());
let cur_ver = pulled.meta.reference.rsplit_once(':').map(|(_, vv)| vv.to_string()).unwrap_or_else(|| "1.0.0".to_string());
let new_ref = format!("{owner}/{name}:{}", bump_version(&cur_ver));
let result = match cm_brain::hub::push(&new_ref, &path, "Refined by Master Planner (Opus 4.8)", &[]).await {
Ok(()) => new_ref,
Err(_) => reference.to_string(),
};
let _ = std::fs::remove_file(&path);
Ok(result)
}
/// Attach a brain reference to a freshly-created claw (merge + set its
/// authoritative system prompt). Used by the Master Planner scaffold.
pub(crate) async fn apply_reference_to_claw(state: &AppState, id: AgentId, reference: &str) -> Result<(), String> {
let path = brain_dir().join(format!("claw_{id}.h5"));
let pulled = cm_brain::hub::pull_merge(reference, &path).await.map_err(|e| e.to_string())?;
if !pulled.system_prompt.trim().is_empty() {
let _ = cm_db::repo::agents::update_profile(
&state.pool, id, None, None, Some(pulled.system_prompt.trim()), None, None, None,
)
.await;
}
// ClawSync: snapshot this agent's starting brain as its first revision.
if let Ok(b) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
let _ = b.commit(Some(&format!("scaffolded from {reference}")));
}
Ok(())
}
/// `POST /api/brainhub/pull` — pull a `.brain` from ClawBrainHub and create a
/// claw from it (identity + skills + memory come from the brain). Public brains
/// pull anonymously; private ones need `BRAINHUB_API_KEY`.
#[derive(Deserialize)]
pub struct PullBrainRequest {
/// `owner/name[:version]`, e.g. `redclawsystems/general-assistant`.
reference: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
job_title: Option<String>,
#[serde(default)]
accent: Option<String>,
}
pub async fn pull_brain(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<PullBrainRequest>,
) -> Result<(StatusCode, Json<Agent>), ApiError> {
let reference = body.reference.trim().to_string();
if reference.is_empty() || !reference.contains('/') {
return Err(ApiError::BadRequest);
}
let id = AgentId::new();
let dest = brain_dir().join(format!("claw_{id}.h5"));
let pulled = cm_brain::hub::pull(&reference, &dest).await.map_err(|e| {
eprintln!("cm-api: brain pull failed for {reference}: {e}");
ApiError::BadRequest
})?;
let agent = Agent {
id,
workspace_id: user.workspace_id,
name: body.name.filter(|s| !s.trim().is_empty()).unwrap_or(pulled.name),
job_title: body
.job_title
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "Pulled from ClawBrainHub".into()),
system_prompt: pulled.system_prompt,
avatar: String::new(),
accent: body.accent.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "#ff6f61".into()),
wallpaper: String::new(),
managed_by: user.user_id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?;
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"agent.pulled_from_brain",
"agent",
&agent.id.to_string(),
json!({"reference": pulled.meta.reference, "trust_score": pulled.meta.trust_score}),
)
.await?;
Ok((StatusCode::CREATED, Json(agent)))
}
/// `POST /api/claws/{id}/brain/push` — publish a claw's `.brain` to ClawBrainHub.
/// Requires `BRAINHUB_API_KEY`.
#[derive(Deserialize)]
pub struct PushBrainRequest {
/// `owner/name:version` to publish under.
reference: String,
#[serde(default)]
description: String,
#[serde(default)]
tags: Vec<String>,
}
pub async fn push_brain(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<PushBrainRequest>,
) -> Result<StatusCode, ApiError> {
let agent = workspace_agent(&state, &user, id).await?;
let path = brain_dir().join(format!("claw_{id}.h5"));
if !path.exists() {
// No working brain yet — seed it from the DB definition so there's
// something to publish.
let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&state.pool, agent.id)
.await
.unwrap_or_default()
.into_iter()
.map(|s| (s.title, s.body))
.collect();
let _ = load_brain(&agent, &skills);
}
cm_brain::hub::push(body.reference.trim(), &path, &body.description, &body.tags)
.await
.map_err(|e| {
eprintln!("cm-api: brain push failed: {e}");
ApiError::BadRequest
})?;
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"agent.pushed_to_brain",
"agent",
&id.to_string(),
json!({"reference": body.reference}),
)
.await?;
Ok(StatusCode::CREATED)
}
/// `GET /api/brainhub/search?q=` — list/search ClawBrainHub brains (anonymous).
#[derive(Deserialize)]
pub struct BrainSearchQuery {
#[serde(default)]
q: String,
}
pub async fn brainhub_search(
State(_state): State<AppState>,
Authed(_user): Authed,
Query(query): Query<BrainSearchQuery>,
) -> Result<Json<Vec<cm_brain::hub::BrainListing>>, ApiError> {
Ok(Json(cm_brain::hub::list(&query.q).await.unwrap_or_default()))
}
/// `GET /api/brainhub/preview?ref=owner/name` — overview of a brain's contents
/// (which sections are populated) for the registry detail slide-out.
#[derive(Deserialize)]
pub struct BrainPreviewQuery {
#[serde(rename = "ref")]
reference: String,
}
pub async fn brainhub_preview(
State(_state): State<AppState>,
Authed(_user): Authed,
Query(query): Query<BrainPreviewQuery>,
) -> Result<Json<cm_brain::hub::BrainPreview>, ApiError> {
let reference = query.reference.trim();
if reference.is_empty() || !reference.contains('/') {
return Err(ApiError::BadRequest);
}
cm_brain::hub::preview(reference).await.map(Json).map_err(|e| {
eprintln!("cm-api: brain preview failed for {reference}: {e}");
ApiError::BadRequest
})
}
/// `POST /api/claws/{id}/brain/apply` — pull a brain and inject its contents
/// into THIS claw (identity → system prompt, +skills/+tools, +memory). Returns
/// the merged brain so the UI repopulates the cards.
#[derive(Deserialize)]
pub struct ApplyBrainRequest {
reference: String,
}
pub async fn apply_brain(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<ApplyBrainRequest>,
) -> Result<Json<ClawBrainResponse>, ApiError> {
let agent = workspace_agent(&state, &user, id).await?;
let reference = body.reference.trim();
if reference.is_empty() || !reference.contains('/') {
return Err(ApiError::BadRequest);
}
let path = brain_dir().join(format!("claw_{id}.h5"));
let pulled = cm_brain::hub::pull_merge(reference, &path).await.map_err(|e| {
eprintln!("cm-api: brain apply failed for {reference}: {e}");
ApiError::BadRequest
})?;
// The assembled identity becomes the agent's authoritative system prompt
// (safe replace — the chat path is rawAPI for every provider).
if !pulled.system_prompt.trim().is_empty() {
let _ = cm_db::repo::agents::update_profile(
&state.pool,
id,
None,
None,
Some(pulled.system_prompt.trim()),
None,
None,
None,
)
.await;
}
// ClawSync: snapshot the applied state as a revision (enables rollback).
if let Ok(b) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
let _ = b.commit(Some(&format!("applied {}", pulled.meta.reference)));
}
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"agent.brain_applied",
"agent",
&id.to_string(),
json!({"reference": pulled.meta.reference}),
)
.await?;
let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&state.pool, agent.id)
.await
.unwrap_or_default()
.into_iter()
.map(|s| (s.title, s.body))
.collect();
Ok(Json(load_brain(&agent, &skills)))
}
#[derive(Deserialize)]
pub struct RollbackRequest {
pub revision: u64,
}
/// `GET /api/claws/{id}/brain/revisions` — the brain's ClawSync revision history.
pub async fn brain_revisions(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<Value>, ApiError> {
workspace_agent(&state, &user, id).await?;
let path = brain_dir().join(format!("claw_{id}.h5"));
let revs = match cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
Ok(b) => b.revisions().unwrap_or_default(),
Err(_) => Vec::new(),
};
let out: Vec<Value> = revs
.iter()
.map(|r| json!({"revision": r.revision, "branch_id": r.branch_id, "annotation": r.annotation, "is_snapshot": r.is_snapshot}))
.collect();
Ok(Json(json!({ "revisions": out })))
}
/// `POST /api/claws/{id}/brain/rollback {revision}` — materialize a prior brain
/// revision and re-make its identity the agent's authoritative system prompt.
pub async fn brain_rollback(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<RollbackRequest>,
) -> Result<Json<Value>, ApiError> {
workspace_agent(&state, &user, id).await?;
let path = brain_dir().join(format!("claw_{id}.h5"));
let b = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()).map_err(|_| ApiError::Internal)?;
b.rollback(body.revision).map_err(|_| ApiError::BadRequest)?;
// Re-open the rolled-back brain and restore its identity as the live prompt.
if let Ok(reb) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
let sp = reb.assembled_identity();
if !sp.trim().is_empty() {
let _ = cm_db::repo::agents::update_profile(&state.pool, id, None, None, Some(sp.trim()), None, None, None).await;
}
}
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"agent.brain_rolledback",
"agent",
&id.to_string(),
json!({"revision": body.revision}),
)
.await
.ok();
Ok(Json(json!({ "ok": true, "revision": body.revision })))
}
#[derive(Deserialize)]
pub struct CreateClawRequest {
name: String,
@@ -234,6 +835,69 @@ pub async fn delete(
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct BatchDeleteRequest {
pub ids: Vec<AgentId>,
}
/// `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.
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);
let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env();
let mut done = 0usize;
for id in body.ids {
let base = 100 * done / total;
let agent = match workspace_agent(&state, &user, id).await {
Ok(a) => a,
Err(_) => { yield sse(json!({"stage":"skip","pct":base,"label":format!("{id}: not found or no access")})); done += 1; continue; }
};
if !user.role.is_owner() && agent.managed_by != user.user_id {
yield sse(json!({"stage":"skip","pct":base,"label":format!("{}: not permitted", agent.name)})); done += 1; continue;
}
let name = agent.name.clone();
yield sse(json!({"stage":"start","pct":base,"label":format!("Removing {name}…")}));
// 1. Deprovision the ZeroClaw runtime agent (best-effort).
yield sse(json!({"stage":"deprovision","pct":base,"label":format!("{name}: deprovisioning runtime…")}));
if let Some(p) = &provisioner {
let _ = p.deprovision_claw(id.as_uuid()).await;
}
// 2. Reap the sandbox/browser container if one is attached.
let had_container = state.runtime.reap_sandbox(id).await;
yield sse(json!({"stage":"container","pct":base,"label":format!("{name}: {}", if had_container { "reaped sandbox container" } else { "no container attached" })}));
// 3. Unlink the brain files.
let brain_gone = std::fs::remove_file(brain_dir().join(format!("claw_{id}.h5"))).is_ok();
let _ = std::fs::remove_file(brain_dir().join(format!("claw_{id}.h5.onion")));
yield sse(json!({"stage":"brain","pct":base,"label":format!("{name}: {}", if brain_gone { "deleted .brain file" } else { "no .brain file" })}));
// 4. Transactionally purge all DB rows + the agent itself.
yield sse(json!({"stage":"purge","pct":base,"label":format!("{name}: purging data…")}));
match cm_db::repo::agents::hard_purge(&state.pool, id).await {
Ok(c) => {
let _ = cm_db::repo::audit::append(
&state.pool, user.workspace_id, Actor::User(user.user_id),
"agent.purged", "agent", &id.to_string(),
json!({"name": name, "sessions": c.sessions, "files": c.files, "approvals": c.approvals, "connections": c.connections}),
).await;
done += 1;
yield sse(json!({"stage":"removed","pct":100 * done / total,"label":format!("✓ {name} removed — {} sessions, {} files, {} connections cleared", c.sessions, c.files, c.connections)}));
}
Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("{name}: purge failed: {e}")})); }
}
}
yield sse(json!({"stage":"done","pct":100,"label":"Done"}));
};
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())
}
/// PUT /api/claws/{id}/access — the §7.7 access toggles.
pub async fn set_access(
State(state): State<AppState>,