diff --git a/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx b/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx index b2e5847431..d047430439 100644 --- a/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx +++ b/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx @@ -41,6 +41,10 @@ export function EditWebhookTriggerContent({ // Build URLs based on context const routes = getWebhookRoutes(organizationId); + const { data: capabilities, isPending: isLoadingCapabilities } = useQuery( + trpc.webhookTriggers.capabilities.queryOptions({ organizationId }) + ); + // Fetch trigger configuration const { data: triggerData, @@ -110,6 +114,7 @@ export function EditWebhookTriggerContent({ mode: (triggerData.mode ?? 'code') as AgentMode, model: triggerData.model ?? '', variant: triggerData.variant ?? undefined, + sandboxAllocation: triggerData.sandboxAllocation ?? undefined, promptTemplate: triggerData.promptTemplate, profileId: triggerData.profileId ?? undefined, autoCommit: triggerData.autoCommit ?? undefined, @@ -162,6 +167,7 @@ export function EditWebhookTriggerContent({ mode: formData.mode, model: formData.model, variant: formData.variant, + sandboxAllocation: formData.sandboxAllocation, promptTemplate: formData.promptTemplate, profileId: formData.profileId, autoCommit: formData.autoCommit ?? null, @@ -381,6 +387,8 @@ export function EditWebhookTriggerContent({ repositoriesError={repoError?.message} models={modelOptions} isLoadingModels={isLoadingModels} + canSetSandboxAllocation={capabilities?.canSetSandboxAllocation ?? false} + isLoadingCapabilities={isLoadingCapabilities} onSubmit={handleSubmit} onCancel={handleCancel} onDelete={handleDelete} diff --git a/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx b/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx index ddd4cc9404..130953d246 100644 --- a/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx +++ b/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx @@ -34,7 +34,9 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri ? `/organizations/${organizationId}/integrations` : '/integrations'; - // Fetch eligibility to check if user can create webhook triggers (requires credits) + const { data: capabilities, isPending: isLoadingCapabilities } = useQuery( + trpc.webhookTriggers.capabilities.queryOptions({ organizationId }) + ); // Fetch GitHub repositories const { @@ -118,6 +120,7 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri mode: formData.mode, model: formData.model, variant: formData.variant ?? undefined, + sandboxAllocation: formData.sandboxAllocation ?? undefined, promptTemplate: formData.promptTemplate, profileId: formData.profileId, autoCommit: formData.autoCommit, @@ -203,6 +206,8 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri repositoriesError={repoError?.message} models={modelOptions} isLoadingModels={isLoadingModels} + canSetSandboxAllocation={capabilities?.canSetSandboxAllocation ?? false} + isLoadingCapabilities={isLoadingCapabilities} onSubmit={handleSubmit} onCancel={handleCancel} isLoading={isCreatePending} diff --git a/apps/web/src/components/webhook-triggers/TriggerForm.test.ts b/apps/web/src/components/webhook-triggers/TriggerForm.test.ts index ac9b52271a..690a747d82 100644 --- a/apps/web/src/components/webhook-triggers/TriggerForm.test.ts +++ b/apps/web/src/components/webhook-triggers/TriggerForm.test.ts @@ -31,6 +31,74 @@ jest.mock('@/components/ui/label', () => ({ })); jest.mock('@/components/ui/switch', () => ({ Switch: () => null })); jest.mock('@/components/ui/checkbox', () => ({ Checkbox: () => null })); +type SelectInjectedProps = { + onValueChange?: (value: string) => void; + selectDisabled?: boolean; +}; + +jest.mock('@/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + disabled, + }: { + children: React.ReactNode; + value: string; + onValueChange: (value: string) => void; + disabled?: boolean; + }) => + createElement( + 'div', + { 'data-container-allocation': value, 'data-disabled': String(disabled) }, + React.Children.map(children, child => { + if (!React.isValidElement(child)) return child; + return React.cloneElement(child, { onValueChange, selectDisabled: disabled }); + }) + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => + createElement(React.Fragment, {}, children), + SelectValue: () => null, + SelectContent: ({ + children, + onValueChange, + selectDisabled, + }: { + children: React.ReactNode; + onValueChange?: (value: string) => void; + selectDisabled?: boolean; + }) => + createElement( + React.Fragment, + {}, + React.Children.map(children, child => { + if (!React.isValidElement(child)) return child; + return React.cloneElement(child, { onValueChange, selectDisabled }); + }) + ), + SelectItem: ({ + children, + value, + disabled, + onValueChange, + selectDisabled, + }: { + children: React.ReactNode; + value: string; + disabled?: boolean; + onValueChange?: (value: string) => void; + selectDisabled?: boolean; + }) => + createElement( + 'button', + { + type: 'button', + disabled: disabled || selectDisabled, + onClick: () => onValueChange?.(value), + }, + children + ), +})); jest.mock('@/components/ui/inline-delete-confirmation', () => ({ InlineDeleteConfirmation: () => null, })); @@ -192,6 +260,21 @@ function expectSubmittedVariantToBeOmitted(onSubmit: jest.Mock +) { + const [submission] = onSubmit.mock.calls.at(-1) ?? []; + if (!submission) throw new Error('form submission missing'); + expect(Object.hasOwn(submission, 'sandboxAllocation')).toBe(false); +} + let TriggerForm!: typeof TriggerFormComponent; beforeAll(async () => { @@ -335,3 +418,149 @@ describe('TriggerForm variants', () => { ).toHaveProperty('disabled', true); }); }); + +describe('TriggerForm container allocation', () => { + let root: Root | undefined; + let cleanup: (() => void) | undefined; + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = undefined; + cleanup?.(); + cleanup = undefined; + }); + + function render(props: Partial) { + const dom = installDom(); + cleanup = dom.cleanup; + root = createRoot(dom.container); + const onSubmit = jest.fn(); + onSubmit.mockResolvedValue(undefined); + const allProps: TriggerFormProps = { + mode: 'edit', + initialData: initialData(), + repositories: [], + models, + onSubmit, + canSetSandboxAllocation: true, + ...props, + }; + act(() => root?.render(createElement(TriggerForm, allProps))); + return { container: dom.container, onSubmit, allProps }; + } + + it('omits Automatic and submits Dedicated Standard in create mode', async () => { + const mounted = render({ mode: 'create', initialData: initialData() }); + submit(mounted.container); + await act(async () => Promise.resolve()); + expectSubmittedSandboxAllocationToBeOmitted(mounted.onSubmit); + + selectContainerAllocation(mounted.container, 'isolated-standard'); + submit(mounted.container); + await act(async () => Promise.resolve()); + expect(mounted.onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ sandboxAllocation: 'isolated-standard' }) + ); + }); + + it('omits unchanged edit allocation and supports set, clear, and restoring the saved value', async () => { + const unset = render({ initialData: initialData() }); + submit(unset.container); + await act(async () => Promise.resolve()); + expectSubmittedSandboxAllocationToBeOmitted(unset.onSubmit); + + selectContainerAllocation(unset.container, 'isolated-standard'); + submit(unset.container); + await act(async () => Promise.resolve()); + expect(unset.onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ sandboxAllocation: 'isolated-standard' }) + ); + + act(() => root?.unmount()); + const saved = { ...initialData(), sandboxAllocation: 'isolated-standard' as const }; + const mounted = render({ initialData: saved }); + submit(mounted.container); + await act(async () => Promise.resolve()); + expectSubmittedSandboxAllocationToBeOmitted(mounted.onSubmit); + + selectContainerAllocation(mounted.container, 'automatic'); + submit(mounted.container); + await act(async () => Promise.resolve()); + expect(mounted.onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ sandboxAllocation: null }) + ); + + selectContainerAllocation(mounted.container, 'isolated-standard'); + submit(mounted.container); + await act(async () => Promise.resolve()); + expectSubmittedSandboxAllocationToBeOmitted(mounted.onSubmit); + }); + + it('hides fresh allocation from ineligible users but preserves a saved allocation and clearing it', async () => { + const fresh = render({ canSetSandboxAllocation: false }); + expect(fresh.container.querySelector('[data-container-allocation]')).toBeNull(); + + act(() => root?.unmount()); + const saved = render({ + canSetSandboxAllocation: false, + initialData: { ...initialData(), sandboxAllocation: 'isolated-standard' }, + }); + expect(saved.container.querySelector('[data-container-allocation]')).not.toBeNull(); + selectContainerAllocation(saved.container, 'automatic'); + submit(saved.container); + await act(async () => Promise.resolve()); + expect(saved.onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ sandboxAllocation: null }) + ); + }); + + it('blocks a pending allocation selection after eligibility is revoked until Automatic is restored', async () => { + const mounted = render({ mode: 'create' }); + selectContainerAllocation(mounted.container, 'isolated-standard'); + act(() => + root?.render( + createElement(TriggerForm, { ...mounted.allProps, canSetSandboxAllocation: false }) + ) + ); + submit(mounted.container); + expect(mounted.onSubmit).not.toHaveBeenCalled(); + + expect(mounted.container.querySelector('[data-container-allocation]')).not.toBeNull(); + selectContainerAllocation(mounted.container, 'automatic'); + submit(mounted.container); + await act(async () => Promise.resolve()); + expectSubmittedSandboxAllocationToBeOmitted(mounted.onSubmit); + }); + + it('blocks a new Dedicated Standard selection while capabilities are loading', () => { + const mounted = render({ mode: 'create', isLoadingCapabilities: true }); + act(() => + root?.render( + createElement(TriggerForm, { ...mounted.allProps, isLoadingCapabilities: false }) + ) + ); + selectContainerAllocation(mounted.container, 'isolated-standard'); + act(() => + root?.render(createElement(TriggerForm, { ...mounted.allProps, isLoadingCapabilities: true })) + ); + submit(mounted.container); + expect(mounted.onSubmit).not.toHaveBeenCalled(); + }); + + it('resets from refreshed initial data and disables while capabilities load', () => { + const mounted = render({}); + selectContainerAllocation(mounted.container, 'isolated-standard'); + act(() => + root?.render( + createElement(TriggerForm, { + ...mounted.allProps, + initialData: { ...initialData(), sandboxAllocation: null }, + isLoadingCapabilities: true, + }) + ) + ); + const select = mounted.container.querySelector('[data-container-allocation]'); + expect(select?.getAttribute('data-container-allocation')).toBe('automatic'); + expect(select?.getAttribute('data-disabled')).toBe('true'); + }); +}); diff --git a/apps/web/src/components/webhook-triggers/TriggerForm.tsx b/apps/web/src/components/webhook-triggers/TriggerForm.tsx index 0a972bace0..18d0ffceec 100644 --- a/apps/web/src/components/webhook-triggers/TriggerForm.tsx +++ b/apps/web/src/components/webhook-triggers/TriggerForm.tsx @@ -8,6 +8,13 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { ProfileSelector } from '@/components/cloud-agent/ProfileSelector'; import { RepositoryCombobox, type RepositoryOption } from '@/components/shared/RepositoryCombobox'; import { ModeCombobox } from '@/components/shared/ModeCombobox'; @@ -44,6 +51,7 @@ export type TriggerFormData = { mode: AgentMode; model: string; variant?: string | null; + sandboxAllocation?: 'isolated-standard' | null; promptTemplate: string; profileId: string; autoCommit?: boolean; @@ -68,6 +76,7 @@ export type TriggerFormProps = { mode: AgentMode; model: string; variant?: string; + sandboxAllocation?: 'isolated-standard' | null; promptTemplate: string; profileId?: string; autoCommit?: boolean; @@ -87,6 +96,8 @@ export type TriggerFormProps = { onCancel?: () => void; onDelete?: () => Promise; isLoading?: boolean; + canSetSandboxAllocation?: boolean; + isLoadingCapabilities?: boolean; /** Full inbound webhook URL (only needed in edit mode) */ inboundUrl?: string; }; @@ -114,6 +125,8 @@ export function TriggerForm({ onCancel, onDelete, isLoading = false, + canSetSandboxAllocation = false, + isLoadingCapabilities = false, inboundUrl, }: TriggerFormProps) { const isEditMode = formMode === 'edit'; @@ -133,6 +146,16 @@ export function TriggerForm({ const [agentMode, setAgentMode] = useState((initialData?.mode as AgentMode) ?? 'ask'); const [model, setModel] = useState(initialData?.model ?? ''); const [variant, setVariant] = useState(initialData?.variant); + const initialSandboxAllocation = + initialData?.sandboxAllocation === 'isolated-standard' ? 'isolated-standard' : 'automatic'; + const [sandboxAllocation, setSandboxAllocation] = useState<'automatic' | 'isolated-standard'>( + initialSandboxAllocation + ); + const hasSavedSandboxAllocation = initialSandboxAllocation === 'isolated-standard'; + const showSandboxAllocation = + canSetSandboxAllocation || + hasSavedSandboxAllocation || + sandboxAllocation === 'isolated-standard'; const modelVariants = models.find(option => option.id === model)?.variants ?? []; const handleModelChange = (nextModel: string) => { @@ -171,6 +194,7 @@ export function TriggerForm({ setAgentMode(initialData.mode ?? 'ask'); setModel(initialData.model); setVariant(initialData.variant); + setSandboxAllocation(initialData.sandboxAllocation ?? 'automatic'); setPromptTemplate(initialData.promptTemplate); setProfileId(initialData.profileId ?? null); setAutoCommit(initialData.autoCommit ?? false); @@ -182,6 +206,7 @@ export function TriggerForm({ if (!initialData) { setWebhookAuthEnabled(false); setWebhookAuthHeader(''); + setSandboxAllocation('automatic'); } setWebhookAuthSecret(''); }, [initialData]); @@ -297,6 +322,16 @@ export function TriggerForm({ errors.push(webhookAuthSecretError); } + const requiresSandboxAllocationEligibility = + sandboxAllocation === 'isolated-standard' && + (!isEditMode || sandboxAllocation !== initialSandboxAllocation); + if ( + requiresSandboxAllocationEligibility && + (!canSetSandboxAllocation || isLoadingCapabilities) + ) { + errors.push('Dedicated Standard allocation is not available'); + } + return errors; }, [ activeTriggerIdSchema, @@ -309,6 +344,11 @@ export function TriggerForm({ profileId, webhookAuthHeaderError, webhookAuthSecretError, + sandboxAllocation, + initialSandboxAllocation, + isEditMode, + canSetSandboxAllocation, + isLoadingCapabilities, ]); const isFormValid = formErrors.length === 0; @@ -337,6 +377,15 @@ export function TriggerForm({ ? undefined : (variant ?? null) : variant; + const submittedSandboxAllocation = isEditMode + ? sandboxAllocation === initialSandboxAllocation + ? undefined + : sandboxAllocation === 'automatic' + ? null + : sandboxAllocation + : sandboxAllocation === 'isolated-standard' + ? sandboxAllocation + : undefined; await onSubmit({ triggerId, @@ -347,6 +396,9 @@ export function TriggerForm({ mode: agentMode, model, ...(submittedVariant !== undefined ? { variant: submittedVariant } : {}), + ...(submittedSandboxAllocation !== undefined + ? { sandboxAllocation: submittedSandboxAllocation } + : {}), promptTemplate: promptTemplate.trim(), profileId, autoCommit, @@ -369,6 +421,8 @@ export function TriggerForm({ model, variant, initialData?.variant, + sandboxAllocation, + initialSandboxAllocation, promptTemplate, autoCommit, condenseOnComplete, @@ -570,6 +624,49 @@ export function TriggerForm({ isScheduled={isScheduled} /> + {showSandboxAllocation && ( +
+ + +

+ Automatic uses existing Cloud Agent routing. Dedicated Standard provisions one + container per execution with 4 vCPU, 12 GiB RAM, and 20 GB disk, which may affect + compute charges. +

+ {!canSetSandboxAllocation && hasSavedSandboxAllocation && ( +

+ Only Kilo admins can newly enable Dedicated Standard. You can keep this + allocation or clear it. +

+ )} +
+ )} + {/* Profile Selection (Required) */}