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:
Omar Sobh
2026-06-18 13:13:14 -07:00
co-authored by Claude Opus 4.8
parent 3402a3b56d
commit 8123a27bcf
12 changed files with 745 additions and 0 deletions
+1
View File
@@ -11,6 +11,7 @@ pub mod runs;
pub mod sessions;
pub mod skills;
pub mod steps;
pub mod teams;
pub mod threads;
pub mod topology_runs;
pub mod users;
+150
View File
@@ -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())
}