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>,
+49
View File
@@ -112,6 +112,55 @@ pub async fn create_company(
))
}
#[derive(Deserialize)]
pub struct PatchCompanyRequest {
/// New TopologyKind (snake_case).
pub kind: String,
}
/// `PATCH /api/companies/{id}` — change a company's topology: rebuild the graph
/// over its bound teams (stable node ids keep the node→team bindings) + persist.
pub async fn patch_company(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<PatchCompanyRequest>,
) -> Result<StatusCode, ApiError> {
let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?;
let kind = parse_kind(&body.kind)?;
let bindings = cm_db::repo::companies::teams_for_company(&state.pool, company.id).await?;
let by_node: std::collections::HashMap<String, (Uuid, String)> = bindings
.into_iter()
.map(|b| (b.node_id, (b.team_id, b.role)))
.collect();
let n = by_node.len();
let roles: Vec<String> = (0..n)
.map(|i| by_node.get(&format!("n{i}")).map(|(_, r)| r.clone()).unwrap_or_else(|| "team".into()))
.collect();
let role_refs: Vec<&str> = roles.iter().map(String::as_str).collect();
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
for node in graph.nodes.iter_mut() {
if let Some((tid, _)) = by_node.get(&node.id) {
node.attrs.insert("team_id".into(), tid.to_string());
node.attrs.insert("agent".into(), tid.to_string());
}
}
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::companies::set_topology(&state.pool, company.id, user.workspace_id, kind.as_str(), &graph_json).await?;
Ok(StatusCode::NO_CONTENT)
}
/// `DELETE /api/companies/{id}` — remove a company (its teams remain).
pub async fn delete_company(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::companies::delete_company(&state.pool, id, user.workspace_id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize)]
pub struct CompanySummaryOut {
pub id: String,
+2
View File
@@ -6,6 +6,8 @@ pub mod browser;
pub mod claw_chat;
pub mod claws;
pub mod companies;
pub mod planner;
pub mod webhooks;
pub mod files;
pub mod gateway;
pub mod health;
+11
View File
@@ -152,6 +152,17 @@ pub struct OrgDetail {
}
/// `GET /api/orgs/{id}` — an org's graph + node→company bindings.
/// `DELETE /api/orgs/{id}` — remove the org (structural: companies survive, just
/// ungrouped from it).
pub async fn delete_org(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<axum::http::StatusCode, ApiError> {
cm_db::repo::orgs::delete_org(&state.pool, id, user.workspace_id).await?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
pub async fn get_org(
State(state): State<AppState>,
Authed(user): Authed,
+225
View File
@@ -0,0 +1,225 @@
//! Master Planner — a chat with Claude Opus 4.8 that proposes a team of agents
//! (named, role'd, one model each) and then scaffolds it end-to-end: creates the
//! team + topology, refines + attaches a brain per agent, and sets up the nightly
//! loop. Replaces the old "+" deploy wizard.
use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::convert::Infallible;
use crate::routes::claws::{apply_reference_to_claw, enhance_and_publish, extract_json};
use crate::routes::teams::{build_team, TeamMemberInput};
use crate::{AppState, Authed};
fn sse(v: Value) -> Result<Event, Infallible> {
Ok(Event::default().data(v.to_string()))
}
const PLANNER_SYSTEM: &str = "You are the ClawMates Master Planner. You design teams of AI agents to \
accomplish a user's goal across the platform's hierarchy (organizations → companies → teams → agents). \
Have a brief, helpful conversation to understand the goal, then propose a concrete team. For the proposal: \
choose a sensible number of agents (usually 26), each with a UNIQUE human name, a clear ROLE, the best \
MODEL for that role (see catalog), a short brain_query (one domain keyword to fetch a starting brain from \
the registry, e.g. 'research', 'writing', 'data', 'security'), a focused system_prompt, and a one-line \
rationale. Pick a topology_kind from: hub_spoke, hierarchical, pipeline, mesh, flat. If the goal implies \
recurring/autonomous work (e.g. 'nightly'), include a schedule {cron (5-field), prompt (the mission the \
lead agent runs each cycle)}. Use web search to ground version-sensitive or current-fact claims. \
ALWAYS respond with STRICT JSON ONLY (no prose, no markdown), exactly: \
{\"reply\":\"<concise message to the user>\",\"proposal\":null|{\"team_name\":\"...\",\
\"topology_kind\":\"hub_spoke\",\"schedule\":null|{\"cron\":\"0 2 * * *\",\"prompt\":\"...\"},\
\"members\":[{\"name\":\"...\",\"role\":\"...\",\"model\":\"...\",\"brain_query\":\"...\",\
\"system_prompt\":\"...\",\"rationale\":\"...\"}]}}. Set proposal to null while still clarifying; include \
it once you have a concrete team. \n\nMODELS (set each member's \"model\" to exactly one token):\n\
- claude — Claude Opus 4.8: strongest reasoning/planning; coordinators, hard analysis. Highest cost.\n\
- glm-4.7 — strong general reasoning (Z.ai); best cost/quality default for most workers.\n\
- glm-5.2 — GLM Opus-class for the hardest reasoning roles; higher cost.\n\
- kimi — excellent for code-heavy roles.\n\
- gemini — Gemini 2.5 Flash: very fast; classification, summarization, high-volume tasks.\n\
- groq — fastest/cheapest; simple sequential high-throughput steps.\n\
AGENT TOOLS each agent can use at runtime: web.search (find sources), browser.goto (fetch a URL), \
files.write (build a markdown vault in the shared drive), chat.send (delegate to teammates), \
routine.schedule (self-schedule).";
#[derive(Deserialize)]
pub struct PlannerMessage {
pub role: String,
pub content: String,
}
#[derive(Deserialize)]
pub struct PlannerChatRequest {
pub messages: Vec<PlannerMessage>,
/// Deploy mode: specialists | swarm | scheduled | triggered (default specialists).
#[serde(default)]
pub mode: String,
}
const SPECIALISTS_NOTE: &str = "\n\nMODE: Specialists. Each member is a DOMAIN SPECIALIST — set each member's \
brain_query to a concrete domain brain keyword (e.g. 'rust-2024', 'react-native', 'pentest-web', 'db-ops').";
const SCHEDULED_NOTE: &str = "\n\nMODE: Scheduled. Include a schedule in the proposal. Recurring: \
{\"cron\":\"<5-field>\",\"prompt\":\"<mission run each cycle>\"}. One-time: \
{\"one_shot_at\":\"<RFC3339 UTC datetime>\",\"prompt\":\"<mission>\"}.";
const TRIGGERED_NOTE: &str = "\n\nMODE: Triggered. The team will be fired by a webhook on demand. Set the \
schedule field to {\"prompt\":\"<the default task the webhook runs>\"} (NO cron / one_shot_at).";
const SWARM_SYSTEM: &str = "You are the planner for a self-verifying agent SWARM (Opus plans + verifies, a worker \
swarm executes, the loop repeats until every output passes). The user describes a job; you turn it into a swarm \
spec. The CHECKLIST is the verification contract — each item must be objectively checkable per task (e.g. 'states \
a revenue figure', 'cites a resolvable source URL', 'no field left empty'). Have a brief conversation, then emit \
the spec. ALWAYS respond with STRICT JSON ONLY: {\"reply\":\"<concise message>\",\"swarm\":null|{\"goal\":\"<the \
decomposable job>\",\"checklist\":[\"...\",\"...\"],\"task_count\":<int>,\"worker_model\":\"auto\"}}. Set swarm to \
null while still clarifying; include it once the job + checklist are concrete.";
fn planner_system_for(mode: &str) -> String {
match mode {
"swarm" => SWARM_SYSTEM.to_string(),
"scheduled" => format!("{PLANNER_SYSTEM}{SCHEDULED_NOTE}"),
"triggered" => format!("{PLANNER_SYSTEM}{TRIGGERED_NOTE}"),
_ => format!("{PLANNER_SYSTEM}{SPECIALISTS_NOTE}"),
}
}
/// `POST /api/planner/chat` — one planner turn (SSE: thinking → done{reply,proposal}).
pub async fn planner_chat(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<PlannerChatRequest>,
) -> impl axum::response::IntoResponse {
let runtime = state.runtime.clone();
let pool = state.pool.clone();
let ws = user.workspace_id;
let stream = async_stream::stream! {
yield sse(json!({"stage":"thinking","label":"Planning with Claude Opus 4.8…"}));
let agents = cm_db::repo::agents::roster(&pool, ws).await.unwrap_or_default();
let teams = cm_db::repo::teams::list_for_workspace(&pool, ws, 100).await.unwrap_or_default();
let agent_names: Vec<String> = agents.iter().take(40).map(|a| format!("{} ({})", a.name, a.job_title)).collect();
let team_names: Vec<String> = teams.iter().take(40).map(|t| t.name.clone()).collect();
let hierarchy = format!(
"CURRENT WORKSPACE: {} agents, {} teams.\nExisting agents: {}\nExisting teams: {}",
agents.len(), teams.len(),
if agent_names.is_empty() { "(none)".to_string() } else { agent_names.join(", ") },
if team_names.is_empty() { "(none)".to_string() } else { team_names.join(", ") },
);
let convo = body.messages.iter()
.map(|m| format!("{}: {}", if m.role == "user" { "USER" } else { "PLANNER" }, m.content))
.collect::<Vec<_>>().join("\n\n");
let user_prompt = format!("{hierarchy}\n\n=== CONVERSATION ===\n{convo}\n\nRespond now (JSON only).");
let system = planner_system_for(&body.mode);
let raw = match runtime.complete(&system, &user_prompt, "claude-opus-4-8", 8000, true).await {
Ok(t) => t,
Err(e) => { yield sse(json!({"stage":"error","label":format!("Opus error: {e}")})); return; }
};
match extract_json(&raw) {
Some(v) => {
let reply = v.get("reply").and_then(|x| x.as_str()).unwrap_or("").to_string();
let proposal = v.get("proposal").cloned().unwrap_or(Value::Null);
let swarm = v.get("swarm").cloned().unwrap_or(Value::Null);
yield sse(json!({"stage":"done","reply":reply,"proposal":proposal,"swarm":swarm}));
}
None => yield sse(json!({"stage":"done","reply":raw,"proposal":Value::Null,"swarm":Value::Null})),
}
};
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())
}
#[derive(Deserialize)]
pub struct ScaffoldMember {
pub name: String,
pub role: String,
#[serde(default)]
pub model: String,
#[serde(default)]
pub brain_query: String,
#[serde(default)]
pub system_prompt: String,
}
#[derive(Deserialize)]
pub struct ScaffoldSchedule {
#[serde(default)]
pub cron: String,
/// One-shot fire time (RFC3339 UTC). When set, takes precedence over `cron`.
#[serde(default)]
pub one_shot_at: String,
pub prompt: String,
}
#[derive(Deserialize)]
pub struct ScaffoldRequest {
pub team_name: String,
#[serde(default = "default_kind")]
pub topology_kind: String,
#[serde(default)]
pub schedule: Option<ScaffoldSchedule>,
pub members: Vec<ScaffoldMember>,
}
fn default_kind() -> String {
"hub_spoke".to_string()
}
/// `POST /api/planner/scaffold` — build the approved team (SSE progress): create
/// agents + topology, refine+attach a brain per agent, set up the nightly loop.
pub async fn planner_scaffold(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<ScaffoldRequest>,
) -> impl axum::response::IntoResponse {
let runtime = state.runtime.clone();
let stream = async_stream::stream! {
if body.members.is_empty() {
yield sse(json!({"stage":"error","pct":100,"label":"Empty proposal"}));
return;
}
let n = body.members.len().max(1);
yield sse(json!({"stage":"team","pct":8,"label":format!("Creating team “{}” ({} agents)…", body.team_name, n)}));
let members: Vec<TeamMemberInput> = body.members.iter().map(|m| TeamMemberInput {
role: m.role.clone(),
name: m.name.clone(),
model: if m.model.trim().is_empty() { "claude".to_string() } else { m.model.clone() },
system_prompt: m.system_prompt.clone(),
accent: String::new(),
}).collect();
let (team_id, claw_ids) = match build_team(&state, user.workspace_id, user.user_id, &body.team_name, &body.topology_kind, &members).await {
Ok(r) => r,
Err(_) => { yield sse(json!({"stage":"error","pct":100,"label":"Team creation failed"})); return; }
};
for (i, (cid, m)) in claw_ids.iter().zip(body.members.iter()).enumerate() {
let pct = 15 + (i as u32) * 70 / (n as u32);
yield sse(json!({"stage":"brain","pct":pct,"label":format!("Refining brain for {} ({})…", m.name, m.role)}));
let q = if m.brain_query.trim().is_empty() { m.role.clone() } else { m.brain_query.clone() };
let found = cm_brain::hub::list(&q).await.unwrap_or_default().into_iter().next().map(|b| b.reference);
if let Some(reference) = found {
let role_ctx = format!("Agent '{}', role '{}', on team '{}'. Mission: {}", m.name, m.role, body.team_name, m.system_prompt);
let refined = enhance_and_publish(&runtime, &reference, &role_ctx).await.unwrap_or(reference);
let id = cm_domain::AgentId::from(*cid);
let _ = apply_reference_to_claw(&state, id, &refined).await;
}
}
if let Some(sch) = &body.schedule {
// A `topology` action fires the whole team's stored graph as a durable
// run (not just one message to the lead).
if let Some(cid) = claw_ids.first() {
let id = cm_domain::AgentId::from(*cid);
let one_shot_at = sch.one_shot_at.trim();
if !one_shot_at.is_empty() {
// One-shot: fire once at the given datetime, never reschedule.
if let Ok(when) = time::OffsetDateTime::parse(one_shot_at, &time::format_description::well_known::Rfc3339) {
yield sse(json!({"stage":"routine","pct":92,"label":"Scheduling the one-time run…"}));
let action = json!({"topology": {"team_id": team_id.to_string(), "task": sch.prompt}, "one_shot": true});
let _ = cm_db::repo::routines::create(&state.pool, id, "Scheduled run", "0 0 1 1 *", action, when).await;
}
} else if !sch.cron.trim().is_empty() {
if let Ok(next) = cm_scheduler::next_occurrence(&sch.cron, time::OffsetDateTime::now_utc()) {
yield sse(json!({"stage":"routine","pct":92,"label":"Scheduling the recurring team loop…"}));
let action = json!({"topology": {"team_id": team_id.to_string(), "task": sch.prompt}});
let _ = cm_db::repo::routines::create(&state.pool, id, "Scheduled team loop", &sch.cron, action, next).await;
}
}
}
}
yield sse(json!({"stage":"done","pct":100,"label":"Team deployed","team_id":team_id.to_string()}));
};
Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new())
}
+147 -28
View File
@@ -45,52 +45,49 @@ fn parse_kind(s: &str) -> Result<TopologyKind, ApiError> {
serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest)
}
/// `POST /api/teams` — create a team: for each member create a claw + provision a
/// runtime agent, build the baseline topology, bind node→claw, persist.
pub async fn create_team(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateTeamRequest>,
) -> Result<(StatusCode, Json<TeamCreated>), ApiError> {
if body.members.is_empty() {
/// Create a team end-to-end: for each member create a claw + provision a runtime
/// agent, build the baseline topology, bind node→claw, persist. Returns the team
/// id + the created claw ids (in member order) so callers (e.g. the Master
/// Planner scaffold) can attach brains afterward.
pub(crate) async fn build_team(
state: &AppState,
workspace_id: cm_domain::WorkspaceId,
user_id: cm_domain::UserId,
name: &str,
kind_str: &str,
members: &[TeamMemberInput],
) -> Result<(Uuid, Vec<Uuid>), ApiError> {
if members.is_empty() {
return Err(ApiError::BadRequest);
}
let kind = parse_kind(&body.kind)?;
let kind = parse_kind(kind_str)?;
let provisioner = RuntimeProvisioner::from_env().ok_or(ApiError::Internal)?;
// 1. Create each claw (DB row) + provision it as a live runtime agent.
let mut claw_ids: Vec<Uuid> = Vec::with_capacity(body.members.len());
for m in &body.members {
let mut claw_ids: Vec<Uuid> = Vec::with_capacity(members.len());
for m in members {
let agent = Agent {
id: AgentId::new(),
workspace_id: user.workspace_id,
workspace_id,
name: m.name.clone(),
job_title: m.role.clone(),
system_prompt: m.system_prompt.clone(),
avatar: String::new(),
accent: m.accent.clone(),
wallpaper: String::new(),
managed_by: user.user_id,
managed_by: user_id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?;
let claw_id = agent.id.as_uuid();
// Provisioning failure rolls the team back at the runtime layer is best-
// effort; the claw row stays (visible in the roster) so nothing is lost.
provisioner
.provision_claw(claw_id, &m.model)
.await
.map_err(|e| {
eprintln!("teams: provision claw {claw_id} failed: {e}");
ApiError::Internal
})?;
// Persist the model so the claw card / anatomy can show it later.
provisioner.provision_claw(claw_id, &m.model).await.map_err(|e| {
eprintln!("teams: provision claw {claw_id} failed: {e}");
ApiError::Internal
})?;
cm_db::repo::agents::set_model_binding(&state.pool, agent.id, &m.model).await?;
claw_ids.push(claw_id);
}
// 2. Build the baseline topology and bind each node to its claw's runtime alias.
let roles: Vec<&str> = body.members.iter().map(|m| m.role.as_str()).collect();
let roles: Vec<&str> = members.iter().map(|m| m.role.as_str()).collect();
let mut graph = build(kind, &roles).map_err(|_| ApiError::BadRequest)?;
for (i, node) in graph.nodes.iter_mut().enumerate() {
if let Some(cid) = claw_ids.get(i) {
@@ -99,7 +96,78 @@ pub async fn create_team(
}
}
// 3. Persist team + node→claw bindings.
let team_id = Uuid::now_v7();
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::teams::insert_team(&state.pool, team_id, workspace_id, name, kind.as_str(), &graph_json).await?;
for (i, node) in graph.nodes.iter().enumerate() {
if let Some(cid) = claw_ids.get(i) {
cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role).await?;
}
}
Ok((team_id, claw_ids))
}
/// `POST /api/teams` — create a team from explicit members.
pub async fn create_team(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateTeamRequest>,
) -> Result<(StatusCode, Json<TeamCreated>), ApiError> {
let (team_id, _) = build_team(
&state,
user.workspace_id,
user.user_id,
&body.name,
&body.kind,
&body.members,
)
.await?;
Ok((StatusCode::CREATED, Json(TeamCreated { team_id: team_id.to_string() })))
}
#[derive(Deserialize)]
pub struct ComposeTeamRequest {
pub name: String,
/// TopologyKind (snake_case); defaults to `hub_spoke` when omitted.
#[serde(default)]
pub kind: String,
/// Existing claws (agents) to group into the new team.
pub claw_ids: Vec<Uuid>,
}
/// `POST /api/teams/from-claws` — create a team from EXISTING claws (no new
/// provisioning): verify each claw is in the caller's workspace, build the
/// baseline topology over their roles, bind node→claw, persist.
pub async fn create_team_from_claws(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<ComposeTeamRequest>,
) -> Result<(StatusCode, Json<TeamCreated>), ApiError> {
if body.claw_ids.is_empty() {
return Err(ApiError::BadRequest);
}
let kind = parse_kind(if body.kind.is_empty() { "hub_spoke" } else { &body.kind })?;
// Resolve + authorize each claw, collecting its role for the topology.
let mut roles: Vec<String> = Vec::with_capacity(body.claw_ids.len());
for cid in &body.claw_ids {
let agent = crate::routes::claws::workspace_agent(&state, &user, AgentId::from(*cid)).await?;
roles.push(if agent.job_title.is_empty() {
"claw".into()
} else {
agent.job_title
});
}
let role_refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect();
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
for (i, node) in graph.nodes.iter_mut().enumerate() {
if let Some(cid) = body.claw_ids.get(i) {
node.attrs.insert("agent".into(), claw_alias(*cid));
node.attrs.insert("claw_id".into(), cid.to_string());
}
}
let team_id = Uuid::now_v7();
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::teams::insert_team(
@@ -112,7 +180,7 @@ pub async fn create_team(
)
.await?;
for (i, node) in graph.nodes.iter().enumerate() {
if let Some(cid) = claw_ids.get(i) {
if let Some(cid) = body.claw_ids.get(i) {
cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role)
.await?;
}
@@ -198,6 +266,57 @@ pub async fn get_team(
}))
}
#[derive(Deserialize)]
pub struct PatchTeamRequest {
/// New TopologyKind (snake_case), e.g. "hierarchical", "hub_spoke".
pub kind: String,
}
/// `PATCH /api/teams/{id}` — change a team's topology: rebuild the graph over the
/// existing members' roles (stable node ids keep the node→claw bindings valid)
/// and persist the new kind + graph.
pub async fn patch_team(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<PatchTeamRequest>,
) -> Result<StatusCode, ApiError> {
let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
let kind = parse_kind(&body.kind)?;
let members = cm_db::repo::teams::members_for_team(&state.pool, team.id).await?;
let by_node: std::collections::HashMap<String, (Uuid, String)> = members
.into_iter()
.map(|m| (m.node_id, (m.claw_id, m.role)))
.collect();
let n = by_node.len();
let roles: Vec<String> = (0..n)
.map(|i| by_node.get(&format!("n{i}")).map(|(_, r)| r.clone()).unwrap_or_else(|| "claw".into()))
.collect();
let role_refs: Vec<&str> = roles.iter().map(String::as_str).collect();
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
for node in graph.nodes.iter_mut() {
if let Some((cid, _)) = by_node.get(&node.id) {
node.attrs.insert("agent".into(), claw_alias(*cid));
node.attrs.insert("claw_id".into(), cid.to_string());
}
}
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::teams::set_topology(&state.pool, team.id, user.workspace_id, kind.as_str(), &graph_json).await?;
Ok(StatusCode::NO_CONTENT)
}
/// `DELETE /api/teams/{id}` — remove a team and its node→claw bindings (the claws
/// themselves remain in the workspace).
pub async fn delete_team(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::teams::delete_team(&state.pool, id, user.workspace_id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct RunTeamRequest {
pub task: String,
+37
View File
@@ -168,6 +168,43 @@ pub async fn run_topology(
))
}
/// `POST /api/swarm/run` — ENQUEUE a self-verifying swarm run (tier `swarm`): Opus
/// plans tasks → a worker swarm executes → Opus verifies each against the checklist
/// → failures requeue → loop until clean. Streams into the Runs view like any run.
#[derive(serde::Deserialize)]
pub struct SwarmRunRequest {
pub goal: String,
#[serde(default)]
pub checklist: Vec<String>,
#[serde(default)]
pub task_count: Option<usize>,
#[serde(default)]
pub worker_model: String,
}
pub async fn run_swarm(
State(state): State<AppState>,
Authed(user): Authed,
Json(req): Json<SwarmRunRequest>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
if req.goal.trim().is_empty() {
return Err(ApiError::BadRequest);
}
let id = Uuid::now_v7();
let config = serde_json::json!({
"goal": req.goal,
"checklist": req.checklist,
"task_count": req.task_count,
"worker_model": req.worker_model,
});
cm_db::repo::topology_runs::enqueue_run_tier(&state.pool, id, user.workspace_id, &req.goal, &config, "swarm")
.await?;
Ok((
StatusCode::ACCEPTED,
Json(RunAccepted { run_id: id.to_string(), status: "queued".into() }),
))
}
/// A saved/queued run, summarized (now includes lifecycle status + kind).
#[derive(Serialize)]
pub struct RunSummary {
+122
View File
@@ -0,0 +1,122 @@
//! Inbound webhook triggers (Triggered deploy mode). A team gets a token whose
//! public URL, when POSTed, enqueues the team's topology run — reusing the same
//! durable-run path as the scheduler and the UI "Run team" button.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
/// `POST /api/teams/{id}/webhooks` — mint a webhook token for the team.
pub async fn create_webhook(
State(state): State<AppState>,
Authed(user): Authed,
Path(team_id): Path<Uuid>,
body: Option<Json<CreateBody>>,
) -> Result<Json<Value>, ApiError> {
// Scope check: the team must belong to the caller's workspace.
cm_db::repo::teams::get_team(&state.pool, team_id, user.workspace_id).await?;
let default_task = body.map(|b| b.0.task).unwrap_or_default();
let id = Uuid::now_v7();
let token = Uuid::now_v7();
sqlx::query("INSERT INTO webhook_tokens (id, workspace_id, team_id, token, task) VALUES ($1, $2, $3, $4, $5)")
.bind(id)
.bind(user.workspace_id.as_uuid())
.bind(team_id)
.bind(token)
.bind(&default_task)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(Json(json!({ "token": token.to_string(), "url": format!("/api/hooks/{token}") })))
}
#[derive(Deserialize, Default)]
pub struct CreateBody {
#[serde(default)]
pub task: String,
}
/// `GET /api/teams/{id}/webhooks` — list the team's webhook tokens.
pub async fn list_webhooks(
State(state): State<AppState>,
Authed(user): Authed,
Path(team_id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
cm_db::repo::teams::get_team(&state.pool, team_id, user.workspace_id).await?;
let rows = sqlx::query_as::<_, (Uuid,)>(
"SELECT token FROM webhook_tokens WHERE team_id = $1 ORDER BY created_at DESC",
)
.bind(team_id)
.fetch_all(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let hooks: Vec<Value> = rows
.into_iter()
.map(|(t,)| json!({ "token": t.to_string(), "url": format!("/api/hooks/{t}") }))
.collect();
Ok(Json(json!({ "webhooks": hooks })))
}
#[derive(Deserialize, Default)]
struct HookBody {
#[serde(default)]
task: String,
}
/// `POST /api/hooks/{token}` — PUBLIC. Fire the bound team's topology run. Body
/// `{task?}` overrides the team's default task. Mirrors the Slack/Stripe inbound
/// pattern (no session auth; the unguessable token is the credential).
pub async fn trigger_hook(
State(state): State<AppState>,
Path(token): Path<Uuid>,
body: axum::body::Bytes,
) -> StatusCode {
let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
"SELECT workspace_id, team_id, task FROM webhook_tokens WHERE token = $1",
)
.bind(token)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let Some((ws_id, team_id, default_task)) = row else {
return StatusCode::NOT_FOUND;
};
let ws = cm_domain::WorkspaceId::from(ws_id);
let Ok(team) = cm_db::repo::teams::get_team(&state.pool, team_id, ws).await else {
return StatusCode::NOT_FOUND;
};
let task = serde_json::from_slice::<HookBody>(&body)
.ok()
.map(|b| b.task)
.filter(|t| !t.trim().is_empty())
.or_else(|| (!default_task.trim().is_empty()).then_some(default_task))
.unwrap_or_else(|| "webhook trigger".to_string());
let run_id = Uuid::now_v7();
if cm_db::repo::topology_runs::enqueue_run(&state.pool, run_id, ws, &task, &team.graph)
.await
.is_err()
{
return StatusCode::INTERNAL_SERVER_ERROR;
}
let _ = sqlx::query("UPDATE webhook_tokens SET last_fired_at = now() WHERE token = $1")
.bind(token)
.execute(&state.pool)
.await;
let _ = cm_db::repo::audit::append(
&state.pool,
ws,
cm_db::repo::audit::Actor::System,
"topology.webhook_triggered",
"webhook",
&token.to_string(),
json!({ "team_id": team_id, "run_id": run_id }),
)
.await;
StatusCode::ACCEPTED
}