Scaling Phase 1: multi-tenant onboarding + replica-safe coordination

Decouples "many users" + "many server replicas" from "many machines" so the
platform is tenant-isolated and horizontally safe on the current single node.

- Per-signup workspaces (cm-auth): a new hosted-identity sign-in provisions and
  owns its own workspace instead of joining the first. Config-gated by
  auth.per_signup_workspace (default off); concurrent first-logins serialized by
  a per-subject advisory lock so no duplicate workspaces.
- Terminal tickets in Postgres (migration 0016, hashed, single-use): any replica
  can redeem a ticket minted by another. Drops the in-process ticket map.
- Container registry in Postgres (migration 0017, agent_containers): Terminal
  and Sandbox managers resolve an agent's container through a shared registry,
  so a 2nd replica reuses it instead of spawning a duplicate. node_id recorded
  as 'local' (Phase 2 hook). Boot reconcile removes only true orphans, so
  terminals now survive a redeploy (tmux sessions resume).
- Per-workspace quotas (cm-api/quota.rs): plan-tier caps on agents + live
  containers, enforced at agent create + terminal spin-up (reconnects allowed),
  returned as HTTP 402. New GET /api/quota surfaces usage vs limits.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-23 18:24:51 -07:00
co-authored by Claude Opus 4.8
parent f8f2b65e1f
commit e9ce368ec1
19 changed files with 669 additions and 205 deletions
+104
View File
@@ -0,0 +1,104 @@
//! Per-workspace resource quotas keyed off the workspace plan. Phase 1 of the
//! multi-tenant scaling work: keep one tenant from exhausting the shared pool.
use axum::extract::State;
use axum::Json;
use cm_domain::WorkspaceId;
use serde::Serialize;
use sqlx::Row;
use crate::{ApiError, AppState, Authed};
/// Caps for a plan tier.
pub struct Quota {
pub max_agents: i64,
pub max_live_containers: i64,
}
/// Per-plan limits. Unknown plans fall back to the free tier.
pub fn plan_quota(plan: &str) -> Quota {
match plan {
"team" => Quota {
max_agents: 50,
max_live_containers: 50,
},
"pro" => Quota {
max_agents: 20,
max_live_containers: 20,
},
_ => Quota {
max_agents: 3,
max_live_containers: 3,
},
}
}
/// The workspace's plan name (defaults to "free").
async fn plan_of(state: &AppState, workspace_id: WorkspaceId) -> Result<String, ApiError> {
let row = sqlx::query("SELECT plan FROM workspaces WHERE id = $1")
.bind(workspace_id.as_uuid())
.fetch_optional(&state.pool)
.await?;
Ok(row
.map(|r| r.get::<String, _>("plan"))
.unwrap_or_else(|| "free".to_string()))
}
/// Reject creating another agent if the workspace is at its plan cap.
pub async fn enforce_new_agent(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::agents::count_active(&state.pool, workspace_id).await?;
if used >= quota.max_agents {
return Err(ApiError::Quota(format!(
"agent limit reached ({} on the {plan} plan) — upgrade your plan or remove an agent",
quota.max_agents
)));
}
Ok(())
}
/// Reject spinning up another container if the workspace is at its plan cap.
pub async fn enforce_new_container(
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::agent_containers::count_for_workspace(&state.pool, workspace_id).await?;
if used >= quota.max_live_containers {
return Err(ApiError::Quota(format!(
"live container limit reached ({} on the {plan} plan) — close a terminal/agent or upgrade",
quota.max_live_containers
)));
}
Ok(())
}
#[derive(Serialize)]
pub struct QuotaUsage {
plan: String,
agents_used: i64,
max_agents: i64,
containers_used: i64,
max_live_containers: i64,
}
/// `GET /api/quota` — the caller's workspace usage + limits (for the UI).
pub async fn get_quota(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<QuotaUsage>, ApiError> {
let plan = plan_of(&state, user.workspace_id).await?;
let quota = plan_quota(&plan);
let agents_used = cm_db::repo::agents::count_active(&state.pool, user.workspace_id).await?;
let containers_used =
cm_db::repo::agent_containers::count_for_workspace(&state.pool, user.workspace_id).await?;
Ok(Json(QuotaUsage {
plan,
agents_used,
max_agents: quota.max_agents,
containers_used,
max_live_containers: quota.max_live_containers,
}))
}