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:
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user