CI on 6ffbe97 failed on two auto-fixable gates. Both fixed:
* cargo fmt --all — rustfmt applied across the surface touched
by the last ~20 commits (world.rs, security_scan.rs,
routes/{missions,nodes,terminal}.rs, fleet_herdr.rs,
mission_workspace.rs, benchmark_runner.rs, mission_refiner.rs,
lib.rs, tests/mission_orchestrator.rs, cm-db/repo/{missions,teams}.rs,
bins/clawmates-node/src/main.rs)
* eslint apostrophe escapes in HerdrSessions + MissionWizard
* eslint max-lines: extracted EditMissionModal + RefineDiffModal
(each ~200 LoC) into their own files. MissionCanvas drops from
1424 to 1026, comfortably under both the 1250 eslint cap and the
1500 CI budget.
New files:
frontend/src/components/dashboard/EditMissionModal.tsx (211 LoC)
frontend/src/components/dashboard/RefineDiffModal.tsx (208 LoC)
Verified locally: cargo fmt --check clean, cargo check clean,
mission_orchestrator test 3/3 pass, tsc + eslint --quiet both silent.
320 lines
8.9 KiB
Rust
320 lines
8.9 KiB
Rust
//! Persistence for deployed teams — a baseline topology staffed with real
|
|
//! workspace claws. `teams` holds the topology graph; `team_members` is the
|
|
//! durable node→claw binding.
|
|
|
|
use cm_domain::WorkspaceId;
|
|
use serde_json::Value;
|
|
use sqlx::PgPool;
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
use crate::DbError;
|
|
|
|
/// A team row summary (list view).
|
|
pub struct TeamSummary {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub status: String,
|
|
pub created_at: OffsetDateTime,
|
|
}
|
|
|
|
/// A full team (graph + metadata).
|
|
pub struct Team {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub graph: Value,
|
|
pub status: String,
|
|
pub created_at: OffsetDateTime,
|
|
}
|
|
|
|
/// A node→claw binding within a team.
|
|
pub struct TeamMember {
|
|
pub node_id: String,
|
|
pub claw_id: Uuid,
|
|
pub role: String,
|
|
}
|
|
|
|
/// Insert a team (the topology graph). Members are added separately.
|
|
/// Lifecycle defaults to `permanent`; use `insert_ephemeral_team` for the
|
|
/// scheduled / triggered flows.
|
|
pub async fn insert_team(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
name: &str,
|
|
kind: &str,
|
|
graph: &Value,
|
|
) -> Result<(), DbError> {
|
|
insert_team_with_lifecycle(pool, id, workspace_id, name, kind, graph, "permanent").await
|
|
}
|
|
|
|
/// Insert a team with an explicit `lifecycle` (`permanent` | `ephemeral`).
|
|
/// Ephemeral teams are torn down after the last in-flight run terminates —
|
|
/// see cm-api::topology_worker::maybe_teardown_ephemeral_team.
|
|
pub async fn insert_team_with_lifecycle(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
name: &str,
|
|
kind: &str,
|
|
graph: &Value,
|
|
lifecycle: &str,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query!(
|
|
"INSERT INTO teams (id, workspace_id, name, kind, graph, lifecycle)
|
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
id,
|
|
workspace_id.as_uuid(),
|
|
name,
|
|
kind,
|
|
graph,
|
|
lifecycle,
|
|
)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Rebuild a team's topology (kind + graph) in place; node→claw bindings (which
|
|
/// key off stable node ids `n0..`) are left untouched. Non-macro query so it
|
|
/// needs no offline sqlx cache entry.
|
|
pub async fn set_topology(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
kind: &str,
|
|
graph: &Value,
|
|
) -> Result<(), DbError> {
|
|
let res =
|
|
sqlx::query("UPDATE teams SET kind = $3, graph = $4 WHERE id = $1 AND workspace_id = $2")
|
|
.bind(id)
|
|
.bind(workspace_id.as_uuid())
|
|
.bind(kind)
|
|
.bind(graph)
|
|
.execute(pool)
|
|
.await?;
|
|
if res.rows_affected() == 0 {
|
|
return Err(DbError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a team and its node→claw bindings.
|
|
/// Rename a team in-place. Only the display name changes; the topology
|
|
/// graph + member bindings are untouched.
|
|
pub async fn rename_team(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
name: &str,
|
|
) -> Result<(), DbError> {
|
|
let res = sqlx::query("UPDATE teams SET name = $3 WHERE id = $1 AND workspace_id = $2")
|
|
.bind(id)
|
|
.bind(workspace_id.as_uuid())
|
|
.bind(name)
|
|
.execute(pool)
|
|
.await?;
|
|
if res.rows_affected() == 0 {
|
|
return Err(DbError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_team(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query("DELETE FROM team_members WHERE team_id = $1")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await?;
|
|
let res = sqlx::query("DELETE FROM teams WHERE id = $1 AND workspace_id = $2")
|
|
.bind(id)
|
|
.bind(workspace_id.as_uuid())
|
|
.execute(pool)
|
|
.await?;
|
|
if res.rows_affected() == 0 {
|
|
return Err(DbError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Bind a claw to a topology node within a team.
|
|
pub async fn add_member(
|
|
pool: &PgPool,
|
|
team_id: Uuid,
|
|
node_id: &str,
|
|
claw_id: Uuid,
|
|
role: &str,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query!(
|
|
"INSERT INTO team_members (team_id, node_id, claw_id, role)
|
|
VALUES ($1, $2, $3, $4)",
|
|
team_id,
|
|
node_id,
|
|
claw_id,
|
|
role,
|
|
)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The most recent teams for a workspace, newest first.
|
|
pub async fn list_for_workspace(
|
|
pool: &PgPool,
|
|
workspace_id: WorkspaceId,
|
|
limit: i64,
|
|
) -> Result<Vec<TeamSummary>, DbError> {
|
|
let rows = sqlx::query!(
|
|
"SELECT id, name, kind, status, created_at FROM teams
|
|
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
|
workspace_id.as_uuid(),
|
|
limit,
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| TeamSummary {
|
|
id: r.id,
|
|
name: r.name,
|
|
kind: r.kind,
|
|
status: r.status,
|
|
created_at: r.created_at,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// A single team, workspace-scoped.
|
|
pub async fn get_team(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<Team, DbError> {
|
|
let row = sqlx::query!(
|
|
"SELECT id, name, kind, graph, status, created_at FROM teams
|
|
WHERE id = $1 AND workspace_id = $2",
|
|
id,
|
|
workspace_id.as_uuid(),
|
|
)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or(DbError::NotFound)?;
|
|
Ok(Team {
|
|
id: row.id,
|
|
name: row.name,
|
|
kind: row.kind,
|
|
graph: row.graph,
|
|
status: row.status,
|
|
created_at: row.created_at,
|
|
})
|
|
}
|
|
|
|
/// Distinct agent ids bound to a team via `team_members`. Used by the
|
|
/// cascade-reap path so deleting a team also purges the agents inside it.
|
|
pub async fn agents_of_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<Uuid>, DbError> {
|
|
let rows = sqlx::query!(
|
|
"SELECT DISTINCT claw_id FROM team_members WHERE team_id = $1",
|
|
team_id,
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows.into_iter().map(|r| r.claw_id).collect())
|
|
}
|
|
|
|
/// The node→claw bindings for a team.
|
|
pub async fn members_for_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<TeamMember>, DbError> {
|
|
let rows = sqlx::query!(
|
|
"SELECT node_id, claw_id, role FROM team_members WHERE team_id = $1 ORDER BY node_id",
|
|
team_id,
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| TeamMember {
|
|
node_id: r.node_id,
|
|
claw_id: r.claw_id,
|
|
role: r.role,
|
|
})
|
|
.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(())
|
|
}
|