teams: per-team runtime posture (risk_profile + mcp_bundles) + FK from loops/topics
Foundation slice for letting a coding loop bring its own team instead
of reusing the paired research topic's team. Turns out the teams
table already exists (0010_teams.sql) with full CRUD — this scales
back to the minimal missing bits:
Schema (0045_teams.sql)
- ALTER TABLE teams ADD risk_profile TEXT (NULL = template default)
- ALTER TABLE teams ADD mcp_bundles JSONB DEFAULT '[]'
- ALTER TABLE loops ADD team_id UUID REFERENCES teams ON DELETE SET NULL
- ALTER TABLE research_topics ADD team_id UUID REFERENCES teams
- Two partial indexes (team_id NOT NULL) for the future cascade queries
cm-db (dynamic sqlx::query so the existing get_team's compile-time
cache doesn't need regenerating):
- TeamRuntimeConfig struct
- get_team_runtime_config / set_team_runtime_config
- team_for_loop / team_for_research_topic (resolvers)
- set_team_for_loop / set_team_for_research_topic (binders)
cm-api
- GET /api/teams/{id} now surfaces risk_profile + mcp_bundles
- PATCH /api/teams/{id}/runtime-config sets them
Not touched (comes in follow-up slices):
- Wizard picker exposing 'reuse research team' vs 'fresh coding team'
- Runtime container spawn keyed on team_id
- Migration of existing paired coding loops onto their own team
This commit is contained in:
@@ -239,3 +239,136 @@ pub async fn members_for_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<TeamMe
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── 0045 additions: per-team runtime posture ─────────────────────────────
|
||||
//
|
||||
// Kept as dynamic sqlx::query() calls so the query cache doesn't need
|
||||
// regenerating when the base Team struct evolves. get_team above still
|
||||
// returns the pre-0045 shape; call these helpers directly when the
|
||||
// runtime hookup needs the new columns.
|
||||
|
||||
/// The runtime-side config a team carries — nullable/optional so a team
|
||||
/// with `NULL risk_profile` falls back to the template default at
|
||||
/// container spawn time.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TeamRuntimeConfig {
|
||||
pub risk_profile: Option<String>,
|
||||
pub mcp_bundles: Vec<String>,
|
||||
}
|
||||
|
||||
/// Look up the runtime posture columns for a team without hitting the
|
||||
/// full compile-time-checked SELECT. Returns `None` when the team
|
||||
/// doesn't exist (or belongs to a different workspace).
|
||||
pub async fn get_team_runtime_config(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
workspace_id: WorkspaceId,
|
||||
) -> Result<Option<TeamRuntimeConfig>, DbError> {
|
||||
use sqlx::Row;
|
||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
||||
"SELECT risk_profile, mcp_bundles FROM teams
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(workspace_id.as_uuid())
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| {
|
||||
let risk_profile = r
|
||||
.try_get::<Option<String>, _>("risk_profile")
|
||||
.ok()
|
||||
.flatten();
|
||||
let mcp_bundles: Vec<String> = r
|
||||
.try_get::<Value, _>("mcp_bundles")
|
||||
.ok()
|
||||
.and_then(|v| v.as_array().cloned())
|
||||
.map(|arr| {
|
||||
arr.into_iter()
|
||||
.filter_map(|x| x.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
TeamRuntimeConfig {
|
||||
risk_profile,
|
||||
mcp_bundles,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Set (or clear) the per-team runtime posture. Pass `None` for
|
||||
/// risk_profile to clear it — the runtime falls back to the template
|
||||
/// default. `mcp_bundles = []` means "inherit template only".
|
||||
pub async fn set_team_runtime_config(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
workspace_id: WorkspaceId,
|
||||
cfg: &TeamRuntimeConfig,
|
||||
) -> Result<(), DbError> {
|
||||
let bundles = serde_json::to_value(&cfg.mcp_bundles).unwrap_or(Value::Array(Vec::new()));
|
||||
sqlx::query(
|
||||
"UPDATE teams SET risk_profile = $3, mcp_bundles = $4
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(workspace_id.as_uuid())
|
||||
.bind(cfg.risk_profile.as_ref())
|
||||
.bind(&bundles)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the team attached to a loop (via loops.team_id, added in
|
||||
/// 0045). Returns `None` when the loop has no team bound — the runtime
|
||||
/// then uses whatever fallback rules apply (paired research topic's
|
||||
/// team, or the template default).
|
||||
pub async fn team_for_loop(pool: &PgPool, loop_id: Uuid) -> Result<Option<Uuid>, DbError> {
|
||||
use sqlx::Row;
|
||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query("SELECT team_id FROM loops WHERE id = $1")
|
||||
.bind(loop_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.and_then(|r| r.try_get::<Option<Uuid>, _>("team_id").ok().flatten()))
|
||||
}
|
||||
|
||||
/// Symmetric to `team_for_loop` but for research topics.
|
||||
pub async fn team_for_research_topic(
|
||||
pool: &PgPool,
|
||||
topic_id: Uuid,
|
||||
) -> Result<Option<Uuid>, DbError> {
|
||||
use sqlx::Row;
|
||||
let row: Option<sqlx::postgres::PgRow> =
|
||||
sqlx::query("SELECT team_id FROM research_topics WHERE id = $1")
|
||||
.bind(topic_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.and_then(|r| r.try_get::<Option<Uuid>, _>("team_id").ok().flatten()))
|
||||
}
|
||||
|
||||
/// Bind a team to a loop (or clear the binding by passing None).
|
||||
pub async fn set_team_for_loop(
|
||||
pool: &PgPool,
|
||||
loop_id: Uuid,
|
||||
team_id: Option<Uuid>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query("UPDATE loops SET team_id = $2 WHERE id = $1")
|
||||
.bind(loop_id)
|
||||
.bind(team_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bind a team to a research topic (or clear).
|
||||
pub async fn set_team_for_research_topic(
|
||||
pool: &PgPool,
|
||||
topic_id: Uuid,
|
||||
team_id: Option<Uuid>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query("UPDATE research_topics SET team_id = $2 WHERE id = $1")
|
||||
.bind(topic_id)
|
||||
.bind(team_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user