WIP cleanup: split branch stash into 8 focused commits #2
@@ -13,6 +13,10 @@ use crate::{ApiError, AppState, Authed};
|
|||||||
pub struct Quota {
|
pub struct Quota {
|
||||||
pub max_agents: i64,
|
pub max_agents: i64,
|
||||||
pub max_live_containers: i64,
|
pub max_live_containers: i64,
|
||||||
|
/// Ceiling on `queued` + `running` topology runs at once. Prevents one
|
||||||
|
/// workspace flooding the shared queue (a single team run also spawns a
|
||||||
|
/// tier-tree of children, so the practical cap grows with the topology).
|
||||||
|
pub max_active_runs: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-plan limits. Unknown plans fall back to the free tier.
|
/// Per-plan limits. Unknown plans fall back to the free tier.
|
||||||
@@ -21,14 +25,17 @@ pub fn plan_quota(plan: &str) -> Quota {
|
|||||||
"team" => Quota {
|
"team" => Quota {
|
||||||
max_agents: 50,
|
max_agents: 50,
|
||||||
max_live_containers: 50,
|
max_live_containers: 50,
|
||||||
|
max_active_runs: 100,
|
||||||
},
|
},
|
||||||
"pro" => Quota {
|
"pro" => Quota {
|
||||||
max_agents: 20,
|
max_agents: 20,
|
||||||
max_live_containers: 20,
|
max_live_containers: 20,
|
||||||
|
max_active_runs: 25,
|
||||||
},
|
},
|
||||||
_ => Quota {
|
_ => Quota {
|
||||||
max_agents: 3,
|
max_agents: 3,
|
||||||
max_live_containers: 3,
|
max_live_containers: 3,
|
||||||
|
max_active_runs: 5,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +68,23 @@ pub async fn enforce_new_agent(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject enqueueing another topology run if the workspace is at its plan cap.
|
||||||
|
pub async fn enforce_new_run(
|
||||||
|
state: &AppState,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let plan = plan_of(state, workspace_id).await?;
|
||||||
|
let quota = plan_quota(&plan);
|
||||||
|
let used = cm_db::repo::topology_runs::count_active(&state.pool, workspace_id).await?;
|
||||||
|
if used >= quota.max_active_runs {
|
||||||
|
return Err(ApiError::Quota(format!(
|
||||||
|
"active-run limit reached ({} on the {plan} plan) — wait for a run to finish or upgrade",
|
||||||
|
quota.max_active_runs
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Reject spinning up another container if the workspace is at its plan cap.
|
/// Reject spinning up another container if the workspace is at its plan cap.
|
||||||
pub async fn enforce_new_container(
|
pub async fn enforce_new_container(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -86,6 +110,8 @@ pub struct QuotaUsage {
|
|||||||
max_agents: i64,
|
max_agents: i64,
|
||||||
containers_used: i64,
|
containers_used: i64,
|
||||||
max_live_containers: i64,
|
max_live_containers: i64,
|
||||||
|
active_runs: i64,
|
||||||
|
max_active_runs: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/quota` — the caller's workspace usage + limits (for the UI).
|
/// `GET /api/quota` — the caller's workspace usage + limits (for the UI).
|
||||||
@@ -98,11 +124,15 @@ pub async fn get_quota(
|
|||||||
let agents_used = cm_db::repo::agents::count_active(&state.pool, user.workspace_id).await?;
|
let agents_used = cm_db::repo::agents::count_active(&state.pool, user.workspace_id).await?;
|
||||||
let containers_used =
|
let containers_used =
|
||||||
cm_db::repo::agent_containers::count_for_workspace(&state.pool, user.workspace_id).await?;
|
cm_db::repo::agent_containers::count_for_workspace(&state.pool, user.workspace_id).await?;
|
||||||
|
let active_runs =
|
||||||
|
cm_db::repo::topology_runs::count_active(&state.pool, user.workspace_id).await?;
|
||||||
Ok(Json(QuotaUsage {
|
Ok(Json(QuotaUsage {
|
||||||
plan,
|
plan,
|
||||||
agents_used,
|
agents_used,
|
||||||
max_agents: quota.max_agents,
|
max_agents: quota.max_agents,
|
||||||
containers_used,
|
containers_used,
|
||||||
max_live_containers: quota.max_live_containers,
|
max_live_containers: quota.max_live_containers,
|
||||||
|
active_runs,
|
||||||
|
max_active_runs: quota.max_active_runs,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ pub async fn run_company(
|
|||||||
Json(body): Json<RunCompanyRequest>,
|
Json(body): Json<RunCompanyRequest>,
|
||||||
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
|
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
|
||||||
let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?;
|
let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?;
|
||||||
|
crate::quota::enforce_new_run(&state, user.workspace_id).await?;
|
||||||
let run_id = Uuid::now_v7();
|
let run_id = Uuid::now_v7();
|
||||||
cm_db::repo::topology_runs::enqueue_run_tier(
|
cm_db::repo::topology_runs::enqueue_run_tier(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ pub async fn run_org(
|
|||||||
Json(body): Json<RunOrgRequest>,
|
Json(body): Json<RunOrgRequest>,
|
||||||
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
|
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
|
||||||
let org = cm_db::repo::orgs::get(&state.pool, id, user.workspace_id).await?;
|
let org = cm_db::repo::orgs::get(&state.pool, id, user.workspace_id).await?;
|
||||||
|
crate::quota::enforce_new_run(&state, user.workspace_id).await?;
|
||||||
let run_id = Uuid::now_v7();
|
let run_id = Uuid::now_v7();
|
||||||
cm_db::repo::topology_runs::enqueue_run_tier(
|
cm_db::repo::topology_runs::enqueue_run_tier(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ pub async fn run_team(
|
|||||||
Json(body): Json<RunTeamRequest>,
|
Json(body): Json<RunTeamRequest>,
|
||||||
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
|
) -> Result<(StatusCode, Json<RunAccepted>), 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?;
|
||||||
|
crate::quota::enforce_new_run(&state, user.workspace_id).await?;
|
||||||
let run_id = Uuid::now_v7();
|
let run_id = Uuid::now_v7();
|
||||||
cm_db::repo::topology_runs::enqueue_run(
|
cm_db::repo::topology_runs::enqueue_run(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ pub async fn trigger_hook(
|
|||||||
.filter(|t| !t.trim().is_empty())
|
.filter(|t| !t.trim().is_empty())
|
||||||
.or_else(|| (!default_task.trim().is_empty()).then_some(default_task))
|
.or_else(|| (!default_task.trim().is_empty()).then_some(default_task))
|
||||||
.unwrap_or_else(|| "webhook trigger".to_string());
|
.unwrap_or_else(|| "webhook trigger".to_string());
|
||||||
|
// Webhooks are unauthenticated public endpoints — the enforce_new_run
|
||||||
|
// guard is what stops a leaked token from being weaponized into a queue
|
||||||
|
// flood. 429 (not 402) so external callers can back off.
|
||||||
|
if crate::quota::enforce_new_run(&state, ws).await.is_err() {
|
||||||
|
return StatusCode::TOO_MANY_REQUESTS;
|
||||||
|
}
|
||||||
let run_id = Uuid::now_v7();
|
let run_id = Uuid::now_v7();
|
||||||
if cm_db::repo::topology_runs::enqueue_run(&state.pool, run_id, ws, &task, &team.graph)
|
if cm_db::repo::topology_runs::enqueue_run(&state.pool, run_id, ws, &task, &team.graph)
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in New Issue
Block a user