From c435a161851ee90ffc29a62d74ffb19a404f1af1 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Sat, 11 Jul 2026 21:22:16 -0400
Subject: [PATCH 01/18] fix: prioritize above-fold Roomote logo loading for LCP
(#218)
Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com>
---
apps/web/src/components/layout/Logo.tsx | 1 +
apps/web/src/components/layout/navbar/NavbarHeader.tsx | 1 +
apps/web/src/components/layout/side-nav/SideNav.tsx | 1 +
3 files changed, 3 insertions(+)
diff --git a/apps/web/src/components/layout/Logo.tsx b/apps/web/src/components/layout/Logo.tsx
index 983ccccb5..1df4f2a54 100644
--- a/apps/web/src/components/layout/Logo.tsx
+++ b/apps/web/src/components/layout/Logo.tsx
@@ -29,6 +29,7 @@ export const Logo = ({
alt={`${PRODUCT_NAME} logo`}
width={iconSize}
height={iconSize}
+ priority
onClick={() => router.push('/')}
className={cn(
'logo cursor-pointer transition-all duration-300 hover:scale-105 hover:opacity-80',
diff --git a/apps/web/src/components/layout/navbar/NavbarHeader.tsx b/apps/web/src/components/layout/navbar/NavbarHeader.tsx
index 98bf6a0cb..8cd0c21dd 100644
--- a/apps/web/src/components/layout/navbar/NavbarHeader.tsx
+++ b/apps/web/src/components/layout/navbar/NavbarHeader.tsx
@@ -35,6 +35,7 @@ export const NavbarHeader = ({ className, ...props }: NavbarHeaderProps) => {
alt="Roomote"
width={28}
height={28}
+ priority
className="h-7 w-7 cursor-pointer transition-all duration-300 hover:scale-105 hover:opacity-80 dark:invert"
/>
diff --git a/apps/web/src/components/layout/side-nav/SideNav.tsx b/apps/web/src/components/layout/side-nav/SideNav.tsx
index 9434416f3..62dcb2c0a 100644
--- a/apps/web/src/components/layout/side-nav/SideNav.tsx
+++ b/apps/web/src/components/layout/side-nav/SideNav.tsx
@@ -252,6 +252,7 @@ export const SideNav = () => {
alt=""
width={28}
height={28}
+ priority
aria-hidden="true"
className="pointer-events-none absolute inset-0 m-auto h-7 w-7 opacity-100 transition-opacity duration-200 group-hover:opacity-0 dark:invert"
/>
From 0a5daf564ca67aa35ba7e6d4b7b1e7b22440de6c Mon Sep 17 00:00:00 2001
From: Matt Rubens
Date: Sat, 11 Jul 2026 21:23:28 -0400
Subject: [PATCH 02/18] fix(preview-proxy): secure local preview auth cookies
(#219)
Co-authored-by: Roomote
---
.../src/__tests__/integration.test.ts | 31 +++++++++++++++++++
.../src/handlers/auth-callback.ts | 29 ++---------------
apps/preview-proxy/src/handlers/http.ts | 28 ++---------------
3 files changed, 37 insertions(+), 51 deletions(-)
diff --git a/apps/preview-proxy/src/__tests__/integration.test.ts b/apps/preview-proxy/src/__tests__/integration.test.ts
index 7bed23fd8..62938eace 100644
--- a/apps/preview-proxy/src/__tests__/integration.test.ts
+++ b/apps/preview-proxy/src/__tests__/integration.test.ts
@@ -332,6 +332,36 @@ describe('preview-proxy integration', () => {
});
describe('auth callback state handling', () => {
+ it('sets a Secure partitioned auth cookie on local HTTP callbacks', async () => {
+ const { validateState, validateToken } = await import('../services/auth');
+
+ vi.mocked(validateState).mockResolvedValue({
+ redirectUri: 'http://task-web.roomotepreview.localhost:18081/',
+ runId: 1,
+ });
+ vi.mocked(validateToken).mockResolvedValue({
+ userId: 'user-1',
+ tokenType: 'pt',
+ version: 1,
+ });
+
+ const res = await request(server)
+ .get('/auth/callback?token=test-token&state=test-state')
+ .set('Host', 'task-web.roomotepreview.localhost:18081')
+ .set('x-forwarded-proto', 'http')
+ .redirects(0);
+
+ expect(res.status).toBe(302);
+ expect(res.headers.location).toBe(
+ 'http://task-web.roomotepreview.localhost:18081/',
+ );
+ expect(res.headers['set-cookie']).toEqual([
+ expect.stringContaining(
+ 'preview_auth=test-token; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=None; Partitioned',
+ ),
+ ]);
+ });
+
it('forwards nested callback when state is missing locally', async () => {
const { validateState } = await import('../services/auth');
const { tryNestedFallback } = await import('../lib/nested-routing');
@@ -806,6 +836,7 @@ describe('preview-proxy integration', () => {
expect(authCookie).toBeDefined();
expect(authCookie).toContain('preview_auth=');
expect(authCookie).toContain('HttpOnly');
+ expect(authCookie).toContain('Secure');
expect(authCookie).toContain('SameSite=None');
expect(authCookie).toContain('Partitioned');
expect(authCookie).toContain('Path=/');
diff --git a/apps/preview-proxy/src/handlers/auth-callback.ts b/apps/preview-proxy/src/handlers/auth-callback.ts
index e524280e3..23386a051 100644
--- a/apps/preview-proxy/src/handlers/auth-callback.ts
+++ b/apps/preview-proxy/src/handlers/auth-callback.ts
@@ -5,33 +5,9 @@ import { buildSetCookieHeader, getCookieDomain } from '../lib/cookies';
import { tryNestedFallback } from '../lib/nested-routing';
import { proxyRequest } from '../lib/proxy';
import { logger, escapeForLog } from '../lib/logger';
-import { isLoopbackHostname } from '@roomote/types';
import { config } from '../config';
import type { AccessLogContext } from '../lib/access-log';
-/**
- * Determine if the Secure flag should be set on the cookie.
- * With SameSite=None, browsers silently reject cookies without Secure on
- * non-localhost HTTPS origins. Set Secure based on request protocol, not
- * just NODE_ENV, so staging/preview deploys work correctly.
- */
-function shouldSetSecure(req: IncomingMessage): boolean {
- const forwardedProto = req.headers['x-forwarded-proto'];
- if (typeof forwardedProto === 'string' && forwardedProto.length > 0) {
- const protocol = forwardedProto.split(',')[0]!.trim();
- return protocol === 'https';
- }
-
- // In production, always secure. In dev, check if host is localhost.
- if (config.NODE_ENV === 'production') {
- return true;
- }
-
- const host = req.headers.host || '';
- const hostname = host.split(':')[0] || '';
- return !isLoopbackHostname(hostname);
-}
-
/**
* Handle the /auth/callback route.
* Called after the user authenticates with the main app.
@@ -133,14 +109,15 @@ export async function handleAuthCallback(
// Set cookie on the base domain so it works across all preview subdomains
const cookieDomain = await getCookieDomain();
- const secure = shouldSetSecure(req);
const setCookieValue = buildSetCookieHeader(
config.PREVIEW_AUTH_COOKIE_NAME,
token,
{
httpOnly: true,
- secure,
+ // SameSite=None and Partitioned cookies require Secure. Browsers
+ // treat localhost as a trustworthy origin for local development.
+ secure: true,
sameSite: 'None',
maxAge: parseInt(config.PREVIEW_TOKEN_TTL_SECONDS),
path: '/',
diff --git a/apps/preview-proxy/src/handlers/http.ts b/apps/preview-proxy/src/handlers/http.ts
index db4e9b66d..5831fc5a9 100644
--- a/apps/preview-proxy/src/handlers/http.ts
+++ b/apps/preview-proxy/src/handlers/http.ts
@@ -19,7 +19,6 @@ import {
renderUnavailablePage,
} from '../lib/error-pages';
import { triggerAutoResume } from './auto-resume';
-import { isLoopbackHostname } from '@roomote/types';
import { logger, escapeForLog } from '../lib/logger';
import { config } from '../config';
import type { AccessLogContext } from '../lib/access-log';
@@ -100,28 +99,6 @@ function getRequestProtocol(req: IncomingMessage): string {
return config.NODE_ENV === 'production' ? 'https' : 'http';
}
-/**
- * Determine if the Secure flag should be set on the cookie.
- * With SameSite=None, browsers silently reject cookies without Secure on
- * non-localhost HTTPS origins.
- */
-function shouldSetSecure(req: IncomingMessage): boolean {
- const forwardedProto = req.headers['x-forwarded-proto'];
- if (typeof forwardedProto === 'string' && forwardedProto.length > 0) {
- const protocol = forwardedProto.split(',')[0]!.trim();
- return protocol === 'https';
- }
-
- // In production, always secure. In dev, check if host is localhost.
- if (config.NODE_ENV === 'production') {
- return true;
- }
-
- const host = req.headers.host || '';
- const hostname = host.split(':')[0] || '';
- return !isLoopbackHostname(hostname);
-}
-
/**
* Build the auth redirect URL for a request that needs authentication.
* Auth redirects are keyed by task_id.
@@ -524,14 +501,15 @@ async function resolveAuthAndProxy(
const cleanUrl = `${protocol}://${host}${requestUrl.pathname}${requestUrl.search}`;
// Set preview_auth cookie (replaces old preview_session)
- const secure = shouldSetSecure(req);
const cookieDomain = await getCookieDomain();
const cookieValue = buildSetCookieHeader(
config.PREVIEW_AUTH_COOKIE_NAME,
inlineToken,
{
httpOnly: true,
- secure,
+ // SameSite=None and Partitioned cookies require Secure. Browsers
+ // treat localhost as a trustworthy origin for local development.
+ secure: true,
sameSite: 'None',
maxAge: parseInt(config.PREVIEW_TOKEN_TTL_SECONDS),
path: '/',
From 87fe85078911e4542def19bf85f6ef3f7bd685e1 Mon Sep 17 00:00:00 2001
From: Matt Rubens
Date: Sat, 11 Jul 2026 22:09:14 -0400
Subject: [PATCH 03/18] Retain Docker and Blaxel standby tasks with bounded
cleanup (#220)
* feat: retain Docker and Blaxel standby tasks
* feat: expose sandbox advanced settings
* fix: restore Docker resume network policy
* chore: remove sandbox recommendations
---------
Co-authored-by: Roomote
---
.env.production.example | 2 +
.../__tests__/sleep-check.test.ts | 37 +
apps/bullmq/src/scheduled-jobs/index.ts | 1 +
apps/bullmq/src/scheduled-jobs/sleep-check.ts | 4 +-
.../scheduled-jobs/standby-retention.test.ts | 78 +
.../src/scheduled-jobs/standby-retention.ts | 212 +
apps/bullmq/src/scheduler.ts | 8 +
apps/bullmq/src/types.ts | 1 +
.../__tests__/docker-sandbox-security.test.ts | 93 +
.../__tests__/spawn-docker-worker.test.ts | 29 +-
.../docker-sandbox-security.ts | 56 +-
.../compute-providers/spawn-docker-worker.ts | 210 +-
apps/docs/environment-variables.mdx | 116 +-
apps/docs/providers/compute/blaxel.mdx | 18 +-
apps/docs/providers/compute/docker.mdx | 13 +
.../(onboarding)/setup/StepComputeConfig.tsx | 2 +-
apps/web/src/app/(onboarding)/setup/page.tsx | 16 +-
.../ComputeProviderSection.client.test.tsx | 85 +-
.../settings/ComputeProviderSection.tsx | 88 +-
.../components/settings/ComputeProviders.tsx | 8 +-
.../src/trpc/commands/compute/index.test.ts | 50 +-
apps/web/src/trpc/commands/compute/index.ts | 9 +-
apps/web/src/trpc/commands/setup-new/index.ts | 13 +-
docker-compose.compute-docker.yml | 9 +
docker-compose.production.yml | 4 +
docker-compose.self-host.yml | 4 +
.../src/__tests__/adapters.contract.test.ts | 5 +-
.../compute-providers/src/adapters/blaxel.ts | 9 +-
.../compute-providers/src/adapters/docker.ts | 88 +-
packages/compute-providers/src/factory.ts | 8 +
packages/compute-providers/src/types.ts | 2 +
packages/db/drizzle/0009_abnormal_nuke.sql | 6 +
packages/db/drizzle/meta/0009_snapshot.json | 9006 +++++++++++++++++
packages/db/drizzle/meta/_journal.json | 7 +
.../__tests__/compute-runtime-config.test.ts | 14 +
packages/db/src/schema.ts | 6 +-
packages/env/src/__tests__/index.test.ts | 4 +
packages/env/src/index.ts | 12 +
.../src/compute-providers/capabilities.ts | 4 +-
.../src/compute-providers/compute-provider.ts | 2 +
.../types/src/setup-compute-config.test.ts | 41 +
packages/types/src/setup-compute-config.ts | 95 +-
42 files changed, 10271 insertions(+), 204 deletions(-)
create mode 100644 apps/bullmq/src/scheduled-jobs/standby-retention.test.ts
create mode 100644 apps/bullmq/src/scheduled-jobs/standby-retention.ts
create mode 100644 packages/db/drizzle/0009_abnormal_nuke.sql
create mode 100644 packages/db/drizzle/meta/0009_snapshot.json
diff --git a/.env.production.example b/.env.production.example
index db258f483..ba5a69d97 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -90,6 +90,8 @@ DEFAULT_COMPUTE_PROVIDER=docker
# DOCKER_WORKER_LOG_MAX_SIZE=10m
# DOCKER_WORKER_LOG_MAX_FILES=3
# DOCKER_WORKER_EGRESS_POLICY=internet
+# DOCKER_STANDBY_MAX_COUNT=10
+# DOCKER_STANDBY_MAX_AGE_HOURS=24
# MODAL_TOKEN_ID=
# MODAL_TOKEN_SECRET=
# Leave blank: the installer and deployer keep it in sync with the matching
diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts
index c4dc88b68..029345dee 100644
--- a/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts
+++ b/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts
@@ -617,6 +617,43 @@ describe('sleepCheckJob', () => {
);
});
+ it('retains due Docker jobs as stopped-container standby', async () => {
+ const mockJob = {
+ id: 6627,
+ machineId: 'roomote-worker-6627',
+ sandboxCmdId: null,
+ payloadKind: TaskPayloadKind.StandardTask,
+ status: RunStatus.Idle,
+ taskPhase: 'waiting_for_prompt',
+ vendor: 'docker',
+ taskId: 'task-docker-1',
+ snapshotId: null,
+ sleepRequestedAt: null,
+ snapshotRequestedAt: null,
+ };
+
+ mockJobQueries({ dueJobs: [mockJob] });
+ mockGetInstanceStatus.mockResolvedValue({ status: 'running' });
+ returningFn.mockResolvedValue([{ id: 6627 }]);
+
+ await sleepCheckJob();
+
+ expect(mockCreateComputeProviderClient).toHaveBeenCalledWith({
+ provider: 'docker',
+ });
+ expect(mockEnterStandby).toHaveBeenCalledWith({
+ instanceId: 'roomote-worker-6627',
+ commandId: undefined,
+ });
+ expect(mockCreateSnapshot).not.toHaveBeenCalled();
+ expect(setFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ snapshotId: 'roomote-worker-6627',
+ status: RunStatus.Completed,
+ }),
+ );
+ });
+
it('shuts down due non-resumable jobs', async () => {
const mockJob = {
id: 99,
diff --git a/apps/bullmq/src/scheduled-jobs/index.ts b/apps/bullmq/src/scheduled-jobs/index.ts
index a57d2bc35..0ac7585ef 100644
--- a/apps/bullmq/src/scheduled-jobs/index.ts
+++ b/apps/bullmq/src/scheduled-jobs/index.ts
@@ -4,3 +4,4 @@ export { refreshSnapshotsJob } from './refresh-snapshots';
export { pullRequestAnalyticsSyncJob } from './pull-request-analytics-sync';
export { instancePingJob } from './instance-ping';
export { webhookCleanupJob } from './webhook-cleanup';
+export { standbyRetentionJob } from './standby-retention';
diff --git a/apps/bullmq/src/scheduled-jobs/sleep-check.ts b/apps/bullmq/src/scheduled-jobs/sleep-check.ts
index b3adbe22d..26e4eb791 100644
--- a/apps/bullmq/src/scheduled-jobs/sleep-check.ts
+++ b/apps/bullmq/src/scheduled-jobs/sleep-check.ts
@@ -157,6 +157,8 @@ async function createSleepCheckClient(provider: ComputeProvider) {
provider: 'blaxel',
envFallback: await resolveComputeProviderEnvValues('blaxel'),
});
+ case 'docker':
+ return createComputeProviderClient({ provider: 'docker' });
default:
throw new Error(
`Sleep check has no compute client for provider "${provider}"`,
@@ -688,7 +690,7 @@ async function claimAndEnterStandby(
await recordSleepCheckEvent(
job,
'completed',
- `Retained Blaxel sandbox ${job.machineId} on standby for task run #${job.id}.`,
+ `Retained ${job.vendor} sandbox ${job.machineId} on standby for task run #${job.id}.`,
{
path,
decision: 'standby_completed',
diff --git a/apps/bullmq/src/scheduled-jobs/standby-retention.test.ts b/apps/bullmq/src/scheduled-jobs/standby-retention.test.ts
new file mode 100644
index 000000000..c5894eb6b
--- /dev/null
+++ b/apps/bullmq/src/scheduled-jobs/standby-retention.test.ts
@@ -0,0 +1,78 @@
+import {
+ resolveStandbyRetentionPolicy,
+ selectStandbyEvictions,
+} from './standby-retention';
+
+const now = new Date('2026-07-12T12:00:00.000Z');
+
+function candidate(handle: string, ageHours: number) {
+ return {
+ runId: Number(handle.slice(1)),
+ taskId: `task-${handle}`,
+ provider: 'docker' as const,
+ handle,
+ createdAt: new Date(now.getTime() - ageHours * 60 * 60 * 1_000),
+ };
+}
+
+describe('selectStandbyEvictions', () => {
+ it('evicts expired and over-count standbys while protecting active resumes', () => {
+ const candidates = [
+ candidate('h1', 1),
+ candidate('h2', 2),
+ candidate('h3', 3),
+ candidate('h4', 30),
+ ];
+
+ expect(
+ selectStandbyEvictions(
+ candidates,
+ new Set(['h3']),
+ { maxCount: 2, maxAgeMs: 24 * 60 * 60 * 1_000 },
+ now,
+ ).map(({ handle }) => handle),
+ ).toEqual(['h4']);
+ });
+
+ it('evicts every unprotected standby when the count is zero', () => {
+ expect(
+ selectStandbyEvictions(
+ [candidate('h1', 1), candidate('h2', 2)],
+ new Set(),
+ { maxCount: 0, maxAgeMs: 24 * 60 * 60 * 1_000 },
+ now,
+ ).map(({ handle }) => handle),
+ ).toEqual(['h1', 'h2']);
+ });
+});
+
+describe('resolveStandbyRetentionPolicy', () => {
+ it('uses provider defaults when no overrides are configured', () => {
+ expect(resolveStandbyRetentionPolicy('docker', {})).toEqual({
+ maxCount: 10,
+ maxAgeMs: 24 * 60 * 60 * 1_000,
+ });
+ expect(resolveStandbyRetentionPolicy('blaxel', {})).toEqual({
+ maxCount: 25,
+ maxAgeMs: 168 * 60 * 60 * 1_000,
+ });
+ });
+
+ it('applies saved or runtime provider overrides', () => {
+ expect(
+ resolveStandbyRetentionPolicy('docker', {
+ DOCKER_STANDBY_MAX_COUNT: '3',
+ DOCKER_STANDBY_MAX_AGE_HOURS: '12',
+ }),
+ ).toEqual({ maxCount: 3, maxAgeMs: 12 * 60 * 60 * 1_000 });
+ });
+
+ it('falls back safely for invalid values', () => {
+ expect(
+ resolveStandbyRetentionPolicy('blaxel', {
+ BLAXEL_STANDBY_MAX_COUNT: '-1',
+ BLAXEL_STANDBY_MAX_AGE_HOURS: '169',
+ }),
+ ).toEqual({ maxCount: 25, maxAgeMs: 168 * 60 * 60 * 1_000 });
+ });
+});
diff --git a/apps/bullmq/src/scheduled-jobs/standby-retention.ts b/apps/bullmq/src/scheduled-jobs/standby-retention.ts
new file mode 100644
index 000000000..6bf2834d2
--- /dev/null
+++ b/apps/bullmq/src/scheduled-jobs/standby-retention.ts
@@ -0,0 +1,212 @@
+import {
+ and,
+ db,
+ desc,
+ eq,
+ inArray,
+ isNotNull,
+ resolveComputeProviderEnvValues,
+ taskRuns,
+} from '@roomote/db/server';
+import { createComputeProviderClient } from '@roomote/compute-providers';
+import { activeRunStatuses, type RunStatus } from '@roomote/types';
+
+const LOG_PREFIX = '[standbyRetention]';
+const MS_PER_HOUR = 60 * 60 * 1_000;
+const STANDBY_PROVIDERS = ['docker', 'blaxel'] as const;
+
+type StandbyProvider = (typeof STANDBY_PROVIDERS)[number];
+
+type StandbyCandidate = {
+ runId: number;
+ taskId: string;
+ provider: StandbyProvider;
+ handle: string;
+ createdAt: Date;
+};
+
+export function selectStandbyEvictions(
+ candidates: StandbyCandidate[],
+ protectedHandles: ReadonlySet,
+ policy: { maxCount: number; maxAgeMs: number },
+ now: Date,
+): StandbyCandidate[] {
+ const cutoffMs = now.getTime() - policy.maxAgeMs;
+ return candidates.filter(
+ (candidate, index) =>
+ !protectedHandles.has(candidate.handle) &&
+ (candidate.createdAt.getTime() < cutoffMs || index >= policy.maxCount),
+ );
+}
+
+const DEFAULT_POLICY = {
+ docker: { maxCount: 10, maxAgeHours: 24 },
+ blaxel: { maxCount: 25, maxAgeHours: 168 },
+} as const;
+
+function parsePolicyInteger(
+ value: string | undefined,
+ fallback: number,
+ min: number,
+ max = Number.POSITIVE_INFINITY,
+): number {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed >= min && parsed <= max
+ ? parsed
+ : fallback;
+}
+
+export function resolveStandbyRetentionPolicy(
+ provider: StandbyProvider,
+ env: Partial>,
+): {
+ maxCount: number;
+ maxAgeMs: number;
+} {
+ const prefix = provider === 'docker' ? 'DOCKER' : 'BLAXEL';
+ const defaults = DEFAULT_POLICY[provider];
+ const maxCount = parsePolicyInteger(
+ env[`${prefix}_STANDBY_MAX_COUNT`],
+ defaults.maxCount,
+ 0,
+ );
+ const maxAgeHours = parsePolicyInteger(
+ env[`${prefix}_STANDBY_MAX_AGE_HOURS`],
+ defaults.maxAgeHours,
+ 1,
+ 168,
+ );
+
+ return { maxCount, maxAgeMs: maxAgeHours * MS_PER_HOUR };
+}
+
+async function createClient(provider: StandbyProvider) {
+ if (provider === 'docker') {
+ return createComputeProviderClient({ provider: 'docker' });
+ }
+
+ return createComputeProviderClient({
+ provider: 'blaxel',
+ envFallback: await resolveComputeProviderEnvValues('blaxel'),
+ });
+}
+
+async function getProtectedHandles(provider: StandbyProvider) {
+ const rows = await db
+ .select({ handle: taskRuns.sourceSnapshotId })
+ .from(taskRuns)
+ .where(
+ and(
+ eq(taskRuns.vendor, provider),
+ isNotNull(taskRuns.sourceSnapshotId),
+ inArray(taskRuns.status, activeRunStatuses as readonly RunStatus[]),
+ ),
+ );
+
+ return new Set(
+ rows.flatMap(({ handle }) => (handle === null ? [] : [handle])),
+ );
+}
+
+async function getCandidates(
+ provider: StandbyProvider,
+): Promise {
+ const rows = await db
+ .select({
+ runId: taskRuns.id,
+ taskId: taskRuns.taskId,
+ provider: taskRuns.vendor,
+ handle: taskRuns.snapshotId,
+ createdAt: taskRuns.snapshotCreatedAt,
+ })
+ .from(taskRuns)
+ .where(
+ and(
+ eq(taskRuns.vendor, provider),
+ isNotNull(taskRuns.snapshotId),
+ isNotNull(taskRuns.snapshotCreatedAt),
+ ),
+ )
+ .orderBy(desc(taskRuns.snapshotCreatedAt));
+
+ const seen = new Set();
+ return rows.flatMap((row) => {
+ if (
+ row.provider !== provider ||
+ row.handle === null ||
+ row.createdAt === null ||
+ seen.has(row.handle)
+ ) {
+ return [];
+ }
+
+ seen.add(row.handle);
+ return [{ ...row, provider, handle: row.handle, createdAt: row.createdAt }];
+ });
+}
+
+async function enforceProviderRetention(
+ provider: StandbyProvider,
+ now: Date,
+): Promise {
+ const resolvedEnv = await resolveComputeProviderEnvValues(provider);
+ const policy = resolveStandbyRetentionPolicy(provider, resolvedEnv);
+ const candidates = await getCandidates(provider);
+ const protectedHandles = await getProtectedHandles(provider);
+ const evicted = selectStandbyEvictions(
+ candidates,
+ protectedHandles,
+ policy,
+ now,
+ );
+
+ if (evicted.length === 0) return 0;
+
+ const client = await createClient(provider);
+ let removed = 0;
+
+ for (const candidate of evicted) {
+ try {
+ await client.destroyInstance({ instanceId: candidate.handle });
+ await db
+ .update(taskRuns)
+ .set({
+ snapshotId: null,
+ snapshotCreatedAt: null,
+ })
+ .where(
+ and(
+ eq(taskRuns.vendor, provider),
+ eq(taskRuns.snapshotId, candidate.handle),
+ ),
+ );
+ removed += 1;
+ console.log(
+ `${LOG_PREFIX} removed ${provider} standby ${candidate.handle} from task run #${candidate.runId}`,
+ );
+ } catch (error) {
+ console.error(
+ `${LOG_PREFIX} failed to remove ${provider} standby ${candidate.handle}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ return removed;
+}
+
+export async function standbyRetentionJob(
+ now: Date = new Date(),
+): Promise {
+ const results = await Promise.all(
+ STANDBY_PROVIDERS.map(async (provider) => ({
+ provider,
+ removed: await enforceProviderRetention(provider, now),
+ })),
+ );
+
+ console.log(
+ `${LOG_PREFIX} completed ${results
+ .map(({ provider, removed }) => `${provider}=${removed}`)
+ .join(' ')}`,
+ );
+}
diff --git a/apps/bullmq/src/scheduler.ts b/apps/bullmq/src/scheduler.ts
index bff3fc066..1361cff1d 100644
--- a/apps/bullmq/src/scheduler.ts
+++ b/apps/bullmq/src/scheduler.ts
@@ -26,6 +26,7 @@ import {
pullRequestAnalyticsSyncJob,
instancePingJob,
webhookCleanupJob,
+ standbyRetentionJob,
} from './scheduled-jobs';
const QUEUE_NAME = 'scheduled-jobs';
@@ -83,6 +84,11 @@ async function createJobs(queue: Queue): Promise {
{ every: 60 * 1000 }, // Every 60 seconds.
);
+ await queue.upsertJobScheduler(
+ ScheduledJobName.StandbyRetention,
+ { every: 5 * 60 * 1000 }, // Every 5 minutes.
+ );
+
await queue.upsertJobScheduler(
ScheduledJobName.RefreshSnapshots,
{ every: 24 * 60 * 60 * 1000 }, // Every 24 hours.
@@ -170,6 +176,8 @@ const runJobs = async (job: ScheduledJob): Promise => {
return instancePingJob();
case ScheduledJobName.WebhookCleanup:
return webhookCleanupJob();
+ case ScheduledJobName.StandbyRetention:
+ return standbyRetentionJob();
default:
throw new Error(`Unknown job type: ${job.name}`);
}
diff --git a/apps/bullmq/src/types.ts b/apps/bullmq/src/types.ts
index a0247bb0a..f4df555f7 100644
--- a/apps/bullmq/src/types.ts
+++ b/apps/bullmq/src/types.ts
@@ -10,6 +10,7 @@ export enum ScheduledJobName {
PullRequestAnalyticsSync = 'PullRequestAnalyticsSync',
InstancePing = 'InstancePing',
WebhookCleanup = 'WebhookCleanup',
+ StandbyRetention = 'StandbyRetention',
}
/**
diff --git a/apps/controller/src/compute-providers/__tests__/docker-sandbox-security.test.ts b/apps/controller/src/compute-providers/__tests__/docker-sandbox-security.test.ts
index a3e42b6a9..88fb2b8ca 100644
--- a/apps/controller/src/compute-providers/__tests__/docker-sandbox-security.test.ts
+++ b/apps/controller/src/compute-providers/__tests__/docker-sandbox-security.test.ts
@@ -7,6 +7,7 @@ import {
getDockerTaskNetworkName,
isUnsupportedDockerDiskLimitError,
prepareDockerTaskNetwork,
+ restoreDockerStandbyNetworking,
type DockerCommand,
} from '../docker-sandbox-security';
@@ -228,6 +229,98 @@ describe('attachDockerEgressPolicy', () => {
});
});
+describe('restoreDockerStandbyNetworking', () => {
+ it('re-applies egress routes and reconnects recreated trusted services', async () => {
+ const runDocker = vi.fn(async (args) => {
+ if (
+ args[0] === 'network' &&
+ args[1] === 'inspect' &&
+ args[2] === 'roomote-control'
+ ) {
+ return JSON.stringify([
+ {
+ Id: 'control-network-id',
+ Containers: {
+ apiContainerId: { Name: 'roomote-api' },
+ previewContainerId: { Name: 'roomote-preview-proxy' },
+ },
+ },
+ ]);
+ }
+ if (args[0] === 'inspect' && args[1] === 'apiContainerId') {
+ return JSON.stringify([
+ {
+ Config: { Labels: { 'com.docker.compose.service': 'api' } },
+ NetworkSettings: {
+ Networks: {
+ control: {
+ NetworkID: 'control-network-id',
+ Aliases: ['api'],
+ },
+ },
+ },
+ },
+ ]);
+ }
+ if (args[0] === 'inspect' && args[1] === 'previewContainerId') {
+ return JSON.stringify([
+ {
+ Config: {
+ Labels: { 'com.docker.compose.service': 'preview-proxy' },
+ },
+ NetworkSettings: {
+ Networks: {
+ control: {
+ NetworkID: 'control-network-id',
+ Aliases: ['preview-proxy'],
+ },
+ },
+ },
+ },
+ ]);
+ }
+ return '';
+ });
+
+ await restoreDockerStandbyNetworking(
+ {
+ containerName: 'roomote-worker-96',
+ taskNetwork: 'roomote-task-96',
+ controlNetwork: 'roomote-control',
+ egressPolicy: 'internet',
+ image: 'roomote-worker:test',
+ platform: 'linux/amd64',
+ },
+ runDocker,
+ );
+
+ expect(runDocker).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ 'run',
+ '--network',
+ 'container:roomote-worker-96',
+ 'roomote-worker:test',
+ ]),
+ );
+ expect(runDocker).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ 'network',
+ 'connect',
+ 'roomote-task-96',
+ 'apiContainerId',
+ ]),
+ );
+ expect(runDocker).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ 'network',
+ 'connect',
+ 'roomote-task-96',
+ 'previewContainerId',
+ ]),
+ );
+ });
+});
+
describe('cleanupStaleDockerSandboxes', () => {
it('removes a stopped auto-remove container and disconnects trusted peers after a controller restart', async () => {
const taskNetwork = getDockerTaskNetworkName(93);
diff --git a/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts b/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts
index c818622a8..13b5543cf 100644
--- a/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts
+++ b/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts
@@ -3,11 +3,13 @@ import { describe, expect, it } from 'vitest';
import { processListIncludesDockerWorkerRun } from '../docker-sandbox-security';
import {
buildDockerSandboxServerUrl,
+ getDockerWorkerCommand,
resolveDockerWorkerOwnershipTargetFromLookup,
shouldRetryDockerWorkerWithoutDiskLimit,
shouldAutoRemoveDockerWorkerContainer,
toContainerReachableUrl,
} from '../spawn-docker-worker';
+import { TaskPayloadKind } from '@roomote/types';
describe('processListIncludesDockerWorkerRun', () => {
it('matches the shell launcher command while the Docker worker is starting', () => {
@@ -42,6 +44,24 @@ describe('processListIncludesDockerWorkerRun', () => {
),
).toBe(false);
});
+
+ it('matches a retained container running the resume command', () => {
+ expect(
+ processListIncludesDockerWorkerRun(
+ '/sandbox/worker/dist/worker.js resume 14',
+ 14,
+ ),
+ ).toBe(true);
+ });
+});
+
+describe('getDockerWorkerCommand', () => {
+ it('uses resume only for standby resume task runs', () => {
+ expect(getDockerWorkerCommand(TaskPayloadKind.SnapshotResume)).toBe(
+ 'resume',
+ );
+ expect(getDockerWorkerCommand(TaskPayloadKind.StandardTask)).toBe('run');
+ });
});
describe('shouldRetryDockerWorkerWithoutDiskLimit', () => {
@@ -116,12 +136,9 @@ describe('toContainerReachableUrl', () => {
});
describe('shouldAutoRemoveDockerWorkerContainer', () => {
- it('reaps containers in production and preview so token files do not linger', () => {
- expect(shouldAutoRemoveDockerWorkerContainer('production')).toBe(true);
- expect(shouldAutoRemoveDockerWorkerContainer('preview')).toBe(true);
- });
-
- it('preserves containers in development for post-mortem debugging', () => {
+ it('preserves containers in every environment for bounded standby retention', () => {
+ expect(shouldAutoRemoveDockerWorkerContainer('production')).toBe(false);
+ expect(shouldAutoRemoveDockerWorkerContainer('preview')).toBe(false);
expect(shouldAutoRemoveDockerWorkerContainer('development')).toBe(false);
});
});
diff --git a/apps/controller/src/compute-providers/docker-sandbox-security.ts b/apps/controller/src/compute-providers/docker-sandbox-security.ts
index 30dcd57cc..586cc2de4 100644
--- a/apps/controller/src/compute-providers/docker-sandbox-security.ts
+++ b/apps/controller/src/compute-providers/docker-sandbox-security.ts
@@ -176,10 +176,23 @@ export function processListIncludesDockerWorkerRun(
.split('\n')
.some((line) =>
[
- new RegExp(`(?:^|\\s)worker\\s+run\\s+${escapedTaskRunId}(?:\\s|$)`),
new RegExp(
- `(?:^|[\\s/])worker\\.js\\s+run\\s+${escapedTaskRunId}(?:\\s|$)`,
+ `(?:^|\\s)worker\\s+(?:run|resume)\\s+${escapedTaskRunId}(?:\\s|$)`,
),
+ new RegExp(
+ `(?:^|[\\s/])worker\\.js\\s+(?:run|resume)\\s+${escapedTaskRunId}(?:\\s|$)`,
+ ),
+ ].some((pattern) => pattern.test(line)),
+ );
+}
+
+function processListIncludesDockerWorkerProcess(processList: string): boolean {
+ return processList
+ .split('\n')
+ .some((line) =>
+ [
+ /(?:^|\s)worker\s+(?:run|resume)\s+\d+(?:\s|$)/,
+ /(?:^|[\s/])worker\.js\s+(?:run|resume)\s+\d+(?:\s|$)/,
].some((pattern) => pattern.test(line)),
);
}
@@ -290,6 +303,43 @@ export async function attachDockerEgressPolicy(
]);
}
+/**
+ * Restores network state that is not guaranteed to survive while a retained
+ * worker container is stopped. Egress routes live in the container network
+ * namespace, and trusted control-plane containers may have been recreated
+ * while the task was in standby.
+ */
+export async function restoreDockerStandbyNetworking(
+ params: {
+ containerName: string;
+ taskNetwork: string;
+ controlNetwork?: string;
+ egressPolicy: DockerWorkerEgressPolicy;
+ image: string;
+ platform: string;
+ },
+ runDocker: DockerCommand = docker,
+): Promise {
+ await attachDockerEgressPolicy(
+ {
+ containerName: params.containerName,
+ egressPolicy: params.egressPolicy,
+ image: params.image,
+ platform: params.platform,
+ blockDockerGateway: Boolean(params.controlNetwork),
+ },
+ runDocker,
+ );
+
+ if (params.controlNetwork) {
+ await connectTrustedControlPlaneServices(
+ params.controlNetwork,
+ params.taskNetwork,
+ runDocker,
+ );
+ }
+}
+
export async function removeDockerSandboxResources(
params: { containerName: string; taskNetwork: string },
runDocker: DockerCommand = docker,
@@ -411,7 +461,7 @@ async function reconcileTaskNetwork(
return;
}
- if (!processListIncludesDockerWorkerRun(processList, taskRunId)) {
+ if (!processListIncludesDockerWorkerProcess(processList)) {
await removeDockerSandboxResources(
{ containerName, taskNetwork },
runDocker,
diff --git a/apps/controller/src/compute-providers/spawn-docker-worker.ts b/apps/controller/src/compute-providers/spawn-docker-worker.ts
index 1ad889fac..c83bb77ac 100644
--- a/apps/controller/src/compute-providers/spawn-docker-worker.ts
+++ b/apps/controller/src/compute-providers/spawn-docker-worker.ts
@@ -41,6 +41,7 @@ import {
prepareDockerTaskNetwork,
processListIncludesDockerWorkerRun,
removeDockerSandboxResources,
+ restoreDockerStandbyNetworking,
type DockerWorkerEgressPolicy,
} from './docker-sandbox-security';
@@ -72,22 +73,31 @@ export async function spawnDockerWorker(
deploymentSlug?: string;
},
): Promise<{ containerId: string }> {
- if (
- taskRun.payloadKind === TaskPayloadKind.SnapshotEnvironment ||
- taskRun.payloadKind === TaskPayloadKind.SnapshotResume
- ) {
+ if (taskRun.payloadKind === TaskPayloadKind.SnapshotEnvironment) {
throw new NonRetryableSpawnError(
`Docker provider does not support ${taskRun.payloadKind} task run kinds`,
);
}
- if (!config.localWorkerReleasePath) {
+ const isStandbyResume =
+ taskRun.payloadKind === TaskPayloadKind.SnapshotResume;
+ if (isStandbyResume && !taskRun.sourceSnapshotId) {
+ throw new NonRetryableSpawnError(
+ `SnapshotResume task run #${taskRun.id} missing sourceSnapshotId`,
+ );
+ }
+
+ if (!isStandbyResume && !config.localWorkerReleasePath) {
throw new Error(
'Docker provider requires a local worker release archive. TaskRun pnpm dev without --use-release.',
);
}
- if (!existsSync(config.localWorkerReleasePath)) {
+ if (
+ !isStandbyResume &&
+ config.localWorkerReleasePath &&
+ !existsSync(config.localWorkerReleasePath)
+ ) {
throw new Error(
`Docker worker release archive does not exist: ${config.localWorkerReleasePath}`,
);
@@ -116,29 +126,33 @@ export async function spawnDockerWorker(
defaultPreviewDomains: Env.PREVIEW_DOMAINS,
});
- const containerName = getDockerWorkerContainerName(taskRun.id);
+ const containerName = isStandbyResume
+ ? taskRun.sourceSnapshotId!
+ : getDockerWorkerContainerName(taskRun.id);
+ const sourceRunId = getDockerSourceRunId(containerName);
const controlNetwork = config.network?.trim();
const portArgs = controlNetwork
? []
: namedPorts.flatMap(({ port }) => ['-p', `127.0.0.1::${port}`]);
- // Auto-remove the container when its worker process exits so the cloned repo
- // and the worker's short-lived token files do not linger in a stopped
- // container. Development keeps containers for post-mortem debugging.
+ // Retained containers are reaped by the standby retention policy instead of
+ // Docker's --rm lifecycle, so their writable layer remains resumable.
const autoRemoveContainer = shouldAutoRemoveDockerWorkerContainer(
resolveAppEnv(process.env),
);
- const dockerNetwork = await prepareDockerTaskNetwork({
- taskRunId: taskRun.id,
- controlNetwork,
- egressPolicy: config.egressPolicy,
- autoRemove: autoRemoveContainer,
- });
+ const dockerNetwork = isStandbyResume
+ ? getDockerTaskNetworkName(sourceRunId)
+ : await prepareDockerTaskNetwork({
+ taskRunId: taskRun.id,
+ controlNetwork,
+ egressPolicy: config.egressPolicy,
+ autoRemove: autoRemoveContainer,
+ });
console.log(
`[spawnDockerWorker] Starting Docker worker container for task run #${taskRun.id} ${JSON.stringify(
{
- image: config.image,
+ image: isStandbyResume ? '(retained container)' : config.image,
platform: config.platform,
controlNetwork,
taskNetwork: dockerNetwork,
@@ -180,59 +194,78 @@ export async function spawnDockerWorker(
).trim();
try {
- try {
- containerId = await startContainer(config.diskLimit);
- } catch (error) {
- if (
- !shouldRetryDockerWorkerWithoutDiskLimit({
- diskLimit: config.diskLimit,
- allowUnboundedDisk: config.allowUnboundedDisk,
- error,
- })
- ) {
- throw error;
+ if (isStandbyResume) {
+ await docker(['start', containerName]);
+ containerId = containerName;
+ await restoreDockerStandbyNetworking({
+ containerName,
+ taskNetwork: dockerNetwork,
+ controlNetwork,
+ egressPolicy: config.egressPolicy,
+ image: config.image,
+ platform: config.platform,
+ });
+ } else {
+ try {
+ containerId = await startContainer(config.diskLimit);
+ } catch (error) {
+ if (
+ !shouldRetryDockerWorkerWithoutDiskLimit({
+ diskLimit: config.diskLimit,
+ allowUnboundedDisk: config.allowUnboundedDisk,
+ error,
+ })
+ ) {
+ throw error;
+ }
+
+ console.warn(
+ `[spawnDockerWorker] Docker storage driver cannot enforce writable-layer limit ${config.diskLimit}; DOCKER_WORKER_ALLOW_UNBOUNDED_DISK is enabled, so this task will continue without --storage-opt size.`,
+ );
+ await docker(['rm', '-f', containerName], { allowFailure: true });
+ containerId = await startContainer();
}
- console.warn(
- `[spawnDockerWorker] Docker storage driver cannot enforce writable-layer limit ${config.diskLimit}; DOCKER_WORKER_ALLOW_UNBOUNDED_DISK is enabled, so this task will continue without --storage-opt size.`,
- );
- await docker(['rm', '-f', containerName], { allowFailure: true });
- containerId = await startContainer();
+ await attachDockerEgressPolicy({
+ containerName,
+ egressPolicy: config.egressPolicy,
+ image: config.image,
+ platform: config.platform,
+ blockDockerGateway: Boolean(controlNetwork),
+ });
+ await docker([
+ 'cp',
+ `${resolveFromWorkspaceRoot('.docker/sandbox')}/.`,
+ `${containerName}:${DOCKER_WORKER_ROOT}/`,
+ ]);
+ await docker([
+ 'cp',
+ config.localWorkerReleasePath!,
+ `${containerName}:${DOCKER_WORKER_ARCHIVE_PATH}`,
+ ]);
+ // docker cp preserves host-side ownership on the copied tree, which on
+ // macOS leaves /sandbox owned by the host uid instead of the image user
+ // and makes install-worker.sh unable to create /sandbox/worker.
+ const workerOwner =
+ await resolveDockerWorkerOwnershipTarget(containerName);
+ await docker([
+ 'exec',
+ '-u',
+ 'root',
+ containerName,
+ 'chown',
+ '-R',
+ workerOwner,
+ DOCKER_WORKER_ROOT,
+ ]);
+ await docker([
+ 'exec',
+ containerName,
+ 'bash',
+ DOCKER_INSTALL_WORKER_SCRIPT,
+ ]);
}
- await attachDockerEgressPolicy({
- containerName,
- egressPolicy: config.egressPolicy,
- image: config.image,
- platform: config.platform,
- blockDockerGateway: Boolean(controlNetwork),
- });
- await docker([
- 'cp',
- `${resolveFromWorkspaceRoot('.docker/sandbox')}/.`,
- `${containerName}:${DOCKER_WORKER_ROOT}/`,
- ]);
- await docker([
- 'cp',
- config.localWorkerReleasePath,
- `${containerName}:${DOCKER_WORKER_ARCHIVE_PATH}`,
- ]);
- // docker cp preserves host-side ownership on the copied tree, which on
- // macOS leaves /sandbox owned by the host uid instead of the image user
- // and makes install-worker.sh unable to create /sandbox/worker.
- const workerOwner = await resolveDockerWorkerOwnershipTarget(containerName);
- await docker([
- 'exec',
- '-u',
- 'root',
- containerName,
- 'chown',
- '-R',
- workerOwner,
- DOCKER_WORKER_ROOT,
- ]);
- await docker(['exec', containerName, 'bash', DOCKER_INSTALL_WORKER_SCRIPT]);
-
const portMap = controlNetwork
? new Map()
: await getPublishedPorts(containerName, namedPorts);
@@ -256,7 +289,7 @@ export async function spawnDockerWorker(
environmentConfig?.ports,
)?.name,
sandboxServerUrl,
- sourceSnapshotId: null,
+ sourceSnapshotId: isStandbyResume ? containerName : null,
authBypassValue,
authBypassHeaderName,
});
@@ -305,6 +338,7 @@ export async function spawnDockerWorker(
},
});
+ const workerCommand = getDockerWorkerCommand(taskRun.payloadKind);
await docker([
'exec',
'-d',
@@ -315,12 +349,7 @@ export async function spawnDockerWorker(
containerName,
'bash',
'-lc',
- [
- `worker run ${taskRun.id} > /proc/1/fd/1 2> /proc/1/fd/2`,
- 'status=$?',
- 'kill 1',
- 'exit "$status"',
- ].join('; '),
+ `worker ${workerCommand} ${taskRun.id} > /proc/1/fd/1 2> /proc/1/fd/2`,
]);
await assertDetachedWorkerStarted(containerName, taskRun.id);
@@ -333,14 +362,18 @@ export async function spawnDockerWorker(
return { containerId: containerName };
} catch (error) {
- if (resolveAppEnv(process.env) === 'development' && containerId) {
+ if (isStandbyResume && containerId) {
+ await docker(['stop', '--time', '10', containerName], {
+ allowFailure: true,
+ });
+ } else if (resolveAppEnv(process.env) === 'development' && containerId) {
console.error(
`[spawnDockerWorker] Preserving failed Docker worker container ${containerName} for local debugging`,
);
} else {
await removeDockerSandboxResources({
containerName,
- taskNetwork: getDockerTaskNetworkName(taskRun.id),
+ taskNetwork: dockerNetwork,
});
}
throw error;
@@ -586,14 +619,27 @@ export function resolveDockerWorkerOwnershipTargetFromLookup({
return `${trimmedImageUser}:${groupName}`;
}
-/**
- * Whether a Docker worker container should be started with `--rm` so it is
- * removed once its worker exits. Development preserves stopped containers for
- * debugging; every other environment reaps them to avoid accumulating stopped
- * containers that still hold the cloned repo and the worker's token files.
- */
+/** Retention owns cleanup, so Docker must not remove a resumable container. */
export function shouldAutoRemoveDockerWorkerContainer(appEnv: string): boolean {
- return appEnv !== 'development';
+ void appEnv;
+ return false;
+}
+
+function getDockerSourceRunId(containerName: string): number {
+ const match = /^roomote-worker-(\d+)$/.exec(containerName);
+ const runId = Number(match?.[1]);
+ if (!Number.isInteger(runId)) {
+ throw new NonRetryableSpawnError(
+ `Invalid Docker standby handle: ${containerName}`,
+ );
+ }
+ return runId;
+}
+
+export function getDockerWorkerCommand(
+ payloadKind: TaskPayloadKind,
+): 'resume' | 'run' {
+ return payloadKind === TaskPayloadKind.SnapshotResume ? 'resume' : 'run';
}
export function toContainerReachableUrl(value: string | undefined): string {
diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx
index 6354a56a9..433f4d42e 100644
--- a/apps/docs/environment-variables.mdx
+++ b/apps/docs/environment-variables.mdx
@@ -107,19 +107,19 @@ as per-task auth tokens or workspace paths.
### Runtime and URLs
-| Env var | Required | Used for |
-| --------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `NODE_ENV` | Production | Node runtime mode: `development`, `test`, or `production`. Production deploys should set `production`. |
-| `R_APP_ENV` | Recommended | Roomote app environment: `development`, `preview`, or `production`. Also affects monitoring and local URL defaults. |
-| `R_PUBLIC_URL` | Local callbacks | Stable public HTTPS origin used by local development for OAuth and webhook callbacks. |
-| `R_APP_URL` | Production | Public web app origin. Workers and integrations use it for links back to Roomote. |
-| `TRPC_URL` | Production | API/tRPC origin used by workers and services. In single-origin production, this is often the app URL plus `/_roomote-api`. |
-| `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. |
-| `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. |
-| `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. |
-| `WEB_DEV_LOGIN_EMAIL` | Local only | Convenience email for development login flows. Do not use as production access control. |
-| `WEB_DEV_LOGIN_ENABLED` | Local only | Explicit opt-in (`true` or `1`) for the `/auth/dev-login` development login route. Dev login stays disabled without it, even in development app envs. `pnpm dev` sets it automatically for local development. |
-| `SKIP_ENV_VALIDATION` | Avoid | Skips env validation when present. Useful for narrow tooling cases, not normal deployments. |
+| Env var | Required | Used for |
+| ----------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `NODE_ENV` | Production | Node runtime mode: `development`, `test`, or `production`. Production deploys should set `production`. |
+| `R_APP_ENV` | Recommended | Roomote app environment: `development`, `preview`, or `production`. Also affects monitoring and local URL defaults. |
+| `R_PUBLIC_URL` | Local callbacks | Stable public HTTPS origin used by local development for OAuth and webhook callbacks. |
+| `R_APP_URL` | Production | Public web app origin. Workers and integrations use it for links back to Roomote. |
+| `TRPC_URL` | Production | API/tRPC origin used by workers and services. In single-origin production, this is often the app URL plus `/_roomote-api`. |
+| `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. |
+| `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. |
+| `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. |
+| `WEB_DEV_LOGIN_EMAIL` | Local only | Convenience email for development login flows. Do not use as production access control. |
+| `WEB_DEV_LOGIN_ENABLED` | Local only | Explicit opt-in (`true` or `1`) for the `/auth/dev-login` development login route. Dev login stays disabled without it, even in development app envs. `pnpm dev` sets it automatically for local development. |
+| `SKIP_ENV_VALIDATION` | Avoid | Skips env validation when present. Useful for narrow tooling cases, not normal deployments. |
### Database, Redis, and artifacts
@@ -188,49 +188,53 @@ as per-task auth tokens or workspace paths.
### Sandbox providers
-| Env var | Required | Used for |
-| ------------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------- |
-| `DEFAULT_COMPUTE_PROVIDER` | Optional | Runtime default sandbox provider when no admin default is saved. Supported values are `docker`, `modal`, `e2b`, `daytona`, and `blaxel`. |
-| `EXCLUDED_COMPUTE_PROVIDERS` | Optional | Comma-separated provider IDs to hide or exclude from default selection. |
-| `DOCKER_WORKER_IMAGE` | Optional | Worker image used by Docker and for hosted sandbox derivation. In production, prefer an immutable registry-qualified tag. |
-| `ROOMOTE_WORKER_IMAGE_REPO` | Optional | Registry repository used to derive the worker image from `RELEASE_VERSION` when `DOCKER_WORKER_IMAGE` is unset. |
-| `DOCKER_WORKER_PLATFORM` | Optional | Docker worker platform. Defaults to the host architecture (`linux/arm64` on arm hosts, `linux/amd64` otherwise). |
-| `DOCKER_WORKER_NETWORK` | Self-host Docker | Docker network for worker containers in Compose/self-host mode. |
-| `DOCKER_WORKER_RELEASE_PATH` | Optional | Path to a packaged worker release archive used by Docker worker bootstrap. |
-| `DOCKER_WORKER_CPU_LIMIT` | Optional | CPU limit per Docker task. Defaults to `2`. |
-| `DOCKER_WORKER_MEMORY_LIMIT` | Optional | Memory and memory+swap limit per Docker task. Defaults to `4g`. |
-| `DOCKER_WORKER_PIDS_LIMIT` | Optional | Process limit per Docker task. Defaults to `512`. |
-| `DOCKER_WORKER_DISK_LIMIT` | Optional | Writable-layer limit. Defaults to `20g`; tasks fail closed when the Docker storage driver cannot enforce it. |
-| `DOCKER_WORKER_ALLOW_UNBOUNDED_DISK` | Optional | Explicitly permits startup without `--storage-opt size`. Defaults to `false`; use only with an equivalent host-level quota. |
-| `DOCKER_WORKER_LOG_MAX_SIZE` | Optional | Maximum size of each Docker JSON log file. Defaults to `10m`. |
-| `DOCKER_WORKER_LOG_MAX_FILES` | Optional | Number of rotated Docker JSON log files retained. Defaults to `3`. |
-| `DOCKER_WORKER_EGRESS_POLICY` | Optional | `internet` (public egress; self-host blocks private+metadata ranges, local dev blocks metadata) or `none` (no external egress). |
-| `MODAL_TOKEN_ID` | Modal | Modal token ID. Can also be saved from **Settings > Sandboxes**. |
-| `MODAL_TOKEN_SECRET` | Modal | Modal token secret. |
-| `MODAL_BASE_IMAGE_REF` | Modal | Modal base image reference. Usually derived from the worker image; set only to pin a custom image. |
-| `MODAL_ENDPOINT` | Optional | Modal endpoint override. |
-| `MODAL_ENVIRONMENT` | Optional | Modal environment name. |
-| `MODAL_APP_NAME` | Optional | Modal app name override. |
-| `MODAL_REGISTRY_USERNAME` | Private registry | Registry username for private non-ECR image pulls. |
-| `MODAL_REGISTRY_PASSWORD` | Private registry | Registry password for private non-ECR image pulls. |
-| `MODAL_ECR_OIDC_ROLE_ARN` | ECR | AWS IAM role ARN used by Modal for ECR OIDC pulls. |
-| `MODAL_ECR_REGION` | ECR | AWS region for Modal ECR OIDC pulls. |
-| `MODAL_REGIONS` | Optional | Comma-separated Modal sandbox placement regions (for example `us` or `us-west`). Unset keeps Modal default placement. |
-| `E2B_API_KEY` | E2B | E2B API key. Can also be saved from **Settings > Sandboxes**. |
-| `E2B_TEMPLATE_ID` | E2B | Worker template ID. Usually provisioned from the worker image by Roomote. |
-| `E2B_DOMAIN` | Optional | E2B domain for self-hosted or custom E2B clusters. |
-| `E2B_MAX_SANDBOX_TIMEOUT_MS` | Optional | Maximum E2B sandbox timeout Roomote will request. Defaults to one hour. |
-| `DAYTONA_API_KEY` | Daytona | Daytona API key. Can also be saved from **Settings > Sandboxes**. |
-| `DAYTONA_API_URL` | Optional | Daytona API URL override. |
-| `DAYTONA_TARGET` | Optional | Daytona target or region. |
-| `DAYTONA_SNAPSHOT_NAME` | Daytona | Worker snapshot name. Usually registered from the worker image by Roomote. |
-| `BL_API_KEY` | Blaxel | Blaxel API key. Can also be saved from **Settings > Sandboxes**. |
-| `BL_WORKSPACE` | Blaxel | Blaxel workspace name. |
-| `BLAXEL_IMAGE` | Blaxel | Blaxel sandbox image in `sandbox/name:version` form. Roomote provisions it automatically when unset. |
-| `BLAXEL_REGION` | Optional | Blaxel sandbox placement region. Unset lets Blaxel choose the closest region. |
-| `WORKER_RELEASE_CHANNEL` | Optional | Worker release channel, `stable` or `preview`, for hosted worker release selection. |
-| `WORKER_RELEASE_VERSION` | Optional | Explicit worker release version for hosted worker bootstrap. |
-| `LOCAL_SANDBOX_FILES_DIR` | Local development | Local override for sandbox bootstrap files. Used only by development workflows. |
+| Env var | Required | Used for |
+| ------------------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| `DEFAULT_COMPUTE_PROVIDER` | Optional | Runtime default sandbox provider when no admin default is saved. Supported values are `docker`, `modal`, `e2b`, `daytona`, and `blaxel`. |
+| `EXCLUDED_COMPUTE_PROVIDERS` | Optional | Comma-separated provider IDs to hide or exclude from default selection. |
+| `DOCKER_WORKER_IMAGE` | Optional | Worker image used by Docker and for hosted sandbox derivation. In production, prefer an immutable registry-qualified tag. |
+| `ROOMOTE_WORKER_IMAGE_REPO` | Optional | Registry repository used to derive the worker image from `RELEASE_VERSION` when `DOCKER_WORKER_IMAGE` is unset. |
+| `DOCKER_WORKER_PLATFORM` | Optional | Docker worker platform. Defaults to the host architecture (`linux/arm64` on arm hosts, `linux/amd64` otherwise). |
+| `DOCKER_WORKER_NETWORK` | Self-host Docker | Docker network for worker containers in Compose/self-host mode. |
+| `DOCKER_WORKER_RELEASE_PATH` | Optional | Path to a packaged worker release archive used by Docker worker bootstrap. |
+| `DOCKER_WORKER_CPU_LIMIT` | Optional | CPU limit per Docker task. Defaults to `2`. |
+| `DOCKER_WORKER_MEMORY_LIMIT` | Optional | Memory and memory+swap limit per Docker task. Defaults to `4g`. |
+| `DOCKER_WORKER_PIDS_LIMIT` | Optional | Process limit per Docker task. Defaults to `512`. |
+| `DOCKER_WORKER_DISK_LIMIT` | Optional | Writable-layer limit. Defaults to `20g`; tasks fail closed when the Docker storage driver cannot enforce it. |
+| `DOCKER_WORKER_ALLOW_UNBOUNDED_DISK` | Optional | Explicitly permits startup without `--storage-opt size`. Defaults to `false`; use only with an equivalent host-level quota. |
+| `DOCKER_WORKER_LOG_MAX_SIZE` | Optional | Maximum size of each Docker JSON log file. Defaults to `10m`. |
+| `DOCKER_WORKER_LOG_MAX_FILES` | Optional | Number of rotated Docker JSON log files retained. Defaults to `3`. |
+| `DOCKER_WORKER_EGRESS_POLICY` | Optional | `internet` (public egress; self-host blocks private+metadata ranges, local dev blocks metadata) or `none` (no external egress). |
+| `DOCKER_STANDBY_MAX_COUNT` | Optional | Maximum stopped Docker task containers retained for resume. Defaults to `10`; `0` disables retention. |
+| `DOCKER_STANDBY_MAX_AGE_HOURS` | Optional | Maximum age of a retained Docker task container. Defaults to `24`, capped at `168`. |
+| `MODAL_TOKEN_ID` | Modal | Modal token ID. Can also be saved from **Settings > Sandboxes**. |
+| `MODAL_TOKEN_SECRET` | Modal | Modal token secret. |
+| `MODAL_BASE_IMAGE_REF` | Modal | Modal base image reference. Usually derived from the worker image; set only to pin a custom image. |
+| `MODAL_ENDPOINT` | Optional | Modal endpoint override. |
+| `MODAL_ENVIRONMENT` | Optional | Modal environment name. |
+| `MODAL_APP_NAME` | Optional | Modal app name override. |
+| `MODAL_REGISTRY_USERNAME` | Private registry | Registry username for private non-ECR image pulls. |
+| `MODAL_REGISTRY_PASSWORD` | Private registry | Registry password for private non-ECR image pulls. |
+| `MODAL_ECR_OIDC_ROLE_ARN` | ECR | AWS IAM role ARN used by Modal for ECR OIDC pulls. |
+| `MODAL_ECR_REGION` | ECR | AWS region for Modal ECR OIDC pulls. |
+| `MODAL_REGIONS` | Optional | Comma-separated Modal sandbox placement regions (for example `us` or `us-west`). Unset keeps Modal default placement. |
+| `E2B_API_KEY` | E2B | E2B API key. Can also be saved from **Settings > Sandboxes**. |
+| `E2B_TEMPLATE_ID` | E2B | Worker template ID. Usually provisioned from the worker image by Roomote. |
+| `E2B_DOMAIN` | Optional | E2B domain for self-hosted or custom E2B clusters. |
+| `E2B_MAX_SANDBOX_TIMEOUT_MS` | Optional | Maximum E2B sandbox timeout Roomote will request. Defaults to one hour. |
+| `DAYTONA_API_KEY` | Daytona | Daytona API key. Can also be saved from **Settings > Sandboxes**. |
+| `DAYTONA_API_URL` | Optional | Daytona API URL override. |
+| `DAYTONA_TARGET` | Optional | Daytona target or region. |
+| `DAYTONA_SNAPSHOT_NAME` | Daytona | Worker snapshot name. Usually registered from the worker image by Roomote. |
+| `BL_API_KEY` | Blaxel | Blaxel API key. Can also be saved from **Settings > Sandboxes**. |
+| `BL_WORKSPACE` | Blaxel | Blaxel workspace name. |
+| `BLAXEL_IMAGE` | Blaxel | Blaxel sandbox image in `sandbox/name:version` form. Roomote provisions it automatically when unset. |
+| `BLAXEL_REGION` | Optional | Blaxel sandbox placement region. Unset lets Blaxel choose the closest region. |
+| `BLAXEL_STANDBY_MAX_COUNT` | Optional | Maximum Blaxel standby sandboxes retained for resume. Defaults to `25`; `0` disables retention. |
+| `BLAXEL_STANDBY_MAX_AGE_HOURS` | Optional | Maximum age of a Blaxel standby sandbox. Defaults to `168`, capped at `168`. |
+| `WORKER_RELEASE_CHANNEL` | Optional | Worker release channel, `stable` or `preview`, for hosted worker release selection. |
+| `WORKER_RELEASE_VERSION` | Optional | Explicit worker release version for hosted worker bootstrap. |
+| `LOCAL_SANDBOX_FILES_DIR` | Local development | Local override for sandbox bootstrap files. Used only by development workflows. |
### Source control providers
diff --git a/apps/docs/providers/compute/blaxel.mdx b/apps/docs/providers/compute/blaxel.mdx
index 3f59b2078..f010704c2 100644
--- a/apps/docs/providers/compute/blaxel.mdx
+++ b/apps/docs/providers/compute/blaxel.mdx
@@ -38,6 +38,17 @@ You can optionally select a region:
BLAXEL_REGION=us-pdx-1
```
+Standby retention can be bounded separately from active task timeouts:
+
+```sh
+BLAXEL_STANDBY_MAX_COUNT=25
+BLAXEL_STANDBY_MAX_AGE_HOURS=168
+```
+
+Region and standby retention are also editable under **Settings > Sandboxes >
+Blaxel > Advanced settings**. Process environment variables take precedence
+and appear as locked values in Settings.
+
## Runtime behavior
- Roomote requests a Blaxel TTL matching the task timeout.
@@ -45,12 +56,13 @@ BLAXEL_REGION=us-pdx-1
proxy remains the user-facing authentication layer.
- The detached worker process uses Blaxel keep-alive while the task is active.
- When a resumable task goes to sleep, Roomote stops its worker, extends the
- sandbox lifetime to seven days, and lets Blaxel move the sandbox to standby.
+ sandbox lifetime to the configured standby age (seven days by default), and
+ lets Blaxel move the sandbox to standby.
A follow-up reconnects to the same sandbox and launches a new worker without
reinstalling the workspace.
- Non-resumable workflows are still destroyed when their sleep deadline is
- reached. A standby sandbox is also deleted when its seven-day resume window
- expires.
+ reached. A five-minute retention sweep deletes standby sandboxes that exceed
+ the configured count or age. A count of `0` disables Blaxel standby retention.
Blaxel standby is intentionally separate from Roomote environment snapshots.
A standby handle reconnects to one mutable sandbox; it cannot be cloned or
diff --git a/apps/docs/providers/compute/docker.mdx b/apps/docs/providers/compute/docker.mdx
index 65d845bae..fbf2fb3d5 100644
--- a/apps/docs/providers/compute/docker.mdx
+++ b/apps/docs/providers/compute/docker.mdx
@@ -46,6 +46,8 @@ DOCKER_WORKER_LOG_MAX_FILES=3
# internet allows public egress but blocks private/metadata ranges
# none disables external egress entirely
DOCKER_WORKER_EGRESS_POLICY=internet
+DOCKER_STANDBY_MAX_COUNT=10
+DOCKER_STANDBY_MAX_AGE_HOURS=24
```
Local development builds `roomote-worker:local` automatically. Self-hosted
@@ -75,6 +77,17 @@ that enforce an equivalent host-level quota can explicitly set
`DOCKER_WORKER_ALLOW_UNBOUNDED_DISK=true`; Roomote then logs a warning and
starts without `--storage-opt size`.
+Completed resumable tasks keep their stopped container and writable layer so a
+follow-up can restart the same workspace. Roomote retains at most 10 containers
+for 24 hours by default. Configure `DOCKER_STANDBY_MAX_COUNT` and
+`DOCKER_STANDBY_MAX_AGE_HOURS` to change those bounds; a count of `0` disables
+Docker standby retention. The five-minute retention sweep removes the oldest
+or expired containers and their task networks.
+
+These retention limits are also editable under **Settings > Sandboxes > Local
+Docker > Advanced settings**. Process environment variables take precedence
+and appear as locked values in Settings.
+
The default `internet` egress policy allows public repository and tool access
while installing immutable blackhole routes for private, shared-address, and
cloud metadata ranges (including AWS/GCP/Azure, Alibaba, and Oracle endpoints).
diff --git a/apps/web/src/app/(onboarding)/setup/StepComputeConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepComputeConfig.tsx
index 1d24d4432..fcdbba8a7 100644
--- a/apps/web/src/app/(onboarding)/setup/StepComputeConfig.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepComputeConfig.tsx
@@ -208,7 +208,7 @@ export function StepComputeConfig({
// registry-qualified worker image. Missing or editable worker image values
// live in the advanced section instead of the primary credentials step.
const isHostedProvider =
- selectedProvider?.fields.some(isComputeInfrastructureField) ?? false;
+ selectedProvider !== undefined && selectedProvider.provider !== 'docker';
const workerImage = computeSetup.workerImage;
const workerImageValue = values[SHARED_WORKER_IMAGE_ENV_VAR]?.trim() ?? '';
// Local tags (e.g. roomote-worker:local) satisfy Docker but not hosted
diff --git a/apps/web/src/app/(onboarding)/setup/page.tsx b/apps/web/src/app/(onboarding)/setup/page.tsx
index 77b575272..80aa4fbd1 100644
--- a/apps/web/src/app/(onboarding)/setup/page.tsx
+++ b/apps/web/src/app/(onboarding)/setup/page.tsx
@@ -4,10 +4,11 @@ import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
-import type {
- ComputeProvider,
- SetupAuthProviderId,
- SourceControlProvider,
+import {
+ isComputeCredentialField,
+ type ComputeProvider,
+ type SetupAuthProviderId,
+ type SourceControlProvider,
} from '@roomote/types';
import { useRedirectToSignIn } from '@/hooks/useSignInRedirect';
@@ -177,7 +178,12 @@ export default function SetupPage() {
(provider) => provider.provider === variables.provider,
);
- if (selectedProvider && selectedProvider.fields.length === 0) {
+ // Credentialless providers stay on the short path even when they
+ // expose optional advanced settings later in Settings.
+ if (
+ selectedProvider &&
+ !selectedProvider.fields.some(isComputeCredentialField)
+ ) {
goToNextStep();
return;
}
diff --git a/apps/web/src/components/settings/ComputeProviderSection.client.test.tsx b/apps/web/src/components/settings/ComputeProviderSection.client.test.tsx
index 18b22b0d0..b3320d4c6 100644
--- a/apps/web/src/components/settings/ComputeProviderSection.client.test.tsx
+++ b/apps/web/src/components/settings/ComputeProviderSection.client.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import type {
SetupComputeStatus,
SetupNewComputeProvisioningState,
@@ -120,3 +120,86 @@ describe('ComputeProviderSection provisioning states', () => {
).not.toBeInTheDocument();
});
});
+
+describe('ComputeProviderSection advanced settings', () => {
+ const dockerProvider: ComputeProviderStatus = {
+ provider: 'docker',
+ label: 'Local Docker',
+ description: 'Local Docker sandboxes.',
+ supportsSnapshots: false,
+ fields: [
+ {
+ envVarName: 'DOCKER_STANDBY_MAX_COUNT',
+ label: 'Maximum retained tasks',
+ required: false,
+ category: 'infrastructure',
+ advanced: true,
+ input: { type: 'number', min: 0, step: 1, placeholder: '10' },
+ runtimeSatisfied: false,
+ savedSatisfied: false,
+ defaultSatisfied: false,
+ setupProvisionable: false,
+ },
+ ],
+ runtimeConfigSatisfied: true,
+ savedConfigSatisfied: true,
+ configSatisfied: true,
+ infrastructureSatisfied: true,
+ };
+
+ it('keeps provider overrides collapsed until requested and saves edits', () => {
+ const onSave = vi.fn();
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByLabelText('Maximum retained tasks (optional)'),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Advanced settings' }));
+ const maxCount = screen.getByLabelText('Maximum retained tasks (optional)');
+ expect(maxCount).toHaveAttribute('type', 'number');
+
+ fireEvent.change(maxCount, { target: { value: '4' } });
+ fireEvent.click(screen.getByRole('button', { name: /Save/ }));
+
+ expect(onSave).toHaveBeenCalledWith('docker', {
+ DOCKER_STANDBY_MAX_COUNT: '4',
+ });
+ });
+
+ it('shows process environment overrides as locked', () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Advanced settings' }));
+ expect(
+ screen.getByLabelText('Maximum retained tasks (optional)'),
+ ).toBeDisabled();
+ });
+});
diff --git a/apps/web/src/components/settings/ComputeProviderSection.tsx b/apps/web/src/components/settings/ComputeProviderSection.tsx
index f03fe634c..8c5c805d6 100644
--- a/apps/web/src/components/settings/ComputeProviderSection.tsx
+++ b/apps/web/src/components/settings/ComputeProviderSection.tsx
@@ -20,6 +20,10 @@ import {
BrandIcon,
Button,
Check,
+ ChevronDown,
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
Dialog,
DialogContent,
DialogDescription,
@@ -107,16 +111,15 @@ export function ComputeProviderSection({
clearPending,
}: ComputeProviderSectionProps) {
const inputFields = provider.fields.filter(isComputeCredentialField);
- // Optional operator-editable infrastructure (domain/region). Managed Modal
- // base image and E2B/Daytona artifacts are never form inputs.
- const optionalInfraFields = provider.fields.filter(
+ // Provider-specific routing, endpoint, and retention settings. Managed
+ // worker artifacts are never form inputs. Runtime overrides remain visible
+ // here but locked so operators can see where the effective policy comes from.
+ const advancedInfraFields = provider.fields.filter(
(field) =>
isComputeInfrastructureField(field) &&
isComputeOperatorEditableField(field) &&
- !field.runtimeSatisfied,
+ field.advanced,
);
- // Keep the old name for the rest of this component without a big rename.
- const advancedInfraFields = optionalInfraFields;
const missingDefaultBlockingInfraFields = provider.fields.filter(
(field) =>
isComputeInfrastructureField(field) &&
@@ -159,11 +162,13 @@ export function ComputeProviderSection({
const [editingSavedValues, setEditingSavedValues] = useState<
Record
>({});
+ const [advancedExpanded, setAdvancedExpanded] = useState(false);
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
useEffect(() => {
setValues(nonSecretInitialValues);
setEditingSavedValues({});
+ setAdvancedExpanded(false);
setRemoveDialogOpen(false);
setExpanded(hasNoInputFields || hasConfiguredValues);
}, [
@@ -248,7 +253,7 @@ export function ComputeProviderSection({
const hasEditableFields =
inputFields.some((field) => !field.runtimeSatisfied) ||
- advancedInfraFields.length > 0;
+ advancedInfraFields.some((field) => !field.runtimeSatisfied);
const runtimeConfigured =
inputFields.length > 0 &&
inputFields.every((field) => field.runtimeSatisfied);
@@ -276,14 +281,21 @@ export function ComputeProviderSection({
key={field.envVarName}
className="grid gap-2 md:grid-cols-[220px_minmax(0,1fr)] md:items-center max-w-xl"
>
-
+
+
{(field.runtimeSatisfied || field.savedSatisfied) && }
+ {field.helpText ? (
+
+ {field.runtimeSatisfied
+ ? `${field.helpText} Managed by ${field.envVarName}.`
+ : field.helpText}
+
+ ) : null}
);
};
@@ -386,7 +409,7 @@ export function ComputeProviderSection({
- {hasNoInputFields && advancedInfraFields.length === 0 && (
+ {hasNoInputFields && (
Configuration values
@@ -395,7 +418,7 @@ export function ComputeProviderSection({
)}
- {(inputFields.length > 0 || advancedInfraFields.length > 0) && (
+ {inputFields.length > 0 && (
{hasConfiguredValues
@@ -413,11 +436,41 @@ export function ComputeProviderSection({
) : null}
{inputFields.map((field) => renderFieldInput(field))}
- {advancedInfraFields.map((field) => renderFieldInput(field))}
)}
+ {advancedInfraFields.length > 0 ? (
+
+
+
+
+
+
+ Provider routing, endpoint, and standby retention overrides.
+ Leave optional values blank to use provider defaults.
+
+
+ {advancedInfraFields.map((field) =>
+ renderFieldInput(field),
+ )}
+
+
+
+ ) : null}
+
{(inputFields.length > 0 || advancedInfraFields.length > 0) && (
)}
@@ -487,10 +540,11 @@ export function ComputeProviderSection({