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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 2–6), 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())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user