feat(quotas): per-workspace soft caps + AI recovery rule
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -66,6 +66,19 @@ const RULES: RecoveryRule[] = [
|
||||
antipattern:
|
||||
'Do NOT retry the same `apps_deploy` blindly hoping the registry will respond differently. The pull failure is persistent until the underlying image-availability issue is fixed.',
|
||||
},
|
||||
{
|
||||
id: 'workspace-quota-exceeded',
|
||||
// Matches the structured 402 returned by quotas.ts. The substring
|
||||
// "QUOTA_EXCEEDED" (the .code field) plus "active dev containers"
|
||||
// or "active projects" disambiguates from arbitrary text.
|
||||
pattern: /(QUOTA_EXCEEDED.*active (dev containers|projects)|already has \d+\/\d+ active (dev containers|projects))/i,
|
||||
diagnosis:
|
||||
'The workspace has hit its soft cap on active resources. This is a beta-limit guardrail, not a real error.',
|
||||
requiredAction:
|
||||
'Tell the user clearly which cap was hit and offer the two options: (1) suspend an existing dev container with `devcontainer_suspend { projectId }` if they have an idle one, or delete an unused project, OR (2) email support@vibnai.com to raise their cap. Do NOT retry the same call expecting a different result.',
|
||||
antipattern:
|
||||
'Do NOT keep retrying `devcontainer_ensure` or `projects.create` blindly. The cap is real until something is freed up. Do not try to bypass it by switching workspaces or projects.',
|
||||
},
|
||||
{
|
||||
id: 'port-already-allocated',
|
||||
// Matches: `port is already allocated` / `bind: address already in use`.
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
linkResourceToProject,
|
||||
} from '@/lib/projects';
|
||||
import type { VibnWorkspace } from '@/lib/workspaces';
|
||||
import { assertDevContainerQuota } from '@/lib/quotas';
|
||||
|
||||
// ── Configuration ────────────────────────────────────────────────────
|
||||
|
||||
@@ -243,12 +244,23 @@ export async function ensureDevContainer(
|
||||
const existing = await getDevContainerRow(opts.projectId);
|
||||
if (existing) {
|
||||
if (existing.state === 'suspended' && !opts.noStart) {
|
||||
// Resume counts as "starting one more" against the quota, since
|
||||
// a suspended container is free but a running one isn't.
|
||||
await assertDevContainerQuota(opts.workspace.slug);
|
||||
await resumeDevContainer(opts.projectId);
|
||||
return { serviceUuid: existing.service_uuid, state: 'running', created: false };
|
||||
}
|
||||
return { serviceUuid: existing.service_uuid, state: existing.state, created: false };
|
||||
}
|
||||
|
||||
// Net-new container creation hits the quota (skip if noStart=true,
|
||||
// since a never-started container costs nothing). The QuotaExceededError
|
||||
// bubbles up to the MCP route which surfaces it as a 402 to the AI;
|
||||
// the AI's recovery middleware can offer to suspend an idle one.
|
||||
if (!opts.noStart) {
|
||||
await assertDevContainerQuota(opts.workspace.slug);
|
||||
}
|
||||
|
||||
// Need a Coolify project to land the service in.
|
||||
let coolifyProjectUuid = await getProjectCoolifyUuid(opts.projectId, opts.workspace);
|
||||
if (!coolifyProjectUuid) {
|
||||
|
||||
158
lib/quotas.ts
Normal file
158
lib/quotas.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Per-workspace compute quotas.
|
||||
*
|
||||
* Why these exist: the platform spawns Coolify projects, Docker
|
||||
* containers, and dev VMs in response to AI tool calls. Without a
|
||||
* cap, a single bad-actor signup could spin up unbounded resources
|
||||
* before we notice — and the GCE bill is the one place where bad
|
||||
* actors hit *us* for real money.
|
||||
*
|
||||
* Quotas are SOFT caps — they return friendly errors with a clear
|
||||
* upgrade path. They are NOT a security boundary; auth + tenant
|
||||
* isolation are. They are a "don't melt the fleet" control.
|
||||
*
|
||||
* Numbers are intentionally small for beta. Lift via env vars
|
||||
* once we trust the load profile of real users:
|
||||
*
|
||||
* VIBN_QUOTA_MAX_PROJECTS_PER_WORKSPACE (default 3)
|
||||
* VIBN_QUOTA_MAX_DEV_CONTAINERS_PER_WORKSPACE (default 3)
|
||||
*
|
||||
* To bump for a single power-user, set their workspace's
|
||||
* `data.quotaOverrides` jsonb on fs_users — TODO when we have
|
||||
* an admin UI; for now, raise the global env var.
|
||||
*/
|
||||
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
export class QuotaExceededError extends Error {
|
||||
readonly code = 'QUOTA_EXCEEDED';
|
||||
readonly resource: string;
|
||||
readonly current: number;
|
||||
readonly limit: number;
|
||||
|
||||
constructor(resource: string, current: number, limit: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'QuotaExceededError';
|
||||
this.resource = resource;
|
||||
this.current = current;
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_PROJECT_LIMIT = 3;
|
||||
const DEFAULT_DEV_CONTAINER_LIMIT = 3;
|
||||
|
||||
function projectLimit(): number {
|
||||
return parsePositiveInt(
|
||||
process.env.VIBN_QUOTA_MAX_PROJECTS_PER_WORKSPACE,
|
||||
DEFAULT_PROJECT_LIMIT,
|
||||
);
|
||||
}
|
||||
function devContainerLimit(): number {
|
||||
return parsePositiveInt(
|
||||
process.env.VIBN_QUOTA_MAX_DEV_CONTAINERS_PER_WORKSPACE,
|
||||
DEFAULT_DEV_CONTAINER_LIMIT,
|
||||
);
|
||||
}
|
||||
function parsePositiveInt(raw: string | undefined, fallback: number): number {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 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<void> {
|
||||
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<void> {
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user