From 70d2176cb4f8c8233fc5bad869c16239e444b45f Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Fri, 1 May 2026 12:54:51 -0700 Subject: [PATCH] feat(quotas): per-workspace soft caps + AI recovery rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft caps on the two resources a bad-actor signup could pump fastest: - 3 active projects per workspace - 3 active (running/provisioning) dev containers per workspace Suspended dev containers don't count (they're free), so a power user can have many projects with most containers idle. Limits are overridable via env vars (VIBN_QUOTA_MAX_*) for a global lift. Hits surface as HTTP 402 with structured payload {error, code, current, limit}. AI's error-recovery middleware matches the QUOTA_EXCEEDED code and synthesizes guidance: tell the user which cap was hit, offer to suspend something or contact support, do NOT retry blindly. Wired: - lib/quotas.ts — assertProjectQuota, assertDevContainerQuota, getQuotaStatus - app/api/projects/create/route.ts — checks before create - lib/dev-container.ts — checks before resume + net-new ensure - app/api/mcp/route.ts — devcontainer.ensure translates QuotaExceededError to 402 - lib/ai/error-recovery.ts — workspace-quota-exceeded rule Closes BETA_LAUNCH_PLAN.md task 4.6. Co-authored-by: Cursor --- app/api/mcp/route.ts | 9 ++ app/api/projects/create/route.ts | 15 +++ lib/ai/error-recovery.ts | 13 +++ lib/dev-container.ts | 12 +++ lib/quotas.ts | 158 +++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+) create mode 100644 lib/quotas.ts diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 255c3a88..80ce14a0 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -60,6 +60,7 @@ import { getSentryIssueDetail, resolveSentryIssue, } from '@/lib/integrations/sentry'; +import { QuotaExceededError } from '@/lib/quotas'; import { composeUp, composePs, @@ -3461,6 +3462,14 @@ async function toolDevContainerEnsure(principal: Principal, params: Record 0) return Math.floor(n); + return fallback; +} + +/** + * Count active projects for a Vibn workspace and throw + * QuotaExceededError if creating one more would breach the cap. + * + * "Active" = fs_projects.data.status === 'active' (the default for + * a freshly-created project). Soft-deleted / archived projects are + * not counted; they don't consume runtime resources. + * + * Pass either the workspace UUID (preferred — survives slug changes) + * or the slug, or both. We'll OR them together so back-compat works + * for older projects that pre-date the vibn_workspace_id column. + */ +export async function assertProjectQuota(workspace: { + id?: string | null; + slug?: string | null; +}): Promise { + const limit = projectLimit(); + const rows = await query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM fs_projects + WHERE (vibn_workspace_id = $1 OR workspace = $2) + AND COALESCE(data->>'status', 'active') = 'active'`, + [workspace.id ?? null, workspace.slug ?? null], + ); + const current = Number(rows[0]?.count ?? 0); + if (current >= limit) { + throw new QuotaExceededError( + 'projects', + current, + limit, + `Workspace already has ${current}/${limit} active projects. ` + + `Delete one or contact support@vibnai.com to lift the cap.`, + ); + } +} + +/** + * Count running/provisioning dev containers for a workspace and + * throw if creating one more would breach the cap. Each Vibn + * project has at most one dev container (project_id is unique + * key on fs_project_dev_containers), so this functionally caps + * the number of *projects with active containers*. + * + * "Running/provisioning" excludes suspended containers — those + * cost nothing while idle and resume on demand. + */ +export async function assertDevContainerQuota(workspaceSlug: string): Promise { + const limit = devContainerLimit(); + const rows = await query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM fs_project_dev_containers + WHERE workspace = $1 + AND state IN ('running', 'provisioning')`, + [workspaceSlug], + ); + const current = Number(rows[0]?.count ?? 0); + if (current >= limit) { + throw new QuotaExceededError( + 'devContainers', + current, + limit, + `Workspace already has ${current}/${limit} active dev containers running. ` + + `Suspend an idle one (it'll resume next time you chat with that project) ` + + `or contact support@vibnai.com to lift the cap.`, + ); + } +} + +/** + * Read-only quota status for the workspace — used by an admin UI + * (eventually) and by the AI when explaining limits to users. + */ +export async function getQuotaStatus(workspace: { id?: string | null; slug?: string | null }) { + const [pRows, cRows] = await Promise.all([ + query<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM fs_projects + WHERE (vibn_workspace_id = $1 OR workspace = $2) + AND COALESCE(data->>'status', 'active') = 'active'`, + [workspace.id ?? null, workspace.slug ?? null], + ), + workspace.slug + ? query<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM fs_project_dev_containers + WHERE workspace = $1 AND state IN ('running', 'provisioning')`, + [workspace.slug], + ) + : Promise.resolve([{ count: '0' }]), + ]); + return { + projects: { current: Number(pRows[0]?.count ?? 0), limit: projectLimit() }, + devContainers: { + current: Number(cRows[0]?.count ?? 0), + limit: devContainerLimit(), + }, + }; +}