api: gate create_loop + create_topic on empty workspace roster (W2)
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 28s
ci / rust (push) Failing after 3m31s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Frontend already disables the create button when the roster is empty,
but nothing stopped a direct POST from materializing an orphan loop /
topic with nothing to staff it. Both handlers now check the workspace
agent count up front and return 409 Conflict when it's zero.

- crates/cm-api/src/routes/loops.rs: gate at top of create_loop
- crates/cm-api/src/routes/research.rs: gate at top of create_topic

Uses the existing cm_db::repo::agents::count_active(pool, ws) helper
(count of workspace agents where deleted_at IS NULL). 409 is the right
mapping: state-of-the-workspace-prevents-this, not client-input-bad.
This commit is contained in:
Omar Sobh
2026-07-09 14:00:54 -07:00
parent 059873b59f
commit 763b95a253
2 changed files with 13 additions and 0 deletions
+7
View File
@@ -114,6 +114,13 @@ pub async fn create_loop(
if body.title.trim().is_empty() || body.task_template.trim().is_empty() {
return Err(ApiError::BadRequest);
}
// Empty-roster gate: a workspace with zero agents has nothing to staff
// the loop with — refuse before any DB writes. Frontend already
// disables the create button in this state; this closes the direct-POST
// hole so we don't materialize orphan loops that never fire.
if cm_db::repo::agents::count_active(&state.pool, user.workspace_id).await? == 0 {
return Err(ApiError::Conflict);
}
let webhook_enabled = parse_triggers(&body.triggers)
.map(|t| t.webhook_enabled)
.unwrap_or(false);
+6
View File
@@ -349,6 +349,12 @@ pub async fn create_topic(
if !VALID_TOPOLOGY_KINDS.contains(&topology_kind) {
return Err(ApiError::BadRequest);
}
// Empty-roster gate: a workspace with zero agents cannot host research.
// The frontend already disables the create button; this closes the
// direct-POST hole so we never materialize an unstaffed topic.
if cm_db::repo::agents::count_active(&state.pool, user.workspace_id).await? == 0 {
return Err(ApiError::Conflict);
}
// Ownership check before any writes: every agent must be in the caller's
// workspace. Refuses to leak "agent exists" if it isn't visible.