loops: repo picker + agent/team/org staffing + sidebar edit/delete
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user