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]>
296 lines
10 KiB
Rust
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"),
|
|
}
|
|
}
|