diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx
index a7b47cb4..aed9d2ae 100644
--- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx
+++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx
@@ -874,20 +874,19 @@ describe('Home', () => {
});
});
- it('falls back to the first catalog-ordered available provider', async () => {
+ it('falls back to the last catalog-ordered available cloud provider', async () => {
render(
,
);
- expect(screen.getByLabelText('Sandbox provider')).toHaveTextContent(
- 'Modal',
- );
+ expect(screen.getByLabelText('Sandbox provider')).toHaveTextContent('E2B');
fireEvent.click(
screen.getByRole('button', { name: 'Use single-repo environment' }),
@@ -897,7 +896,7 @@ describe('Home', () => {
await waitFor(() => {
expect(mockCreateStandardTaskRun).toHaveBeenCalledWith(
expect.objectContaining({
- computeProvider: 'modal',
+ computeProvider: 'e2b',
}),
);
});
diff --git a/apps/web/src/app/(authenticated)/home/Home.tsx b/apps/web/src/app/(authenticated)/home/Home.tsx
index 7f7a6fa0..f1c17440 100644
--- a/apps/web/src/app/(authenticated)/home/Home.tsx
+++ b/apps/web/src/app/(authenticated)/home/Home.tsx
@@ -11,6 +11,7 @@ import {
type ComputeProvider,
ALL_REPOSITORIES,
DEFAULT_LAUNCH_CODING_HARNESS,
+ pickPreferredConfiguredComputeProvider,
SETUP_COMPUTE_PROVIDER_CATALOG,
} from '@roomote/types';
import type { RoutingDecision } from '@roomote/cloud-agents/server';
@@ -86,7 +87,10 @@ function resolveInitialComputeProvider(
return defaultComputeProvider;
}
- return availableComputeProviders[0] ?? defaultComputeProvider;
+ return (
+ pickPreferredConfiguredComputeProvider(availableComputeProviders) ??
+ defaultComputeProvider
+ );
}
type HomeProps = {
diff --git a/packages/db/src/lib/__tests__/compute-runtime-config.test.ts b/packages/db/src/lib/__tests__/compute-runtime-config.test.ts
index 9cb43114..7a6144c6 100644
--- a/packages/db/src/lib/__tests__/compute-runtime-config.test.ts
+++ b/packages/db/src/lib/__tests__/compute-runtime-config.test.ts
@@ -9,6 +9,7 @@ vi.mock('../../encryption', () => ({
import {
listConfiguredComputeProviders,
resolveComputeProviderEnvValues,
+ resolveDefaultComputeProvider,
} from '../compute-runtime-config';
type Executor = NonNullable<
@@ -17,7 +18,15 @@ type Executor = NonNullable<
type EnvVarRow = { name: string; value: string };
-function makeExecutor(rows: EnvVarRow[]): NonNullable {
+function makeExecutor(
+ rows: EnvVarRow[],
+ options: {
+ runtimeComputeConfig?: {
+ defaultProvider?: string | null;
+ excludedProviders?: string[];
+ } | null;
+ } = {},
+): NonNullable {
// `where` must satisfy both call shapes used by the module: the direct
// `inArray` read (awaited) and `resolveSavedWorkerImage`'s `.limit(1)` read.
const whereResult = {
@@ -31,6 +40,13 @@ function makeExecutor(rows: EnvVarRow[]): NonNullable {
where: vi.fn(() => whereResult),
})),
})),
+ query: {
+ deploymentSettings: {
+ findFirst: vi.fn().mockResolvedValue({
+ runtimeComputeConfig: options.runtimeComputeConfig ?? null,
+ }),
+ },
+ },
} as unknown as NonNullable;
}
@@ -250,14 +266,9 @@ describe('listConfiguredComputeProviders', () => {
});
it('excludes persisted providers from task launch options', async () => {
- const executor = makeExecutor([]);
- executor.query = {
- deploymentSettings: {
- findFirst: vi.fn().mockResolvedValue({
- runtimeComputeConfig: { excludedProviders: ['docker'] },
- }),
- },
- } as never;
+ const executor = makeExecutor([], {
+ runtimeComputeConfig: { excludedProviders: ['docker'] },
+ });
const providers = await listConfiguredComputeProviders({
runtimeEnv: {},
@@ -267,3 +278,99 @@ describe('listConfiguredComputeProviders', () => {
expect(providers).toEqual([]);
});
});
+
+describe('resolveDefaultComputeProvider', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockDecryptSecrets.mockImplementation(async (value) => value);
+ });
+
+ it('keeps an explicit persisted default', async () => {
+ const provider = await resolveDefaultComputeProvider({
+ runtimeEnv: {
+ DEFAULT_COMPUTE_PROVIDER: 'docker',
+ MODAL_TOKEN_ID: 'id',
+ MODAL_TOKEN_SECRET: 'secret',
+ MODAL_BASE_IMAGE_REF: 'registry.example.com/image:tag',
+ },
+ executor: makeExecutor([], {
+ runtimeComputeConfig: { defaultProvider: 'docker' },
+ }),
+ });
+
+ expect(provider).toBe('docker');
+ });
+
+ it('prefers configured cloud over DEFAULT_COMPUTE_PROVIDER=docker', async () => {
+ const provider = await resolveDefaultComputeProvider({
+ runtimeEnv: {
+ DEFAULT_COMPUTE_PROVIDER: 'docker',
+ MODAL_TOKEN_ID: 'id',
+ MODAL_TOKEN_SECRET: 'secret',
+ MODAL_BASE_IMAGE_REF: 'registry.example.com/image:tag',
+ },
+ executor: makeExecutor([]),
+ });
+
+ expect(provider).toBe('modal');
+ });
+
+ it('uses the last catalog-ordered configured cloud when several are ready', async () => {
+ const provider = await resolveDefaultComputeProvider({
+ runtimeEnv: {
+ DEFAULT_COMPUTE_PROVIDER: 'docker',
+ MODAL_TOKEN_ID: 'id',
+ MODAL_TOKEN_SECRET: 'secret',
+ MODAL_BASE_IMAGE_REF: 'registry.example.com/image:tag',
+ E2B_API_KEY: 'e2b-key',
+ E2B_TEMPLATE_ID: 'template',
+ },
+ executor: makeExecutor([]),
+ });
+
+ expect(provider).toBe('e2b');
+ });
+
+ it('honors a non-docker env default when it is set', async () => {
+ const provider = await resolveDefaultComputeProvider({
+ runtimeEnv: {
+ DEFAULT_COMPUTE_PROVIDER: 'modal',
+ MODAL_TOKEN_ID: 'id',
+ MODAL_TOKEN_SECRET: 'secret',
+ MODAL_BASE_IMAGE_REF: 'registry.example.com/image:tag',
+ E2B_API_KEY: 'e2b-key',
+ E2B_TEMPLATE_ID: 'template',
+ },
+ executor: makeExecutor([]),
+ });
+
+ expect(provider).toBe('modal');
+ });
+
+ it('skips catalog readiness scans for non-docker env defaults', async () => {
+ const executor = makeExecutor([]);
+
+ const provider = await resolveDefaultComputeProvider({
+ runtimeEnv: {
+ DEFAULT_COMPUTE_PROVIDER: 'modal',
+ },
+ executor,
+ });
+
+ expect(provider).toBe('modal');
+ // listConfiguredComputeProviders is the only caller path that hits
+ // environment_variables selects for every catalog provider.
+ expect(executor.select).not.toHaveBeenCalled();
+ });
+
+ it('falls back to docker when only Local Docker is configured', async () => {
+ const provider = await resolveDefaultComputeProvider({
+ runtimeEnv: {
+ DEFAULT_COMPUTE_PROVIDER: 'docker',
+ },
+ executor: makeExecutor([]),
+ });
+
+ expect(provider).toBe('docker');
+ });
+});
diff --git a/packages/db/src/lib/compute-runtime-config.ts b/packages/db/src/lib/compute-runtime-config.ts
index a82c0990..ea35be9c 100644
--- a/packages/db/src/lib/compute-runtime-config.ts
+++ b/packages/db/src/lib/compute-runtime-config.ts
@@ -2,8 +2,8 @@ import { and, eq, inArray, isNull } from 'drizzle-orm';
import {
getDefaultAvailableComputeProvider,
getSetupComputeProvider,
+ pickPreferredConfiguredComputeProvider,
SETUP_COMPUTE_PROVIDER_CATALOG,
- SETUP_COMPUTE_PROVIDER_IDS,
SHARED_WORKER_IMAGE_ENV_VAR,
deriveModalBaseImageRefDefault,
resolveEffectiveDockerWorkerImage,
@@ -116,10 +116,15 @@ export async function listConfiguredComputeProviders(
}
/**
- * Resolves the deployment default compute provider. The persisted setup
- * choice wins over the DEFAULT_COMPUTE_PROVIDER env value because compose and
- * PM2 stacks always inject an env default; the admin's explicit setup choice
- * is the stronger signal.
+ * Resolves the deployment default compute provider. Order of preference:
+ * 1. Persisted setup/admin default (explicit deployment choice).
+ * 2. DEFAULT_COMPUTE_PROVIDER env when it is a non-docker provider (direct
+ * return — no catalog readiness scan on hot dispatch paths).
+ * 3. When the env default is docker or unset: scan configured providers and
+ * prefer the last catalog-ordered configured cloud over Local Docker.
+ * Compose stacks often inject DEFAULT_COMPUTE_PROVIDER=docker; a ready
+ * cloud provider outranks that barrier default.
+ * 4. Local Docker / remaining availability fallback.
*/
export async function resolveDefaultComputeProvider(
options: {
@@ -128,9 +133,9 @@ export async function resolveDefaultComputeProvider(
} = {},
): Promise {
const runtimeEnv = options.runtimeEnv ?? process.env;
- const persistedComputeConfig = await loadPersistedRuntimeComputeConfig(
- options.executor ?? db,
- );
+ const executor = options.executor ?? db;
+ const persistedComputeConfig =
+ await loadPersistedRuntimeComputeConfig(executor);
const excludedProviders = parseExcludedComputeProviders(
runtimeEnv.EXCLUDED_COMPUTE_PROVIDERS,
);
@@ -149,29 +154,38 @@ export async function resolveDefaultComputeProvider(
if (
runtimeDefault &&
isComputeProvider(runtimeDefault) &&
- !excludedProviders.has(runtimeDefault)
+ !excludedProviders.has(runtimeDefault) &&
+ runtimeDefault !== 'docker'
) {
+ // Authoritative non-Docker env defaults skip the catalog readiness scan
+ // used by enqueue/retry/dequeue paths.
return runtimeDefault;
}
- const availableProviders = await Promise.all(
- SETUP_COMPUTE_PROVIDER_IDS.filter(
- (provider) => !excludedProviders.has(provider),
- )
- .filter((provider) => provider !== 'docker')
- .map(async (provider) => ({
- provider,
- configSatisfied: await isComputeProviderConfigured(provider, {
- runtimeEnv,
- executor: options.executor ?? db,
- }),
- })),
- );
+ // Docker env default or unset: resolve cloud-vs-Local Docker from readiness.
+ const configuredProviders = await listConfiguredComputeProviders({
+ runtimeEnv,
+ executor,
+ });
+ const preferredConfigured =
+ pickPreferredConfiguredComputeProvider(configuredProviders);
- return getDefaultAvailableComputeProvider(
- excludedProviders,
- availableProviders,
- );
+ if (
+ runtimeDefault === 'docker' &&
+ isComputeProvider(runtimeDefault) &&
+ !excludedProviders.has(runtimeDefault)
+ ) {
+ if (preferredConfigured && preferredConfigured !== 'docker') {
+ return preferredConfigured;
+ }
+ return 'docker';
+ }
+
+ if (preferredConfigured) {
+ return preferredConfigured;
+ }
+
+ return getDefaultAvailableComputeProvider(excludedProviders);
}
/**
diff --git a/packages/types/src/setup-compute-config.test.ts b/packages/types/src/setup-compute-config.test.ts
index dd4d8a7f..529c96e4 100644
--- a/packages/types/src/setup-compute-config.test.ts
+++ b/packages/types/src/setup-compute-config.test.ts
@@ -13,6 +13,7 @@ import {
isComputeOperatorEditableField,
normalizeDeploymentComputeConfig,
parseExcludedComputeProviders,
+ pickPreferredConfiguredComputeProvider,
resolveDerivedModalBaseImageRef,
resolveEffectiveDockerWorkerImage,
resolveEffectiveModalBaseImageRef,
@@ -503,6 +504,22 @@ describe('buildSetupComputeStatus', () => {
expect(status.preselectedProvider).toBe('modal');
});
+ it('prefers a configured cloud over DEFAULT_COMPUTE_PROVIDER=docker for preselection', () => {
+ const status = buildSetupComputeStatus({
+ runtimeEnv: {
+ DEFAULT_COMPUTE_PROVIDER: 'docker',
+ MODAL_TOKEN_ID: 'id',
+ MODAL_TOKEN_SECRET: 'secret',
+ MODAL_BASE_IMAGE_REF: 'registry/image:tag',
+ E2B_API_KEY: 'key',
+ E2B_TEMPLATE_ID: 'template',
+ },
+ });
+
+ expect(status.runtimeDefaultProvider).toBe('docker');
+ expect(status.preselectedProvider).toBe('e2b');
+ });
+
it('satisfies the Modal base image via the derived worker-image default', () => {
const status = buildSetupComputeStatus({
runtimeEnv: {
@@ -909,8 +926,20 @@ describe('parseExcludedComputeProviders', () => {
});
});
+describe('pickPreferredConfiguredComputeProvider', () => {
+ it('prefers the last catalog-ordered cloud over Local Docker', () => {
+ expect(
+ pickPreferredConfiguredComputeProvider(['docker', 'modal', 'e2b']),
+ ).toBe('e2b');
+ });
+
+ it('returns docker when it is the only available provider', () => {
+ expect(pickPreferredConfiguredComputeProvider(['docker'])).toBe('docker');
+ });
+});
+
describe('getDefaultAvailableComputeProvider', () => {
- it('returns the first non-excluded provider', () => {
+ it('returns the preferred configured non-excluded provider', () => {
expect(
getDefaultAvailableComputeProvider(new Set(['docker', 'modal']), [
{ provider: 'daytona', configSatisfied: true },
@@ -918,12 +947,31 @@ describe('getDefaultAvailableComputeProvider', () => {
).toBe('daytona');
});
- it('keeps docker when it is not excluded', () => {
+ it('keeps docker when it is not excluded and no live status is provided', () => {
expect(getDefaultAvailableComputeProvider(new Set(['modal']))).toBe(
'docker',
);
});
+ it('prefers a configured cloud provider over Local Docker', () => {
+ expect(
+ getDefaultAvailableComputeProvider(new Set(), [
+ { provider: 'modal', configSatisfied: true },
+ { provider: 'docker', configSatisfied: true },
+ ]),
+ ).toBe('modal');
+ });
+
+ it('prefers the last catalog-ordered configured cloud when several are ready', () => {
+ expect(
+ getDefaultAvailableComputeProvider(new Set(), [
+ { provider: 'modal', configSatisfied: true },
+ { provider: 'e2b', configSatisfied: true },
+ { provider: 'docker', configSatisfied: true },
+ ]),
+ ).toBe('e2b');
+ });
+
it('falls back to docker when excluded providers are not configured', () => {
expect(
getDefaultAvailableComputeProvider(new Set(['docker']), [
diff --git a/packages/types/src/setup-compute-config.ts b/packages/types/src/setup-compute-config.ts
index 43fdac07..905a9e87 100644
--- a/packages/types/src/setup-compute-config.ts
+++ b/packages/types/src/setup-compute-config.ts
@@ -205,6 +205,33 @@ export function parseExcludedComputeProviders(
);
}
+/**
+ * Preferring a configured cloud provider over Local Docker when both are
+ * available. When multiple clouds are available, prefer the last one in
+ * setup-catalog order (latest cloud option among those that are ready).
+ */
+export function pickPreferredConfiguredComputeProvider(
+ providers: readonly ComputeProvider[],
+): ComputeProvider | undefined {
+ if (providers.length === 0) {
+ return undefined;
+ }
+
+ const available = new Set(providers);
+ const catalogOrdered = SETUP_COMPUTE_PROVIDER_CATALOG.map(
+ (descriptor) => descriptor.provider,
+ ).filter((provider) => available.has(provider));
+ const cloudProviders = catalogOrdered.filter(
+ (provider) => provider !== DEFAULT_SETUP_COMPUTE_PROVIDER_ID,
+ );
+
+ if (cloudProviders.length > 0) {
+ return cloudProviders[cloudProviders.length - 1];
+ }
+
+ return catalogOrdered[0];
+}
+
export function getDefaultAvailableComputeProvider(
excludedProviders: ReadonlySet = new Set(),
availableProviders?: Iterable<{
@@ -212,24 +239,41 @@ export function getDefaultAvailableComputeProvider(
configSatisfied: boolean;
}>,
): ComputeProvider {
+ if (availableProviders !== undefined) {
+ const satisfiedProviders = Array.from(availableProviders)
+ .filter(
+ (provider) =>
+ !excludedProviders.has(provider.provider) &&
+ provider.configSatisfied !== false,
+ )
+ .map((provider) => provider.provider);
+
+ const preferred =
+ pickPreferredConfiguredComputeProvider(satisfiedProviders);
+ if (preferred) {
+ return preferred;
+ }
+
+ // Nothing reported as ready (or everything excluded from the live set):
+ // keep Local Docker as the last-resort default id even when excluded from
+ // the operator-facing catalog.
+ return DEFAULT_SETUP_COMPUTE_PROVIDER_ID;
+ }
+
if (!excludedProviders.has(DEFAULT_SETUP_COMPUTE_PROVIDER_ID)) {
+ // No live readiness: Local Docker is the barrier default for setup when
+ // it is still allowed. Callers that know which providers are configured
+ // pass `availableProviders` so cloud can outrank docker.
return DEFAULT_SETUP_COMPUTE_PROVIDER_ID;
}
+ // Docker excluded and no live readiness: first catalog entry that could be
+ // operator-configured.
return (
- Array.from(
- availableProviders ??
- SETUP_COMPUTE_PROVIDER_CATALOG.map((provider) => ({
- provider: provider.provider,
- // Without live status, deployment-managed providers (no
- // operator-editable fields, e.g. Roomote Cloud) cannot be assumed
- // satisfiable: only their runtime env can configure them.
- configSatisfied: provider.fields.some(isComputeOperatorEditableField),
- })),
- ).find(
+ SETUP_COMPUTE_PROVIDER_CATALOG.find(
(provider) =>
!excludedProviders.has(provider.provider) &&
- provider.configSatisfied !== false,
+ provider.fields.some(isComputeOperatorEditableField),
)?.provider ?? DEFAULT_SETUP_COMPUTE_PROVIDER_ID
);
}
@@ -888,16 +932,27 @@ export function buildSetupComputeStatus(input: {
!excludedProviders.has(persistedComputeConfig.defaultProvider)
? persistedComputeConfig.defaultProvider
: null;
+ const providerReadiness = providers.map((provider) => ({
+ provider: provider.provider,
+ configSatisfied: provider.configSatisfied,
+ }));
+ const configuredCloudReady = providers.some(
+ (provider) =>
+ provider.provider !== DEFAULT_SETUP_COMPUTE_PROVIDER_ID &&
+ provider.configSatisfied &&
+ !excludedProviders.has(provider.provider),
+ );
+ // Compose stacks often inject DEFAULT_COMPUTE_PROVIDER=docker. When a cloud
+ // provider is already configured, ignore that barrier default for preselect.
+ const effectiveRuntimeDefaultProvider =
+ runtimeDefaultProvider === DEFAULT_SETUP_COMPUTE_PROVIDER_ID &&
+ configuredCloudReady
+ ? null
+ : runtimeDefaultProvider;
const preselectedProvider =
selectedProvider ??
- runtimeDefaultProvider ??
- getDefaultAvailableComputeProvider(
- excludedProviders,
- providers.map((provider) => ({
- provider: provider.provider,
- configSatisfied: provider.configSatisfied,
- })),
- );
+ effectiveRuntimeDefaultProvider ??
+ getDefaultAvailableComputeProvider(excludedProviders, providerReadiness);
const selectedProviderStatus = selectedProvider
? providers.find((candidate) => candidate.provider === selectedProvider)