`upsert_builtin` replaced the role set with DELETE + reinsert. That looks
equivalent to an upsert and is not: `agent_template_link` carries a plain FK
on (template_id, role_slot), so the delete is rejected as soon as one agent
has been minted from the template, rolling back the whole transaction.
The failure mode was silent and self-targeting. The loader logs the error and
continues, so the on-disk TOML and the DB drifted apart — and only for the
templates someone had actually used. Running the smoke mission against
insight_research is what put it on the boot log:
failed to load insight_research.toml: violates foreign key constraint
"agent_template_link_template_id_role_slot_fkey"
which also means that template never received the skill-name fix.
- Upsert each role in place via ON CONFLICT (template_id, slot), the table's
primary key.
- Prune only slots the TOML dropped, and skip a slot still referenced by a
live agent with a log line. Keeping one stale role row is a smaller failure
than discarding every edit to the template.
- Regression test drives the real sequence — upsert, mint an agent, link it,
upsert again — and asserts both the prompt and skill edits land. Verified to
fail without the fix with the same 23503 the server logged.
Co-Authored-By: Claude Opus 5 <[email protected]>
285 lines
9.4 KiB
Rust
285 lines
9.4 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>,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[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)
|
|
VALUES ($1,$2,$3,$4,$5,$6)
|
|
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",
|
|
)
|
|
.bind(id)
|
|
.bind(r.slot)
|
|
.bind(r.order_idx)
|
|
.bind(r.system_prompt)
|
|
.bind(&r.skills)
|
|
.bind(r.brain_seed)
|
|
.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
|
|
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"),
|
|
})
|
|
.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"),
|
|
}
|
|
}
|