diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts
index cf1785bbd5..02f120d172 100644
--- a/cli/src/types/supabase.types.ts
+++ b/cli/src/types/supabase.types.ts
@@ -591,6 +591,7 @@ export type Database = {
build_config: Json | null
build_mode: string
builder_job_id: string | null
+ builder_pool: string | null
created_at: string
id: string
last_error: string | null
@@ -611,6 +612,7 @@ export type Database = {
build_config?: Json | null
build_mode?: string
builder_job_id?: string | null
+ builder_pool?: string | null
created_at?: string
id?: string
last_error?: string | null
@@ -631,6 +633,7 @@ export type Database = {
build_config?: Json | null
build_mode?: string
builder_job_id?: string | null
+ builder_pool?: string | null
created_at?: string
id?: string
last_error?: string | null
@@ -1371,6 +1374,85 @@ export type Database = {
}
Relationships: []
}
+
+ dedicated_builders: {
+ Row: {
+ activated_at: string | null
+ allow_shared_fallback: boolean
+ cancelled_at: string | null
+ created_at: string
+ id: string
+ monthly_builds_estimate: number | null
+ org_id: string
+ platforms: string[]
+ pool_id: string | null
+ requested_by: string | null
+ status: string
+ suspended_at: string | null
+ updated_at: string
+ use_case: string | null
+ worker_current_job_id: string | null
+ worker_last_seen_at: string | null
+ worker_name: string | null
+ worker_status: string
+ }
+ Insert: {
+ activated_at?: string | null
+ allow_shared_fallback?: boolean
+ cancelled_at?: string | null
+ created_at?: string
+ id?: string
+ monthly_builds_estimate?: number | null
+ org_id: string
+ platforms?: string[]
+ pool_id?: string | null
+ requested_by?: string | null
+ status?: string
+ suspended_at?: string | null
+ updated_at?: string
+ use_case?: string | null
+ worker_current_job_id?: string | null
+ worker_last_seen_at?: string | null
+ worker_name?: string | null
+ worker_status?: string
+ }
+ Update: {
+ activated_at?: string | null
+ allow_shared_fallback?: boolean
+ cancelled_at?: string | null
+ created_at?: string
+ id?: string
+ monthly_builds_estimate?: number | null
+ org_id?: string
+ platforms?: string[]
+ pool_id?: string | null
+ requested_by?: string | null
+ status?: string
+ suspended_at?: string | null
+ updated_at?: string
+ use_case?: string | null
+ worker_current_job_id?: string | null
+ worker_last_seen_at?: string | null
+ worker_name?: string | null
+ worker_status?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "dedicated_builders_org_id_fkey"
+ columns: ["org_id"]
+ isOneToOne: true
+ referencedRelation: "orgs"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "dedicated_builders_requested_by_fkey"
+ columns: ["requested_by"]
+ isOneToOne: false
+ referencedRelation: "users"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
deploy_history: {
Row: {
app_id: string
diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts
index e04f7391aa..5fde4a4f7b 100644
--- a/cloudflare_workers/api/index.ts
+++ b/cloudflare_workers/api/index.ts
@@ -11,6 +11,7 @@ import { app as config } from '../../supabase/functions/_backend/private/config.
import { app as configBuilder } from '../../supabase/functions/_backend/private/config_builder.ts'
import { app as create_device } from '../../supabase/functions/_backend/private/create_device.ts'
import { app as credits } from '../../supabase/functions/_backend/private/credits.ts'
+import { app as dedicated_builder } from '../../supabase/functions/_backend/private/dedicated_builder.ts'
import { app as deleted_failed_version } from '../../supabase/functions/_backend/private/delete_failed_version.ts'
import { app as devices_priv } from '../../supabase/functions/_backend/private/devices.ts'
import { app as events } from '../../supabase/functions/_backend/private/events.ts'
@@ -113,6 +114,7 @@ const functionNamePrivate = 'private'
const appPrivate = createHono(functionNamePrivate, version)
appPrivate.route('/plans', plans)
appPrivate.route('/credits', credits)
+appPrivate.route('/dedicated_builder', dedicated_builder)
appPrivate.route('/store_top', storeTop)
appPrivate.route('/website_stats', publicStats)
appPrivate.route('/config', config)
diff --git a/messages/en.json b/messages/en.json
index 9555f3565b..886304a89b 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2559,7 +2559,6 @@
"error-categories": "Error categories",
"error-share": "{share} of errors",
"errors-in-period": "Errors in period",
- "events": "Events",
"failed-to-fetch-log-insights": "Failed to fetch log insights",
"log-insights": "Insights",
"log-insights-period-help": "Error categories, affected devices, and trends use this period.",
@@ -2573,5 +2572,69 @@
"version-count": "Versions",
"view-action-logs": "View action logs",
"view-logs": "View logs",
- "last-seen": "Last seen"
+ "last-seen": "Last seen",
+ "dedicated-builder": "Dedicated builder",
+ "dedicated-builder-page-subtitle": "Request a Capgo native builder worker dedicated to your organization.",
+ "dedicated-builder-hero-title": "Your own native build worker",
+ "dedicated-builder-hero-desc": "Skip the shared queue with a dedicated builder pool. When your worker is busy, builds can fall back to Capgo's shared workers.",
+ "dedicated-builder-benefit-queue": "Priority on your own pool",
+ "dedicated-builder-benefit-worker": "One worker reserved for your org",
+ "dedicated-builder-benefit-fallback": "Shared pool fallback when busy",
+ "dedicated-builder-request-title": "Request a dedicated builder",
+ "dedicated-builder-use-case": "What will you use it for?",
+ "dedicated-builder-use-case-placeholder": "e.g. CI builds for iOS/Android releases every day",
+ "dedicated-builder-monthly-estimate": "Estimated native builds per month",
+ "dedicated-builder-monthly-estimate-placeholder": "e.g. 40",
+ "dedicated-builder-platforms": "Platforms",
+ "dedicated-builder-platforms-required": "Select at least one platform",
+ "dedicated-builder-request-cta": "Request dedicated builder",
+ "dedicated-builder-need-permission": "Why can't I request this?",
+ "dedicated-builder-request-success": "Dedicated builder requested. Our team will provision it and update this page when it is ready.",
+ "dedicated-builder-request-error": "Could not submit the dedicated builder request",
+ "dedicated-builder-already-exists": "A dedicated builder request already exists for this organization",
+ "dedicated-builder-load-error": "Could not load dedicated builder status",
+ "dedicated-builder-requested-title": "Request received",
+ "dedicated-builder-requested-desc": "Our team is reviewing your request and will provision a dedicated worker for your organization.",
+ "dedicated-builder-provisioning-title": "Provisioning your worker",
+ "dedicated-builder-provisioning-desc": "Your dedicated builder is being set up. Builds will start using it once it becomes active.",
+ "dedicated-builder-requested-at": "Requested on {date}",
+ "dedicated-builder-step-requested": "Request submitted",
+ "dedicated-builder-step-provisioning": "Worker provisioning",
+ "dedicated-builder-step-active": "Dedicated pool active",
+ "dedicated-builder-cancel-cta": "Cancel request",
+ "dedicated-builder-cancel-title": "Cancel dedicated builder request?",
+ "dedicated-builder-cancel-description": "You can request a dedicated builder again later.",
+ "dedicated-builder-cancel-confirm": "Cancel request",
+ "dedicated-builder-cancel-success": "Dedicated builder request cancelled",
+ "dedicated-builder-cancel-error": "Could not cancel the dedicated builder request",
+ "dedicated-builder-your-worker": "Your dedicated worker",
+ "dedicated-builder-active-desc": "New native builds prefer your dedicated worker. If it is busy or offline, they can use Capgo shared workers when fallback is enabled.",
+ "dedicated-builder-worker-unknown": "Unknown",
+ "dedicated-builder-worker-idle": "Idle",
+ "dedicated-builder-worker-busy": "Busy",
+ "dedicated-builder-worker-offline": "Offline",
+ "dedicated-builder-pool-id": "Pool ID",
+ "dedicated-builder-pool-pending": "Assigned at activation",
+ "dedicated-builder-active-builds": "Active builds preferring dedicated",
+ "dedicated-builder-activated-at": "Activated",
+ "dedicated-builder-fallback-title": "Fall back to shared workers",
+ "dedicated-builder-fallback-desc": "When your dedicated worker is busy or offline, route builds to Capgo's shared pool instead of waiting.",
+ "dedicated-builder-fallback-updated": "Fallback setting updated",
+ "dedicated-builder-fallback-error": "Could not update fallback setting",
+ "dedicated-builder-how-it-works-title": "How routing works",
+ "dedicated-builder-how-it-works-1": "New builds prefer your dedicated worker first.",
+ "dedicated-builder-how-it-works-2": "If that worker is already running a build (or offline), Capgo uses the shared pool when fallback is on.",
+ "dedicated-builder-how-it-works-3": "Build history shows whether a job used your dedicated pool or the shared pool.",
+ "dedicated-builder-suspended-title": "Dedicated builder suspended",
+ "dedicated-builder-suspended-desc": "Your dedicated worker is temporarily unavailable. Contact support if you need it reactivated.",
+ "dedicated-builder-access-required": "Billing access required",
+ "dedicated-builder-banner-title": "Need a dedicated native builder?",
+ "dedicated-builder-banner-desc": "Get your own worker pool for faster, isolated native builds — with shared fallback when busy.",
+ "dedicated-builder-banner-cta": "Set up dedicated builder",
+ "dedicated-builder-banner-view": "View dedicated builder",
+ "dedicated-builder-banner-view-status": "View request status",
+ "builder-pool": "Preferred pool",
+ "builder-pool-dedicated": "Dedicated",
+ "builder-pool-shared": "Shared",
+ "plan-feature-dedicated-builder": "Dedicated native builder available"
}
diff --git a/src/components/dashboard/DedicatedBuilderBanner.vue b/src/components/dashboard/DedicatedBuilderBanner.vue
new file mode 100644
index 0000000000..a79c1b7947
--- /dev/null
+++ b/src/components/dashboard/DedicatedBuilderBanner.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('dedicated-builder-banner-title') }}
+
+
+ {{ t('dedicated-builder-banner-desc') }}
+
+
+
+
+
+
diff --git a/src/components/tables/BuildTable.vue b/src/components/tables/BuildTable.vue
index 00e2b00a7a..501eccfd46 100644
--- a/src/components/tables/BuildTable.vue
+++ b/src/components/tables/BuildTable.vue
@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
import type { TableColumn } from '../comp_def'
import type { Database } from '~/types/supabase.types'
import { Capacitor } from '@capacitor/core'
-import { computed, onMounted, ref, watch } from 'vue'
+import { computed, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import IconEye from '~icons/heroicons/eye'
@@ -287,17 +287,56 @@ columns.value = [
key: 'status',
mobile: true,
class: 'truncate max-w-24',
- displayFunction: (elem: Element) => elem.status,
+ renderFunction: (elem: Element) => h('span', {
+ class: `font-semibold ${getStatusColor(elem.status)}`,
+ }, elem.status),
+ },
+ {
+ label: t('builder-pool'),
+ key: 'builder_pool',
+ class: 'truncate max-w-24',
+ // Preferred pool at request time (may differ from actual when fallback runs).
+ renderFunction: (elem: Element) => {
+ if (elem.builder_pool === 'dedicated') {
+ return h('span', {
+ class: 'inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-azure-500/10 text-azure-700 dark:text-azure-300',
+ }, t('builder-pool-dedicated'))
+ }
+ if (elem.builder_pool === 'shared') {
+ return h('span', {
+ class: 'inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
+ }, t('builder-pool-shared'))
+ }
+ return h('span', { class: 'text-gray-400 dark:text-gray-600' }, '—')
+ },
},
{
label: t('error'),
key: 'last_error',
mobile: true,
class: 'max-w-48',
- displayFunction: (elem: Element) => {
+ renderFunction: (elem: Element) => {
if (!elem.last_error)
- return '-'
- return elem.last_error.length > 50 ? `${elem.last_error.substring(0, 50)}...` : elem.last_error
+ return h('span', {}, '-')
+ const truncated = elem.last_error.length > 50
+ ? `${elem.last_error.substring(0, 50)}...`
+ : elem.last_error
+ return h('div', { class: 'flex items-center gap-2' }, [
+ h('span', {
+ class: 'max-w-xs text-red-600 truncate dark:text-red-400',
+ }, truncated),
+ h('button', {
+ 'type': 'button',
+ 'aria-label': t('build-error-details'),
+ 'class': 'p-1 text-gray-500 rounded-md cursor-pointer shrink-0 dark:text-gray-400 hover:text-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 dark:hover:text-gray-300',
+ 'onClick': (event: MouseEvent) => {
+ event.stopPropagation()
+ showErrorDetails(elem.last_error!)
+ },
+ }, [
+ h(IconEye, { class: 'w-4 h-4' }),
+ ]),
+ ])
},
},
{
@@ -353,40 +392,7 @@ watch(showSetupFlow, (newValue) => {
@add="addOne()"
@reset="reload()"
@reload="getData()"
- >
-
-
- {{ element.status }}
-
-
-
-
-
- {{ element.last_error.length > 50 ? `${element.last_error.substring(0, 50)}...` : element.last_error }}
-
-
-
- -
-
-
-
-
- {{ t('no-builds-yet') }}
-
-
- {{ t('no-builds-description') }}
-
-
-
-
+ />
diff --git a/src/constants/organizationTabs.ts b/src/constants/organizationTabs.ts
index b0e13c42e7..837e57d0e9 100644
--- a/src/constants/organizationTabs.ts
+++ b/src/constants/organizationTabs.ts
@@ -5,6 +5,7 @@ import IconPlan from '~icons/heroicons/credit-card'
import IconCredits from '~icons/heroicons/currency-dollar'
import IconWebhook from '~icons/heroicons/globe-alt'
import IconInfo from '~icons/heroicons/information-circle'
+import IconServer from '~icons/heroicons/server-stack'
import IconSecurity from '~icons/heroicons/shield-check'
import IconUsers from '~icons/heroicons/users'
@@ -14,6 +15,7 @@ export const organizationTabs: Tab[] = [
{ label: 'groups', key: '/settings/organization/groups', icon: IconUsers },
{ label: 'plans', key: '/settings/organization/plans', icon: IconPlan },
{ label: 'credits', key: '/settings/organization/credits', icon: IconCredits },
+ { label: 'dedicated-builder', key: '/settings/organization/dedicated-builder', icon: IconServer },
{ label: 'security', key: '/settings/organization/security', icon: IconSecurity },
{ label: 'usage', key: '/settings/organization/usage', icon: IconChart },
{ label: 'audit-logs', key: '/settings/organization/auditlogs', icon: IconAudit },
diff --git a/src/layouts/settings.vue b/src/layouts/settings.vue
index a020cfd2c8..7a7ae75cce 100644
--- a/src/layouts/settings.vue
+++ b/src/layouts/settings.vue
@@ -167,6 +167,17 @@ watchEffect(() => {
if (!needsPlans && hasPlans)
organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/plans')
+ // Dedicated builder - visible to users who can read billing
+ const needsDedicatedBuilder = canReadBilling.value
+ const hasDedicatedBuilder = organizationTabs.value.some(tab => tab.key === '/settings/organization/dedicated-builder')
+ if (needsDedicatedBuilder && !hasDedicatedBuilder) {
+ const base = baseOrgTabs.find(t => t.key === '/settings/organization/dedicated-builder')
+ if (base)
+ organizationTabs.value.push({ ...base })
+ }
+ if (!needsDedicatedBuilder && hasDedicatedBuilder)
+ organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/dedicated-builder')
+
// Audit logs - visible only to super_admins
const needsAuditLogs = canReadAuditLogs.value
const hasAuditLogs = organizationTabs.value.find(tab => tab.key === '/settings/organization/audit-logs')
diff --git a/src/pages/app/[app].builds.vue b/src/pages/app/[app].builds.vue
index f78ba0d8c0..a742570404 100644
--- a/src/pages/app/[app].builds.vue
+++ b/src/pages/app/[app].builds.vue
@@ -7,6 +7,7 @@ import IconAlertCircle from '~icons/lucide/alert-circle'
import BuildChartControls from '~/components/dashboard/BuildChartControls.vue'
import BuildStatsCard from '~/components/dashboard/BuildStatsCard.vue'
import BuildTimeCard from '~/components/dashboard/BuildTimeCard.vue'
+import DedicatedBuilderBanner from '~/components/dashboard/DedicatedBuilderBanner.vue'
import { useSupabase } from '~/services/supabase'
import { useDisplayStore } from '~/stores/display'
@@ -73,6 +74,7 @@ watchEffect(async () => {
+
+import type { DedicatedBuilder } from '~/services/dedicatedBuilder'
+import { computedAsync } from '@vueuse/core'
+import { storeToRefs } from 'pinia'
+import { computed, onMounted, ref, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { toast } from 'vue-sonner'
+import IconCheck from '~icons/heroicons/check-circle'
+import IconClock from '~icons/heroicons/clock'
+import IconCpu from '~icons/heroicons/cpu-chip'
+import IconServer from '~icons/heroicons/server-stack'
+import IconSparkles from '~icons/heroicons/sparkles'
+import RbacPermissionOnlyModal from '~/components/RbacPermissionOnlyModal.vue'
+import Spinner from '~/components/Spinner.vue'
+import { formatLocalDateTime } from '~/services/date'
+import {
+ DedicatedBuilderApiError,
+ fetchDedicatedBuilder,
+ requestDedicatedBuilder,
+ updateDedicatedBuilder,
+} from '~/services/dedicatedBuilder'
+import { checkPermissions } from '~/services/permissions'
+import { useDialogV2Store } from '~/stores/dialogv2'
+import { useDisplayStore } from '~/stores/display'
+import { useOrganizationStore } from '~/stores/organization'
+
+const { t } = useI18n()
+const displayStore = useDisplayStore()
+const organizationStore = useOrganizationStore()
+const dialogStore = useDialogV2Store()
+const { currentOrganization } = storeToRefs(organizationStore)
+
+displayStore.NavTitle = t('dedicated-builder')
+
+const isLoading = ref(true)
+const isSubmitting = ref(false)
+const isSavingFallback = ref(false)
+const showAdminModal = ref(false)
+const dedicatedBuilder = ref(null)
+
+const useCase = ref('')
+const monthlyBuildsEstimate = ref(null)
+const platformIos = ref(true)
+const platformAndroid = ref(true)
+let loadToken = 0
+
+const canReadBilling = computedAsync(async () => {
+ const orgId = currentOrganization.value?.gid
+ if (!orgId)
+ return false
+ return await checkPermissions('org.read_billing', { orgId })
+}, false)
+
+const canUpdateBilling = computedAsync(async () => {
+ const orgId = currentOrganization.value?.gid
+ if (!orgId)
+ return false
+ return await checkPermissions('org.update_billing', { orgId })
+}, false)
+
+const status = computed(() => dedicatedBuilder.value?.status ?? null)
+const isPending = computed(() => status.value === 'requested' || status.value === 'provisioning')
+const isActive = computed(() => status.value === 'active')
+const showRequestForm = computed(() => !dedicatedBuilder.value || status.value === 'cancelled')
+
+const workerStatusLabel = computed(() => {
+ const workerStatus = dedicatedBuilder.value?.worker_status
+ if (!workerStatus || workerStatus === 'unknown')
+ return t('dedicated-builder-worker-unknown')
+ return t(`dedicated-builder-worker-${workerStatus}`)
+})
+
+const workerStatusClass = computed(() => {
+ switch (dedicatedBuilder.value?.worker_status) {
+ case 'idle':
+ return 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
+ case 'busy':
+ return 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/30'
+ case 'offline':
+ return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30'
+ default:
+ return 'text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800'
+ }
+})
+
+async function loadDedicatedBuilder() {
+ const orgId = currentOrganization.value?.gid
+ const token = ++loadToken
+ if (!orgId || !canReadBilling.value) {
+ if (token === loadToken) {
+ dedicatedBuilder.value = null
+ isLoading.value = false
+ }
+ return
+ }
+
+ isLoading.value = true
+ try {
+ const row = await fetchDedicatedBuilder(orgId)
+ if (token !== loadToken)
+ return
+ dedicatedBuilder.value = row
+ }
+ catch (error) {
+ if (token !== loadToken)
+ return
+ console.error('Failed to load dedicated builder', error)
+ dedicatedBuilder.value = null
+ toast.error(t('dedicated-builder-load-error'))
+ }
+ finally {
+ if (token === loadToken)
+ isLoading.value = false
+ }
+}
+
+onMounted(async () => {
+ await organizationStore.dedupFetchOrganizations()
+ await loadDedicatedBuilder()
+})
+
+watch(currentOrganization, async () => {
+ await loadDedicatedBuilder()
+})
+
+watch(canReadBilling, async () => {
+ // Load on grant and clear/invalidate in-flight work on revoke.
+ await loadDedicatedBuilder()
+})
+
+async function submitRequest() {
+ const orgId = currentOrganization.value?.gid
+ if (!orgId)
+ return
+
+ if (!canUpdateBilling.value) {
+ showAdminModal.value = true
+ return
+ }
+
+ if (!platformIos.value && !platformAndroid.value) {
+ toast.error(t('dedicated-builder-platforms-required'))
+ return
+ }
+
+ isSubmitting.value = true
+ try {
+ const platforms = [
+ ...(platformIos.value ? ['ios'] : []),
+ ...(platformAndroid.value ? ['android'] : []),
+ ]
+ dedicatedBuilder.value = await requestDedicatedBuilder({
+ orgId,
+ useCase: useCase.value.trim() || undefined,
+ monthlyBuildsEstimate: monthlyBuildsEstimate.value,
+ platforms,
+ })
+ toast.success(t('dedicated-builder-request-success'))
+ }
+ catch (error) {
+ console.error('Failed to request dedicated builder', error)
+ const message = error instanceof DedicatedBuilderApiError && error.code === 'dedicated_builder_exists'
+ ? t('dedicated-builder-already-exists')
+ : t('dedicated-builder-request-error')
+ toast.error(message)
+ }
+ finally {
+ isSubmitting.value = false
+ }
+}
+
+async function toggleFallback(nextValue: boolean) {
+ const orgId = currentOrganization.value?.gid
+ if (!orgId || !dedicatedBuilder.value)
+ return
+
+ if (!canUpdateBilling.value) {
+ showAdminModal.value = true
+ return
+ }
+
+ isSavingFallback.value = true
+ try {
+ dedicatedBuilder.value = await updateDedicatedBuilder(orgId, {
+ allow_shared_fallback: nextValue,
+ })
+ toast.success(t('dedicated-builder-fallback-updated'))
+ }
+ catch (error) {
+ console.error('Failed to update fallback', error)
+ toast.error(t('dedicated-builder-fallback-error'))
+ }
+ finally {
+ isSavingFallback.value = false
+ }
+}
+
+function cancelRequest() {
+ const orgId = currentOrganization.value?.gid
+ if (!orgId || !dedicatedBuilder.value)
+ return
+
+ if (!canUpdateBilling.value) {
+ showAdminModal.value = true
+ return
+ }
+
+ dialogStore.openDialog({
+ title: t('dedicated-builder-cancel-title'),
+ description: t('dedicated-builder-cancel-description'),
+ buttons: [
+ { text: t('button-cancel'), role: 'cancel' },
+ {
+ text: t('dedicated-builder-cancel-confirm'),
+ role: 'danger',
+ handler: async () => {
+ try {
+ dedicatedBuilder.value = await updateDedicatedBuilder(orgId, { cancel: true })
+ toast.success(t('dedicated-builder-cancel-success'))
+ }
+ catch (error) {
+ console.error('Failed to cancel dedicated builder', error)
+ toast.error(t('dedicated-builder-cancel-error'))
+ }
+ },
+ },
+ ],
+ })
+}
+
+
+
+
+
+
+ {{ t('dedicated-builder') }}
+
+
+ {{ t('dedicated-builder-page-subtitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('dedicated-builder-hero-title') }}
+
+
+ {{ t('dedicated-builder-hero-desc') }}
+
+
+
+
+
+ -
+
+
+ {{ t('dedicated-builder-benefit-queue') }}
+
+
+ -
+
+
+ {{ t('dedicated-builder-benefit-worker') }}
+
+
+ -
+
+
+ {{ t('dedicated-builder-benefit-fallback') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ status === 'provisioning' ? t('dedicated-builder-provisioning-title') : t('dedicated-builder-requested-title') }}
+
+
+ {{ status === 'provisioning' ? t('dedicated-builder-provisioning-desc') : t('dedicated-builder-requested-desc') }}
+
+
+ {{ t('dedicated-builder-requested-at', { date: formatLocalDateTime(dedicatedBuilder.created_at) }) }}
+
+
+
+
+
+ -
+
+ {{ t('dedicated-builder-step-requested') }}
+
+ -
+
+ {{ t('dedicated-builder-step-provisioning') }}
+
+ -
+
+ {{ t('dedicated-builder-step-active') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dedicatedBuilder.worker_name || t('dedicated-builder-your-worker') }}
+
+
+ {{ t('dedicated-builder-active-desc') }}
+
+
+
+
+ {{ workerStatusLabel }}
+
+
+
+
+
+
-
+ {{ t('dedicated-builder-pool-id') }}
+
+ -
+ {{ dedicatedBuilder.pool_id || t('dedicated-builder-pool-pending') }}
+
+
+
+
-
+ {{ t('dedicated-builder-active-builds') }}
+
+ -
+ {{ dedicatedBuilder.active_dedicated_builds }}
+
+
+
+
-
+ {{ t('dedicated-builder-activated-at') }}
+
+ -
+ {{ formatLocalDateTime(dedicatedBuilder.activated_at) }}
+
+
+
+
-
+ {{ t('dedicated-builder-platforms') }}
+
+ -
+ {{ dedicatedBuilder.platforms.join(', ') }}
+
+
+
+
+
+
+
+
+
+ {{ t('dedicated-builder-fallback-title') }}
+
+
+ {{ t('dedicated-builder-fallback-desc') }}
+
+
+
+
+
+
+
+
+ {{ t('dedicated-builder-how-it-works-title') }}
+
+
+ - {{ t('dedicated-builder-how-it-works-1') }}
+ - {{ t('dedicated-builder-how-it-works-2') }}
+ - {{ t('dedicated-builder-how-it-works-3') }}
+
+
+
+
+
+
+
+ {{ t('dedicated-builder-suspended-title') }}
+
+
+ {{ t('dedicated-builder-suspended-desc') }}
+
+
+
+
+
+
+
+
+
+path: /settings/organization/dedicated-builder
+meta:
+ layout: settings
+
diff --git a/src/pages/settings/organization/Plans.vue b/src/pages/settings/organization/Plans.vue
index 28749684f6..bb79f081ea 100644
--- a/src/pages/settings/organization/Plans.vue
+++ b/src/pages/settings/organization/Plans.vue
@@ -64,6 +64,7 @@ const planFeatureLabelKeysByPlan: Record = {
'plan-feature-priority-email-support',
'plan-feature-custom-domain',
'plan-feature-direct-chat-support',
+ 'plan-feature-dedicated-builder',
'plan-feature-service-sla',
'plan-feature-soc2-certified',
],
diff --git a/src/services/dedicatedBuilder.ts b/src/services/dedicatedBuilder.ts
new file mode 100644
index 0000000000..4e5702bf8f
--- /dev/null
+++ b/src/services/dedicatedBuilder.ts
@@ -0,0 +1,130 @@
+import { FunctionsHttpError } from '@supabase/supabase-js'
+import { useSupabase } from '~/services/supabase'
+
+export type DedicatedBuilderStatus = 'requested' | 'provisioning' | 'active' | 'suspended' | 'cancelled'
+export type DedicatedWorkerStatus = 'unknown' | 'idle' | 'busy' | 'offline'
+
+export interface DedicatedBuilder {
+ id: string
+ org_id: string
+ status: DedicatedBuilderStatus
+ requested_by: string | null
+ use_case: string | null
+ monthly_builds_estimate: number | null
+ platforms: string[]
+ allow_shared_fallback: boolean
+ pool_id: string | null
+ worker_name: string | null
+ worker_status: DedicatedWorkerStatus
+ worker_current_job_id: string | null
+ worker_last_seen_at: string | null
+ activated_at: string | null
+ suspended_at: string | null
+ cancelled_at: string | null
+ created_at: string
+ updated_at: string
+ active_dedicated_builds: number
+}
+
+interface DedicatedBuilderResponse {
+ dedicated_builder: DedicatedBuilder | null
+ status?: string
+}
+
+export class DedicatedBuilderApiError extends Error {
+ code: string
+ status: number
+
+ constructor(code: string, status: number, message?: string) {
+ super(message || code)
+ this.name = 'DedicatedBuilderApiError'
+ this.code = code
+ this.status = status
+ }
+}
+
+async function getAuthHeaders(): Promise> {
+ const supabase = useSupabase()
+ const { data: currentSession } = await supabase.auth.getSession()
+ if (!currentSession.session)
+ throw new Error('Not authenticated')
+
+ return {
+ 'Content-Type': 'application/json',
+ 'authorization': `Bearer ${currentSession.session.access_token}`,
+ }
+}
+
+async function normalizeInvokeError(error: unknown): Promise {
+ if (error instanceof FunctionsHttpError && error.context instanceof Response) {
+ let code = 'request_failed'
+ try {
+ const payload = await error.context.clone().json() as { error?: string, status?: string }
+ code = payload.error || payload.status || code
+ }
+ catch {
+ // keep default code
+ }
+ throw new DedicatedBuilderApiError(code, error.context.status, code)
+ }
+ throw error
+}
+
+export async function fetchDedicatedBuilder(orgId: string): Promise {
+ const supabase = useSupabase()
+ const { data, error } = await supabase.functions.invoke(
+ `private/dedicated_builder/${orgId}`,
+ { method: 'GET' },
+ )
+ if (error)
+ await normalizeInvokeError(error)
+ return data?.dedicated_builder ?? null
+}
+
+export async function requestDedicatedBuilder(input: {
+ orgId: string
+ useCase?: string
+ monthlyBuildsEstimate?: number | null
+ platforms?: string[]
+}): Promise {
+ const supabase = useSupabase()
+ const { data, error } = await supabase.functions.invoke(
+ 'private/dedicated_builder',
+ {
+ method: 'POST',
+ body: {
+ org_id: input.orgId,
+ use_case: input.useCase || undefined,
+ monthly_builds_estimate: input.monthlyBuildsEstimate ?? undefined,
+ platforms: input.platforms,
+ },
+ },
+ )
+ if (error)
+ await normalizeInvokeError(error)
+ if (!data?.dedicated_builder)
+ throw new Error('Missing dedicated builder in response')
+ return data.dedicated_builder
+}
+
+export async function updateDedicatedBuilder(
+ orgId: string,
+ body: { allow_shared_fallback?: boolean, cancel?: boolean },
+): Promise {
+ // Keep auth headers explicit for PATCH through functions.invoke compatibility.
+ const headers = await getAuthHeaders()
+ const supabase = useSupabase()
+ const { data, error } = await supabase.functions.invoke(
+ `private/dedicated_builder/${orgId}`,
+ {
+ method: 'PATCH',
+ headers,
+ body,
+ },
+ )
+ if (error)
+ await normalizeInvokeError(error)
+ if (!data?.dedicated_builder)
+ throw new Error('Missing dedicated builder in response')
+ return data.dedicated_builder
+}
diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts
index e87a7d74b5..269b1e3c4f 100644
--- a/src/types/supabase.types.ts
+++ b/src/types/supabase.types.ts
@@ -591,6 +591,7 @@ export type Database = {
build_config: Json | null
build_mode: string
builder_job_id: string | null
+ builder_pool: string | null
created_at: string
id: string
last_error: string | null
@@ -611,6 +612,7 @@ export type Database = {
build_config?: Json | null
build_mode?: string
builder_job_id?: string | null
+ builder_pool?: string | null
created_at?: string
id?: string
last_error?: string | null
@@ -631,6 +633,7 @@ export type Database = {
build_config?: Json | null
build_mode?: string
builder_job_id?: string | null
+ builder_pool?: string | null
created_at?: string
id?: string
last_error?: string | null
@@ -1371,6 +1374,85 @@ export type Database = {
}
Relationships: []
}
+
+ dedicated_builders: {
+ Row: {
+ activated_at: string | null
+ allow_shared_fallback: boolean
+ cancelled_at: string | null
+ created_at: string
+ id: string
+ monthly_builds_estimate: number | null
+ org_id: string
+ platforms: string[]
+ pool_id: string | null
+ requested_by: string | null
+ status: string
+ suspended_at: string | null
+ updated_at: string
+ use_case: string | null
+ worker_current_job_id: string | null
+ worker_last_seen_at: string | null
+ worker_name: string | null
+ worker_status: string
+ }
+ Insert: {
+ activated_at?: string | null
+ allow_shared_fallback?: boolean
+ cancelled_at?: string | null
+ created_at?: string
+ id?: string
+ monthly_builds_estimate?: number | null
+ org_id: string
+ platforms?: string[]
+ pool_id?: string | null
+ requested_by?: string | null
+ status?: string
+ suspended_at?: string | null
+ updated_at?: string
+ use_case?: string | null
+ worker_current_job_id?: string | null
+ worker_last_seen_at?: string | null
+ worker_name?: string | null
+ worker_status?: string
+ }
+ Update: {
+ activated_at?: string | null
+ allow_shared_fallback?: boolean
+ cancelled_at?: string | null
+ created_at?: string
+ id?: string
+ monthly_builds_estimate?: number | null
+ org_id?: string
+ platforms?: string[]
+ pool_id?: string | null
+ requested_by?: string | null
+ status?: string
+ suspended_at?: string | null
+ updated_at?: string
+ use_case?: string | null
+ worker_current_job_id?: string | null
+ worker_last_seen_at?: string | null
+ worker_name?: string | null
+ worker_status?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "dedicated_builders_org_id_fkey"
+ columns: ["org_id"]
+ isOneToOne: true
+ referencedRelation: "orgs"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "dedicated_builders_requested_by_fkey"
+ columns: ["requested_by"]
+ isOneToOne: false
+ referencedRelation: "users"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
deploy_history: {
Row: {
app_id: string
diff --git a/supabase/functions/_backend/private/dedicated_builder.ts b/supabase/functions/_backend/private/dedicated_builder.ts
new file mode 100644
index 0000000000..3fbae6a36a
--- /dev/null
+++ b/supabase/functions/_backend/private/dedicated_builder.ts
@@ -0,0 +1,348 @@
+import type { Context } from 'hono'
+import type { MiddlewareKeyVariables } from '../utils/hono.ts'
+import { type } from 'arktype'
+import { safeParseSchema } from '../utils/ark_validation.ts'
+import {
+ countActiveDedicatedBuilds,
+ getDedicatedBuilderForOrg,
+ publicDedicatedBuilderView,
+} from '../utils/dedicated_builder.ts'
+import { sendDiscordAlert } from '../utils/discord.ts'
+import { BRES, createHono, middlewareAuth, parseBody, quickError, simpleError, useCors } from '../utils/hono.ts'
+import { cloudlog, cloudlogErr } from '../utils/logging.ts'
+import { checkPermission } from '../utils/rbac.ts'
+import { supabaseAdmin } from '../utils/supabase.ts'
+import { sendEventToTracking } from '../utils/tracking.ts'
+import { version } from '../utils/version.ts'
+
+const uuidSchema = type('string.uuid')
+
+const requestBodySchema = type({
+ 'org_id': 'string.uuid',
+ 'use_case?': 'string <= 2000',
+ 'monthly_builds_estimate?': 'number.integer >= 0',
+ 'platforms?': 'string[]',
+})
+
+const patchBodySchema = type({
+ 'allow_shared_fallback?': 'boolean',
+ 'cancel?': 'boolean',
+})
+
+const ALLOWED_PLATFORMS = new Set(['ios', 'android'])
+
+export const app = createHono('', version)
+
+app.use('*', useCors)
+
+async function requireReadBilling(c: Context, orgId: string) {
+ const allowed = await checkPermission(c, 'org.read_billing' as any, { orgId })
+ if (!allowed)
+ throw quickError(403, 'not_authorized', 'Not authorized to view dedicated builder')
+}
+
+async function requireUpdateBilling(c: Context, orgId: string) {
+ const allowed = await checkPermission(c, 'org.update_billing' as any, { orgId })
+ if (!allowed)
+ throw quickError(403, 'not_authorized', 'Not authorized to manage dedicated builder')
+}
+
+function normalizePlatforms(platforms: string[] | undefined): string[] {
+ if (!platforms || platforms.length === 0)
+ return ['ios', 'android']
+
+ const normalized = [...new Set(platforms.map(p => p.trim().toLowerCase()).filter(Boolean))]
+ if (normalized.length === 0)
+ return ['ios', 'android']
+
+ for (const platform of normalized) {
+ if (!ALLOWED_PLATFORMS.has(platform))
+ throw simpleError('invalid_body', `Unsupported platform "${platform}". Allowed: ios, android`)
+ }
+ return normalized
+}
+
+async function loadOrgName(c: Context, orgId: string): Promise {
+ const { data } = await supabaseAdmin(c)
+ .from('orgs')
+ .select('name')
+ .eq('id', orgId)
+ .maybeSingle()
+ return data?.name ?? orgId
+}
+
+async function notifyDedicatedBuilderRequested(
+ c: Context,
+ input: {
+ orgId: string
+ orgName: string
+ userId: string
+ useCase: string | null
+ monthlyBuildsEstimate: number | null
+ platforms: string[]
+ },
+) {
+ try {
+ await sendDiscordAlert(c, {
+ content: '🏗️ **Dedicated builder requested**',
+ embeds: [
+ {
+ title: 'Dedicated native builder request',
+ color: 0x119EFF,
+ fields: [
+ { name: 'Organization', value: `${input.orgName}\n\`${input.orgId}\``, inline: false },
+ { name: 'Requested by', value: `\`${input.userId}\``, inline: true },
+ { name: 'Platforms', value: input.platforms.join(', ') || 'ios, android', inline: true },
+ {
+ name: 'Monthly builds estimate',
+ value: input.monthlyBuildsEstimate != null ? String(input.monthlyBuildsEstimate) : 'not provided',
+ inline: true,
+ },
+ {
+ name: 'Use case',
+ value: (input.useCase?.trim() || 'not provided').slice(0, 1000),
+ inline: false,
+ },
+ ],
+ },
+ ],
+ })
+ }
+ catch (error) {
+ cloudlogErr({
+ requestId: c.get('requestId'),
+ message: 'Failed to send dedicated builder Discord alert',
+ error: (error as Error)?.message,
+ })
+ }
+
+ try {
+ await sendEventToTracking(c, {
+ event: 'Dedicated Builder Requested',
+ channel: 'build-lifecycle',
+ icon: '🏗️',
+ notify: false,
+ user_id: input.userId,
+ groups: { organization: input.orgId },
+ tags: {
+ org_id: input.orgId,
+ platforms: input.platforms.join(','),
+ ...(input.monthlyBuildsEstimate != null
+ ? { monthly_builds_estimate: input.monthlyBuildsEstimate }
+ : {}),
+ },
+ })
+ }
+ catch (error) {
+ cloudlogErr({
+ requestId: c.get('requestId'),
+ message: 'Failed to track dedicated builder request',
+ error: (error as Error)?.message,
+ })
+ }
+}
+
+// GET /private/dedicated_builder/:orgId
+app.get('/:orgId', middlewareAuth, async (c) => {
+ const orgIdResult = uuidSchema(c.req.param('orgId'))
+ if (orgIdResult instanceof type.errors)
+ throw simpleError('invalid_org_id', 'Invalid organization id')
+
+ const orgId = orgIdResult
+ await requireReadBilling(c, orgId)
+
+ const row = await getDedicatedBuilderForOrg(c, orgId)
+ if (!row)
+ return c.json({ dedicated_builder: null }, 200)
+
+ const activeDedicatedBuilds = await countActiveDedicatedBuilds(c, orgId)
+ return c.json({
+ dedicated_builder: publicDedicatedBuilderView(row, activeDedicatedBuilds),
+ }, 200)
+})
+
+// POST /private/dedicated_builder — request a dedicated builder
+app.post('/', middlewareAuth, async (c) => {
+ const auth = c.get('auth')!
+ const body = await parseBody(c)
+ const parsed = safeParseSchema(requestBodySchema, body)
+ if (!parsed.success)
+ throw simpleError('invalid_body', 'Invalid request body', { body, parsed })
+
+ const orgId = parsed.data.org_id
+ await requireUpdateBilling(c, orgId)
+
+ const platforms = normalizePlatforms(parsed.data.platforms)
+ const useCase = parsed.data.use_case?.trim() || null
+ const monthlyBuildsEstimate = parsed.data.monthly_builds_estimate ?? null
+
+ const existing = await getDedicatedBuilderForOrg(c, orgId)
+ if (existing && existing.status !== 'cancelled') {
+ throw quickError(409, 'dedicated_builder_exists', 'A dedicated builder request already exists for this organization', {
+ status: existing.status,
+ })
+ }
+
+ let row
+ if (existing?.status === 'cancelled') {
+ const { data, error } = await supabaseAdmin(c)
+ .from('dedicated_builders')
+ .update({
+ status: 'requested',
+ requested_by: auth.userId,
+ use_case: useCase,
+ monthly_builds_estimate: monthlyBuildsEstimate,
+ platforms,
+ allow_shared_fallback: true,
+ pool_id: null,
+ worker_name: null,
+ worker_status: 'unknown',
+ worker_current_job_id: null,
+ worker_last_seen_at: null,
+ activated_at: null,
+ suspended_at: null,
+ cancelled_at: null,
+ })
+ .eq('id', existing.id)
+ .select('*')
+ .single()
+
+ if (error || !data) {
+ cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to re-request dedicated builder', error })
+ throw simpleError('internal_error', 'Unable to request dedicated builder')
+ }
+ row = data
+ }
+ else {
+ const { data, error } = await supabaseAdmin(c)
+ .from('dedicated_builders')
+ .insert({
+ org_id: orgId,
+ status: 'requested',
+ requested_by: auth.userId,
+ use_case: useCase,
+ monthly_builds_estimate: monthlyBuildsEstimate,
+ platforms,
+ allow_shared_fallback: true,
+ })
+ .select('*')
+ .single()
+
+ if (error || !data) {
+ cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to create dedicated builder request', error })
+ throw simpleError('internal_error', 'Unable to request dedicated builder')
+ }
+ row = data
+ }
+
+ const orgName = await loadOrgName(c, orgId)
+ await notifyDedicatedBuilderRequested(c, {
+ orgId,
+ orgName,
+ userId: auth.userId,
+ useCase,
+ monthlyBuildsEstimate,
+ platforms,
+ })
+
+ cloudlog({
+ requestId: c.get('requestId'),
+ message: 'Dedicated builder requested',
+ org_id: orgId,
+ dedicated_builder_id: row.id,
+ })
+
+ return c.json({
+ dedicated_builder: publicDedicatedBuilderView(row, 0),
+ }, 200)
+})
+
+async function cancelDedicatedBuilderRequest(
+ c: Context,
+ existing: NonNullable>>,
+) {
+ if (existing.status !== 'requested' && existing.status !== 'provisioning') {
+ throw quickError(400, 'cannot_cancel', 'Only requested or provisioning dedicated builders can be cancelled from the console')
+ }
+
+ const { data, error } = await supabaseAdmin(c)
+ .from('dedicated_builders')
+ .update({
+ status: 'cancelled',
+ cancelled_at: new Date().toISOString(),
+ })
+ .eq('id', existing.id)
+ .select('*')
+ .single()
+
+ if (error || !data) {
+ cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to cancel dedicated builder', error })
+ throw simpleError('internal_error', 'Unable to cancel dedicated builder request')
+ }
+
+ return c.json({
+ dedicated_builder: publicDedicatedBuilderView(data, 0),
+ ...BRES,
+ }, 200)
+}
+
+async function updateDedicatedBuilderFallback(
+ c: Context,
+ orgId: string,
+ existing: NonNullable>>,
+ allowSharedFallback: boolean,
+) {
+ if (existing.status !== 'active' && existing.status !== 'provisioning' && existing.status !== 'requested') {
+ throw quickError(400, 'invalid_status', 'Fallback can only be changed while the dedicated builder is active or pending')
+ }
+
+ const { data, error } = await supabaseAdmin(c)
+ .from('dedicated_builders')
+ .update({
+ allow_shared_fallback: allowSharedFallback,
+ })
+ .eq('id', existing.id)
+ .select('*')
+ .single()
+
+ if (error || !data) {
+ cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to update dedicated builder fallback', error })
+ throw simpleError('internal_error', 'Unable to update dedicated builder')
+ }
+
+ const activeDedicatedBuilds = await countActiveDedicatedBuilds(c, orgId)
+ return c.json({
+ dedicated_builder: publicDedicatedBuilderView(data, activeDedicatedBuilds),
+ ...BRES,
+ }, 200)
+}
+
+// PATCH /private/dedicated_builder/:orgId — toggle fallback or cancel a pending request
+app.patch('/:orgId', middlewareAuth, async (c) => {
+ const orgIdResult = uuidSchema(c.req.param('orgId'))
+ if (orgIdResult instanceof type.errors)
+ throw simpleError('invalid_org_id', 'Invalid organization id')
+
+ const orgId = orgIdResult
+ await requireUpdateBilling(c, orgId)
+
+ const body = await parseBody(c)
+ const parsed = safeParseSchema(patchBodySchema, body)
+ if (!parsed.success)
+ throw simpleError('invalid_body', 'Invalid request body', { body, parsed })
+
+ if (parsed.data.allow_shared_fallback === undefined && parsed.data.cancel === undefined)
+ throw simpleError('invalid_body', 'Nothing to update')
+
+ const existing = await getDedicatedBuilderForOrg(c, orgId)
+ if (!existing)
+ throw quickError(404, 'not_found', 'No dedicated builder found for this organization')
+
+ if (parsed.data.cancel)
+ return cancelDedicatedBuilderRequest(c, existing)
+
+ if (parsed.data.allow_shared_fallback !== undefined)
+ return updateDedicatedBuilderFallback(c, orgId, existing, parsed.data.allow_shared_fallback)
+
+ throw simpleError('invalid_body', 'Nothing to update')
+})
diff --git a/supabase/functions/_backend/public/build/request.ts b/supabase/functions/_backend/public/build/request.ts
index 2541190d19..6b337aa24a 100644
--- a/supabase/functions/_backend/public/build/request.ts
+++ b/supabase/functions/_backend/public/build/request.ts
@@ -1,5 +1,7 @@
import type { Context } from 'hono'
+import type { DedicatedPoolRouting } from '../../utils/dedicated_builder.ts'
import type { Database } from '../../utils/supabase.types.ts'
+import { getDedicatedBuilderForOrg, toDedicatedPoolRouting } from '../../utils/dedicated_builder.ts'
import { quickError, simpleError } from '../../utils/hono.ts'
import { cloudlog, cloudlogErr, serializeError } from '../../utils/logging.ts'
import { checkPermission } from '../../utils/rbac.ts'
@@ -58,11 +60,12 @@ export function buildBuilderPayload(input: {
platform: string
buildOptions: Record
buildCredentials: Record
+ poolRouting?: DedicatedPoolRouting | null
}) {
const buildOptions = { ...input.buildOptions }
delete buildOptions.timeoutSeconds
- return {
+ const payload: Record = {
// userId carries the org_id (anonymized owner) — kept for backwards compat.
userId: input.orgId,
// actorUserId is the human user who triggered the build (apikey.user_id). The builder
@@ -73,6 +76,17 @@ export function buildBuilderPayload(input: {
buildOptions,
buildCredentials: input.buildCredentials,
}
+
+ // Prefer the org dedicated pool when active. Builder may fall back to shared
+ // when the dedicated worker is busy/offline and allowSharedFallback is true.
+ if (input.poolRouting?.preferDedicated) {
+ payload.poolPreference = 'dedicated'
+ payload.allowSharedFallback = input.poolRouting.allowSharedFallback
+ if (input.poolRouting.poolId)
+ payload.poolId = input.poolRouting.poolId
+ }
+
+ return payload
}
/** Exported for unit tests — follows bundleUsageTestUtils pattern. */
@@ -237,8 +251,9 @@ async function createBuilderJob(c: Context, input: {
uploadPath: string
buildOptions: Record
buildCredentials: Record
+ poolRouting?: DedicatedPoolRouting | null
}): Promise {
- const { builderUrl, builderApiKey, orgId, actorUserId, appId, platform, uploadPath, buildOptions, buildCredentials } = input
+ const { builderUrl, builderApiKey, orgId, actorUserId, appId, platform, uploadPath, buildOptions, buildCredentials, poolRouting } = input
cloudlog({
requestId: c.get('requestId'),
message: 'Calling builder API',
@@ -247,6 +262,8 @@ async function createBuilderJob(c: Context, input: {
app_id: appId,
platform,
artifact_key: uploadPath,
+ pool_preference: poolRouting?.preferDedicated ? 'dedicated' : 'shared',
+ allow_shared_fallback: poolRouting?.allowSharedFallback ?? true,
})
try {
@@ -263,6 +280,7 @@ async function createBuilderJob(c: Context, input: {
platform,
buildOptions,
buildCredentials,
+ poolRouting,
})),
})
@@ -346,6 +364,7 @@ async function persistBuildRequest(c: Context, input: {
upload_path: string
upload_url: string
upload_expires_at: Date
+ builder_pool: 'dedicated' | 'shared' | null
}) {
const {
app_id,
@@ -359,6 +378,7 @@ async function persistBuildRequest(c: Context, input: {
upload_path,
upload_url,
upload_expires_at,
+ builder_pool,
} = input
const supabaseAdminClient = supabaseAdmin(c)
@@ -377,6 +397,7 @@ async function persistBuildRequest(c: Context, input: {
upload_path,
upload_url,
upload_expires_at: upload_expires_at.toISOString(),
+ builder_pool,
})
.select('*')
.single()
@@ -457,6 +478,21 @@ export async function requestBuild(
const upload_session_key = crypto.randomUUID()
const upload_path = `orgs/${org_id}/apps/${app_id}/native-builds/${upload_session_key}.zip`
const { builderUrl, builderApiKey } = getBuilderConfig(c)
+
+ let poolRouting: DedicatedPoolRouting | null = null
+ try {
+ poolRouting = toDedicatedPoolRouting(await getDedicatedBuilderForOrg(c, org_id))
+ }
+ catch (error) {
+ // Prefer shared pool over failing the build if dedicated lookup fails.
+ cloudlogErr({
+ requestId: c.get('requestId'),
+ message: 'Dedicated builder lookup failed; continuing on shared pool',
+ org_id,
+ error: serializeError(error),
+ })
+ }
+
const builderJob = await createBuilderJob(c, {
builderUrl,
builderApiKey,
@@ -467,6 +503,7 @@ export async function requestBuild(
uploadPath: upload_path,
buildOptions: build_options,
buildCredentials: build_credentials,
+ poolRouting,
})
ensureBuilderUploadUrl(c, builderUrl, builderApiKey, builderJob)
@@ -492,6 +529,9 @@ export async function requestBuild(
upload_path,
upload_url,
upload_expires_at,
+ // Preferred pool at request time. Actual assignment may fall back to shared
+ // when the dedicated worker is busy/offline and allowSharedFallback is true.
+ builder_pool: poolRouting?.preferDedicated ? 'dedicated' : 'shared',
})
cloudlog({
diff --git a/supabase/functions/_backend/utils/dedicated_builder.ts b/supabase/functions/_backend/utils/dedicated_builder.ts
new file mode 100644
index 0000000000..21ce7c6ba6
--- /dev/null
+++ b/supabase/functions/_backend/utils/dedicated_builder.ts
@@ -0,0 +1,139 @@
+import type { Context } from 'hono'
+import type { Database } from './supabase.types.ts'
+import { cloudlogErr } from './logging.ts'
+import { supabaseAdmin } from './supabase.ts'
+
+export type DedicatedBuilderStatus = 'requested' | 'provisioning' | 'active' | 'suspended' | 'cancelled'
+export type DedicatedWorkerStatus = 'unknown' | 'idle' | 'busy' | 'offline'
+export type BuilderPool = 'dedicated' | 'shared'
+
+export type DedicatedBuilderRow = Database['public']['Tables']['dedicated_builders']['Row']
+
+export interface DedicatedPoolRouting {
+ preferDedicated: boolean
+ allowSharedFallback: boolean
+ poolId: string | null
+ workerName: string | null
+}
+
+/** Statuses that mean a dedicated worker is currently occupied. */
+const ACTIVE_DEDICATED_BUILD_STATUSES = ['starting', 'running'] as const
+
+export function isDedicatedBuilderActive(row: Pick | null | undefined): boolean {
+ return row?.status === 'active'
+}
+
+export async function getDedicatedBuilderForOrg(
+ c: Context,
+ orgId: string,
+): Promise {
+ const { data, error } = await supabaseAdmin(c)
+ .from('dedicated_builders')
+ .select('*')
+ .eq('org_id', orgId)
+ .maybeSingle()
+
+ if (error) {
+ cloudlogErr({
+ requestId: c.get('requestId'),
+ message: 'Failed to load dedicated builder',
+ org_id: orgId,
+ error: error.message,
+ })
+ throw error
+ }
+
+ return data
+}
+
+export function toDedicatedPoolRouting(row: DedicatedBuilderRow | null): DedicatedPoolRouting | null {
+ if (!isDedicatedBuilderActive(row) || !row)
+ return null
+
+ return {
+ preferDedicated: true,
+ allowSharedFallback: row.allow_shared_fallback,
+ poolId: row.pool_id,
+ workerName: row.worker_name,
+ }
+}
+
+export async function countActiveDedicatedBuilds(
+ c: Context,
+ orgId: string,
+): Promise {
+ const { count, error } = await supabaseAdmin(c)
+ .from('build_requests')
+ .select('id', { count: 'exact', head: true })
+ .eq('owner_org', orgId)
+ .eq('builder_pool', 'dedicated')
+ .in('status', [...ACTIVE_DEDICATED_BUILD_STATUSES])
+
+ if (error) {
+ cloudlogErr({
+ requestId: c.get('requestId'),
+ message: 'Failed to count active dedicated builds',
+ org_id: orgId,
+ error: error.message,
+ })
+ // Do not invent idle/zero — callers must surface the failure.
+ throw error
+ }
+
+ return count ?? 0
+}
+
+/**
+ * Derive a customer-facing worker status from stored state + active dedicated jobs.
+ *
+ * `builder_pool` on build_requests is the preferred pool at request time. When
+ * shared fallback is enabled, a preferred-dedicated job may still run on shared,
+ * so busy must not be inferred from that count alone — trust explicit worker
+ * heartbeats (`worker_status`) instead. When fallback is disabled, preferred
+ * dedicated jobs must use the dedicated worker, so the count is reliable.
+ */
+export function deriveWorkerStatus(
+ row: DedicatedBuilderRow,
+ activeDedicatedBuilds: number,
+): DedicatedWorkerStatus {
+ if (row.status !== 'active')
+ return 'unknown'
+ if (row.worker_status === 'offline')
+ return 'offline'
+ if (row.allow_shared_fallback) {
+ if (row.worker_status === 'busy' || row.worker_status === 'idle')
+ return row.worker_status
+ return 'unknown'
+ }
+ if (activeDedicatedBuilds > 0)
+ return 'busy'
+ return 'idle'
+}
+
+export function publicDedicatedBuilderView(
+ row: DedicatedBuilderRow,
+ activeDedicatedBuilds: number,
+) {
+ const workerStatus = deriveWorkerStatus(row, activeDedicatedBuilds)
+ return {
+ id: row.id,
+ org_id: row.org_id,
+ status: row.status as DedicatedBuilderStatus,
+ requested_by: row.requested_by,
+ use_case: row.use_case,
+ monthly_builds_estimate: row.monthly_builds_estimate,
+ platforms: row.platforms ?? [],
+ allow_shared_fallback: row.allow_shared_fallback,
+ pool_id: row.pool_id,
+ worker_name: row.worker_name,
+ worker_status: workerStatus,
+ worker_current_job_id: workerStatus === 'busy' ? row.worker_current_job_id : null,
+ worker_last_seen_at: row.worker_last_seen_at,
+ activated_at: row.activated_at,
+ suspended_at: row.suspended_at,
+ cancelled_at: row.cancelled_at,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ active_dedicated_builds: activeDedicatedBuilds,
+ }
+}
diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts
index e87a7d74b5..269b1e3c4f 100644
--- a/supabase/functions/_backend/utils/supabase.types.ts
+++ b/supabase/functions/_backend/utils/supabase.types.ts
@@ -591,6 +591,7 @@ export type Database = {
build_config: Json | null
build_mode: string
builder_job_id: string | null
+ builder_pool: string | null
created_at: string
id: string
last_error: string | null
@@ -611,6 +612,7 @@ export type Database = {
build_config?: Json | null
build_mode?: string
builder_job_id?: string | null
+ builder_pool?: string | null
created_at?: string
id?: string
last_error?: string | null
@@ -631,6 +633,7 @@ export type Database = {
build_config?: Json | null
build_mode?: string
builder_job_id?: string | null
+ builder_pool?: string | null
created_at?: string
id?: string
last_error?: string | null
@@ -1371,6 +1374,85 @@ export type Database = {
}
Relationships: []
}
+
+ dedicated_builders: {
+ Row: {
+ activated_at: string | null
+ allow_shared_fallback: boolean
+ cancelled_at: string | null
+ created_at: string
+ id: string
+ monthly_builds_estimate: number | null
+ org_id: string
+ platforms: string[]
+ pool_id: string | null
+ requested_by: string | null
+ status: string
+ suspended_at: string | null
+ updated_at: string
+ use_case: string | null
+ worker_current_job_id: string | null
+ worker_last_seen_at: string | null
+ worker_name: string | null
+ worker_status: string
+ }
+ Insert: {
+ activated_at?: string | null
+ allow_shared_fallback?: boolean
+ cancelled_at?: string | null
+ created_at?: string
+ id?: string
+ monthly_builds_estimate?: number | null
+ org_id: string
+ platforms?: string[]
+ pool_id?: string | null
+ requested_by?: string | null
+ status?: string
+ suspended_at?: string | null
+ updated_at?: string
+ use_case?: string | null
+ worker_current_job_id?: string | null
+ worker_last_seen_at?: string | null
+ worker_name?: string | null
+ worker_status?: string
+ }
+ Update: {
+ activated_at?: string | null
+ allow_shared_fallback?: boolean
+ cancelled_at?: string | null
+ created_at?: string
+ id?: string
+ monthly_builds_estimate?: number | null
+ org_id?: string
+ platforms?: string[]
+ pool_id?: string | null
+ requested_by?: string | null
+ status?: string
+ suspended_at?: string | null
+ updated_at?: string
+ use_case?: string | null
+ worker_current_job_id?: string | null
+ worker_last_seen_at?: string | null
+ worker_name?: string | null
+ worker_status?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "dedicated_builders_org_id_fkey"
+ columns: ["org_id"]
+ isOneToOne: true
+ referencedRelation: "orgs"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "dedicated_builders_requested_by_fkey"
+ columns: ["requested_by"]
+ isOneToOne: false
+ referencedRelation: "users"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
deploy_history: {
Row: {
app_id: string
diff --git a/supabase/functions/private/index.ts b/supabase/functions/private/index.ts
index b6c3e12980..0747e85e2f 100644
--- a/supabase/functions/private/index.ts
+++ b/supabase/functions/private/index.ts
@@ -8,6 +8,7 @@ import { app as config } from '../_backend/private/config.ts'
import { app as configBuilder } from '../_backend/private/config_builder.ts'
import { app as create_device } from '../_backend/private/create_device.ts'
import { app as credits } from '../_backend/private/credits.ts'
+import { app as dedicated_builder } from '../_backend/private/dedicated_builder.ts'
import { app as deleted_failed_version } from '../_backend/private/delete_failed_version.ts'
import { app as devices_priv } from '../_backend/private/devices.ts'
import { app as download_link } from '../_backend/private/download_link.ts'
@@ -50,6 +51,7 @@ const appGlobal = createHono(functionName, version)
appGlobal.route('/plans', plans)
appGlobal.route('/credits', credits)
+appGlobal.route('/dedicated_builder', dedicated_builder)
appGlobal.route('/store_top', storeTop)
appGlobal.route('/website_stats', publicStats)
appGlobal.route('/config', config)
diff --git a/supabase/migrations/20260723144749_dedicated_native_builder.sql b/supabase/migrations/20260723144749_dedicated_native_builder.sql
new file mode 100644
index 0000000000..0c4e102d30
--- /dev/null
+++ b/supabase/migrations/20260723144749_dedicated_native_builder.sql
@@ -0,0 +1,155 @@
+-- Dedicated native builders: org-scoped request + provisioning state.
+-- Actual runner routing lives in the external builder service; Capgo stores
+-- preference, fallback policy, and customer-facing status.
+
+ALTER TABLE "public"."build_requests"
+ ADD COLUMN IF NOT EXISTS "builder_pool" text;
+
+COMMENT ON COLUMN "public"."build_requests"."builder_pool" IS
+ 'Preferred builder pool at request time (dedicated or shared). '
+ 'May differ from the pool that actually ran the job when shared '
+ 'fallback is enabled. Null for legacy rows.';
+
+ALTER TABLE "public"."build_requests"
+ DROP CONSTRAINT IF EXISTS "build_requests_builder_pool_check";
+
+-- NOT VALID: avoid a blocking ACCESS EXCLUSIVE full scan of
+-- build_requests on apply. New/updated rows are still checked;
+-- validate later in a maintenance window if desired.
+ALTER TABLE "public"."build_requests"
+ ADD CONSTRAINT "build_requests_builder_pool_check"
+ CHECK (
+ (
+ "builder_pool" IS NULL
+ OR "builder_pool" = ANY (
+ ARRAY['dedicated'::text, 'shared'::text]
+ )
+ )
+ )
+ NOT VALID;
+
+CREATE INDEX IF NOT EXISTS "idx_build_requests_org_pool_status"
+ ON "public"."build_requests" USING "btree" ("owner_org", "builder_pool", "status");
+
+CREATE TABLE IF NOT EXISTS "public"."dedicated_builders" (
+ "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
+ "org_id" "uuid" NOT NULL,
+ "status" "text" DEFAULT 'requested'::"text" NOT NULL,
+ "requested_by" "uuid",
+ "use_case" "text",
+ "monthly_builds_estimate" integer,
+ "platforms" "text"[] DEFAULT ARRAY[]::"text"[] NOT NULL,
+ "allow_shared_fallback" boolean DEFAULT true NOT NULL,
+ "pool_id" "text",
+ "worker_name" "text",
+ "worker_status" "text" DEFAULT 'unknown'::"text" NOT NULL,
+ "worker_current_job_id" "text",
+ "worker_last_seen_at" timestamp with time zone,
+ "activated_at" timestamp with time zone,
+ "suspended_at" timestamp with time zone,
+ "cancelled_at" timestamp with time zone,
+ "created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL,
+ CONSTRAINT "dedicated_builders_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "dedicated_builders_org_id_key" UNIQUE ("org_id"),
+ CONSTRAINT "dedicated_builders_org_id_fkey"
+ FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE CASCADE,
+ CONSTRAINT "dedicated_builders_requested_by_fkey"
+ FOREIGN KEY ("requested_by") REFERENCES "auth"."users"("id") ON DELETE SET NULL,
+ CONSTRAINT "dedicated_builders_status_check" CHECK (
+ ("status" = ANY (ARRAY[
+ 'requested'::"text",
+ 'provisioning'::"text",
+ 'active'::"text",
+ 'suspended'::"text",
+ 'cancelled'::"text"
+ ]))
+ ),
+ CONSTRAINT "dedicated_builders_worker_status_check" CHECK (
+ ("worker_status" = ANY (ARRAY[
+ 'unknown'::"text",
+ 'idle'::"text",
+ 'busy'::"text",
+ 'offline'::"text"
+ ]))
+ ),
+ CONSTRAINT "dedicated_builders_monthly_builds_estimate_check" CHECK (
+ ("monthly_builds_estimate" IS NULL OR "monthly_builds_estimate" >= 0)
+ ),
+ CONSTRAINT "dedicated_builders_use_case_length_check" CHECK (
+ ("use_case" IS NULL OR "char_length"("use_case") <= 2000)
+ ),
+ CONSTRAINT "dedicated_builders_pool_id_length_check" CHECK (
+ ("pool_id" IS NULL OR "char_length"("pool_id") <= 128)
+ ),
+ CONSTRAINT "dedicated_builders_worker_name_length_check" CHECK (
+ ("worker_name" IS NULL OR "char_length"("worker_name") <= 128)
+ ),
+ CONSTRAINT "dedicated_builders_platforms_check" CHECK (
+ ("platforms" <@ ARRAY['ios'::text, 'android'::text])
+ )
+);
+
+ALTER TABLE "public"."dedicated_builders" OWNER TO "postgres";
+
+COMMENT ON TABLE "public"."dedicated_builders" IS
+ 'Org-scoped dedicated native builder requests and provisioning state. Mutations go through the private API (service_role).';
+
+COMMENT ON COLUMN "public"."dedicated_builders"."status" IS
+ 'requested → provisioning → active (or suspended/cancelled). Capgo ops advances status after provisioning the worker.';
+
+COMMENT ON COLUMN "public"."dedicated_builders"."allow_shared_fallback" IS
+ 'When true, builds may use the shared Capgo pool if the dedicated worker is busy or offline.';
+
+COMMENT ON COLUMN "public"."dedicated_builders"."pool_id" IS
+ 'External builder pool identifier used when routing jobs to this org dedicated worker.';
+
+CREATE INDEX IF NOT EXISTS "idx_dedicated_builders_status"
+ ON "public"."dedicated_builders" USING "btree" ("status");
+
+CREATE OR REPLACE TRIGGER "handle_dedicated_builders_updated_at"
+ BEFORE UPDATE ON "public"."dedicated_builders"
+ FOR EACH ROW
+ EXECUTE FUNCTION "extensions"."moddatetime"('updated_at');
+
+ALTER TABLE "public"."dedicated_builders" ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Allow service_role full access to dedicated_builders"
+ON "public"."dedicated_builders"
+TO "service_role"
+USING (true)
+WITH CHECK (true);
+
+-- Clients must use the private API; deny PostgREST direct access.
+CREATE POLICY "deny_direct_select_on_dedicated_builders"
+ON "public"."dedicated_builders"
+AS RESTRICTIVE
+FOR SELECT
+TO "anon", "authenticated"
+USING (false);
+
+CREATE POLICY "deny_direct_insert_on_dedicated_builders"
+ON "public"."dedicated_builders"
+AS RESTRICTIVE
+FOR INSERT
+TO "anon", "authenticated"
+WITH CHECK (false);
+
+CREATE POLICY "deny_direct_update_on_dedicated_builders"
+ON "public"."dedicated_builders"
+AS RESTRICTIVE
+FOR UPDATE
+TO "anon", "authenticated"
+USING (false)
+WITH CHECK (false);
+
+CREATE POLICY "deny_direct_delete_on_dedicated_builders"
+ON "public"."dedicated_builders"
+AS RESTRICTIVE
+FOR DELETE
+TO "anon", "authenticated"
+USING (false);
+
+GRANT ALL ON TABLE "public"."dedicated_builders" TO "anon";
+GRANT ALL ON TABLE "public"."dedicated_builders" TO "authenticated";
+GRANT ALL ON TABLE "public"."dedicated_builders" TO "service_role";
diff --git a/tests/builder-payload.unit.test.ts b/tests/builder-payload.unit.test.ts
index 30b28d9892..3df19b0c2c 100644
--- a/tests/builder-payload.unit.test.ts
+++ b/tests/builder-payload.unit.test.ts
@@ -86,6 +86,48 @@ describe('builder payload shape', () => {
])
})
+ it.concurrent('adds dedicated pool preference when routing is active', () => {
+ const payload = buildBuilderPayload({
+ orgId: 'org-dedicated',
+ actorUserId: 'user-1',
+ uploadPath: 'path.zip',
+ platform: 'ios',
+ buildOptions: {},
+ buildCredentials: {},
+ poolRouting: {
+ preferDedicated: true,
+ allowSharedFallback: true,
+ poolId: 'pool-42',
+ workerName: 'worker-42',
+ },
+ })
+
+ expect(payload.poolPreference).toBe('dedicated')
+ expect(payload.allowSharedFallback).toBe(true)
+ expect(payload.poolId).toBe('pool-42')
+ })
+
+ it.concurrent('omits poolId when dedicated routing has no pool assigned yet', () => {
+ const payload = buildBuilderPayload({
+ orgId: 'org-dedicated',
+ actorUserId: 'user-1',
+ uploadPath: 'path.zip',
+ platform: 'android',
+ buildOptions: {},
+ buildCredentials: {},
+ poolRouting: {
+ preferDedicated: true,
+ allowSharedFallback: false,
+ poolId: null,
+ workerName: null,
+ },
+ })
+
+ expect(payload.poolPreference).toBe('dedicated')
+ expect(payload.allowSharedFallback).toBe(false)
+ expect(payload).not.toHaveProperty('poolId')
+ })
+
it.concurrent('drops timeoutSeconds from buildOptions', () => {
const payload = buildBuilderPayload({
orgId: 'org-timeout',
diff --git a/tests/dedicated-builder.unit.test.ts b/tests/dedicated-builder.unit.test.ts
new file mode 100644
index 0000000000..b9c305cb90
--- /dev/null
+++ b/tests/dedicated-builder.unit.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest'
+import {
+ deriveWorkerStatus,
+ isDedicatedBuilderActive,
+ publicDedicatedBuilderView,
+ toDedicatedPoolRouting,
+} from '../supabase/functions/_backend/utils/dedicated_builder.ts'
+
+function makeRow(overrides: Record = {}) {
+ return {
+ id: 'db-1',
+ org_id: 'org-1',
+ status: 'active',
+ requested_by: 'user-1',
+ use_case: 'CI',
+ monthly_builds_estimate: 40,
+ platforms: ['ios', 'android'],
+ allow_shared_fallback: true,
+ pool_id: 'pool-org-1',
+ worker_name: 'org-1-worker',
+ worker_status: 'idle',
+ worker_current_job_id: null,
+ worker_last_seen_at: null,
+ activated_at: '2026-07-01T00:00:00.000Z',
+ suspended_at: null,
+ cancelled_at: null,
+ created_at: '2026-06-01T00:00:00.000Z',
+ updated_at: '2026-07-01T00:00:00.000Z',
+ ...overrides,
+ } as any
+}
+
+describe('dedicated builder helpers', () => {
+ it.concurrent('treats only active status as routable', () => {
+ expect(isDedicatedBuilderActive(makeRow({ status: 'active' }))).toBe(true)
+ expect(isDedicatedBuilderActive(makeRow({ status: 'requested' }))).toBe(false)
+ expect(isDedicatedBuilderActive(makeRow({ status: 'provisioning' }))).toBe(false)
+ expect(isDedicatedBuilderActive(null)).toBe(false)
+ })
+
+ it.concurrent('builds pool routing only for active dedicated builders', () => {
+ expect(toDedicatedPoolRouting(makeRow({ allow_shared_fallback: false }))).toEqual({
+ preferDedicated: true,
+ allowSharedFallback: false,
+ poolId: 'pool-org-1',
+ workerName: 'org-1-worker',
+ })
+ expect(toDedicatedPoolRouting(makeRow({ status: 'requested' }))).toBeNull()
+ })
+
+ it.concurrent('derives busy from active dedicated builds only when fallback is off', () => {
+ expect(deriveWorkerStatus(makeRow({
+ allow_shared_fallback: false,
+ worker_status: 'idle',
+ }), 0)).toBe('idle')
+ expect(deriveWorkerStatus(makeRow({
+ allow_shared_fallback: false,
+ worker_status: 'idle',
+ }), 2)).toBe('busy')
+ expect(deriveWorkerStatus(makeRow({ worker_status: 'offline' }), 0)).toBe('offline')
+ expect(deriveWorkerStatus(makeRow({ status: 'requested' }), 1)).toBe('unknown')
+ })
+
+ it.concurrent('does not infer busy from preferred-pool counts when shared fallback is on', () => {
+ expect(deriveWorkerStatus(makeRow({
+ allow_shared_fallback: true,
+ worker_status: 'idle',
+ }), 2)).toBe('idle')
+ expect(deriveWorkerStatus(makeRow({
+ allow_shared_fallback: true,
+ worker_status: 'busy',
+ }), 0)).toBe('busy')
+ expect(deriveWorkerStatus(makeRow({
+ allow_shared_fallback: true,
+ worker_status: 'unknown',
+ }), 2)).toBe('unknown')
+ })
+
+ it.concurrent('exposes a sanitized public view with derived worker status', () => {
+ const view = publicDedicatedBuilderView(makeRow({
+ allow_shared_fallback: false,
+ worker_status: 'idle',
+ worker_current_job_id: 'job-9',
+ }), 1)
+ expect(view.worker_status).toBe('busy')
+ expect(view.active_dedicated_builds).toBe(1)
+ expect(view.pool_id).toBe('pool-org-1')
+ expect(view.allow_shared_fallback).toBe(false)
+ })
+})