Skip to content
64 changes: 63 additions & 1 deletion cli/src/build/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<void> {
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)
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Fetch with retry logic for build requests
* Retries failed requests with exponential backoff, logging each failure
Expand Down Expand Up @@ -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}`)
}

Expand Down Expand Up @@ -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}`)
}

Expand Down
5 changes: 4 additions & 1 deletion docs/BENTO_EMAIL_PREFERENCES_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Comment thread
cursor[bot] marked this conversation as resolved.

`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`
Expand Down
29 changes: 29 additions & 0 deletions src/components/tables/BuildTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -373,6 +394,14 @@ watch(showSetupFlow, (newValue) => {
>
<IconEye class="w-4 h-4" />
</button>
<button
Comment thread
cursor[bot] marked this conversation as resolved.
v-if="isPlanUpgradeError(element.last_error)"
type="button"
class="px-2 py-1 text-xs font-semibold text-white rounded-md d-btn d-btn-xs d-btn-primary shrink-0"
@click.stop="router.push('/settings/organization/plans')"
>
{{ t('plan-upgrade-v2') }}
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
</div>
<span v-else class="text-gray-400 dark:text-gray-600">-</span>
</template>
Expand Down
Loading
Loading