Files
clawmates/crates/cm-db/src/repo/team_templates.rs
T
Omar SobhandClaude Opus 5 f7f3dfe495 feat(fleet): GLM as a real microVM backend, and per-role models for claws
Three threads, all of which end at the same place: a mission whose verifier does
not share a model with the coder it reviews.

**GLM has a credential contract now.** `microvm_credential_for` returned one env
var name, which quietly assumed every provider reads its secret from the same
place Anthropic does. It returns a `Credential { source, target }` instead —
z.ai's key lives in the server's `ZAI_API_KEY` and Claude Code reads it as
`ANTHROPIC_AUTH_TOKEN`, and collapsing those two names is what forces a guess at
the other end. A wrong guess here sends one provider's credential to another
provider's endpoint.

`images/agent-glm` is the same CLI at the same pinned version as `agent-claude`
with `ANTHROPIC_BASE_URL` baked in. The split is deliberate: the ENDPOINT is a
property of the image, the CREDENTIAL is a property of the turn. That makes the
dangerous mix-up unrepresentable — a GLM VM cannot be handed an Anthropic
subscription token, and a claude VM cannot be pointed at z.ai. Asserted both
ways, because "the GLM VM must not carry CLAUDE_CODE_OAUTH_TOKEN" is the
property that costs a credential if it ever stops holding.

Kimi stays refused. `KIMI_API_KEY` is set and Moonshot serves an
Anthropic-compatible API, but I have not verified its base URL against the
running service, and this function is precisely where guessing a URL is
expensive. It becomes an arm the day someone measures it.

`api.z.ai` joins the node's default egress allow-list. A default that cannot
run the images we ship is a trap rather than a policy — the alternative is an
operator discovering it as a hung agent with no model access.

**Per-role models for claws** (migration 0071). `template_roles` had no model
column, so `mint_team_from_template` bound every role of every mission team to
one literal — a template whose whole point is an independent reviewer minted a
reviewer sharing a model with the coder. A role may now name its own; roles that
say nothing still take the mint's default, so every template written before this
behaves exactly as it did. The literal is now that default rather than a
hardcode.

**A harness scenario for the roster flow.** `verify-mission-delivery.sh roster`
runs the whole Slice 5 loop — planner proposes, human approves, mission runs —
and asserts the roster LANDED on the mission row rather than trusting the API's
answer. That distinction is not theoretical: the first live approval returned an
error while leaving the proposal marked approved.

Built and proven on tank ahead of the deploy: `clawmates/agent-glm:dev` reports
`2.1.223` and `BASE=https://api.z.ai/api/anthropic`, and
`fc-build-rootfs.sh … glm 8G` boots a VM from it that has git, can write
/mission, and answers `claude --version`.

533 tests pass, clippy clean. Migration 0071.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 17:14:53 -07:00

296 lines
10 KiB
Rust

//! 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<String>,
pub default_topology: String,
pub risk_profile: String,
pub mcp_bundles: Vec<String>,
pub version: i32,
pub description: Option<String>,
pub config: Value,
pub source: String,
pub workspace_id: Option<Uuid>,
/// '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<String>,
pub brain_seed: Option<String>,
/// 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<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TeamTemplateDetail {
#[serde(flatten)]
pub template: TeamTemplate,
pub roles: Vec<TemplateRole>,
}
#[derive(Debug, Clone)]
pub struct UpsertBuiltinRole<'a> {
pub slot: &'a str,
pub order_idx: i32,
pub system_prompt: &'a str,
pub skills: Vec<String>,
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<String>,
pub default_topology: &'a str,
pub risk_profile: &'a str,
pub mcp_bundles: Vec<String>,
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<UpsertBuiltinRole<'a>>,
}
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<Uuid, DbError> {
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<String> = b.roles.iter().map(|r| r.slot.to_string()).collect();
let stale: Vec<String> = 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<Vec<TeamTemplate>, 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<Option<TeamTemplateDetail>, 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<Option<TeamTemplate>, 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"),
}
}