Teams (deploy ladder rung 1): schema + provisioning + API
A team = a baseline topology staffed with real claws. Migration 0010 (teams + team_members node→claw bindings) + cm-db repo/teams.rs. runtime_provision.rs turns a claw into a live runtime agent claw_<id> via the synced gateway config API (#7468): create agent + bind model_provider (mapped from chosen model) + risk_profile=toolfree + clawmates_door bundle — atomic, immediately drivable. routes/teams.rs: POST /api/teams (create claws + provision + build(kind,roles) + bind node.attrs["agent"]=claw_<id> + persist), GET /api/teams[/{id}], POST /api/teams/{id}/run (enqueue a durable run of the team graph — reuses the topology worker + SSE). v1 persona = topology role via the prompt builder; the claw's system_prompt stays its chat identity. Spike confirmed: runtime agent provisioning works; IDENTITY.md persona works for API models (Gemini/Groq), masked by CLI models (Claude/Kimi Code). 16 cm-api tests + provision unit tests pass, clippy clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3402a3b56d
commit
8123a27bcf
+47
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, name, kind, status, created_at FROM teams\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "kind",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "39e601efa7be91d2e5dcc3ef87864c7175e268d4c1f1f6064b824984b832d018"
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO team_members (team_id, node_id, claw_id, role)\n VALUES ($1, $2, $3, $4)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "528ae9fe25e494fa2031ed558911dea2ed53f2f901c039d7a34a4bbe08a57371"
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, name, kind, graph, status, created_at FROM teams\n WHERE id = $1 AND workspace_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "kind",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "graph",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "98a380135b389b2788b7e3b647f4a9a84f143e57b0d3ecd927d1775f9f845c6d"
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT node_id, claw_id, role FROM team_members WHERE team_id = $1 ORDER BY node_id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "node_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "claw_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "role",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "b3c480db2b497a995cf47477ad051fd4a7d5d5400aa198159d9390b15826fb81"
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO teams (id, workspace_id, name, kind, graph)\n VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Jsonb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "c121bdbab5de2f82f58c6762bb90ebf08a45cb6e2766e4c69dddd765e168579a"
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ mod error;
|
|||||||
mod extract;
|
mod extract;
|
||||||
mod mcp_door;
|
mod mcp_door;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod runtime_provision;
|
||||||
mod topology_exec;
|
mod topology_exec;
|
||||||
pub mod topology_worker;
|
pub mod topology_worker;
|
||||||
|
|
||||||
@@ -144,6 +145,12 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/topologies/build", post(routes::topology::build_graph))
|
.route("/api/topologies/build", post(routes::topology::build_graph))
|
||||||
.route("/api/topologies/compare", post(routes::topology::compare_topologies))
|
.route("/api/topologies/compare", post(routes::topology::compare_topologies))
|
||||||
.route("/api/topologies/run", post(routes::topology::run_topology))
|
.route("/api/topologies/run", post(routes::topology::run_topology))
|
||||||
|
.route(
|
||||||
|
"/api/teams",
|
||||||
|
get(routes::teams::list_teams).post(routes::teams::create_team),
|
||||||
|
)
|
||||||
|
.route("/api/teams/{id}", get(routes::teams::get_team))
|
||||||
|
.route("/api/teams/{id}/run", post(routes::teams::run_team))
|
||||||
.route("/api/topology-runs", get(routes::topology::list_runs))
|
.route("/api/topology-runs", get(routes::topology::list_runs))
|
||||||
.route("/api/topology-runs/{id}", get(routes::topology::get_run))
|
.route("/api/topology-runs/{id}", get(routes::topology::get_run))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ pub mod sessions;
|
|||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod slack;
|
pub mod slack;
|
||||||
pub mod team;
|
pub mod team;
|
||||||
|
pub mod teams;
|
||||||
pub mod topology;
|
pub mod topology;
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! 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_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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateTeamRequest {
|
||||||
|
pub name: String,
|
||||||
|
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
|
||||||
|
pub kind: String,
|
||||||
|
pub members: Vec<TeamMemberInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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() {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
let kind = parse_kind(&body.kind)?;
|
||||||
|
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 agent = Agent {
|
||||||
|
id: AgentId::new(),
|
||||||
|
workspace_id: user.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,
|
||||||
|
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
|
||||||
|
})?;
|
||||||
|
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 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
user.workspace_id,
|
||||||
|
&body.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((
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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?;
|
||||||
|
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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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?;
|
||||||
|
let run_id = Uuid::now_v7();
|
||||||
|
cm_db::repo::topology_runs::enqueue_run(
|
||||||
|
&state.pool,
|
||||||
|
run_id,
|
||||||
|
user.workspace_id,
|
||||||
|
&body.task,
|
||||||
|
&team.graph,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::ACCEPTED,
|
||||||
|
Json(RunAccepted {
|
||||||
|
run_id: run_id.to_string(),
|
||||||
|
status: "queued".into(),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! Provision a workspace claw as a live agent in the ZeroClaw runtime.
|
||||||
|
//!
|
||||||
|
//! A team deploy turns each persisted claw into a real runtime agent
|
||||||
|
//! (`claw_<id>`) via the gateway config API (added upstream in #7468): create
|
||||||
|
//! the agent, then bind its model provider, risk profile, and the §15 door
|
||||||
|
//! bundle. The agent is atomic + immediately drivable via `/ws/chat?agent=...`,
|
||||||
|
//! so the durable topology runner can execute the team on these claws (each node
|
||||||
|
//! carries `attrs["agent"] = claw_<id>`).
|
||||||
|
//!
|
||||||
|
//! Persona note (v1): behavior is driven by the topology **role** in the turn
|
||||||
|
//! prompt; the claw's rich `system_prompt` remains its chat-path identity.
|
||||||
|
//! Injecting per-claw persona into runtime turns is a fast-follow.
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// The runtime agent alias for a claw id.
|
||||||
|
pub fn claw_alias(claw_id: Uuid) -> String {
|
||||||
|
format!("claw_{}", claw_id.simple())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a claw's chosen model to an EXISTING runtime provider alias (no new
|
||||||
|
/// provider provisioning for v1). Unknown → Claude default.
|
||||||
|
pub fn provider_alias_for(model: &str) -> &'static str {
|
||||||
|
match model.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"glm" | "glm-4.7" | "glm4.7" => "claude_cli.glm",
|
||||||
|
"glm-5.2" | "glm5.2" | "glm5" => "claude_cli.glm5",
|
||||||
|
"kimi" | "kimi-for-coding" => "kimi_cli.default",
|
||||||
|
"gemini" | "gemini-2.5-flash" => "gemini.default",
|
||||||
|
"groq" | "llama" => "groq.default",
|
||||||
|
_ => "claude_cli.default", // "claude" / unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Talks to a live ZeroClaw runtime's config API to provision/deprovision agents.
|
||||||
|
pub struct RuntimeProvisioner {
|
||||||
|
http: reqwest::Client,
|
||||||
|
gateway_url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeProvisioner {
|
||||||
|
/// Build from the same env the topology executor uses (`ZEROCLAW_GATEWAY_URL`
|
||||||
|
/// + a durable `ZEROCLAW_TOKEN`). Returns `None` if not configured.
|
||||||
|
pub fn from_env() -> Option<RuntimeProvisioner> {
|
||||||
|
let gateway_url = std::env::var("ZEROCLAW_GATEWAY_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|u| !u.is_empty())?;
|
||||||
|
let token = std::env::var("ZEROCLAW_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|t| !t.is_empty())?;
|
||||||
|
Some(RuntimeProvisioner {
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
gateway_url,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_prop(&self, path: &str, value: serde_json::Value) -> Result<(), String> {
|
||||||
|
let res = self
|
||||||
|
.http
|
||||||
|
.put(format!("{}/api/config/prop", self.gateway_url))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.json(&serde_json::json!({ "path": path, "value": value }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("prop {path} request failed: {e}"))?;
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let code = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("prop {path} failed ({code}): {body}"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create `claw_<id>` as a live runtime agent bound to `model_alias`, the
|
||||||
|
/// `toolfree` risk profile, and the `clawmates_door` MCP bundle. Idempotent
|
||||||
|
/// on the create step.
|
||||||
|
pub async fn provision_claw(&self, claw_id: Uuid, model: &str) -> Result<String, String> {
|
||||||
|
let alias = claw_alias(claw_id);
|
||||||
|
let model_alias = provider_alias_for(model);
|
||||||
|
|
||||||
|
// 1. Create the agent map-key (idempotent — returns created:false if exists).
|
||||||
|
let res = self
|
||||||
|
.http
|
||||||
|
.post(format!(
|
||||||
|
"{}/api/config/map-key?path=agents&key={}",
|
||||||
|
self.gateway_url, alias
|
||||||
|
))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("create agent request failed: {e}"))?;
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let code = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("create agent {alias} failed ({code}): {body}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bind provider, risk profile, and the §15 door bundle.
|
||||||
|
self.set_prop(
|
||||||
|
&format!("agents.{alias}.model_provider"),
|
||||||
|
serde_json::json!(model_alias),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.set_prop(
|
||||||
|
&format!("agents.{alias}.risk_profile"),
|
||||||
|
serde_json::json!("toolfree"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.set_prop(
|
||||||
|
&format!("agents.{alias}.mcp_bundles"),
|
||||||
|
serde_json::json!(["clawmates_door"]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a provisioned claw agent (rollback / team teardown — wired when
|
||||||
|
/// team delete lands).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn deprovision_claw(&self, claw_id: Uuid) -> Result<(), String> {
|
||||||
|
let alias = claw_alias(claw_id);
|
||||||
|
let res = self
|
||||||
|
.http
|
||||||
|
.delete(format!(
|
||||||
|
"{}/api/config/map-key?path=agents&key={}",
|
||||||
|
self.gateway_url, alias
|
||||||
|
))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("delete agent request failed: {e}"))?;
|
||||||
|
if !res.status().is_success() {
|
||||||
|
return Err(format!("delete agent {alias} failed ({})", res.status()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_alias_mapping() {
|
||||||
|
assert_eq!(provider_alias_for("gemini"), "gemini.default");
|
||||||
|
assert_eq!(provider_alias_for("GLM-4.7"), "claude_cli.glm");
|
||||||
|
assert_eq!(provider_alias_for("kimi"), "kimi_cli.default");
|
||||||
|
assert_eq!(provider_alias_for("groq"), "groq.default");
|
||||||
|
assert_eq!(provider_alias_for("anything-else"), "claude_cli.default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alias_is_stable_and_safe() {
|
||||||
|
let id = Uuid::nil();
|
||||||
|
assert_eq!(claw_alias(id), "claw_00000000000000000000000000000000");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod runs;
|
|||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod steps;
|
pub mod steps;
|
||||||
|
pub mod teams;
|
||||||
pub mod threads;
|
pub mod threads;
|
||||||
pub mod topology_runs;
|
pub mod topology_runs;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! Persistence for deployed teams — a baseline topology staffed with real
|
||||||
|
//! workspace claws. `teams` holds the topology graph; `team_members` is the
|
||||||
|
//! durable node→claw binding.
|
||||||
|
|
||||||
|
use cm_domain::WorkspaceId;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
/// A team row summary (list view).
|
||||||
|
pub struct TeamSummary {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full team (graph + metadata).
|
||||||
|
pub struct Team {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub graph: Value,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node→claw binding within a team.
|
||||||
|
pub struct TeamMember {
|
||||||
|
pub node_id: String,
|
||||||
|
pub claw_id: Uuid,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a team (the topology graph). Members are added separately.
|
||||||
|
pub async fn insert_team(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
name: &str,
|
||||||
|
kind: &str,
|
||||||
|
graph: &Value,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO teams (id, workspace_id, name, kind, graph)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
id,
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
graph,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind a claw to a topology node within a team.
|
||||||
|
pub async fn add_member(
|
||||||
|
pool: &PgPool,
|
||||||
|
team_id: Uuid,
|
||||||
|
node_id: &str,
|
||||||
|
claw_id: Uuid,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO team_members (team_id, node_id, claw_id, role)
|
||||||
|
VALUES ($1, $2, $3, $4)",
|
||||||
|
team_id,
|
||||||
|
node_id,
|
||||||
|
claw_id,
|
||||||
|
role,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent teams for a workspace, newest first.
|
||||||
|
pub async fn list_for_workspace(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<TeamSummary>, DbError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
"SELECT id, name, kind, status, created_at FROM teams
|
||||||
|
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| TeamSummary {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
kind: r.kind,
|
||||||
|
status: r.status,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single team, workspace-scoped.
|
||||||
|
pub async fn get_team(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
) -> Result<Team, DbError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT id, name, kind, graph, status, created_at FROM teams
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
id,
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(DbError::NotFound)?;
|
||||||
|
Ok(Team {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
kind: row.kind,
|
||||||
|
graph: row.graph,
|
||||||
|
status: row.status,
|
||||||
|
created_at: row.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The node→claw bindings for a team.
|
||||||
|
pub async fn members_for_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<TeamMember>, DbError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
"SELECT node_id, claw_id, role FROM team_members WHERE team_id = $1 ORDER BY node_id",
|
||||||
|
team_id,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| TeamMember {
|
||||||
|
node_id: r.node_id,
|
||||||
|
claw_id: r.claw_id,
|
||||||
|
role: r.role,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Teams: a deployed multi-agent unit = a baseline TOPOLOGY staffed with real
|
||||||
|
-- workspace claws. The first rung of the deploy ladder (single → team → company
|
||||||
|
-- → org). `graph` is the TopologyGraph whose nodes carry attrs["agent"] = the
|
||||||
|
-- claw's runtime alias (claw_<id>); team_members is the durable node→claw
|
||||||
|
-- binding (each claw is also a normal agents-table row, individually chattable).
|
||||||
|
CREATE TABLE teams (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- TopologyKind (snake_case)
|
||||||
|
graph JSONB NOT NULL, -- TopologyGraph (nodes/edges)
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX teams_workspace_idx ON teams (workspace_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE team_members (
|
||||||
|
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
|
||||||
|
node_id TEXT NOT NULL, -- topology node id
|
||||||
|
claw_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (team_id, node_id)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user