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:
co-authored by
Claude Opus 4.8
parent
9f266d5806
commit
34f744734b
@@ -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 raw‑API 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>,
|
||||
|
||||
Reference in New Issue
Block a user