diff --git a/.docker/caddy/Caddyfile b/.docker/caddy/Caddyfile
index 134167794..3e562532f 100644
--- a/.docker/caddy/Caddyfile
+++ b/.docker/caddy/Caddyfile
@@ -31,6 +31,22 @@
}
}
+ # Route the sandbox-server transport through the same public origin as the
+ # web app. A browser opened through ngrok cannot connect to the worker's
+ # loopback-only published port directly because 127.0.0.1 would refer to the
+ # browser device. The preview proxy resolves the task's current worker port
+ # after Caddy translates this path route into its normal virtual host.
+ @local_sandbox path_regexp local_sandbox ^/_roomote-sandbox/([a-z0-9]+)(/.*)$
+
+ handle @local_sandbox {
+ rewrite * {re.local_sandbox.2}
+ reverse_proxy host.docker.internal:18081 {
+ header_up Host {re.local_sandbox.1}-sandbox-server.roomotepreview.localhost:18081
+ lb_try_duration 10s
+ lb_try_interval 250ms
+ }
+ }
+
handle {
reverse_proxy host.docker.internal:13000 {
lb_try_duration 10s
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/CHANGELOG.md b/CHANGELOG.md
index 9d21bc095..8c1103289 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,23 @@
This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`.
+## 0.3.0 (2026-07-12)
+
+### Minor changes
+
+- Local Docker tasks now retain idle containers and resume them in place with bounded cleanup (default 10 retained containers, 24-hour max age; max count `0` disables retention). Blaxel standby retention is likewise bounded (defaults 25 / 168 hours), with env knobs `DOCKER_STANDBY_MAX_*` and `BLAXEL_STANDBY_MAX_*` documented for operators.
+- Tasks can go to sleep early from the task page overflow menu (Sleep above Delete). The action is available for snapshot-capable runs and for resumable Docker/Blaxel standby, so operators can release an awake environment without waiting for the keepalive timer.
+
+### Patch changes
+
+- Signed public artifact raw URLs (allowlisted images and videos used for visual proofs and PR embeds) expire 30 days after they are signed, and cache headers stay within the remaining TTL, so a leaked screenshot link cannot be fetched indefinitely.
+- Blaxel sandbox lifecycle is more resilient: deterministic external IDs with idempotent create, bounded retries on readiness-sensitive calls, reuse of preview resources across standby/resume instead of delete-and-recreate, and immediate failure on non-retryable 4xx errors.
+- Local Docker development rebuilds worker images that lack current networking tools before launching tasks, routes sandbox HTTP/WebSocket traffic through the public app edge so tunneled clients get a usable live session, and marks preview auth cookies Secure when using SameSite=None and Partitioned so iframe previews authenticate reliably.
+- PR review notification updates treat failing CI checks and live merge conflicts as high-signal blockers: triage copy names the problem and offers a fix or conflict resolution instead of burying it after a soft "looked good" wrap-up.
+- Main GitHub PR review summary comments now show a compact status footer with the review phase and short commit SHA (`Reviewing abc1234` / `Reviewed abc1234`), using a linked SHA when a commit URL can be built.
+- Docker and Blaxel standby environments can resume even when they were suspended before the first agent harness session, so early-sleep retains come back to Idle without forcing a new session create path.
+- The worker common env file (`~/.roomote/env.sh`, which holds deployment secrets such as cloud tokens) is written owner-only (`0o600`) with `~/.roomote` locked to `0o700`, so other sandbox users cannot read those secrets.
+
## 0.2.0 (2026-07-12)
### Minor changes
diff --git a/apps/bullmq/src/index.ts b/apps/bullmq/src/index.ts
index 77917bfcd..a7734cfd1 100644
--- a/apps/bullmq/src/index.ts
+++ b/apps/bullmq/src/index.ts
@@ -41,6 +41,7 @@ import { teamsSuggestedTasksOnboardingFollowupJob } from './jobs/teams-suggested
import { startSnapshotQueue } from './snapshot-queue';
import { startSlackPrInactivityQueue } from './slack-pr-inactivity-queue';
import { startPrReviewNotificationQueue } from './pr-review-notification-queue';
+import { startTaskSleepQueue } from './task-sleep-queue';
// Resolve auto-generated auth keypairs before any queue worker starts so
// scheduled jobs that sign tokens observe the resolved keys.
@@ -75,6 +76,11 @@ const {
const { snapshotQueue, snapshotWorker, snapshotQueueEvents } =
startSnapshotQueue();
+const {
+ queue: taskSleepQueue,
+ worker: taskSleepWorker,
+ queueEvents: taskSleepQueueEvents,
+} = startTaskSleepQueue();
const {
slackAccountLinkEducationQueue,
slackAccountLinkEducationWorker,
@@ -124,6 +130,7 @@ createBullBoard({
new BullMQAdapter(schedulerQueue, { readOnlyMode: false }),
new BullMQAdapter(sandboxOidcRefreshQueue, { readOnlyMode: false }),
new BullMQAdapter(snapshotQueue, { readOnlyMode: false }),
+ new BullMQAdapter(taskSleepQueue, { readOnlyMode: false }),
new BullMQAdapter(slackAccountLinkEducationQueue, { readOnlyMode: false }),
new BullMQAdapter(slackSuggestedTasksOnboardingFollowupQueue, {
readOnlyMode: false,
@@ -268,6 +275,9 @@ async function gracefulShutdown() {
await snapshotWorker.close();
await snapshotQueueEvents.close();
await snapshotQueue.close();
+ await taskSleepWorker.close();
+ await taskSleepQueueEvents.close();
+ await taskSleepQueue.close();
await slackAccountLinkEducationWorker.close();
await slackAccountLinkEducationQueueEvents.close();
await slackAccountLinkEducationQueue.close();
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..95b52ba0a 100644
--- a/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts
+++ b/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts
@@ -171,7 +171,7 @@ vi.mock('@roomote/db/server', () => ({
}));
// Import after mocks are set up.
-import { sleepCheckJob } from '../sleep-check';
+import { sleepCheckJob, sleepTaskRunNow } from '../sleep-check';
/**
* Mock the sequential DB select queries in sleepCheckJob.
@@ -209,6 +209,90 @@ function mockJobQueries({
.mockResolvedValueOnce(normalizedHardLimitJobs);
}
+describe('sleepTaskRunNow', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ transactionFn.mockImplementation(async (callback) =>
+ callback({ update: updateFn }),
+ );
+ mockCreateComputeProviderClient.mockReturnValue({
+ enterStandby: mockEnterStandby,
+ getInstanceStatus: mockGetInstanceStatus,
+ });
+ mockCreateComputeProviderMutationEventRecorder.mockReturnValue(
+ mockRecordMutation,
+ );
+ mockRecordMutation.mockResolvedValue(undefined);
+ mockRecordTaskRunEvent.mockResolvedValue(undefined);
+ mockMarkTaskStartParallelCountEndedAt.mockResolvedValue(undefined);
+ mockSyncTaskStateFromRuns.mockResolvedValue(undefined);
+ mockGetInstanceStatus.mockResolvedValue({ status: 'running' });
+ mockEnterStandby.mockResolvedValue({ resumeHandle: 'docker-machine-1' });
+ returningFn.mockResolvedValue([{ id: 123 }]);
+ mockDbQueryTaskRunsFindFirst.mockResolvedValue({
+ id: 123,
+ payloadKind: TaskPayloadKind.StandardTask,
+ status: RunStatus.Running,
+ taskPhase: 'executing',
+ machineId: 'docker-machine-1',
+ vendor: 'docker',
+ taskId: 'task-1',
+ snapshotRequestedAt: null,
+ sandboxCmdId: 'command-1',
+ sleepAt: new Date(Date.now() + 30_000),
+ sleepRequestedAt: null,
+ startedAt: new Date(),
+ workerHeartbeatAt: new Date(),
+ snapshotId: null,
+ });
+ });
+
+ it('puts an active Docker task directly into standby', async () => {
+ await sleepTaskRunNow(123);
+
+ expect(mockEnterStandby).toHaveBeenCalledWith({
+ instanceId: 'docker-machine-1',
+ commandId: 'command-1',
+ });
+ expect(setFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ snapshotId: 'docker-machine-1',
+ status: RunStatus.Completed,
+ }),
+ );
+ });
+
+ it('routes an active snapshot-capable task through the snapshot queue', async () => {
+ mockDbQueryTaskRunsFindFirst.mockResolvedValue({
+ id: 123,
+ payloadKind: TaskPayloadKind.StandardTask,
+ status: RunStatus.Running,
+ taskPhase: 'executing',
+ machineId: 'modal-machine-1',
+ vendor: 'modal',
+ taskId: 'task-1',
+ snapshotRequestedAt: null,
+ sandboxCmdId: 'command-1',
+ sleepAt: new Date(Date.now() + 30_000),
+ sleepRequestedAt: null,
+ startedAt: new Date(),
+ workerHeartbeatAt: new Date(),
+ snapshotId: null,
+ });
+ mockCreateSnapshot.mockResolvedValue(true);
+
+ await sleepTaskRunNow(123);
+
+ expect(mockCreateSnapshot).toHaveBeenCalledWith({
+ runId: 123,
+ sandboxId: 'modal-machine-1',
+ snapshotIntentId: expect.stringMatching(/^manual_sleep-123-/),
+ triggerPath: 'manual_sleep',
+ });
+ expect(mockEnterStandby).not.toHaveBeenCalled();
+ });
+});
+
describe('sleepCheckJob', () => {
let logSpy: ReturnType;
let warnSpy: ReturnType;
@@ -617,6 +701,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..8d914423d 100644
--- a/apps/bullmq/src/scheduled-jobs/sleep-check.ts
+++ b/apps/bullmq/src/scheduled-jobs/sleep-check.ts
@@ -57,6 +57,7 @@ const SLEEP_CHECK_PROVIDERS = sleepCheckManagedComputeProviders;
type SleepCheckPath =
| 'due_sleep'
+ | 'manual_sleep'
| 'stale_worker'
| 'hard_limit'
| 'booting_no_heartbeat';
@@ -157,6 +158,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 +691,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',
@@ -733,6 +736,82 @@ async function claimResumableSleep(
return (await claimAndSnapshot(job, path)) === 'enqueued';
}
+/**
+ * Process a user-requested sleep immediately. Unlike the scheduled due-sleep
+ * path, this intentionally does not extend the deadline for an active phase.
+ */
+export async function sleepTaskRunNow(runId: number): Promise {
+ const job = await db.query.taskRuns.findFirst({
+ where: eq(taskRuns.id, runId),
+ columns: {
+ id: true,
+ payloadKind: true,
+ status: true,
+ taskPhase: true,
+ machineId: true,
+ vendor: true,
+ taskId: true,
+ snapshotRequestedAt: true,
+ sandboxCmdId: true,
+ sleepAt: true,
+ sleepRequestedAt: true,
+ startedAt: true,
+ workerHeartbeatAt: true,
+ snapshotId: true,
+ },
+ });
+
+ if (!job) {
+ throw new Error(`Task run #${runId} was not found`);
+ }
+
+ if (!ACTIVE_SLEEP_CHECK_STATUSES.includes(job.status)) {
+ throw new Error(`Task run #${runId} is not active`);
+ }
+
+ if (!job.machineId || !job.vendor) {
+ throw new Error(`Task run #${runId} has no active machine`);
+ }
+
+ if (
+ !isResumableTaskPayloadKind(job.payloadKind) ||
+ !isTaskResumeCapableComputeProvider(job.vendor)
+ ) {
+ throw new Error(`Task run #${runId} does not support resumable sleep`);
+ }
+
+ if (job.snapshotId || job.snapshotRequestedAt || job.sleepRequestedAt) {
+ return;
+ }
+
+ const client = await createSleepCheckClient(job.vendor);
+ const { status } = await client.getInstanceStatus({
+ instanceId: job.machineId,
+ });
+
+ if (status !== 'running') {
+ throw new Error(
+ `Cannot put task run #${runId} to sleep because its instance is ${status}`,
+ );
+ }
+
+ if (isStandbyResumeCapableComputeProvider(job.vendor)) {
+ const result = await claimAndEnterStandby(job, client, 'manual_sleep');
+
+ if (result === 'error') {
+ throw new Error(`Failed to put task run #${runId} on standby`);
+ }
+
+ return;
+ }
+
+ const result = await claimAndSnapshot(job, 'manual_sleep');
+
+ if (result === 'error') {
+ throw new Error(`Failed to snapshot task run #${runId}`);
+ }
+}
+
/**
* Merge candidate jobs by machine ID while keeping the newest row per category.
*/
@@ -1336,6 +1415,8 @@ function describeSleepCheckPath(path: SleepCheckPath): string {
switch (path) {
case 'due_sleep':
return 'Due sleep handling';
+ case 'manual_sleep':
+ return 'Manual sleep handling';
case 'hard_limit':
return 'Provider-timeout backstop';
case 'stale_worker':
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/task-sleep-queue.ts b/apps/bullmq/src/task-sleep-queue.ts
new file mode 100644
index 000000000..a075458fb
--- /dev/null
+++ b/apps/bullmq/src/task-sleep-queue.ts
@@ -0,0 +1,38 @@
+import { Queue, QueueEvents, Worker } from 'bullmq';
+
+import {
+ TASK_SLEEP_QUEUE_NAME,
+ type TaskSleepRequest,
+} from '@roomote/sdk/server';
+
+import { getRedis } from './redis';
+import { sleepTaskRunNow } from './scheduled-jobs/sleep-check';
+
+export function startTaskSleepQueue() {
+ const connection = getRedis();
+ const queue = new Queue(
+ TASK_SLEEP_QUEUE_NAME,
+ { connection },
+ );
+ const worker = new Worker(
+ TASK_SLEEP_QUEUE_NAME,
+ ({ data }) => sleepTaskRunNow(data.runId),
+ { connection, concurrency: 5, autorun: true },
+ );
+ const queueEvents = new QueueEvents(TASK_SLEEP_QUEUE_NAME, { connection });
+
+ worker.on('failed', (job, error) => {
+ console.error(
+ `[TaskSleepQueue] job ${job?.id} failed for task run #${job?.data.runId}:`,
+ error.message,
+ );
+ });
+
+ worker.on('error', (error) => {
+ console.error('[TaskSleepQueue] worker error:', error);
+ });
+
+ console.log('[TaskSleepQueue] Started task sleep worker');
+
+ return { queue, worker, queueEvents };
+}
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/RoomoteController.ts b/apps/controller/src/RoomoteController.ts
index ceb700b49..af5972d63 100644
--- a/apps/controller/src/RoomoteController.ts
+++ b/apps/controller/src/RoomoteController.ts
@@ -68,8 +68,9 @@ export class RoomoteController extends BaseController {
// Credentials resolve from the runtime env first and fall back to the
// encrypted deployment env vars saved by the setup flow.
const resolvedEnv = await resolveComputeProviderEnvValues(provider, {
- // Env only holds string values for the catalog credential keys.
- runtimeEnv: Env as unknown as Partial>,
+ // The validated env includes typed numeric policy values; the resolver
+ // normalizes scalars before combining them with saved string values.
+ runtimeEnv: Env,
});
switch (provider) {
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-blaxel-worker.test.ts b/apps/controller/src/compute-providers/__tests__/spawn-blaxel-worker.test.ts
index e5b77e6b0..2029c692a 100644
--- a/apps/controller/src/compute-providers/__tests__/spawn-blaxel-worker.test.ts
+++ b/apps/controller/src/compute-providers/__tests__/spawn-blaxel-worker.test.ts
@@ -103,6 +103,7 @@ describe('spawnBlaxelWorker', () => {
expect(mockCreateBlaxelMachine).toHaveBeenCalledWith(
expect.objectContaining({
+ idempotencyKey: 'roomote-task-task-321',
launchMode: 'task_standby',
resumeHandle: 'roomote-blaxel-standby',
}),
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..1eb15311d 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', () => {
@@ -88,7 +108,17 @@ describe('buildDockerSandboxServerUrl', () => {
).toBe('https://task123456789-sandbox-server.preview.roomote.example.com');
});
- it('keeps local Docker workers on their direct published URL path', () => {
+ it('routes local Docker sandbox transport through the public app origin', () => {
+ expect(
+ buildDockerSandboxServerUrl({
+ taskId: 'task123456789',
+ publicAppUrl: 'https://roomote-example.ngrok.app/',
+ previewProxyBaseUrl: 'https://preview.roomote.example.com',
+ }),
+ ).toBe('https://roomote-example.ngrok.app/_roomote-sandbox/task123456789');
+ });
+
+ it('keeps the direct published URL fallback without a public app origin', () => {
expect(
buildDockerSandboxServerUrl({
taskId: 'task123456789',
@@ -116,12 +146,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-blaxel-worker.ts b/apps/controller/src/compute-providers/spawn-blaxel-worker.ts
index 6a4fea796..4700c2cb9 100644
--- a/apps/controller/src/compute-providers/spawn-blaxel-worker.ts
+++ b/apps/controller/src/compute-providers/spawn-blaxel-worker.ts
@@ -108,6 +108,7 @@ export async function spawnBlaxelWorker(
blaxelApiKey: config.blaxelApiKey,
blaxelWorkspace: config.blaxelWorkspace,
blaxelImage: config.blaxelImage,
+ idempotencyKey: `roomote-task-${taskRun.taskId}`,
blaxelRegion: config.blaxelRegion,
namedPorts,
tags: config.blaxelTags,
diff --git a/apps/controller/src/compute-providers/spawn-docker-worker.ts b/apps/controller/src/compute-providers/spawn-docker-worker.ts
index 1ad889fac..40795860d 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,65 +194,85 @@ 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);
const sandboxServerUrl = buildDockerSandboxServerUrl({
network: controlNetwork ? dockerNetwork : undefined,
taskId: taskRun.taskId,
+ publicAppUrl: process.env.R_PUBLIC_URL || process.env.R_APP_URL,
previewProxyBaseUrl:
resolvedPreviewRuntimeConfig.effective.previewProxyBaseUrl ?? undefined,
});
@@ -256,7 +290,7 @@ export async function spawnDockerWorker(
environmentConfig?.ports,
)?.name,
sandboxServerUrl,
- sourceSnapshotId: null,
+ sourceSnapshotId: isStandbyResume ? containerName : null,
authBypassValue,
authBypassHeaderName,
});
@@ -305,6 +339,7 @@ export async function spawnDockerWorker(
},
});
+ const workerCommand = getDockerWorkerCommand(taskRun.payloadKind);
await docker([
'exec',
'-d',
@@ -315,12 +350,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 +363,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;
@@ -443,17 +477,26 @@ async function hasTaskRunStarted(runId: number): Promise {
export function buildDockerSandboxServerUrl(params: {
network?: string;
taskId?: string | null;
+ publicAppUrl?: string;
previewProxyBaseUrl?: string;
}): string | undefined {
- if (!params.network || !params.taskId || !params.previewProxyBaseUrl) {
+ if (!params.taskId) {
return undefined;
}
- return buildPreviewProxyUrl(
- params.taskId,
- portNameToSlug(SANDBOX_SERVER_NAMED_PORT.name),
- params.previewProxyBaseUrl,
- );
+ if (params.network && params.previewProxyBaseUrl) {
+ return buildPreviewProxyUrl(
+ params.taskId,
+ portNameToSlug(SANDBOX_SERVER_NAMED_PORT.name),
+ params.previewProxyBaseUrl,
+ );
+ }
+
+ if (!params.network && params.publicAppUrl) {
+ return `${params.publicAppUrl.replace(/\/+$/, '')}/_roomote-sandbox/${params.taskId}`;
+ }
+
+ return undefined;
}
async function getPublishedPorts(
@@ -586,14 +629,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/dev/src/services/__tests__/docker.test.ts b/apps/dev/src/services/__tests__/docker.test.ts
index aa5000036..fcead02e1 100644
--- a/apps/dev/src/services/__tests__/docker.test.ts
+++ b/apps/dev/src/services/__tests__/docker.test.ts
@@ -374,6 +374,55 @@ describe('DockerService.ensureWorkerImage', () => {
);
});
+ it('reuses an existing worker image when required networking tools are available', async () => {
+ vi.mocked(execa).mockResolvedValue({} as Awaited>);
+
+ await DockerService.ensureWorkerImage(false);
+
+ expect(execa).toHaveBeenCalledWith('docker', [
+ 'run',
+ '--rm',
+ '--platform',
+ process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64',
+ '--entrypoint',
+ '/bin/sh',
+ 'roomote-worker:local',
+ '-c',
+ 'test -x /usr/sbin/ip && /usr/sbin/ip -Version >/dev/null',
+ ]);
+ expect(execa).not.toHaveBeenCalledWith(
+ 'docker',
+ expect.arrayContaining(['build']),
+ expect.anything(),
+ );
+ });
+
+ it('rebuilds an existing worker image that lacks required networking tools', async () => {
+ const rootDir = path.resolve(process.cwd(), '../..');
+
+ vi.mocked(execa)
+ .mockResolvedValueOnce({} as Awaited>)
+ .mockRejectedValueOnce(new Error('ip is missing'))
+ .mockResolvedValueOnce({} as Awaited>);
+
+ await DockerService.ensureWorkerImage(false);
+
+ expect(execa).toHaveBeenCalledWith(
+ 'docker',
+ [
+ 'build',
+ '--platform',
+ process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64',
+ '-f',
+ 'apps/worker/Dockerfile',
+ '-t',
+ 'roomote-worker:local',
+ '.',
+ ],
+ { cwd: rootDir },
+ );
+ });
+
it('honors custom Docker worker image and platform settings', async () => {
const rootDir = path.resolve(process.cwd(), '../..');
process.env.DOCKER_WORKER_IMAGE = 'custom-worker:test';
diff --git a/apps/dev/src/services/docker.ts b/apps/dev/src/services/docker.ts
index 80377b9b9..2b0c47ad1 100644
--- a/apps/dev/src/services/docker.ts
+++ b/apps/dev/src/services/docker.ts
@@ -60,7 +60,10 @@ export class DockerService {
process.env.DOCKER_WORKER_PLATFORM ?? this.DEFAULT_WORKER_PLATFORM;
const rootDir = path.resolve(process.cwd(), '../..');
- if (await this.hasImage(image)) {
+ if (
+ (await this.hasImage(image)) &&
+ (await this.hasRequiredWorkerImageTools(image, platform))
+ ) {
return;
}
@@ -286,6 +289,28 @@ export class DockerService {
}
}
+ private static async hasRequiredWorkerImageTools(
+ image: string,
+ platform: string,
+ ): Promise {
+ try {
+ await execa('docker', [
+ 'run',
+ '--rm',
+ '--platform',
+ platform,
+ '--entrypoint',
+ '/bin/sh',
+ image,
+ '-c',
+ 'test -x /usr/sbin/ip && /usr/sbin/ip -Version >/dev/null',
+ ]);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+ }
+
private static async getSameCheckoutComposeContainers(rootDir: string) {
const docker = new Docker();
await docker.ping();
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..af3305624 100644
--- a/apps/docs/providers/compute/blaxel.mdx
+++ b/apps/docs/providers/compute/blaxel.mdx
@@ -38,19 +38,39 @@ 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.
+- Fresh sandbox provisioning uses a stable task identity and Blaxel's
+ idempotent create API so controller retries reconnect instead of creating
+ duplicate sandboxes.
- Each exposed port receives a public Blaxel preview URL. Roomote's preview
- proxy remains the user-facing authentication layer.
+ proxy remains the user-facing authentication layer. Existing preview
+ resources are reused across standby resumes.
- The detached worker process uses Blaxel keep-alive while the task is active.
+- When Blaxel reports `WORKLOAD_UNAVAILABLE`, Roomote retries workload access
+ with bounded exponential backoff. Missing workloads, invalid routes, and
+ application-origin errors are handled separately rather than retried as
+ transient platform failures.
- 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/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: '/',
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/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx
index a56976a13..35d960359 100644
--- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx
@@ -170,13 +170,13 @@ describe('StepSourceControlConfig', () => {
);
fireEvent.change(screen.getByLabelText('GitHub organization (optional)'), {
- target: { value: ' roovetgit ' },
+ target: { value: ' example-org ' },
});
fireEvent.click(screen.getByRole('button', { name: 'Create GitHub App' }));
expect(createGitHubAppManifestMock).toHaveBeenCalledWith({
redirect: '/setup?step=source-control-connect',
- organization: 'roovetgit',
+ organization: 'example-org',
});
});
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/app/(sandbox)/task/[taskId]/sidebar-actions/OverflowMenu.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/OverflowMenu.client.test.tsx
index 52da4ed3f..79f82480f 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/OverflowMenu.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/OverflowMenu.client.test.tsx
@@ -5,13 +5,17 @@ const {
pushMock,
cancelMutateAsyncMock,
deleteMutateAsyncMock,
+ requestSleepMutateAsyncMock,
errorToastMock,
+ successToastMock,
authState,
} = vi.hoisted(() => ({
pushMock: vi.fn(),
cancelMutateAsyncMock: vi.fn(),
deleteMutateAsyncMock: vi.fn(),
+ requestSleepMutateAsyncMock: vi.fn(),
errorToastMock: vi.fn(),
+ successToastMock: vi.fn(),
authState: {
user: { userId: 'user-1', isAdmin: false } as {
userId: string;
@@ -41,7 +45,7 @@ vi.mock('next/navigation', () => ({
vi.mock('sonner', () => ({
toast: {
- success: vi.fn(),
+ success: successToastMock,
error: errorToastMock,
},
}));
@@ -76,17 +80,20 @@ vi.mock('@/components/system', () => ({
onClick,
className,
variant,
+ disabled,
}: {
children: ReactNode;
onClick?: () => void;
className?: string;
variant?: string;
+ disabled?: boolean;
}) => (
@@ -104,6 +111,7 @@ vi.mock('@/components/system', () => ({
{children}
),
MoreVertical: () => ,
+ Moon: () => ,
Trash2: () => ,
Pencil: () => ,
}));
@@ -130,6 +138,13 @@ vi.mock('@/hooks/task-runs', () => ({
})),
}));
+vi.mock('@/hooks/snapshots', () => ({
+ useRequestTaskRunSleep: vi.fn(() => ({
+ mutateAsync: requestSleepMutateAsyncMock,
+ isPending: false,
+ })),
+}));
+
vi.mock('../hooks/use-preview-urls', () => ({
usePreviewUrls: vi.fn(() => ({ previewUrls: null })),
}));
@@ -147,6 +162,8 @@ function createTaskRun(overrides: Record = {}) {
id: 123,
actingUserId: 'user-1',
status: 'running',
+ payloadKind: 'standard',
+ vendor: 'modal',
machineId: 'machine-1',
snapshotId: null,
snapshotRequestedAt: null,
@@ -163,6 +180,7 @@ describe('OverflowMenu', () => {
authState.user = { userId: 'user-1', isAdmin: false };
cancelMutateAsyncMock.mockResolvedValue({ success: true });
deleteMutateAsyncMock.mockResolvedValue(undefined);
+ requestSleepMutateAsyncMock.mockResolvedValue({ success: true });
});
it('does not render when auth context is unavailable', () => {
@@ -192,6 +210,50 @@ describe('OverflowMenu', () => {
).not.toBeInTheDocument();
});
+ it('shows a Sleep action for awake resumable runs', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: 'Sleep' })).toBeInTheDocument();
+ });
+
+ it('hides Sleep when the task is already asleep', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByRole('button', { name: 'Sleep' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows Sleep for Docker standby runs', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Sleep' })).toBeInTheDocument();
+ });
+
+ it('requests provider-neutral sleep when Sleep is chosen', async () => {
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Sleep' }));
+ });
+
+ await vi.waitFor(() => {
+ expect(requestSleepMutateAsyncMock).toHaveBeenCalledWith({
+ runId: 123,
+ });
+ });
+ });
+
it('keeps the delete confirmation action labeled delete', () => {
render();
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/OverflowMenu.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/OverflowMenu.tsx
index b24a63022..923aad525 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/OverflowMenu.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/OverflowMenu.tsx
@@ -3,14 +3,20 @@
import { memo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
-import { MoreVertical, Trash2 } from '@/components/system';
+import { Moon, MoreVertical, Trash2 } from '@/components/system';
import { SideNavItem } from '@/components/layout/side-nav/SideNavItem';
-import { isExitedRunStatus } from '@roomote/types';
+import {
+ isExitedRunStatus,
+ isResumableTaskPayloadKind,
+ isTaskResumeCapableComputeProvider,
+ runningRunStatuses,
+} from '@roomote/types';
import { useUser } from '@/hooks/useUser';
import { useDeleteTasks } from '@/hooks/tasks';
import { useCancelTaskRun } from '@/hooks/task-runs';
+import { useRequestTaskRunSleep } from '@/hooks/snapshots';
import {
Button,
@@ -26,6 +32,7 @@ import {
} from '@/components/system';
import type { OverflowMenuProps } from './types';
+import { isTaskRunAsleep } from './utils';
function OverflowMenuBase({
taskId,
@@ -40,6 +47,15 @@ function OverflowMenuBase({
// Task deletion is deployment-wide: any member can delete any task.
const canShutdown = !!taskRun && !isExitedRunStatus(taskRun.status);
+ const canSleep =
+ !!taskRun &&
+ !!taskRun.machineId &&
+ !isExitedRunStatus(taskRun.status) &&
+ runningRunStatuses.some((status) => status === taskRun.status) &&
+ !isTaskRunAsleep(taskRun) &&
+ !taskRun.snapshotFailedAt &&
+ isResumableTaskPayloadKind(taskRun.payloadKind) &&
+ isTaskResumeCapableComputeProvider(taskRun.vendor);
const deleteTasks = useDeleteTasks({
onSuccess: () => {
@@ -61,6 +77,12 @@ function OverflowMenuBase({
onError: () => toast.error('Failed to shut down task.'),
});
+ const requestTaskRunSleep = useRequestTaskRunSleep({
+ onSuccess: () => {
+ toast.success('Task is going to sleep.');
+ },
+ });
+
if (!user) {
return null;
}
@@ -73,6 +95,18 @@ function OverflowMenuBase({
);
}
+ const handleSleep = async () => {
+ if (!taskRun || requestTaskRunSleep.isPending) {
+ return;
+ }
+
+ try {
+ await requestTaskRunSleep.mutateAsync({ runId: taskRun.id });
+ } catch {
+ // Errors are toasted by the mutation hook.
+ }
+ };
+
const handleDeleteConfirm = async () => {
if (canShutdown) {
const shutdownResult = await cancelTaskRun.mutateAsync({
@@ -98,6 +132,16 @@ function OverflowMenuBase({
+ {canSleep ? (
+
+
+ Sleep
+
+ ) : null}
setShowDeleteDialog(true)}
diff --git a/apps/web/src/app/api/artifacts/[id]/raw/__tests__/route.test.ts b/apps/web/src/app/api/artifacts/[id]/raw/__tests__/route.test.ts
index 174e1f0bf..cc3cfc1d6 100644
--- a/apps/web/src/app/api/artifacts/[id]/raw/__tests__/route.test.ts
+++ b/apps/web/src/app/api/artifacts/[id]/raw/__tests__/route.test.ts
@@ -33,6 +33,10 @@ function makeRequest(id: string, params?: { sig?: string; ts?: string }) {
return new NextRequest(url, { method: 'GET' });
}
+function freshTs(): string {
+ return String(Math.floor(Date.now() / 1000));
+}
+
describe('GET /api/artifacts/[id]/raw', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -182,7 +186,7 @@ describe('GET /api/artifacts/[id]/raw', () => {
} as never);
const response = await GET(
- makeRequest('art-1', { sig: 'valid-sig', ts: '1700000000' }),
+ makeRequest('art-1', { sig: 'valid-sig', ts: freshTs() }),
{
params: Promise.resolve({ id: 'art-1' }),
},
@@ -225,7 +229,7 @@ describe('GET /api/artifacts/[id]/raw', () => {
} as never);
const response = await GET(
- makeRequest('art-1', { sig: 'valid-sig', ts: '1700000000' }),
+ makeRequest('art-1', { sig: 'valid-sig', ts: freshTs() }),
{
params: Promise.resolve({ id: 'art-1' }),
},
@@ -234,9 +238,14 @@ describe('GET /api/artifacts/[id]/raw', () => {
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('image/png');
expect(response.headers.get('Content-Length')).toBe('4');
- expect(response.headers.get('Cache-Control')).toBe(
- 'public, max-age=86400, immutable',
+ expect(response.headers.get('Cache-Control')).toMatch(
+ /^public, max-age=\d+, immutable$/,
);
+ const cacheControl = response.headers.get('Cache-Control') ?? '';
+ const maxAge = Number(cacheControl.match(/max-age=(\d+)/)?.[1]);
+ expect(maxAge).toBeGreaterThan(0);
+ expect(maxAge).toBeLessThanOrEqual(3600);
+
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(response.headers.get('Content-Security-Policy')).toBe(
"default-src 'none'; style-src 'unsafe-inline'",
@@ -315,7 +324,7 @@ describe('GET /api/artifacts/[id]/raw', () => {
} as never);
const response = await GET(
- makeRequest('art-1', { sig: 'valid-sig', ts: '1700000000' }),
+ makeRequest('art-1', { sig: 'valid-sig', ts: freshTs() }),
{
params: Promise.resolve({ id: 'art-1' }),
},
diff --git a/apps/web/src/app/api/artifacts/[id]/raw/route.ts b/apps/web/src/app/api/artifacts/[id]/raw/route.ts
index 33dcc5f81..c86ade1ff 100644
--- a/apps/web/src/app/api/artifacts/[id]/raw/route.ts
+++ b/apps/web/src/app/api/artifacts/[id]/raw/route.ts
@@ -1,4 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
+import {
+ ARTIFACT_RAW_URL_MAX_AGE_SECONDS,
+ currentEpochSeconds,
+} from '@roomote/sdk/server';
import {
getUploadedArtifactById,
@@ -17,6 +21,9 @@ const ALLOWED_PUBLIC_CONTENT_TYPES = new Set([
'video/webm',
]);
+/** Never cache longer than this, and never past remaining signature / TTL. */
+const RAW_RESPONSE_CACHE_MAX_AGE_SECONDS = 3600;
+
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -94,10 +101,18 @@ export async function GET(
// Convert the S3 readable stream to a Web ReadableStream
const webStream = s3Response.Body.transformToWebStream();
+ const remainingTtlSeconds = Math.max(
+ 0,
+ ARTIFACT_RAW_URL_MAX_AGE_SECONDS - (currentEpochSeconds() - ts),
+ );
+ const cacheMaxAge = Math.min(
+ RAW_RESPONSE_CACHE_MAX_AGE_SECONDS,
+ remainingTtlSeconds,
+ );
const headers = new Headers({
'Content-Type': artifact.contentType,
- 'Cache-Control': 'public, max-age=86400, immutable',
+ 'Cache-Control': `public, max-age=${cacheMaxAge}, immutable`,
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
});
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.client.test.tsx b/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx
index 84747e98b..357cbbab1 100644
--- a/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx
+++ b/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx
@@ -683,7 +683,7 @@ describe('SideNav quick access tasks', () => {
{ id: 'env-1', name: 'Maxolen Staging' },
{ id: 'env-2', name: 'CC Environment' },
{ id: 'env-3', name: 'Roomote Dev' },
- { id: 'env-4', name: 'LogSharp' },
+ { id: 'env-4', name: 'Sandbox' },
{ id: 'env-5', name: 'QA' },
{ id: 'env-6', name: 'Prod' },
];
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"
/>
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({