slice 3: 6 team templates seeded from TOML recipes
Team templates are the canonical rosters + tool bundles that mint
concrete teams for a mission. Every builtin ships as a TOML recipe
under templates/teams/*.toml, loaded into the DB at server boot.
Migration 0048 adds:
- team_templates (id, key, name, stack, default_topology,
risk_profile, mcp_bundles, version, source,
workspace_id)
- template_roles (m2m: template_id + slot; system_prompt,
skills[], brain_seed)
- teams gets template_id + template_version for level-up lineage
Ships 6 builtins:
- rust_sdlc — planner/coder/tester/reviewer/committer for Rust
- backend — api_designer/db_engineer/coder/tester/committer
(Postgres, DuckDB, graph DBs, wire protocols)
- frontend — designer/coder/tester/committer (React + Tailwind + ShadCN)
- mobile — designer/coder/tester/committer (Expo, RN, iOS, Android)
- gpu — arch_analyst/kernel_author/bench_engineer/coder/committer
(CUDA, Metal, ROCm from Rust)
- threejs — scene_designer/coder/shader_author/perf_engineer/
committer (three.js, WebGL, WebGPU)
Each role has a versioned system_prompt + skill list + brain_seed
markdown. Skills column is a name array today; Slice 3.5a promotes it
to a typed m2m join with the real skills catalog.
Server boot:
- team_template_loader::load_builtins reads TOML from
/etc/clawmates/templates/teams (container) or templates/teams (dev),
upserts idempotently. Deterministic uuid per template key (sha256
of a fixed namespace + key) so ids are stable across boots.
- Dockerfile copies templates/ to /etc/clawmates/templates.
Read API:
- GET /api/team-templates — list all
- GET /api/team-templates/{id} — detail with roles
Wizard:
- Step 3 rewired from a raw team_id text field to a template picker
with "LLM auto-provision" as the default option + one card per
builtin, showing stack, topology, risk profile, and description.
- Mission create now passes team_template_id (not team_id) so phase
execution knows which template to mint from.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fc67936e33
commit
9ba5c06a1a
@@ -30,6 +30,7 @@ pub mod sessions;
|
||||
pub mod skills;
|
||||
pub mod steps;
|
||||
pub mod structure_reify;
|
||||
pub mod team_templates;
|
||||
pub mod teams;
|
||||
pub mod terminal_tabs;
|
||||
pub mod threads;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! 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>,
|
||||
#[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,
|
||||
pub roles: Vec<UpsertBuiltinRole<'a>>,
|
||||
}
|
||||
|
||||
/// 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)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'builtin',NULL)
|
||||
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,
|
||||
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)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Replace-in-place role set. Roles that get removed from the TOML
|
||||
// disappear from the DB; keeps the on-disk source authoritative.
|
||||
sqlx::query("DELETE FROM template_roles WHERE template_id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
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)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(r.slot)
|
||||
.bind(r.order_idx)
|
||||
.bind(r.system_prompt)
|
||||
.bind(&r.skills)
|
||||
.bind(r.brain_seed)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn list_all(pool: &PgPool) -> Result<Vec<TeamTemplate>, DbError> {
|
||||
use sqlx::Row;
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, key, name, stack, default_topology, risk_profile,
|
||||
mcp_bundles, version, description, config, source,
|
||||
workspace_id, 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, 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> {
|
||||
use sqlx::Row;
|
||||
let row = sqlx::query(
|
||||
"SELECT id, key, name, stack, default_topology, risk_profile,
|
||||
mcp_bundles, version, description, config, source,
|
||||
workspace_id, 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"),
|
||||
created_at: r.get("created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user