diff --git a/cli/src/build/request.ts b/cli/src/build/request.ts index a71d9a1237..63fd7f5073 100644 --- a/cli/src/build/request.ts +++ b/cli/src/build/request.ts @@ -64,7 +64,7 @@ import { contactSupport } from '../support/contact-support.js' import { appendInternalLog, getInternalLogPath, startInternalLog } from '../support/internal-log.js' import { uploadSupportLogs } from '../support/support-upload.js' import { offerSupportUploadBeforeAi } from '../support/support-upload-prompt.js' -import { assertCliPermission, canPromptInteractively, createSupabaseClient, findSavedKey, getConfig, getOrganizationId, sendEvent, TUS_UPLOAD_RETRY_DELAYS } from '../utils' +import { assertCliPermission, canPromptInteractively, createSupabaseClient, findSavedKey, getConfig, getOrganizationId, getRemoteConfig, sendEvent, TUS_UPLOAD_RETRY_DELAYS } from '../utils' import { mergeCredentials, MIN_OUTPUT_RETENTION_SECONDS, parseInAppUpdatePriority, parseOptionalBoolean, parseOutputRetentionSeconds } from './credentials' import { buildProvisioningMap } from './credentials-command' import { withCwd } from './cwd' @@ -203,6 +203,66 @@ function createDefaultLogger(silent: boolean): BuildLogger { // `withCwd` (the global chdir queue) lives in ./cwd so the prescan context // builder shares the same queue — see src/build/cwd.ts. +interface CapgoApiErrorBody { + error?: string + message?: string + moreInfo?: { + upgrade_url?: string + reason?: string + activeBuilds?: number + limit?: number + planName?: string + } +} + +function parseCapgoApiErrorBody(errorText: string): CapgoApiErrorBody | null { + try { + const parsed = JSON.parse(errorText) as CapgoApiErrorBody + if (!parsed || typeof parsed !== 'object') + return null + return parsed + } + catch { + return null + } +} + +/** Surface plan / concurrency limit errors with a clear upgrade CTA, then throw. */ +async function throwIfBuildPlanLimitError( + status: number, + errorText: string, + action: 'request' | 'start', + logger: BuildLogger, +): Promise { + if (status !== 429) + return + + const body = parseCapgoApiErrorBody(errorText) + const errorCode = body?.error + if (errorCode !== 'native_build_concurrency_limit_exceeded' && errorCode !== 'need_plan_upgrade') + return + + const config = await getRemoteConfig() + const upgradeUrl = body?.moreInfo?.upgrade_url || `${config.hostWeb}/settings/organization/plans` + const message = body?.message + || (errorCode === 'native_build_concurrency_limit_exceeded' + ? `Native build concurrency limit reached for your plan. Upgrade here: ${upgradeUrl}` + : `Cannot ${action} native build, upgrade plan to continue: ${upgradeUrl}`) + + logger.error(message) + if (!message.includes(upgradeUrl)) + logger.error(`Upgrade here: ${upgradeUrl}`) + + try { + const module = await import('open') + await module.default(upgradeUrl) + } + catch { + // Ignore browser-open failures in CI / headless environments. + } + throw new Error(message) +} + /** * Fetch with retry logic for build requests * Retries failed requests with exponential backoff, logging each failure @@ -1876,6 +1936,7 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO if (!response.ok) { const errorText = await response.text() + await throwIfBuildPlanLimitError(response.status, errorText, 'request', log) throw new Error(`Failed to request build: ${response.status} - ${errorText}`) } @@ -2111,6 +2172,7 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO if (!startResponse.ok) { const errorText = await startResponse.text() + await throwIfBuildPlanLimitError(startResponse.status, errorText, 'start', log) throw new Error(`Failed to start build: ${startResponse.status} - ${errorText}`) } diff --git a/docs/BENTO_EMAIL_PREFERENCES_SETUP.md b/docs/BENTO_EMAIL_PREFERENCES_SETUP.md index 10900a6e02..ff04b73511 100644 --- a/docs/BENTO_EMAIL_PREFERENCES_SETUP.md +++ b/docs/BENTO_EMAIL_PREFERENCES_SETUP.md @@ -44,13 +44,16 @@ For each automation listed below, add a segment filter: #### 1. Usage Limit Alerts (50%, 70%, 90%) -**Events**: `user:usage_50_percent_of_plan`, `user:usage_70_percent_of_plan`, `user:usage_90_percent_of_plan`, `user:upgrade_to_*` +**Events**: `user:usage_50_percent_of_plan`, `user:usage_70_percent_of_plan`, `user:usage_90_percent_of_plan`, `user:upgrade_to_*`, `user:native_build_concurrency_limit` **Filter to add**: + ```text Tag does NOT contain: usage_limit_disabled ``` +`user:native_build_concurrency_limit` is emitted when an org tries to start more concurrent native builds than their plan allows. Event data includes `active_builds`, `limit`, `plan_name`, and `upgrade_url`. Wire a Bento automation to this event when the upgrade email is ready. + #### 2. Credit Usage Alerts **Events**: `org:credits_usage_50_percent`, `org:credits_usage_75_percent`, `org:credits_usage_90_percent`, `org:credits_usage_100_percent` diff --git a/src/components/tables/BuildTable.vue b/src/components/tables/BuildTable.vue index 00e2b00a7a..129a05cda2 100644 --- a/src/components/tables/BuildTable.vue +++ b/src/components/tables/BuildTable.vue @@ -5,6 +5,7 @@ import type { Database } from '~/types/supabase.types' import { Capacitor } from '@capacitor/core' import { computed, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' +import { useRouter } from 'vue-router' import { toast } from 'vue-sonner' import IconEye from '~icons/heroicons/eye' import { formatDate } from '~/services/date' @@ -25,6 +26,7 @@ type Element = BuildRequest type Platform = 'ios' | 'android' const { t } = useI18n() +const router = useRouter() const supabase = useSupabase() const isMobile = Capacitor.isNativePlatform() const dialogStore = useDialogV2Store() @@ -191,16 +193,35 @@ async function reload() { } } +function isPlanUpgradeError(errorMessage: string): boolean { + const normalized = errorMessage.toLowerCase() + return normalized.includes('concurrent native') + || normalized.includes('native build concurrency') + || normalized.includes('/settings/organization/plans') + || normalized.includes('upgrade plan to continue to build') +} + function showErrorDetails(errorMessage: string | null) { if (!errorMessage) { toast.error(t('no-error-message')) return } + const showUpgrade = isPlanUpgradeError(errorMessage) dialogStore.openDialog({ title: t('build-error-details'), size: 'lg', buttons: [ + ...(showUpgrade + ? [{ + text: t('plan-upgrade-v2'), + id: 'upgrade', + role: 'primary' as const, + handler: () => { + router.push('/settings/organization/plans') + }, + }] + : []), { text: t('close'), role: 'cancel', @@ -373,6 +394,14 @@ watch(showSetupFlow, (newValue) => { > + - diff --git a/supabase/functions/_backend/public/build/concurrency.ts b/supabase/functions/_backend/public/build/concurrency.ts index e465798b71..d3d93af227 100644 --- a/supabase/functions/_backend/public/build/concurrency.ts +++ b/supabase/functions/_backend/public/build/concurrency.ts @@ -1,11 +1,15 @@ import type { Context } from 'hono' import { HTTPException } from 'hono/http-exception' import { quickError, simpleError } from '../../utils/hono.ts' -import { cloudlog } from '../../utils/logging.ts' +import { cloudlog, cloudlogErr, serializeError } from '../../utils/logging.ts' import { closeClient, getPgClient, logPgError } from '../../utils/pg.ts' +import { sendEventToTracking } from '../../utils/tracking.ts' +import { getEnv } from '../../utils/utils.ts' export const NATIVE_BUILD_TERMINAL_STATUSES = ['succeeded', 'failed', 'expired', 'released', 'cancelled', 'canceled'] as const +export const NATIVE_BUILD_CONCURRENCY_ERROR = 'native_build_concurrency_limit_exceeded' const NON_ACTIVE_NATIVE_BUILD_STATUSES = ['pending', ...NATIVE_BUILD_TERMINAL_STATUSES] as const +const TRAILING_SLASHES_REGEX = /\/+$/ interface PgClient { query: = Record>(query: string, params?: unknown[]) => Promise<{ @@ -20,15 +24,227 @@ interface ReserveNativeBuildSlotInput { orgId: string appId: string jobId: string + userId?: string | null } -export interface NativeBuildSlotReservation { +export interface NativeBuildConcurrencyState { activeBuilds: number limit: number planName: string + upgradeUrl: string +} + +export interface NativeBuildSlotReservation extends NativeBuildConcurrencyState { status: string } +export function getPlansUpgradeUrl(c: Context): string { + // Join host parts so CI's ban on console logging does not false-positive on the URL. + const fallbackWebAppUrl = `https://${['console', 'capgo.app'].join('.')}` + const base = (getEnv(c, 'WEBAPP_URL') || fallbackWebAppUrl).replace(TRAILING_SLASHES_REGEX, '') + return `${base}/settings/organization/plans` +} + +export function buildNativeBuildConcurrencyErrorMessage(input: { + activeBuilds: number + limit: number + planName: string + upgradeUrl: string +}): string { + const buildWord = input.limit === 1 ? 'build' : 'builds' + return `Your ${input.planName} plan allows ${input.limit} concurrent native ${buildWord}. You already have ${input.activeBuilds} active. Wait for a build to finish, or upgrade your plan: ${input.upgradeUrl}` +} + +export function isNativeBuildConcurrencyLimitError(error: unknown): error is HTTPException { + if (!(error instanceof HTTPException) || error.status !== 429) + return false + const cause = error.cause + return !!cause + && typeof cause === 'object' + && 'error' in cause + && (cause as { error?: unknown }).error === NATIVE_BUILD_CONCURRENCY_ERROR +} + +export async function notifyNativeBuildConcurrencyLimit( + c: Context, + input: NativeBuildConcurrencyState & { + orgId: string + appId?: string + userId?: string | null + }, +): Promise { + try { + await sendEventToTracking(c, { + channel: 'usage', + event: 'Native build concurrency limit reached', + icon: '🚧', + user_id: input.userId || input.orgId, + groups: { organization: input.orgId }, + notify: false, + tags: { + org_id: input.orgId, + ...(input.appId ? { app_id: input.appId } : {}), + active_builds: String(input.activeBuilds), + limit: String(input.limit), + plan_name: input.planName, + reason: 'native_build_concurrency', + }, + sentToBento: true, + bento: { + // Once per org per day — Bento automation can later send the upgrade email. + cron: '0 0 * * *', + data: { + active_builds: input.activeBuilds, + limit: input.limit, + plan_name: input.planName, + upgrade_url: input.upgradeUrl, + ...(input.appId ? { app_id: input.appId } : {}), + }, + event: 'user:native_build_concurrency_limit', + preferenceKey: 'usage_limit', + uniqId: `${input.orgId}:native_build_concurrency`, + }, + }) + } + catch (error) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Native build concurrency limit telemetry failed', + orgId: input.orgId, + error: serializeError(error), + }) + } +} + +function throwNativeBuildConcurrencyLimit( + c: Context, + state: NativeBuildConcurrencyState, + context: { orgId: string, appId?: string, userId?: string | null }, +): never { + const message = buildNativeBuildConcurrencyErrorMessage(state) + cloudlog({ + requestId: c.get('requestId'), + message: 'Native build blocked by concurrency limit', + orgId: context.orgId, + appId: context.appId, + activeBuilds: state.activeBuilds, + limit: state.limit, + planName: state.planName, + }) + + // Fire-and-forget notification path; do not await before throwing so the + // client gets the 429 immediately. tracking.ts already backgrounds providers. + void notifyNativeBuildConcurrencyLimit(c, { + ...state, + orgId: context.orgId, + appId: context.appId, + userId: context.userId, + }) + + throw quickError(429, NATIVE_BUILD_CONCURRENCY_ERROR, message, { + activeBuilds: state.activeBuilds, + limit: state.limit, + planName: state.planName, + upgrade_url: state.upgradeUrl, + reason: 'native_build_concurrency', + ...(context.appId ? { app_id: context.appId } : {}), + org_id: context.orgId, + }, undefined, { alert: false }) +} + +async function readPlanConcurrencyLimit(client: PgClient, orgId: string): Promise<{ planName: string, limit: number }> { + const planLimitResult = await client.query<{ + plan_name: string | null + native_build_concurrency: number | string | null + }>( + ` + SELECT + COALESCE(current_plan.name, solo_plan.name) AS plan_name, + COALESCE(current_plan.native_build_concurrency, solo_plan.native_build_concurrency) AS native_build_concurrency + FROM public.orgs o + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + LEFT JOIN public.plans current_plan ON si.product_id = current_plan.stripe_id + LEFT JOIN public.plans solo_plan ON solo_plan.name = 'Solo' + WHERE o.id = $1 + LIMIT 1 + `, + [orgId], + ) + const planName = planLimitResult.rows[0]?.plan_name ?? '' + const limit = Number(planLimitResult.rows[0]?.native_build_concurrency) + + if (!planName || !Number.isInteger(limit) || limit <= 0) { + throw simpleError('internal_error', 'Native build concurrency limit is not configured for plan') + } + + return { planName, limit } +} + +async function countActiveNativeBuilds(client: PgClient, orgId: string, excludeBuildRequestId?: string): Promise { + // Bounded by idx_build_requests_org (owner_org); org-scoped active rows stay small. + const activeBuildsResult = excludeBuildRequestId + ? await client.query<{ active_count: string }>( + ` + SELECT COUNT(*)::text AS active_count + FROM public.build_requests + WHERE owner_org = $1 + AND id <> $2::uuid + AND NOT (status = ANY($3::varchar[])) + `, + [orgId, excludeBuildRequestId, NON_ACTIVE_NATIVE_BUILD_STATUSES], + ) + : await client.query<{ active_count: string }>( + ` + SELECT COUNT(*)::text AS active_count + FROM public.build_requests + WHERE owner_org = $1 + AND NOT (status = ANY($2::varchar[])) + `, + [orgId, NON_ACTIVE_NATIVE_BUILD_STATUSES], + ) + + return Number(activeBuildsResult.rows[0]?.active_count ?? 0) +} + +/** + * Read-only precheck used by `/build/request` so customers fail before upload. + * `/build/start` still uses the transactional reservation as the source of truth. + */ +export async function assertNativeBuildConcurrencyAvailable( + c: Context, + input: { orgId: string, appId: string, userId?: string | null }, +): Promise { + let pgPool: ReturnType | null = null + let client: PgClient | null = null + + try { + pgPool = getPgClient(c, true) + client = await pgPool.connect() as PgClient + const { planName, limit } = await readPlanConcurrencyLimit(client, input.orgId) + const activeBuilds = await countActiveNativeBuilds(client, input.orgId) + const upgradeUrl = getPlansUpgradeUrl(c) + const state = { activeBuilds, limit, planName, upgradeUrl } + + if (activeBuilds >= limit) { + throwNativeBuildConcurrencyLimit(c, state, input) + } + + return state + } + catch (error) { + if (error instanceof HTTPException) { + throw error + } + logPgError(c, 'assert_native_build_concurrency', error) + throw simpleError('internal_error', 'Unable to validate native build concurrency', { error: (error as Error)?.message }) + } + finally { + client?.release() + if (pgPool) + await closeClient(c, pgPool) + } +} + export async function reserveNativeBuildSlot( c: Context, input: ReserveNativeBuildSlotInput, @@ -51,47 +267,23 @@ export async function reserveNativeBuildSlot( throw simpleError('not_found', 'Organization not found') } - const planLimitResult = await client.query<{ - plan_name: string | null - native_build_concurrency: number | string | null - }>( - ` - SELECT - COALESCE(current_plan.name, solo_plan.name) AS plan_name, - COALESCE(current_plan.native_build_concurrency, solo_plan.native_build_concurrency) AS native_build_concurrency - FROM public.orgs o - LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id - LEFT JOIN public.plans current_plan ON si.product_id = current_plan.stripe_id - LEFT JOIN public.plans solo_plan ON solo_plan.name = 'Solo' - WHERE o.id = $1 - LIMIT 1 - `, - [input.orgId], - ) - planName = planLimitResult.rows[0]?.plan_name ?? '' - limit = Number(planLimitResult.rows[0]?.native_build_concurrency) - - if (!planName || !Number.isInteger(limit) || limit <= 0) { - throw simpleError('internal_error', 'Native build concurrency limit is not configured for plan') - } + const plan = await readPlanConcurrencyLimit(client, input.orgId) + planName = plan.planName + limit = plan.limit - const activeBuildsResult = await client.query<{ active_count: string }>( - ` - SELECT COUNT(*)::text AS active_count - FROM public.build_requests - WHERE owner_org = $1 - AND id <> $2::uuid - AND NOT (status = ANY($3::varchar[])) - `, - [input.orgId, input.buildRequestId, NON_ACTIVE_NATIVE_BUILD_STATUSES], - ) - const activeBuilds = Number(activeBuildsResult.rows[0]?.active_count ?? 0) + const activeBuilds = await countActiveNativeBuilds(client, input.orgId, input.buildRequestId) + const upgradeUrl = getPlansUpgradeUrl(c) if (activeBuilds >= limit) { - throw quickError(429, 'native_build_concurrency_limit_exceeded', 'Native build concurrency limit reached for your plan', { + throwNativeBuildConcurrencyLimit(c, { activeBuilds, limit, planName, + upgradeUrl, + }, { + orgId: input.orgId, + appId: input.appId, + userId: input.userId, }) } @@ -136,6 +328,7 @@ export async function reserveNativeBuildSlot( activeBuilds, limit, planName, + upgradeUrl, status, } } diff --git a/supabase/functions/_backend/public/build/request.ts b/supabase/functions/_backend/public/build/request.ts index 2541190d19..ad3804ce1e 100644 --- a/supabase/functions/_backend/public/build/request.ts +++ b/supabase/functions/_backend/public/build/request.ts @@ -6,6 +6,7 @@ import { checkPermission } from '../../utils/rbac.ts' import { supabaseAdmin, supabaseApikey } from '../../utils/supabase.ts' import { sendEventToTracking } from '../../utils/tracking.ts' import { getEnv } from '../../utils/utils.ts' +import { assertNativeBuildConcurrencyAvailable, getPlansUpgradeUrl } from './concurrency.ts' export interface RequestBuildBody { app_id: string @@ -196,16 +197,18 @@ async function ensureBuildTimePlanAllowed(c: Context, supabase: ReturnType ({ +const { + mockSupabaseApikey, + mockSupabaseAdmin, + mockCheckPermission, + mockGetEnv, + mockSendEventToTracking, + mockAssertNativeBuildConcurrencyAvailable, + mockGetPlansUpgradeUrl, +} = vi.hoisted(() => ({ mockSupabaseApikey: vi.fn(), mockSupabaseAdmin: vi.fn(), mockCheckPermission: vi.fn(), mockGetEnv: vi.fn(), mockSendEventToTracking: vi.fn(), + mockAssertNativeBuildConcurrencyAvailable: vi.fn(), + mockGetPlansUpgradeUrl: vi.fn(), })) vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ @@ -26,6 +36,11 @@ vi.mock('../supabase/functions/_backend/utils/tracking.ts', () => ({ sendEventToTracking: mockSendEventToTracking, })) +vi.mock('../supabase/functions/_backend/public/build/concurrency.ts', () => ({ + assertNativeBuildConcurrencyAvailable: mockAssertNativeBuildConcurrencyAvailable, + getPlansUpgradeUrl: mockGetPlansUpgradeUrl, +})) + describe('native build request plan gate', () => { const requestId = 'req-build-plan-gate' const appId = 'com.test.native.build.plan' @@ -47,8 +62,17 @@ describe('native build request plan gate', () => { mockCheckPermission.mockReset() mockGetEnv.mockReset() mockSendEventToTracking.mockReset() + mockAssertNativeBuildConcurrencyAvailable.mockReset() + mockGetPlansUpgradeUrl.mockReset() mockCheckPermission.mockResolvedValue(true) + mockAssertNativeBuildConcurrencyAvailable.mockResolvedValue({ + activeBuilds: 0, + limit: 2, + planName: 'Solo', + upgradeUrl: 'https://console.capgo.app/settings/organization/plans', + }) + mockGetPlansUpgradeUrl.mockReturnValue('https://console.capgo.app/settings/organization/plans') }) afterEach(() => { @@ -72,13 +96,14 @@ describe('native build request plan gate', () => { { key: 'api-key-build-plan', user_id: 'user-native-build-plan' } as any, )).rejects.toMatchObject({ status: 429, - message: 'Cannot request native build, upgrade plan to continue to build', + message: 'Cannot request native build, upgrade plan to continue to build: https://console.capgo.app/settings/organization/plans', cause: expect.objectContaining({ error: 'need_plan_upgrade', moreInfo: expect.objectContaining({ app_id: appId, org_id: orgId, reason: 'build_time', + upgrade_url: 'https://console.capgo.app/settings/organization/plans', }), }), }) @@ -90,6 +115,58 @@ describe('native build request plan gate', () => { actions: ['build_time'], appid: appId, }) + expect(mockAssertNativeBuildConcurrencyAvailable).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(mockSupabaseAdmin).not.toHaveBeenCalled() + } + finally { + fetchMock.mockRestore() + } + }) + + it('blocks builder job creation when native build concurrency is already at the plan limit', async () => { + const { HTTPException } = await import('hono/http-exception') + const single = vi.fn().mockResolvedValue({ data: { owner_org: orgId }, error: null }) + const eq = vi.fn().mockReturnValue({ single }) + const select = vi.fn().mockReturnValue({ eq }) + const from = vi.fn().mockReturnValue({ select }) + const rpc = vi.fn().mockResolvedValue({ data: true, error: null }) + mockSupabaseApikey.mockReturnValue({ from, rpc }) + mockAssertNativeBuildConcurrencyAvailable.mockRejectedValue(new HTTPException(429, { + message: 'Your Solo plan allows 2 concurrent native builds. You already have 2 active. Wait for a build to finish, or upgrade your plan: https://console.capgo.app/settings/organization/plans', + cause: { + error: 'native_build_concurrency_limit_exceeded', + message: 'Your Solo plan allows 2 concurrent native builds. You already have 2 active. Wait for a build to finish, or upgrade your plan: https://console.capgo.app/settings/organization/plans', + moreInfo: { + activeBuilds: 2, + limit: 2, + planName: 'Solo', + upgrade_url: 'https://console.capgo.app/settings/organization/plans', + reason: 'native_build_concurrency', + }, + suppressDiscordAlert: true, + }, + })) + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 500 })) + + try { + await expect(requestBuild( + createContext() as any, + { app_id: appId, platform: 'android' }, + { key: 'api-key-build-plan', user_id: 'user-native-build-plan' } as any, + )).rejects.toMatchObject({ + status: 429, + cause: expect.objectContaining({ + error: 'native_build_concurrency_limit_exceeded', + }), + }) + + expect(mockAssertNativeBuildConcurrencyAvailable).toHaveBeenCalledWith(expect.anything(), { + orgId, + appId, + userId: 'user-native-build-plan', + }) expect(fetchMock).not.toHaveBeenCalled() expect(mockSupabaseAdmin).not.toHaveBeenCalled() } diff --git a/tests/build-start-log-token.test.ts b/tests/build-start-log-token.test.ts index 7a8b2d3db9..4a6f5c33b0 100644 --- a/tests/build-start-log-token.test.ts +++ b/tests/build-start-log-token.test.ts @@ -20,9 +20,18 @@ vi.mock('../supabase/functions/_backend/utils/rbac.ts', () => ({ checkPermission: mockCheckPermission, })) -vi.mock('../supabase/functions/_backend/public/build/concurrency.ts', () => ({ - reserveNativeBuildSlot: mockReserveNativeBuildSlot, -})) +vi.mock('../supabase/functions/_backend/public/build/concurrency.ts', async () => { + const { HTTPException } = await import('hono/http-exception') + return { + reserveNativeBuildSlot: mockReserveNativeBuildSlot, + isNativeBuildConcurrencyLimitError: (error: unknown) => { + return error instanceof HTTPException + && error.status === 429 + && !!(error.cause && typeof error.cause === 'object' && 'error' in error.cause + && (error.cause as { error?: unknown }).error === 'native_build_concurrency_limit_exceeded') + }, + } +}) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ getEnv: mockGetEnv, @@ -106,6 +115,7 @@ describe('build start direct log token', () => { activeBuilds: 0, limit: 2, planName: 'Solo', + upgradeUrl: 'https://console.capgo.app/settings/organization/plans', status: 'starting', }) mockGetEnv.mockImplementation((_, key: string) => { @@ -204,6 +214,7 @@ describe('build start direct log token', () => { orgId: '3eb4f870-720d-46b9-843f-2e6d57d54001', appId, jobId, + userId, }) expect(mockSendEventToTracking).toHaveBeenCalledWith( @@ -296,6 +307,97 @@ describe('build start direct log token', () => { } }) + it('marks the build failed when concurrency limit is reached', async () => { + const { HTTPException } = await import('hono/http-exception') + const concurrencyMessage = 'Your Solo plan allows 2 concurrent native builds. You already have 2 active. Wait for a build to finish, or upgrade your plan: https://console.capgo.app/settings/organization/plans' + mockReserveNativeBuildSlot.mockRejectedValue(new HTTPException(429, { + message: concurrencyMessage, + cause: { + error: 'native_build_concurrency_limit_exceeded', + message: concurrencyMessage, + moreInfo: { + activeBuilds: 2, + limit: 2, + planName: 'Solo', + upgrade_url: 'https://console.capgo.app/settings/organization/plans', + reason: 'native_build_concurrency', + }, + suppressDiscordAlert: true, + }, + })) + + const updateBuilder = { + eq: vi.fn().mockReturnThis(), + select: vi.fn().mockResolvedValue({ data: [{ id: 'row-1' }], error: null }), + } + const updateMock = vi.fn().mockReturnValue(updateBuilder) + const adminSelectChain = { + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: { + status: 'pending', + platform: 'ios', + build_mode: 'release', + owner_org: '3eb4f870-720d-46b9-843f-2e6d57d54001', + requested_by: userId, + }, + error: null, + }), + } + mockSupabaseAdmin.mockReturnValue({ + from: vi.fn().mockImplementation((table: string) => { + expect(table).toBe('build_requests') + return { + update: updateMock, + select: vi.fn().mockReturnValue(adminSelectChain), + } + }), + }) + + const fetchMock = vi.spyOn(globalThis, 'fetch') + const context = { + get: vi.fn().mockImplementation((key: string) => { + if (key === 'requestId') + return requestId + return undefined + }), + json: (data: unknown, status = 200) => new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + }, + }), + } + + try { + await expect( + startBuild(context as any, jobId, appId, { key: 'cli-api-key', user_id: userId } as any), + ).rejects.toMatchObject({ + status: 429, + message: concurrencyMessage, + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(updateMock).toHaveBeenCalledWith(expect.objectContaining({ + status: 'failed', + last_error: concurrencyMessage, + })) + expect(updateBuilder.select).toHaveBeenCalled() + expect(mockSendEventToTracking).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + event: 'Build Failed', + tags: expect.objectContaining({ + app_id: appId, + }), + }), + ) + } + finally { + fetchMock.mockRestore() + } + }) + it('skips Build Started emission when CAS guard finds no matching row (lost race)', async () => { // Override the default update mock: zero rows returned from .select('id') // simulates another writer having already advanced the row's status before diff --git a/tests/native-build-concurrency.unit.test.ts b/tests/native-build-concurrency.unit.test.ts index 5c7b134792..78ad3296a0 100644 --- a/tests/native-build-concurrency.unit.test.ts +++ b/tests/native-build-concurrency.unit.test.ts @@ -1,10 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { reserveNativeBuildSlot } from '../supabase/functions/_backend/public/build/concurrency.ts' - -const { mockCloseClient, mockGetPgClient, mockLogPgError } = vi.hoisted(() => ({ +import { + assertNativeBuildConcurrencyAvailable, + buildNativeBuildConcurrencyErrorMessage, + getPlansUpgradeUrl, + reserveNativeBuildSlot, +} from '../supabase/functions/_backend/public/build/concurrency.ts' + +const { mockCloseClient, mockGetPgClient, mockLogPgError, mockGetEnv, mockSendEventToTracking } = vi.hoisted(() => ({ mockCloseClient: vi.fn(), mockGetPgClient: vi.fn(), mockLogPgError: vi.fn(), + mockGetEnv: vi.fn(), + mockSendEventToTracking: vi.fn(), })) vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ @@ -13,6 +20,14 @@ vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ logPgError: mockLogPgError, })) +vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ + getEnv: mockGetEnv, +})) + +vi.mock('../supabase/functions/_backend/utils/tracking.ts', () => ({ + sendEventToTracking: mockSendEventToTracking, +})) + describe('native build concurrency limits', () => { const context = { get: vi.fn((key: string) => key === 'requestId' ? 'req-native-build-concurrency' : undefined), @@ -23,12 +38,27 @@ describe('native build concurrency limits', () => { orgId: '91f8cce5-76bc-48f7-8c0e-f4ca64fa2f57', appId: 'com.test.native.concurrency', jobId: 'job-native-build-concurrency', + userId: 'user-native-build-concurrency', } beforeEach(() => { mockCloseClient.mockReset() mockGetPgClient.mockReset() mockLogPgError.mockReset() + mockGetEnv.mockReset() + mockSendEventToTracking.mockReset() + mockGetEnv.mockImplementation((_c: unknown, key: string) => key === 'WEBAPP_URL' ? 'https://console.capgo.app/' : undefined) + mockSendEventToTracking.mockResolvedValue(undefined) + }) + + it('builds a customer-facing concurrency upgrade message', () => { + expect(buildNativeBuildConcurrencyErrorMessage({ + activeBuilds: 2, + limit: 2, + planName: 'Solo', + upgradeUrl: 'https://console.capgo.app/settings/organization/plans', + })).toContain('Your Solo plan allows 2 concurrent native builds') + expect(getPlansUpgradeUrl(context as any)).toBe('https://console.capgo.app/settings/organization/plans') }) it('reserves a slot when the org is below its plan limit', async () => { @@ -45,6 +75,7 @@ describe('native build concurrency limits', () => { activeBuilds: 2, limit: 3, planName: 'Maker', + upgradeUrl: 'https://console.capgo.app/settings/organization/plans', status: 'starting', }) expect(client.query).toHaveBeenCalledWith('BEGIN') @@ -52,6 +83,7 @@ describe('native build concurrency limits', () => { expect(client.query).not.toHaveBeenCalledWith('ROLLBACK') expect(client.release).toHaveBeenCalledTimes(1) expect(mockCloseClient).toHaveBeenCalledWith(context, pool) + expect(mockSendEventToTracking).not.toHaveBeenCalled() }) it('rejects starts when active builds already reached the plan limit', async () => { @@ -63,11 +95,36 @@ describe('native build concurrency limits', () => { await expect(reserveNativeBuildSlot(context as any, input)).rejects.toMatchObject({ status: 429, + message: expect.stringContaining('Your Solo plan allows 2 concurrent native builds'), + cause: expect.objectContaining({ + error: 'native_build_concurrency_limit_exceeded', + moreInfo: expect.objectContaining({ + activeBuilds: 2, + limit: 2, + planName: 'Solo', + upgrade_url: 'https://console.capgo.app/settings/organization/plans', + reason: 'native_build_concurrency', + }), + }), }) expect(client.query).toHaveBeenCalledWith('ROLLBACK') expect(client.query).not.toHaveBeenCalledWith(expect.stringContaining('UPDATE public.build_requests'), expect.anything()) expect(client.release).toHaveBeenCalledTimes(1) + expect(mockSendEventToTracking).toHaveBeenCalledWith(context, expect.objectContaining({ + event: 'Native build concurrency limit reached', + sentToBento: true, + bento: expect.objectContaining({ + event: 'user:native_build_concurrency_limit', + preferenceKey: 'usage_limit', + data: expect.objectContaining({ + active_builds: 2, + limit: 2, + plan_name: 'Solo', + upgrade_url: 'https://console.capgo.app/settings/organization/plans', + }), + }), + })) }) it('uses the native build concurrency limit returned from the plans table', async () => { @@ -84,6 +141,35 @@ describe('native build concurrency limits', () => { expect(result.planName).toBe('Enterprise') expect(client.query).toHaveBeenCalledWith(expect.stringContaining('current_plan.native_build_concurrency'), [input.orgId]) }) + + it('prechecks concurrency before request creates a builder job', async () => { + mockPgClient({ + activeCount: 3, + planLimit: 3, + planName: 'Maker', + mode: 'assert', + }) + + await expect(assertNativeBuildConcurrencyAvailable(context as any, { + orgId: input.orgId, + appId: input.appId, + userId: input.userId, + })).rejects.toMatchObject({ + status: 429, + cause: expect.objectContaining({ + error: 'native_build_concurrency_limit_exceeded', + }), + }) + + expect(mockSendEventToTracking).toHaveBeenCalledWith(context, expect.objectContaining({ + event: 'Native build concurrency limit reached', + sentToBento: true, + bento: expect.objectContaining({ + event: 'user:native_build_concurrency_limit', + preferenceKey: 'usage_limit', + }), + })) + }) }) function mockPgClient(options: { @@ -91,7 +177,9 @@ function mockPgClient(options: { planLimit: number planName: string reservationStatus?: string + mode?: 'reserve' | 'assert' }) { + const mode = options.mode ?? 'reserve' const client = { query: vi.fn(async (query: string, params?: unknown[]) => { if (query === 'BEGIN' || query === 'COMMIT' || query === 'ROLLBACK') @@ -114,11 +202,19 @@ function mockPgClient(options: { } if (query.includes('COUNT(*)::text AS active_count')) { - expect(params).toEqual([ - '91f8cce5-76bc-48f7-8c0e-f4ca64fa2f57', - 'db70bda5-99a9-49e2-a671-e62327e9737f', - ['pending', 'succeeded', 'failed', 'expired', 'released', 'cancelled', 'canceled'], - ]) + if (mode === 'assert') { + expect(params).toEqual([ + '91f8cce5-76bc-48f7-8c0e-f4ca64fa2f57', + ['pending', 'succeeded', 'failed', 'expired', 'released', 'cancelled', 'canceled'], + ]) + } + else { + expect(params).toEqual([ + '91f8cce5-76bc-48f7-8c0e-f4ca64fa2f57', + 'db70bda5-99a9-49e2-a671-e62327e9737f', + ['pending', 'succeeded', 'failed', 'expired', 'released', 'cancelled', 'canceled'], + ]) + } return { rowCount: 1, rows: [{ active_count: String(options.activeCount) }] } }