teams: per-team runtime posture (risk_profile + mcp_bundles) + FK from loops/topics
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 3m24s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m19s

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:
Omar Sobh
2026-07-16 18:16:28 -07:00
parent b2a38da3f9
commit 6066e93889
4 changed files with 229 additions and 0 deletions
+4
View File
@@ -361,6 +361,10 @@ pub fn router(state: AppState) -> Router {
axum::routing::patch(routes::teams::rename_team), axum::routing::patch(routes::teams::rename_team),
) )
.route("/api/teams/{id}/run", post(routes::teams::run_team)) .route("/api/teams/{id}/run", post(routes::teams::run_team))
.route(
"/api/teams/{id}/runtime-config",
axum::routing::patch(routes::teams::set_runtime_config),
)
.route( .route(
"/api/companies", "/api/companies",
get(routes::companies::list_companies).post(routes::companies::create_company), get(routes::companies::list_companies).post(routes::companies::create_company),
+46
View File
@@ -321,6 +321,45 @@ pub struct TeamDetail {
pub created_at: String, pub created_at: String,
pub graph: Value, pub graph: Value,
pub members: Vec<TeamMemberOut>, pub members: Vec<TeamMemberOut>,
/// Per-team runtime posture (0045). `None` risk_profile ⇒
/// container inherits the template default at spawn time.
#[serde(skip_serializing_if = "Option::is_none")]
pub risk_profile: Option<String>,
#[serde(default)]
pub mcp_bundles: Vec<String>,
}
/// `PATCH /api/teams/{id}/runtime-config` — set the per-team
/// risk_profile + mcp_bundles. Wizards call this after creating a team
/// to bind it as either "read-heavy research" or "write-capable coding"
/// without weakening the sibling team's posture.
#[derive(Deserialize)]
pub struct RuntimeConfigRequest {
#[serde(default)]
pub risk_profile: Option<String>,
#[serde(default)]
pub mcp_bundles: Vec<String>,
}
pub async fn set_runtime_config(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<RuntimeConfigRequest>,
) -> Result<StatusCode, ApiError> {
// Workspace-scope check via the existing get_team.
let _ = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
cm_db::repo::teams::set_team_runtime_config(
&state.pool,
id,
user.workspace_id,
&cm_db::repo::teams::TeamRuntimeConfig {
risk_profile: body.risk_profile,
mcp_bundles: body.mcp_bundles,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
} }
/// `GET /api/teams/{id}` — a team's graph + node→claw bindings. /// `GET /api/teams/{id}` — a team's graph + node→claw bindings.
@@ -331,6 +370,11 @@ pub async fn get_team(
) -> Result<Json<TeamDetail>, ApiError> { ) -> Result<Json<TeamDetail>, ApiError> {
let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?; let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
let members = cm_db::repo::teams::members_for_team(&state.pool, id).await?; let members = cm_db::repo::teams::members_for_team(&state.pool, id).await?;
let runtime = cm_db::repo::teams::get_team_runtime_config(&state.pool, id, user.workspace_id)
.await
.ok()
.flatten()
.unwrap_or_default();
Ok(Json(TeamDetail { Ok(Json(TeamDetail {
id: team.id.to_string(), id: team.id.to_string(),
name: team.name, name: team.name,
@@ -346,6 +390,8 @@ pub async fn get_team(
role: m.role, role: m.role,
}) })
.collect(), .collect(),
risk_profile: runtime.risk_profile,
mcp_bundles: runtime.mcp_bundles,
})) }))
} }
+133
View File
@@ -239,3 +239,136 @@ pub async fn members_for_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<TeamMe
}) })
.collect()) .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(())
}
+46
View File
@@ -0,0 +1,46 @@
-- Teams-for-loops foundation. The `teams` table already exists (see
-- 0010_teams.sql) — a workspace-scoped topology graph + node→claw
-- bindings via team_members. This migration adds only the fields
-- needed to make an existing team attachable to a loop or research
-- topic with its own runtime constitution.
--
-- Motivation: today the paired coding loop reuses the research topic's
-- ZeroClaw team container, which forces one risk_profile on both the
-- read-heavy research workload and the write-heavy coding workload.
-- Attaching a distinct team lets a loop bring its own risk_profile
-- (e.g. "coding_readwrite" allowing file_write + shell) without
-- weakening the research team's posture.
--
-- Backward compat: every new column is NULLABLE. NULL preserves the
-- legacy "inherit from wherever the runtime template says" behavior.
-- No existing row is invalidated; wizards + runtime opt into the new
-- fields in follow-up slices.
-- Per-team runtime posture. NULL falls back to the runtime template
-- default (currently `research_readonly` for research-spawned
-- containers). Named profiles resolve at daemon startup against
-- [risk_profiles.<name>] in the runtime config.
ALTER TABLE teams
ADD COLUMN IF NOT EXISTS risk_profile TEXT;
-- Extra MCP bundle aliases the team's agents load in addition to the
-- template defaults. Stored as JSONB array of strings so a team can
-- carry {"clawmates_door", "git_writer"} for a coding pair without
-- affecting other teams. Empty array = inherit template only.
ALTER TABLE teams
ADD COLUMN IF NOT EXISTS mcp_bundles JSONB NOT NULL DEFAULT '[]'::jsonb;
-- Wire loops and research_topics to an optional team_id. ON DELETE SET
-- NULL so deleting a team unbinds without destroying the loop/topic
-- (the runtime falls back to the template default).
ALTER TABLE loops
ADD COLUMN IF NOT EXISTS team_id UUID
REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS loops_team_idx ON loops (team_id)
WHERE team_id IS NOT NULL;
ALTER TABLE research_topics
ADD COLUMN IF NOT EXISTS team_id UUID
REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS research_topics_team_idx
ON research_topics (team_id) WHERE team_id IS NOT NULL;