loops: repo picker + agent/team/org staffing + sidebar edit/delete
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 12s
ci / frontend (push) Successful in 25s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Adds the missing pieces the wizard needed and the sidebar controls
around it:

- LoopsWizard is now a 6-step flow (identity → repo → task/topology
  → triggers → repeat → assign agents) plus the existing secrets
  card. ResearchWizard picks up the same repo step and a hard gate
  when the workspace has zero agents.
- New LoopStaffingStep with three tabs — Individual / Team /
  Organization — that mix freely per loop; selections persist via
  new loop_agents / loop_teams / loop_orgs join tables (0035
  migration), each cascading on loop_id so hard-delete stays a
  single-row DELETE.
- Backend CreateLoopRequest / UpdateLoopRequest accept the three
  lists and apply_staffing does a transactional replace-all;
  list_loops / get_loop hydrate the lists via a flattened
  LoopWithStaffing response.
- LoopsList sidebar gains per-row enable/disable, edit (reopens the
  wizard prefilled with the current loop, PATCHes on submit), and
  delete with an inline confirm.
- NoAgentsGate blocks launching a loop or research topic from a
  workspace with no roster; the sidebar `+` buttons also disable
  with a tooltip pointing at the TEAM tier.

Not yet wired: the run driver still fills role slots from the
workspace-wide pool; teaching enqueue_iteration to prefer
loop_agents/loop_teams/loop_orgs is a follow-up.
This commit is contained in:
Omar Sobh
2026-07-07 22:09:06 -07:00
parent 2562541f5b
commit a6da19430f
25 changed files with 1781 additions and 156 deletions
+114
View File
@@ -221,6 +221,120 @@ pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<(), D
Ok(())
}
// --- Staffing ---------------------------------------------------------------
//
// Loops attach agents, teams, and/or orgs. The three join tables are
// parallel; a loop can mix modes (e.g. one team + a couple of specialist
// agents). Callers use the `set_*` replace-all shape so PATCH is a single
// transactional swap — simpler than diffing and cheap for the list sizes
// this UI generates.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSlot {
pub agent_id: Uuid,
pub role_slot: Option<String>,
}
pub async fn set_agents(
pool: &PgPool,
loop_id: Uuid,
slots: &[AgentSlot],
) -> Result<(), DbError> {
let mut tx = pool.begin().await?;
sqlx::query!("DELETE FROM loop_agents WHERE loop_id = $1", loop_id)
.execute(&mut *tx)
.await?;
for s in slots {
sqlx::query!(
"INSERT INTO loop_agents (loop_id, agent_id, role_slot)
VALUES ($1, $2, $3)
ON CONFLICT (loop_id, agent_id) DO UPDATE
SET role_slot = EXCLUDED.role_slot",
loop_id,
s.agent_id,
s.role_slot,
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn agents(pool: &PgPool, loop_id: Uuid) -> Result<Vec<AgentSlot>, DbError> {
let rows = sqlx::query!(
"SELECT agent_id, role_slot FROM loop_agents WHERE loop_id = $1",
loop_id,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| AgentSlot {
agent_id: r.agent_id,
role_slot: r.role_slot,
})
.collect())
}
pub async fn set_teams(pool: &PgPool, loop_id: Uuid, ids: &[Uuid]) -> Result<(), DbError> {
let mut tx = pool.begin().await?;
sqlx::query!("DELETE FROM loop_teams WHERE loop_id = $1", loop_id)
.execute(&mut *tx)
.await?;
for id in ids {
sqlx::query!(
"INSERT INTO loop_teams (loop_id, team_id) VALUES ($1, $2)
ON CONFLICT (loop_id, team_id) DO NOTHING",
loop_id,
id,
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn teams(pool: &PgPool, loop_id: Uuid) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT team_id FROM loop_teams WHERE loop_id = $1",
loop_id,
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.team_id).collect())
}
pub async fn set_orgs(pool: &PgPool, loop_id: Uuid, ids: &[Uuid]) -> Result<(), DbError> {
let mut tx = pool.begin().await?;
sqlx::query!("DELETE FROM loop_orgs WHERE loop_id = $1", loop_id)
.execute(&mut *tx)
.await?;
for id in ids {
sqlx::query!(
"INSERT INTO loop_orgs (loop_id, org_id) VALUES ($1, $2)
ON CONFLICT (loop_id, org_id) DO NOTHING",
loop_id,
id,
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn orgs(pool: &PgPool, loop_id: Uuid) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT org_id FROM loop_orgs WHERE loop_id = $1",
loop_id,
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.org_id).collect())
}
/// Loops the scheduler tick should fire NOW. Only reads what the enqueue
/// path needs, so the tick stays cheap even when the workspace has hundreds
/// of loops.