//! Team templates — canonical rosters + tool bundles that materialize //! concrete `teams` rows. Slice 3 of the missions consolidation. //! //! Rows with `source = 'builtin'` are re-upserted from disk (TOML //! recipes under `templates/teams/`) at server boot. `source = 'user'` //! rows are workspace-authored and never overwritten by the loader. use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; use time::OffsetDateTime; use uuid::Uuid; use crate::DbError; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TeamTemplate { pub id: Uuid, pub key: String, pub name: String, pub stack: Vec, pub default_topology: String, pub risk_profile: String, pub mcp_bundles: Vec, pub version: i32, pub description: Option, pub config: Value, pub source: String, pub workspace_id: Option, /// 'research' | 'development' | 'security' | 'ops' #[serde(default = "default_category")] pub category: String, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TemplateRole { pub template_id: Uuid, pub slot: String, pub order_idx: i32, pub system_prompt: String, pub skills: Vec, pub brain_seed: Option, /// Which model this role's claw runs on. `None` takes the mint's default — /// which is what every role did unconditionally before migration 0071, and /// why a template could not put its reviewer on a different model from the /// coder it reviews. #[serde(default)] pub model: Option, } #[derive(Debug, Clone, Serialize)] pub struct TeamTemplateDetail { #[serde(flatten)] pub template: TeamTemplate, pub roles: Vec, } #[derive(Debug, Clone)] pub struct UpsertBuiltinRole<'a> { pub slot: &'a str, pub order_idx: i32, pub system_prompt: &'a str, pub skills: Vec, pub brain_seed: Option<&'a str>, /// Optional per-role model. `None` leaves the mint's default in place. pub model: Option<&'a str>, } #[derive(Debug, Clone)] pub struct UpsertBuiltin<'a> { /// Caller-provided deterministic id (typically a hash of `key` /// computed in cm-api's loader — cm-db doesn't need the hashing /// dep just for this one thing). pub id: Uuid, pub key: &'a str, pub name: &'a str, pub stack: Vec, pub default_topology: &'a str, pub risk_profile: &'a str, pub mcp_bundles: Vec, pub version: i32, pub description: Option<&'a str>, pub config: Value, /// 'research' | 'development' | 'security' | 'ops'. Defaults to /// 'development' at the loader level so old TOML files without a /// category still upsert as coding teams. pub category: &'a str, pub roles: Vec>, } fn default_category() -> String { "development".to_string() } /// Upsert a builtin template + its roles in one txn. Idempotent. pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result { let id = b.id; let mut tx = pool.begin().await?; sqlx::query( "INSERT INTO team_templates (id, key, name, stack, default_topology, risk_profile, mcp_bundles, version, description, config, source, workspace_id, category) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'builtin',NULL,$11) ON CONFLICT (id) DO UPDATE SET key = EXCLUDED.key, name = EXCLUDED.name, stack = EXCLUDED.stack, default_topology = EXCLUDED.default_topology, risk_profile = EXCLUDED.risk_profile, mcp_bundles = EXCLUDED.mcp_bundles, version = EXCLUDED.version, description = EXCLUDED.description, config = EXCLUDED.config, category = EXCLUDED.category, updated_at = now()", ) .bind(id) .bind(b.key) .bind(b.name) .bind(&b.stack) .bind(b.default_topology) .bind(b.risk_profile) .bind(&b.mcp_bundles) .bind(b.version) .bind(b.description) .bind(&b.config) .bind(b.category) .execute(&mut *tx) .await?; // Upsert each role in place, then prune only the slots the TOML dropped. // // This was `DELETE FROM template_roles` + reinsert, which looks equivalent // and is not: `agent_template_link` carries a plain FK on // (template_id, role_slot), so once a template has minted a single agent // the delete is rejected and the whole upsert transaction rolls back. The // effect was that **a template stopped accepting edits the moment it was // first used** — the loader logged a foreign-key error and moved on, so // the on-disk TOML and the DB drifted apart silently, and only for the // templates anyone actually ran. for r in &b.roles { sqlx::query( "INSERT INTO template_roles (template_id, slot, order_idx, system_prompt, skills, brain_seed, model) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (template_id, slot) DO UPDATE SET order_idx = EXCLUDED.order_idx, system_prompt = EXCLUDED.system_prompt, skills = EXCLUDED.skills, brain_seed = EXCLUDED.brain_seed, model = EXCLUDED.model", ) .bind(id) .bind(r.slot) .bind(r.order_idx) .bind(r.system_prompt) .bind(&r.skills) .bind(r.brain_seed) .bind(r.model) .execute(&mut *tx) .await?; } // Prune removed slots, but never at the cost of the whole upsert: a slot // still referenced by a live agent is left in place and reported. Losing // one stale role row is a smaller failure than losing every edit to the // template. let slots: Vec = b.roles.iter().map(|r| r.slot.to_string()).collect(); let stale: Vec = sqlx::query_scalar( "SELECT slot FROM template_roles WHERE template_id = $1 AND slot <> ALL($2)", ) .bind(id) .bind(&slots) .fetch_all(&mut *tx) .await?; for slot in stale { let referenced: i64 = sqlx::query_scalar( "SELECT count(*) FROM agent_template_link WHERE template_id = $1 AND role_slot = $2", ) .bind(id) .bind(&slot) .fetch_one(&mut *tx) .await?; if referenced > 0 { eprintln!( "team_templates: role {}.{slot} was removed from the TOML but {referenced} \ agent(s) still reference it — keeping the row so the upsert can commit", b.key ); continue; } sqlx::query("DELETE FROM template_roles WHERE template_id = $1 AND slot = $2") .bind(id) .bind(&slot) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(id) } pub async fn list_all(pool: &PgPool) -> Result, DbError> { let rows = sqlx::query( "SELECT id, key, name, stack, default_topology, risk_profile, mcp_bundles, version, description, config, source, workspace_id, category, created_at, updated_at FROM team_templates WHERE source = 'builtin' OR workspace_id IS NOT NULL ORDER BY source DESC, name ASC", ) .fetch_all(pool) .await?; Ok(rows.into_iter().map(row_to_template).collect()) } pub async fn get(pool: &PgPool, id: Uuid) -> Result, DbError> { use sqlx::Row; let Some(t) = sqlx::query( "SELECT id, key, name, stack, default_topology, risk_profile, mcp_bundles, version, description, config, source, workspace_id, category, created_at, updated_at FROM team_templates WHERE id = $1", ) .bind(id) .fetch_optional(pool) .await? .map(row_to_template) else { return Ok(None); }; let role_rows = sqlx::query( "SELECT template_id, slot, order_idx, system_prompt, skills, brain_seed, model FROM template_roles WHERE template_id = $1 ORDER BY order_idx ASC", ) .bind(id) .fetch_all(pool) .await?; let roles = role_rows .into_iter() .map(|r| TemplateRole { template_id: r.get("template_id"), slot: r.get("slot"), order_idx: r.get("order_idx"), system_prompt: r.get("system_prompt"), skills: r.get("skills"), brain_seed: r.get("brain_seed"), model: r.get("model"), }) .collect(); Ok(Some(TeamTemplateDetail { template: t, roles })) } pub async fn get_by_key(pool: &PgPool, key: &str) -> Result, DbError> { let row = sqlx::query( "SELECT id, key, name, stack, default_topology, risk_profile, mcp_bundles, version, description, config, source, workspace_id, category, created_at, updated_at FROM team_templates WHERE key = $1", ) .bind(key) .fetch_optional(pool) .await?; Ok(row.map(row_to_template)) } fn row_to_template(r: sqlx::postgres::PgRow) -> TeamTemplate { use sqlx::Row; TeamTemplate { id: r.get("id"), key: r.get("key"), name: r.get("name"), stack: r.get("stack"), default_topology: r.get("default_topology"), risk_profile: r.get("risk_profile"), mcp_bundles: r.get("mcp_bundles"), version: r.get("version"), description: r.get("description"), config: r.get("config"), source: r.get("source"), workspace_id: r.get("workspace_id"), category: r .try_get("category") .unwrap_or_else(|_| "development".to_string()), created_at: r.get("created_at"), updated_at: r.get("updated_at"), } }