default_risk_profile_for_role decides whether a claw gets file edits, git and shell by substring-matching its role against a fixed keyword list. On the planner path that role string is free text the model invented for this proposal, so a model's choice of wording silently decided tool access: a proposed "implementation_lead" matches no keyword, lands research_readonly, and then fails every file edit for a reason invisible from the role name. TeamMemberInput and the planner's member schema now carry `needs_write`, and resolve_risk_profile prefers it over the guess. The planner prompt asks for it per member and says to grant write only to members that produce code or commits. Absent (older clients, autoprovision, a model that omitted the field) falls back to the old guess, so nothing changes for callers that don't set it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
779 lines
27 KiB
Rust
779 lines
27 KiB
Rust
//! Team endpoints — deploy a baseline topology staffed with real claws, then run
|
|
//! it on the durable topology runner. The first rung of the deploy ladder.
|
|
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::Json;
|
|
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
|
|
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent};
|
|
use cm_topology::{build, TopologyKind};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use time::format_description::well_known::Rfc3339;
|
|
use uuid::Uuid;
|
|
|
|
use crate::runtime_provision::{claw_alias, RuntimeProvisioner};
|
|
use crate::{ApiError, AppState, Authed};
|
|
|
|
/// One staffed role in the team (becomes a claw + a topology node).
|
|
#[derive(Deserialize)]
|
|
pub struct TeamMemberInput {
|
|
pub role: String,
|
|
pub name: String,
|
|
/// Model selector: claude | glm | glm-5.2 | kimi | gemini | groq.
|
|
#[serde(default)]
|
|
pub model: String,
|
|
#[serde(default)]
|
|
pub system_prompt: String,
|
|
#[serde(default)]
|
|
pub accent: String,
|
|
/// Whether this member needs write access (file edits, git, shell) rather
|
|
/// than read-only research tools.
|
|
///
|
|
/// `None` falls back to guessing from the role name, which is what we used
|
|
/// to do unconditionally — see `resolve_risk_profile`.
|
|
#[serde(default)]
|
|
pub needs_write: Option<bool>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateTeamRequest {
|
|
pub name: String,
|
|
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
|
|
pub kind: String,
|
|
pub members: Vec<TeamMemberInput>,
|
|
/// Optional parent company — when provided, the freshly-created team
|
|
/// gets bound to it via `company_teams` so it never lands orphaned.
|
|
/// Wizards typically fetch this from `POST /api/structure/ensure-chain`
|
|
/// so the workspace always has a valid parent before team creation.
|
|
#[serde(default)]
|
|
pub attach_to_company_id: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct TeamCreated {
|
|
pub team_id: String,
|
|
}
|
|
|
|
fn parse_kind(s: &str) -> Result<TopologyKind, ApiError> {
|
|
serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest)
|
|
}
|
|
|
|
/// 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> {
|
|
build_team_with_lifecycle(
|
|
state,
|
|
workspace_id,
|
|
user_id,
|
|
name,
|
|
kind_str,
|
|
members,
|
|
"permanent",
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Same as `build_team` but with an explicit `lifecycle` (`permanent` |
|
|
/// `ephemeral`). Ephemeral teams are torn down by the topology_worker after
|
|
/// their last run terminates — used by the Scheduled + Triggered planner modes.
|
|
pub(crate) async fn build_team_with_lifecycle(
|
|
state: &AppState,
|
|
workspace_id: cm_domain::WorkspaceId,
|
|
user_id: cm_domain::UserId,
|
|
name: &str,
|
|
kind_str: &str,
|
|
members: &[TeamMemberInput],
|
|
lifecycle: &str,
|
|
) -> Result<(Uuid, Vec<Uuid>), ApiError> {
|
|
if members.is_empty() {
|
|
return Err(ApiError::BadRequest);
|
|
}
|
|
let kind = parse_kind(kind_str)?;
|
|
let provisioner = RuntimeProvisioner::from_env().ok_or(ApiError::Internal)?;
|
|
|
|
let mut claw_ids: Vec<Uuid> = Vec::with_capacity(members.len());
|
|
for m in members {
|
|
// Parity with the individual `POST /api/claws` handler — each
|
|
// claw counts against the workspace's agent quota + emits an
|
|
// audit row. Without these the auto-provision + team-wizard
|
|
// paths silently bypassed both governance rails.
|
|
crate::quota::enforce_new_agent(state, workspace_id).await?;
|
|
let agent = Agent {
|
|
id: AgentId::new(),
|
|
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_id,
|
|
status: AgentStatus::Online,
|
|
};
|
|
cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?;
|
|
cm_db::repo::audit::append(
|
|
&state.pool,
|
|
workspace_id,
|
|
cm_db::repo::audit::Actor::User(user_id),
|
|
"agent.created",
|
|
"agent",
|
|
&agent.id.to_string(),
|
|
serde_json::json!({
|
|
"name": agent.name,
|
|
"job_title": agent.job_title,
|
|
"source": "team_wizard",
|
|
}),
|
|
)
|
|
.await?;
|
|
let claw_id = agent.id.as_uuid();
|
|
let risk = RuntimeProvisioner::resolve_risk_profile(&m.role, m.needs_write);
|
|
// Ad-hoc team-wizard teams aren't mission-bound, so they use the
|
|
// default per-agent workspace under <install>/agents/<alias>/workspace/.
|
|
provisioner
|
|
.provision_claw(claw_id, &m.model, risk)
|
|
.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);
|
|
}
|
|
|
|
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) {
|
|
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_with_lifecycle(
|
|
&state.pool,
|
|
team_id,
|
|
workspace_id,
|
|
name,
|
|
kind.as_str(),
|
|
&graph_json,
|
|
lifecycle,
|
|
)
|
|
.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?;
|
|
// Auto-parent the new team when the wizard fetched a company via
|
|
// `POST /api/structure/ensure-chain`. Ownership check + node_id
|
|
// allocation happen inline so the team never lands orphaned mid-turn.
|
|
if let Some(company_id) = body.attach_to_company_id {
|
|
let company = cm_db::repo::companies::get(&state.pool, company_id, user.workspace_id)
|
|
.await
|
|
.map_err(|_| ApiError::NotFound)?;
|
|
let existing = cm_db::repo::companies::teams_for_company(&state.pool, company.id).await?;
|
|
let node_id = format!("n{}", existing.len());
|
|
cm_db::repo::companies::add_team(&state.pool, company.id, &node_id, team_id, "team")
|
|
.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>,
|
|
/// Optional parent company id. When set, the new team is bound under
|
|
/// this company via `companies::add_team` inside the same handler so
|
|
/// the team never lands orphaned. Mirrors `create_team`'s field —
|
|
/// wizards fetch this via `POST /api/structure/ensure-chain`.
|
|
#[serde(default)]
|
|
pub attach_to_company_id: Option<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(
|
|
&state.pool,
|
|
team_id,
|
|
user.workspace_id,
|
|
&body.name,
|
|
kind.as_str(),
|
|
&graph_json,
|
|
)
|
|
.await?;
|
|
for (i, node) in graph.nodes.iter().enumerate() {
|
|
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?;
|
|
}
|
|
}
|
|
|
|
// Same auto-parent path as create_team — if the caller preflighted
|
|
// ensure-chain to get a company_id, bind the new team under it here.
|
|
if let Some(company_id) = body.attach_to_company_id {
|
|
let company = cm_db::repo::companies::get(&state.pool, company_id, user.workspace_id)
|
|
.await
|
|
.map_err(|_| ApiError::NotFound)?;
|
|
let existing = cm_db::repo::companies::teams_for_company(&state.pool, company.id).await?;
|
|
let node_id = format!("n{}", existing.len());
|
|
cm_db::repo::companies::add_team(&state.pool, company.id, &node_id, team_id, "team")
|
|
.await?;
|
|
}
|
|
|
|
Ok((
|
|
StatusCode::CREATED,
|
|
Json(TeamCreated {
|
|
team_id: team_id.to_string(),
|
|
}),
|
|
))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct TeamSummaryOut {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub status: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
/// `GET /api/teams` — recent teams for the workspace.
|
|
pub async fn list_teams(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
) -> Result<Json<Vec<TeamSummaryOut>>, ApiError> {
|
|
let rows = cm_db::repo::teams::list_for_workspace(&state.pool, user.workspace_id, 50).await?;
|
|
Ok(Json(
|
|
rows.into_iter()
|
|
.map(|t| TeamSummaryOut {
|
|
id: t.id.to_string(),
|
|
name: t.name,
|
|
kind: t.kind,
|
|
status: t.status,
|
|
created_at: t.created_at.format(&Rfc3339).unwrap_or_default(),
|
|
})
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct TeamMemberOut {
|
|
pub node_id: String,
|
|
pub claw_id: String,
|
|
pub role: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct TeamDetail {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub status: String,
|
|
pub created_at: String,
|
|
pub graph: Value,
|
|
pub members: Vec<TeamMemberOut>,
|
|
/// Per-team runtime posture (0045). `None` risk_profile ⇒
|
|
/// container inherits the template default at spawn time.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub risk_profile: Option<String>,
|
|
#[serde(default)]
|
|
pub mcp_bundles: Vec<String>,
|
|
}
|
|
|
|
/// `PATCH /api/teams/{id}/runtime-config` — set the per-team
|
|
/// risk_profile + mcp_bundles. Wizards call this after creating a team
|
|
/// to bind it as either "read-heavy research" or "write-capable coding"
|
|
/// without weakening the sibling team's posture.
|
|
#[derive(Deserialize)]
|
|
pub struct RuntimeConfigRequest {
|
|
#[serde(default)]
|
|
pub risk_profile: Option<String>,
|
|
#[serde(default)]
|
|
pub mcp_bundles: Vec<String>,
|
|
}
|
|
|
|
pub async fn set_runtime_config(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<RuntimeConfigRequest>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
// Workspace-scope check via the existing get_team.
|
|
let _ = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
|
|
cm_db::repo::teams::set_team_runtime_config(
|
|
&state.pool,
|
|
id,
|
|
user.workspace_id,
|
|
&cm_db::repo::teams::TeamRuntimeConfig {
|
|
risk_profile: body.risk_profile,
|
|
mcp_bundles: body.mcp_bundles,
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// `GET /api/teams/{id}` — a team's graph + node→claw bindings.
|
|
pub async fn get_team(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<TeamDetail>, ApiError> {
|
|
let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
|
|
let members = cm_db::repo::teams::members_for_team(&state.pool, id).await?;
|
|
let runtime = cm_db::repo::teams::get_team_runtime_config(&state.pool, id, user.workspace_id)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_default();
|
|
Ok(Json(TeamDetail {
|
|
id: team.id.to_string(),
|
|
name: team.name,
|
|
kind: team.kind,
|
|
status: team.status,
|
|
created_at: team.created_at.format(&Rfc3339).unwrap_or_default(),
|
|
graph: team.graph,
|
|
members: members
|
|
.into_iter()
|
|
.map(|m| TeamMemberOut {
|
|
node_id: m.node_id,
|
|
claw_id: m.claw_id.to_string(),
|
|
role: m.role,
|
|
})
|
|
.collect(),
|
|
risk_profile: runtime.risk_profile,
|
|
mcp_bundles: runtime.mcp_bundles,
|
|
}))
|
|
}
|
|
|
|
#[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)
|
|
}
|
|
|
|
/// `PATCH /api/teams/{id}/name` — inline rename from the sidebar.
|
|
#[derive(Deserialize)]
|
|
pub struct RenameTeamRequest {
|
|
pub name: String,
|
|
}
|
|
pub async fn rename_team(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<RenameTeamRequest>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
let name = body.name.trim();
|
|
if name.is_empty() {
|
|
return Err(ApiError::BadRequest);
|
|
}
|
|
cm_db::repo::teams::rename_team(&state.pool, id, user.workspace_id, name).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,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct RunAccepted {
|
|
pub run_id: String,
|
|
pub status: String,
|
|
}
|
|
|
|
/// `POST /api/teams/{id}/run` — enqueue a durable run of the team's topology
|
|
/// (drives the bound claws). Poll/stream via `/api/topology-runs/{run_id}`.
|
|
pub async fn run_team(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<RunTeamRequest>,
|
|
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
|
|
let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
|
|
crate::quota::enforce_new_run(&state, user.workspace_id).await?;
|
|
let run_id = Uuid::now_v7();
|
|
cm_db::repo::topology_runs::enqueue_run_for_team(
|
|
&state.pool,
|
|
run_id,
|
|
user.workspace_id,
|
|
&body.task,
|
|
&team.graph,
|
|
id,
|
|
)
|
|
.await?;
|
|
Ok((
|
|
StatusCode::ACCEPTED,
|
|
Json(RunAccepted {
|
|
run_id: run_id.to_string(),
|
|
status: "queued".into(),
|
|
}),
|
|
))
|
|
}
|
|
|
|
// ── auto-provision (0047 fold): LLM-derived team ─────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AutoProvisionRequest {
|
|
/// Topic title (short — used to name the team).
|
|
pub title: String,
|
|
/// Topic description (drives the LLM roster derivation).
|
|
pub description: String,
|
|
/// Outcome kind (spec / prod_plan / roadmap / paper / integrations)
|
|
/// — steers the roster + topology.
|
|
pub outcome_kind: String,
|
|
/// Optional user hint for topology. When absent, derived from
|
|
/// outcome_kind (integrations → pipeline; everything else →
|
|
/// hub_spoke). Accepts the same strings as the wizard.
|
|
#[serde(default)]
|
|
pub topology_kind: Option<String>,
|
|
/// Optional preferred model for every provisioned agent. Falls back
|
|
/// to `claude-sonnet-5` when omitted. Kept overridable so the same
|
|
/// endpoint serves cost-conscious topics too.
|
|
#[serde(default)]
|
|
pub model: Option<String>,
|
|
/// Optional `["file_read","web_search",...]` risk-profile hint —
|
|
/// when omitted the endpoint picks by outcome_kind (research →
|
|
/// research_web_readonly; coding-adjacent → coding_readwrite).
|
|
#[serde(default)]
|
|
pub risk_profile: Option<String>,
|
|
/// MCP bundle aliases — same fall-back rule applies (always
|
|
/// clawmates_door; gitea_forge when a repo is bound; deep-research
|
|
/// skill for research profiles).
|
|
#[serde(default)]
|
|
pub mcp_bundles: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AutoProvisionedAgent {
|
|
pub agent_id: String,
|
|
pub name: String,
|
|
pub role_slot: String,
|
|
pub model: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AutoProvisionResponse {
|
|
pub team_id: String,
|
|
pub topology_kind: String,
|
|
pub agents: Vec<AutoProvisionedAgent>,
|
|
/// Echo of the runtime-config applied to the team row (0045 fields).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub risk_profile: Option<String>,
|
|
pub mcp_bundles: Vec<String>,
|
|
}
|
|
|
|
const AUTOPROVISION_SYSTEM: &str = "You are a team-composition assistant. \
|
|
Given a research/coding topic prompt + outcome kind, produce a compact roster of \
|
|
3 to 5 agents that could execute the pipeline end to end. For each agent output:\n\
|
|
- role_slot: short lowercase snake_case slot name (e.g. \"harvester\", \"tdd_implementer\"). \
|
|
Must be unique within the roster.\n\
|
|
- name: short human-friendly display name (1-2 words, ASCII, distinct per agent).\n\
|
|
- system_prompt: 2-4 sentence system prompt describing this agent's job in the \
|
|
topology. Should be actionable and reference the topic where relevant.\n\
|
|
Return a SINGLE JSON object with no code fences and no additional keys:\n\
|
|
{\"roles\": [ {\"role_slot\": \"...\", \"name\": \"...\", \"system_prompt\": \"...\"}, ... ]}\n\
|
|
Order matters: it dictates the pipeline / hub_spoke position. First role is the \
|
|
coordinator or first stage.";
|
|
|
|
/// `POST /api/teams/auto-provision` — LLM-derive a roster then materialize
|
|
/// a team + all claws + node→claw bindings, and stamp the runtime posture
|
|
/// (risk_profile + mcp_bundles) so it's ready for wizard step-5 to bind.
|
|
/// Returns the shape the wizard's `agents[]` submit expects, so the caller
|
|
/// can flow straight into `POST /api/research` (or `/api/loops`) with
|
|
/// `agents: response.agents` and no user-visible detour to the roster page.
|
|
pub async fn auto_provision(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Json(body): Json<AutoProvisionRequest>,
|
|
) -> Result<(StatusCode, Json<AutoProvisionResponse>), ApiError> {
|
|
if body.title.trim().is_empty() || body.description.trim().is_empty() {
|
|
return Err(ApiError::BadRequest);
|
|
}
|
|
// Derive topology + defaults from outcome_kind if the caller didn't override.
|
|
let outcome_kind = body.outcome_kind.trim().to_string();
|
|
let topology_kind = body.topology_kind.clone().unwrap_or_else(|| {
|
|
if outcome_kind == "integrations" {
|
|
"pipeline".to_string()
|
|
} else {
|
|
"hub_spoke".to_string()
|
|
}
|
|
});
|
|
let model = body
|
|
.model
|
|
.clone()
|
|
.filter(|s| !s.trim().is_empty())
|
|
.unwrap_or_else(|| "claude-sonnet-5".to_string());
|
|
|
|
// Auto-pick runtime posture when the caller didn't. Research topics
|
|
// default to the read-only + web-fetch profile so agents can fetch
|
|
// papers; coding-adjacent kinds don't apply here (loops.wizard picks
|
|
// them from a different path).
|
|
let risk_profile = body.risk_profile.clone().or_else(|| {
|
|
Some(match outcome_kind.as_str() {
|
|
"integrations" | "paper" | "spec" | "prod_plan" | "roadmap" => {
|
|
"research_web_readonly".to_string()
|
|
}
|
|
_ => "research_web_readonly".to_string(),
|
|
})
|
|
});
|
|
let mut mcp_bundles = body.mcp_bundles.clone();
|
|
if mcp_bundles.is_empty() {
|
|
mcp_bundles.push("clawmates_door".to_string());
|
|
// gitea_forge is scoped to teams that will touch repos; the
|
|
// wizard's downstream repo-binding step is what earns it.
|
|
// Always safe to add now — the MCP layer no-ops when the token
|
|
// isn't present in the container env.
|
|
mcp_bundles.push("gitea_forge".to_string());
|
|
}
|
|
|
|
// 1) LLM plan pass → roster JSON.
|
|
let user_message = format!(
|
|
"Outcome kind: {outcome_kind}\nTopology: {topology_kind}\n\nTopic title: {}\n\nTopic description:\n{}",
|
|
body.title.trim(),
|
|
body.description.trim(),
|
|
);
|
|
let request = ChatRequest {
|
|
system: AUTOPROVISION_SYSTEM.into(),
|
|
messages: vec![ChatMessage {
|
|
role: ChatRole::User,
|
|
parts: vec![ContentPart::Text { text: user_message }],
|
|
}],
|
|
tools: Vec::new(),
|
|
model: state.runtime.model().to_string(),
|
|
max_tokens: 2048,
|
|
web_search: false,
|
|
};
|
|
let provider = state.runtime.provider();
|
|
let mut stream = provider
|
|
.stream(request)
|
|
.await
|
|
.map_err(|_| ApiError::Internal)?;
|
|
let mut buf = String::new();
|
|
use futures::StreamExt;
|
|
while let Some(event) = stream.next().await {
|
|
match event.map_err(|_| ApiError::Internal)? {
|
|
LlmEvent::TextDelta(delta) => buf.push_str(&delta),
|
|
LlmEvent::Stop(_) => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
#[derive(Deserialize)]
|
|
struct DerivedRole {
|
|
role_slot: String,
|
|
name: String,
|
|
system_prompt: String,
|
|
}
|
|
#[derive(Deserialize)]
|
|
struct DerivedRoster {
|
|
roles: Vec<DerivedRole>,
|
|
}
|
|
let roster: DerivedRoster = serde_json::from_str(buf.trim()).map_err(|_| ApiError::Internal)?;
|
|
if roster.roles.is_empty() || roster.roles.len() > 8 {
|
|
return Err(ApiError::Internal);
|
|
}
|
|
|
|
// 2) Materialize the team + all claws via the existing build_team pipeline.
|
|
let members: Vec<TeamMemberInput> = roster
|
|
.roles
|
|
.iter()
|
|
.map(|r| TeamMemberInput {
|
|
role: r.role_slot.trim().to_string(),
|
|
name: r.name.trim().to_string(),
|
|
model: model.clone(),
|
|
system_prompt: r.system_prompt.trim().to_string(),
|
|
accent: String::new(),
|
|
// The autoprovision roster schema doesn't declare access yet, so
|
|
// this path keeps the role-name guess rather than silently
|
|
// changing what it grants.
|
|
needs_write: None,
|
|
})
|
|
.collect();
|
|
let team_name = format!("Auto · {}", body.title.trim());
|
|
let (team_id, claw_ids) = build_team(
|
|
&state,
|
|
user.workspace_id,
|
|
user.user_id,
|
|
&team_name,
|
|
&topology_kind,
|
|
&members,
|
|
)
|
|
.await?;
|
|
|
|
// 3) Stamp the runtime posture so the container spawn slice (3b) has
|
|
// the right risk_profile + bundles when it fires.
|
|
if let Err(e) = cm_db::repo::teams::set_team_runtime_config(
|
|
&state.pool,
|
|
team_id,
|
|
user.workspace_id,
|
|
&cm_db::repo::teams::TeamRuntimeConfig {
|
|
risk_profile: risk_profile.clone(),
|
|
mcp_bundles: mcp_bundles.clone(),
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
eprintln!("auto_provision({team_id}): runtime-config write failed: {e:?}");
|
|
}
|
|
|
|
// 4) Shape the response for the wizard: agent_id + role_slot in the
|
|
// exact form the /api/research submit body expects.
|
|
let agents: Vec<AutoProvisionedAgent> = claw_ids
|
|
.iter()
|
|
.zip(members.iter())
|
|
.map(|(cid, m)| AutoProvisionedAgent {
|
|
agent_id: cid.to_string(),
|
|
name: m.name.clone(),
|
|
role_slot: m.role.clone(),
|
|
model: model.clone(),
|
|
})
|
|
.collect();
|
|
|
|
Ok((
|
|
StatusCode::CREATED,
|
|
Json(AutoProvisionResponse {
|
|
team_id: team_id.to_string(),
|
|
topology_kind,
|
|
agents,
|
|
risk_profile,
|
|
mcp_bundles,
|
|
}),
|
|
))
|
|
}
|