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({ - Remove {provider.label} credentials? + Remove {provider.label} configuration? - Saved {provider.label} credentials will be removed from the - database. Configured environment variables are not affected. + Saved {provider.label} credentials and advanced settings will be + removed from the database. Process environment variables are not + affected. diff --git a/apps/web/src/components/settings/ComputeProviders.tsx b/apps/web/src/components/settings/ComputeProviders.tsx index e08837b4e..cc372fe61 100644 --- a/apps/web/src/components/settings/ComputeProviders.tsx +++ b/apps/web/src/components/settings/ComputeProviders.tsx @@ -69,10 +69,10 @@ export function ComputeProviders() { trpc.compute.saveConfig.mutationOptions({ onSuccess: async () => { await invalidateComputeQueries(); - toast.success('Sandbox provider credentials saved.'); + toast.success('Sandbox provider configuration saved.'); }, onError: (error) => { - toast.error(error.message || 'Failed to save credentials.'); + toast.error(error.message || 'Failed to save configuration.'); }, }), ); @@ -81,10 +81,10 @@ export function ComputeProviders() { trpc.compute.clearConfig.mutationOptions({ onSuccess: async () => { await invalidateComputeQueries(); - toast.success('Sandbox provider credentials cleared.'); + toast.success('Sandbox provider configuration cleared.'); }, onError: (error) => { - toast.error(error.message || 'Failed to clear credentials.'); + toast.error(error.message || 'Failed to clear configuration.'); }, }), ); diff --git a/apps/web/src/hooks/snapshots/index.ts b/apps/web/src/hooks/snapshots/index.ts index f5c28815b..26b917f1e 100644 --- a/apps/web/src/hooks/snapshots/index.ts +++ b/apps/web/src/hooks/snapshots/index.ts @@ -1,3 +1,4 @@ export { useCreateEnvironmentSnapshot } from './useCreateEnvironmentSnapshot'; export { useClearEnvironmentSnapshot } from './useClearEnvironmentSnapshot'; +export { useRequestTaskRunSleep } from './useRequestTaskRunSleep'; export { useRestoreTaskRunSnapshot } from './useRestoreTaskRunSnapshot'; diff --git a/apps/web/src/hooks/snapshots/useRequestTaskRunSleep.ts b/apps/web/src/hooks/snapshots/useRequestTaskRunSleep.ts new file mode 100644 index 000000000..03ac34d21 --- /dev/null +++ b/apps/web/src/hooks/snapshots/useRequestTaskRunSleep.ts @@ -0,0 +1,42 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { useTRPC } from '@/trpc/client'; + +export function useRequestTaskRunSleep(options?: { + onSuccess?: (result: { success: true }) => void; + onError?: (error: unknown) => void; +}) { + const queryClient = useQueryClient(); + const trpc = useTRPC(); + + return useMutation( + trpc.snapshots.requestTaskRunSleep.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + queryClient.invalidateQueries({ + queryKey: trpc.sandboxSession.byTaskId.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.tasks.list.queryKey(), + }); + options?.onSuccess?.({ success: true }); + } else { + toast.error('Oops', { description: result.error }); + options?.onError?.(new Error(result.error)); + } + }, + onError: (error) => { + toast.error('Oops', { + description: + error instanceof Error + ? error.message + : 'An unknown error occurred', + }); + options?.onError?.(error); + }, + }), + ); +} diff --git a/apps/web/src/lib/server/artifact-signature.ts b/apps/web/src/lib/server/artifact-signature.ts index 16b043afb..4169c0b4a 100644 --- a/apps/web/src/lib/server/artifact-signature.ts +++ b/apps/web/src/lib/server/artifact-signature.ts @@ -14,8 +14,9 @@ export { currentEpochSeconds }; * Generate an HMAC-SHA256 signature for an artifact ID bound to a timestamp. * Uses ARTIFACT_SIGNING_KEY (dedicated to this purpose, no prefix needed). * - * The signature covers `artifactId.ts` so that a future expiration check - * can be added without regenerating URLs. + * The signature covers `artifactId.ts`. Verification rejects timestamps older + * than ARTIFACT_RAW_URL_MAX_AGE_SECONDS (default 30 days) so leaked raw URLs + * fail closed after the TTL. */ export function signArtifactId(artifactId: string, ts: number): string { return signArtifactIdWithKey({ @@ -26,11 +27,11 @@ export function signArtifactId(artifactId: string, ts: number): string { } /** - * Verify an HMAC signature for an artifact ID + timestamp. + * Verify an HMAC signature for an artifact ID + timestamp and enforce max age. * * Tries the current ARTIFACT_SIGNING_KEY first. If ARTIFACT_SIGNING_KEY_PREVIOUS * is set, falls back to verifying with the previous key to support graceful - * key rotation. + * key rotation. Expired `ts` values are rejected even when the HMAC matches. */ export function verifyArtifactSignature( artifactId: string, diff --git a/apps/web/src/lib/server/auth.test.ts b/apps/web/src/lib/server/auth.test.ts index 64d572b13..3194f45c5 100644 --- a/apps/web/src/lib/server/auth.test.ts +++ b/apps/web/src/lib/server/auth.test.ts @@ -119,7 +119,7 @@ describe('getAuth', () => { adoBaseUrl: 'https://dev.azure.com', adoClientId: 'ado-client-id', adoClientSecret: 'ado-client-secret', - adoOrganization: 'roo-vet', + adoOrganization: 'ado-organization', adoTenantId: 'roomote-tenant-id', gitlabBaseUrl: undefined, gitlabClientId: undefined, diff --git a/apps/web/src/trpc/commands/compute/index.test.ts b/apps/web/src/trpc/commands/compute/index.test.ts index f52a9a9e9..22137f949 100644 --- a/apps/web/src/trpc/commands/compute/index.test.ts +++ b/apps/web/src/trpc/commands/compute/index.test.ts @@ -140,6 +140,10 @@ describe('compute commands', () => { 'BL_WORKSPACE', 'BLAXEL_IMAGE', 'BLAXEL_REGION', + 'BLAXEL_STANDBY_MAX_COUNT', + 'BLAXEL_STANDBY_MAX_AGE_HOURS', + 'DOCKER_STANDBY_MAX_COUNT', + 'DOCKER_STANDBY_MAX_AGE_HOURS', 'DOCKER_WORKER_IMAGE', 'RELEASE_VERSION', ]; @@ -241,6 +245,36 @@ describe('compute commands', () => { // runs when a registry-qualified worker image is available. }); + it('persists valid Docker standby retention settings', async () => { + await saveComputeConfigCommand(buildMockAuth(), { + provider: 'docker', + values: { + DOCKER_STANDBY_MAX_COUNT: '4', + DOCKER_STANDBY_MAX_AGE_HOURS: '12', + }, + }); + + expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( + expect.anything(), + { + userId: 'compute-test-user', + values: [ + { name: 'DOCKER_STANDBY_MAX_COUNT', value: '4' }, + { name: 'DOCKER_STANDBY_MAX_AGE_HOURS', value: '12' }, + ], + }, + ); + }); + + it('rejects standby retention values outside provider limits', async () => { + await expect( + saveComputeConfigCommand(buildMockAuth(), { + provider: 'blaxel', + values: { BLAXEL_STANDBY_MAX_AGE_HOURS: '169' }, + }), + ).rejects.toThrow('Retention period (hours) must be at most 168.'); + }); + it('uses a submitted worker image to derive Modal base image without sticky persist', async () => { await saveComputeConfigCommand(buildMockAuth(), { provider: 'modal', @@ -559,10 +593,22 @@ describe('compute commands', () => { }); }); - it('is a no-op for providers without credential fields', async () => { + it('deletes Docker standby retention settings', async () => { + const txWhere = vi.fn(async () => undefined); + const txDelete = vi.fn(() => ({ where: txWhere })); + const { inArray: txInArray } = await import('@roomote/db/server'); + + mockDbTransaction.mockImplementation(async (callback) => { + return callback({ delete: txDelete } as never); + }); + await clearComputeConfigCommand(buildMockAuth(), { provider: 'docker' }); - expect(mockDbTransaction).not.toHaveBeenCalled(); + expect(txInArray).toHaveBeenCalledWith('env.name', [ + 'DOCKER_STANDBY_MAX_COUNT', + 'DOCKER_STANDBY_MAX_AGE_HOURS', + ]); + expect(txWhere).toHaveBeenCalled(); }); }); diff --git a/apps/web/src/trpc/commands/compute/index.ts b/apps/web/src/trpc/commands/compute/index.ts index 941231e32..8b68fd823 100644 --- a/apps/web/src/trpc/commands/compute/index.ts +++ b/apps/web/src/trpc/commands/compute/index.ts @@ -13,6 +13,7 @@ import { buildSetupComputeStatus, deriveWorkerImageFromReleaseVersion, getSetupComputeProvider, + getComputeFieldValidationError, isAutoProvisionedComputeArtifactField, isComputeCredentialField, isComputeInfrastructureField, @@ -238,6 +239,10 @@ export async function saveComputeConfigCommand( field.envVarName === 'MODAL_BASE_IMAGE_REF' ? '' : (input.values?.[field.envVarName]?.trim() ?? ''); + const validationError = getComputeFieldValidationError(field, submitted); + if (validationError) { + throw new Error(validationError); + } const nextValue = submitted || (isComputeInfrastructureField(field) @@ -364,8 +369,8 @@ export async function clearComputeConfigCommand( assertAdmin(auth); const provider = getSetupComputeProvider(input.provider); - // Clears this provider's saved credentials and its provider-specific - // infrastructure values (base image ref, template id, snapshot name). + // Clears this provider's saved credentials and provider-specific advanced + // settings/artifacts. Process environment values remain untouched. const providerEnvVarNames = provider.fields.map((field) => field.envVarName); if (providerEnvVarNames.length === 0) { diff --git a/apps/web/src/trpc/commands/github/mutations.test.ts b/apps/web/src/trpc/commands/github/mutations.test.ts index 23befd719..e767db8c3 100644 --- a/apps/web/src/trpc/commands/github/mutations.test.ts +++ b/apps/web/src/trpc/commands/github/mutations.test.ts @@ -207,7 +207,7 @@ describe('GitHub App manifest commands', () => { mode: 'github-app-manifest', redirect: '/setup?step=source-control-connect', }, - 'roovetgit', + 'example-org', ); expect(result.success).toBe(true); @@ -218,7 +218,7 @@ describe('GitHub App manifest commands', () => { const postTarget = new URL(result.postTarget); expect(`${postTarget.origin}${postTarget.pathname}`).toBe( - 'https://github.com/organizations/roovetgit/settings/apps/new', + 'https://github.com/organizations/example-org/settings/apps/new', ); expect(decodeRecord(postTarget.searchParams.get('state') ?? '')).toEqual({ mode: 'github-app-manifest', diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index e4041f706..7429fd22b 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -57,8 +57,10 @@ import { deriveWorkerImageFromReleaseVersion, getSetupAuthProvider, getSetupComputeProvider, + getComputeFieldValidationError, getSetupModelProvider, isAutoProvisionedComputeArtifactField, + isComputeCredentialField, isComputeInfrastructureField, isConfiguredEnvValue, isExitedRunStatus, @@ -1479,7 +1481,9 @@ export async function saveSetupNewComputeProviderChoiceCommand( throw new Error('Selected sandbox provider is unavailable.'); } - const hasCredentialFields = providerStatus.fields.length > 0; + const hasCredentialFields = providerStatus.fields.some( + isComputeCredentialField, + ); const runtimeComputeConfig = hasCredentialFields ? persistedRuntimeComputeConfig : normalizeDeploymentComputeConfig({ @@ -1667,6 +1671,13 @@ export async function saveSetupNewComputeConfigCommand( field.envVarName === 'MODAL_BASE_IMAGE_REF' ? '' : (input.values?.[field.envVarName]?.trim() ?? ''); + const validationError = getComputeFieldValidationError( + field, + submitted, + ); + if (validationError) { + throw new Error(validationError); + } const nextValue = submitted || (isComputeInfrastructureField(field) diff --git a/apps/web/src/trpc/commands/snapshots/index.ts b/apps/web/src/trpc/commands/snapshots/index.ts index f3b1779e0..7cc2e9a3e 100644 --- a/apps/web/src/trpc/commands/snapshots/index.ts +++ b/apps/web/src/trpc/commands/snapshots/index.ts @@ -1,17 +1,19 @@ import { type TaskPayload, activeRunStatuses, + runningRunStatuses, TaskPayloadKind, ORPHANED_PENDING_THRESHOLD_MS, populateSnapshotResumeSlackMetadata, EXPIRED_SNAPSHOT_RESUME_ERROR, + isTaskResumeCapableComputeProvider, + isResumableTaskPayloadKind, isSnapshotResumable, resolveComputeProviderTarget, } from '@roomote/types'; import type { ModalClient as _ModalSdkClient } from 'modal'; import { enqueueTask } from '@roomote/cloud-agents/server'; -import { createComputeProviderClient } from '@roomote/compute-providers/factory'; -import { createClient } from '@roomote/sdk/client'; +import { enqueueTaskSleep } from '@roomote/sdk/server'; import { db, environments, @@ -30,7 +32,6 @@ import { isNull, sql, } from '@roomote/db/server'; -import { createRunToken } from '@roomote/auth'; import { type ComputeProvider, isSnapshotCapableComputeProvider, @@ -38,7 +39,6 @@ import { import type { UserAuthSuccess } from '@/types'; -import { Env } from '@/lib/server'; import { type ClaimedOutOfBandContext, claimOutOfBandContextForPrompt, @@ -306,13 +306,11 @@ export async function clearEnvironmentSnapshotCommand( } } -export async function createTaskRunSnapshotCommand( - auth: UserAuthSuccess, +export async function requestTaskRunSleepCommand( + _auth: UserAuthSuccess, input: { runId: number }, ): Promise { try { - const { userId } = auth; - const taskRun = await db.query.taskRuns.findFirst({ where: eq(taskRuns.id, input.runId), }); @@ -325,79 +323,48 @@ export async function createTaskRunSnapshotCommand( return { success: false, error: 'No machine associated with this job' }; } - const provider = resolveComputeProviderTarget(taskRun.vendor); - const computeClient = createComputeProviderClient({ provider }); + const provider = taskRun.vendor; - if (!computeClient.capabilities.supportsSnapshots) { + if (!provider) { return { success: false, - error: `Snapshots are not supported for ${provider} jobs`, + error: 'No compute provider associated with this task', }; } - const { status } = await computeClient.getInstanceStatus({ - instanceId: taskRun.machineId, - }); + if ( + !isTaskResumeCapableComputeProvider(provider) || + !isResumableTaskPayloadKind(taskRun.payloadKind) + ) { + return { + success: false, + error: `Resumable sleep is not supported for this ${provider} task`, + }; + } - if (status !== 'running') { + if (!runningRunStatuses.some((status) => status === taskRun.status)) { return { success: false, - error: `Cannot create snapshot: instance is ${status}`, + error: 'Only active task runs can be put to sleep', }; } if (taskRun.snapshotId) { - return { success: false, error: 'Snapshot already exists for this job' }; + return { success: false, error: 'This task is already asleep' }; } if (taskRun.snapshotFailedAt) { return { success: false, - error: 'A previous snapshot attempt failed for this job', + error: 'A previous sleep attempt failed for this task', }; } - const authToken = await createRunToken({ - runId: input.runId, - userId, - timeoutMs: 5 * 60 * 1000, - }); - - const client = createClient({ - url: Env.TRPC_URL, - headers: () => ({ Authorization: `Bearer ${authToken}` }), - }); - - const { enqueued } = await client.taskRuns.createSnapshot.mutate({ - runId: input.runId, - sandboxId: taskRun.machineId, - }); - - if (!enqueued) { - return { success: false, error: 'Snapshot creation already requested' }; - } - - await db - .update(taskRuns) - .set({ snapshotRequestedAt: new Date() }) - .where(eq(taskRuns.id, input.runId)); + await enqueueTaskSleep({ runId: input.runId }); return { success: true }; } catch (error) { - console.error('createTaskRunSnapshot error:', error); - - try { - await db - .update(taskRuns) - .set({ - snapshotRequestedAt: null, - snapshotFailedAt: new Date(), - error: `Snapshot creation failed: ${error instanceof Error ? error.message : String(error)}`, - }) - .where(eq(taskRuns.id, input.runId)); - } catch (_error) { - // NO-OP - } + console.error('requestTaskRunSleep error:', error); return error instanceof Error ? { success: false, error: error.message } diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 2b2836141..125b65c7c 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -155,7 +155,7 @@ import { import { createEnvironmentSnapshotCommand, clearEnvironmentSnapshotCommand, - createTaskRunSnapshotCommand, + requestTaskRunSleepCommand, restoreTaskRunSnapshotCommand, } from '../commands/snapshots'; import { @@ -1126,10 +1126,10 @@ export const appRouter = createRouter({ clearEnvironmentSnapshotCommand(auth, input), ), - createTaskRun: protectedProcedure + requestTaskRunSleep: protectedProcedure .input(z.object({ runId: z.number() })) .mutation(({ ctx: { auth }, input }) => - createTaskRunSnapshotCommand(auth, input), + requestTaskRunSleepCommand(auth, input), ), restoreTaskRun: protectedProcedure diff --git a/apps/worker/src/commands/__tests__/resume.test.ts b/apps/worker/src/commands/__tests__/resume.test.ts index d961798f5..38498b12e 100644 --- a/apps/worker/src/commands/__tests__/resume.test.ts +++ b/apps/worker/src/commands/__tests__/resume.test.ts @@ -123,4 +123,39 @@ describe('resume', () => { }), ); }); + + it('restores a retained environment before its first harness session', async () => { + executeTaskRunMock.mockImplementation(async ({ runFn }) => { + await runFn({ + jobContext: { + taskRun: { + id: 44, + payloadKind: TaskPayloadKind.SnapshotResume, + taskId: 'task-44', + }, + task: {}, + envVars: {}, + harnessSessionId: undefined, + }, + workspace: {}, + workspacePath: '/tmp/workspace', + usesSharedWorkspaceRoot: false, + callbacks: {}, + context: {}, + logger: {} as never, + workerEnv: {} as never, + }); + + return true; + }); + + await resume(44); + + expect(runTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '', + harnessSessionId: undefined, + }), + ); + }); }); diff --git a/apps/worker/src/commands/__tests__/utils.test.ts b/apps/worker/src/commands/__tests__/utils.test.ts index 8b1a0a8ff..68cff5ed2 100644 --- a/apps/worker/src/commands/__tests__/utils.test.ts +++ b/apps/worker/src/commands/__tests__/utils.test.ts @@ -13,6 +13,7 @@ vi.mock('fs', () => ({ readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), + chmodSync: vi.fn(), rmSync: vi.fn(), })); @@ -586,6 +587,8 @@ describe('writeCommonEnvFile', () => { expect(content).toContain(`source '${GITLAB_TOKEN_ENV_PATH}'`); expect(content).toContain(`source '${GITEA_TOKEN_ENV_PATH}'`); expect(content).toContain(`source '${ADO_TOKEN_ENV_PATH}'`); + expect(envWrite?.[2]).toEqual({ mode: 0o600 }); + expect(fs.chmodSync).toHaveBeenCalledWith(COMMON_ENV_PATH, 0o600); }); }); diff --git a/apps/worker/src/commands/resume.ts b/apps/worker/src/commands/resume.ts index 00097db6e..753350648 100644 --- a/apps/worker/src/commands/resume.ts +++ b/apps/worker/src/commands/resume.ts @@ -78,7 +78,7 @@ export async function resume(runId: number): Promise { // Seed callback context for resumed integrations before runTask starts. // onStart still runs for resume task runs, but it should reuse the existing // harness session rather than overwrite it. - if (isLinearResume || isSlackResume) { + if ((isLinearResume || isSlackResume) && jobContext.harnessSessionId) { context.sessionId = jobContext.harnessSessionId; context.parentTaskId = jobContext.harnessSessionId; context.taskId = jobContext.taskRun.taskId; diff --git a/apps/worker/src/commands/utils/env-vars.ts b/apps/worker/src/commands/utils/env-vars.ts index e33d01982..c7906d5a1 100644 --- a/apps/worker/src/commands/utils/env-vars.ts +++ b/apps/worker/src/commands/utils/env-vars.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { chmodSync, readFileSync, writeFileSync, existsSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; @@ -101,7 +101,8 @@ export function writeCommonEnvFile(envVars: Record): void { ); lines.push('', 'unset __ROOMOTE_ENV_LOADED', ''); - writeFileSync(COMMON_ENV_FILE_PATH, lines.join('\n'), { mode: 0o644 }); + writeFileSync(COMMON_ENV_FILE_PATH, lines.join('\n'), { mode: 0o600 }); + chmodSync(COMMON_ENV_FILE_PATH, 0o600); } /** diff --git a/apps/worker/src/lib/github-token.ts b/apps/worker/src/lib/github-token.ts index 14110b458..27c361a47 100644 --- a/apps/worker/src/lib/github-token.ts +++ b/apps/worker/src/lib/github-token.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { createServer } from 'http'; import { homedir } from 'os'; import { join } from 'path'; @@ -745,5 +745,6 @@ export async function applySourceControlTokenMetadata( } function ensureGhTokenDirectory(): void { - mkdirSync(GH_TOKEN_DIR, { recursive: true }); + mkdirSync(GH_TOKEN_DIR, { recursive: true, mode: 0o700 }); + chmodSync(GH_TOKEN_DIR, 0o700); } diff --git a/apps/worker/src/run-task/__tests__/wait-for-external-sleep-action.test.ts b/apps/worker/src/run-task/__tests__/wait-for-external-sleep-action.test.ts index 4179ff39e..9c64d65dc 100644 --- a/apps/worker/src/run-task/__tests__/wait-for-external-sleep-action.test.ts +++ b/apps/worker/src/run-task/__tests__/wait-for-external-sleep-action.test.ts @@ -188,7 +188,7 @@ describe('waitForExternalSleepAction', () => { expect(findRuntimeStateByIdMock).toHaveBeenCalledWith(790); }); - it('skips jobs on non-snapshot-capable providers', async () => { + it('skips jobs without a managed compute provider', async () => { const logger = { runId: 123, filePath: '/tmp/test.log', @@ -202,7 +202,7 @@ describe('waitForExternalSleepAction', () => { taskRun: { id: 123, payloadKind: TaskPayloadKind.GithubPrReviewFollowUp, - vendor: 'docker', + vendor: null, machineId: 'worker-123', } as never, logger, diff --git a/docker-compose.compute-docker.yml b/docker-compose.compute-docker.yml index 86ac9a45f..0abecea87 100644 --- a/docker-compose.compute-docker.yml +++ b/docker-compose.compute-docker.yml @@ -25,8 +25,17 @@ services: DOCKER_WORKER_LOG_MAX_SIZE: ${DOCKER_WORKER_LOG_MAX_SIZE:-10m} DOCKER_WORKER_LOG_MAX_FILES: ${DOCKER_WORKER_LOG_MAX_FILES:-3} DOCKER_WORKER_EGRESS_POLICY: ${DOCKER_WORKER_EGRESS_POLICY:-internet} + DOCKER_STANDBY_MAX_COUNT: ${DOCKER_STANDBY_MAX_COUNT:-10} + DOCKER_STANDBY_MAX_AGE_HOURS: ${DOCKER_STANDBY_MAX_AGE_HOURS:-24} volumes: - /var/run/docker.sock:/var/run/docker.sock depends_on: worker-image: condition: service_completed_successfully + + bullmq: + environment: + DOCKER_STANDBY_MAX_COUNT: ${DOCKER_STANDBY_MAX_COUNT:-10} + DOCKER_STANDBY_MAX_AGE_HOURS: ${DOCKER_STANDBY_MAX_AGE_HOURS:-24} + volumes: + - /var/run/docker.sock:/var/run/docker.sock diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 0e9e5e5fb..d6e13f01a 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -80,6 +80,10 @@ x-roomote-production-env: &roomote-production-env BL_WORKSPACE: ${BL_WORKSPACE:-} BLAXEL_IMAGE: ${BLAXEL_IMAGE:-} BLAXEL_REGION: ${BLAXEL_REGION:-} + BLAXEL_STANDBY_MAX_COUNT: ${BLAXEL_STANDBY_MAX_COUNT:-25} + BLAXEL_STANDBY_MAX_AGE_HOURS: ${BLAXEL_STANDBY_MAX_AGE_HOURS:-168} + DOCKER_STANDBY_MAX_COUNT: ${DOCKER_STANDBY_MAX_COUNT:-10} + DOCKER_STANDBY_MAX_AGE_HOURS: ${DOCKER_STANDBY_MAX_AGE_HOURS:-24} x-roomote-production-build-args: &roomote-production-build-args R_APP_ENV: production diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 3ff9a10b0..2887cddf3 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -70,6 +70,10 @@ x-roomote-env: &roomote-env BL_WORKSPACE: ${BL_WORKSPACE:-} BLAXEL_IMAGE: ${BLAXEL_IMAGE:-} BLAXEL_REGION: ${BLAXEL_REGION:-} + BLAXEL_STANDBY_MAX_COUNT: ${BLAXEL_STANDBY_MAX_COUNT:-25} + BLAXEL_STANDBY_MAX_AGE_HOURS: ${BLAXEL_STANDBY_MAX_AGE_HOURS:-168} + DOCKER_STANDBY_MAX_COUNT: ${DOCKER_STANDBY_MAX_COUNT:-10} + DOCKER_STANDBY_MAX_AGE_HOURS: ${DOCKER_STANDBY_MAX_AGE_HOURS:-24} # Declarative environment provisioning: point ROOMOTE_ENVIRONMENTS_DIR at a # directory of environment YAML files mounted into the api container (for # example `- ./environments:/roomote/environments:ro` on the api service), diff --git a/package.json b/package.json index 68fd9b13b..54990f2fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "0.2.0", + "version": "0.3.0", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { diff --git a/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewComment.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewComment.test.ts index 5c55a2b96..09b87ce1f 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewComment.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewComment.test.ts @@ -1,9 +1,13 @@ // pnpm --filter @roomote/cloud-agents test src/server/workflows/__tests__/githubPrReviewComment.test.ts import { + buildGithubCommitHref, + buildInProgressReviewSummaryBody, + buildReviewMetaFooter, buildTerminalReviewStatus, buildTerminalReviewSummaryBody, buildReviewSummaryBody, + parseReviewSummaryMarkerSha, REVIEW_STATUS_START_MARKER, REVIEW_STATUS_END_MARKER, REVIEW_CHECKLIST_START_MARKER, @@ -45,6 +49,83 @@ describe('buildTerminalReviewStatus', () => { }); }); +describe('review meta footer', () => { + it('formats reviewing/reviewed footer with linked SHA', () => { + const commitHref = buildGithubCommitHref({ + repositoryFullName: 'RooCodeInc/Roomote', + sha: 'abc1234deadbeef', + }); + + expect( + buildReviewMetaFooter({ + phase: 'Reviewing', + sha: 'abc1234deadbeef', + commitHref, + }), + ).toBe( + `Reviewing abc1234`, + ); + + expect( + buildReviewMetaFooter({ + phase: 'Reviewed', + sha: 'abc1234deadbeef', + }), + ).toBe('Reviewed abc1234'); + }); + + it('parses the marker SHA', () => { + expect(parseReviewSummaryMarkerSha(MARKER('abcdef0123'))).toBe( + 'abcdef0123', + ); + }); + + it('appends the footer at the bottom of the summary body', () => { + const body = buildReviewSummaryBody({ + summaryMarker: MARKER('abc1234deadbeef'), + statusContent: IN_PROGRESS_INITIAL, + checklistContent: '- [ ] Fix the thing', + repositoryFullName: 'RooCodeInc/Roomote', + }); + + expect(body.endsWith('')).toBe(true); + expect(body).toContain('Reviewing '); + expect(body).toContain('abc1234'); + expect(body).toContain( + 'href="https://github.com/RooCodeInc/Roomote/commit/abc1234deadbeef"', + ); + expect(body).not.toContain('#abc1234'); + expect(body).not.toContain(' UTC'); + expect(body).not.toContain(' · '); + expect(body.indexOf(REVIEW_CHECKLIST_END_MARKER)).toBeLessThan( + body.indexOf('Reviewing '), + ); + }); + + it('keeps footer label and link on the preserved marker SHA during reuse', () => { + const existing = buildReviewSummaryBody({ + summaryMarker: MARKER('aaa1111deadbeef'), + statusContent: COMPLETION, + checklistContent: '- [ ] Prior finding', + repositoryFullName: 'RooCodeInc/Roomote', + }); + + const updated = buildInProgressReviewSummaryBody({ + existingBody: existing, + inProgressStatus: IN_PROGRESS_INITIAL, + summaryMarker: MARKER('bbb2222cafebabe'), + repositoryFullName: 'RooCodeInc/Roomote', + }); + + expect(updated.startsWith(MARKER('aaa1111deadbeef'))).toBe(true); + expect(updated).toContain('>aaa1111'); + expect(updated).toContain( + 'href="https://github.com/RooCodeInc/Roomote/commit/aaa1111deadbeef"', + ); + expect(updated).not.toContain('bbb2222'); + }); +}); + describe('buildTerminalReviewSummaryBody', () => { const terminal = 'Review complete. See task'; @@ -69,6 +150,7 @@ describe('buildTerminalReviewSummaryBody', () => { expect(updated).toContain( `${REVIEW_CHECKLIST_START_MARKER}\n- [ ] Fix the thing\n- [x] Already addressed\n${REVIEW_CHECKLIST_END_MARKER}`, ); + expect(updated).toContain('Reviewed abc123'); }); it('finalizes an in-progress sync summary', () => { @@ -86,6 +168,7 @@ describe('buildTerminalReviewSummaryBody', () => { expect(updated!.startsWith(MARKER('def456', 'sync'))).toBe(true); expect(updated).toContain(terminal); expect(updated).not.toContain(IN_PROGRESS_SYNC); + expect(updated).toContain('Reviewed def456'); }); it('does not clobber a comment the agent already finalized', () => { diff --git a/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewSkill.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewSkill.test.ts index ed33d0c5b..97634a413 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewSkill.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewSkill.test.ts @@ -147,7 +147,7 @@ describe('review-code GitHub workflow paths', () => { 'If surviving or net-new code issues remain, add one unchecked markdown checkbox item (`- [ ]`) for each actionable code issue that should remain open.', ); expect(skillContent).toContain( - 'Keep the summary structured as a hidden status block plus a hidden checklist/history block.', + 'Keep the summary structured as a hidden status block plus a hidden checklist/history block, ending with the trailing `Reviewing|Reviewed [SHORT_SHA]` footer.', ); expect(skillContent).toContain( 'approve the pull request by calling `mcp__roomote__manage_source_control` with `action: "submit_pull_request_review"` and `reviewEvent: "approve"`, passing no body or comment text.', @@ -287,6 +287,12 @@ describe('review-code GitHub workflow paths', () => { expect(skillContent).toContain( '`` and ``', ); + expect(skillContent).toContain( + 'End the comment with a small visible status footer as the final line', + ); + expect(skillContent).toContain( + 'Reviewing [SHORT_SHA](commit_url)', + ); expect(skillContent).not.toContain( 'single-line clean summary is acceptable.', ); diff --git a/packages/cloud-agents/src/server/workflows/githubPrReview.ts b/packages/cloud-agents/src/server/workflows/githubPrReview.ts index 57d98e353..411b83b94 100644 --- a/packages/cloud-agents/src/server/workflows/githubPrReview.ts +++ b/packages/cloud-agents/src/server/workflows/githubPrReview.ts @@ -463,6 +463,8 @@ export async function githubPrReview({ const body = buildReviewSummaryBody({ summaryMarker, statusContent: inProgressStatus, + metaPhase: 'Reviewing', + repositoryFullName: fullName, }); let topLevelCommentId = existingReviewSummaryComment?.id; @@ -489,6 +491,7 @@ export async function githubPrReview({ existingBody: existingReviewSummaryComment.body, inProgressStatus, summaryMarker, + repositoryFullName: fullName, }); await updateIssueComment(gitHubToken, { diff --git a/packages/cloud-agents/src/server/workflows/githubPrReviewComment.ts b/packages/cloud-agents/src/server/workflows/githubPrReviewComment.ts index 9619357eb..d65fbe9f6 100644 --- a/packages/cloud-agents/src/server/workflows/githubPrReviewComment.ts +++ b/packages/cloud-agents/src/server/workflows/githubPrReviewComment.ts @@ -15,6 +15,8 @@ export const REVIEW_CHECKLIST_START_MARKER = export const REVIEW_CHECKLIST_END_MARKER = ''; +export type ReviewMetaPhase = 'Reviewing' | 'Reviewed'; + export function isReviewInProgressStatusLine(line: string): boolean { return /^(Self-reviewing the PR(?: with fresh eyes)? now\.|Reviewing the PR now\.|Re-reviewing new commits now\.)/i.test( line.trim(), @@ -46,15 +48,106 @@ export function getMarkedSection({ return content.slice(afterStart, endIndex).trim(); } +export function parseReviewSummaryMarkerSha( + markerOrBody: string, +): string | undefined { + const match = markerOrBody.match( + /` and `` markers when they exist, and otherwise normalize the comment into the hidden status/checklist block format before continuing. Do not replace the previous review inventory while the initial review is still running, and do not wait until the review is complete to make that status update. If no canonical summary comment exists yet, compose an initial body that contains the hidden summary marker, a hidden status block with a compact in-progress status line, and an empty hidden checklist block, then create the comment with `mcp__roomote__manage_source_control` `action: "create_pull_request_comment"` and capture the returned `commentId` (plus the `threadId` when the provider returns one, which Azure DevOps does for top-level comments) as `TOP_LEVEL_COMMENT_ID`. - Whenever you create or update the summary comment, keep this hidden marker as the first line: ``. Immediately after it, keep a hidden status block bounded by `` and ``, then a hidden checklist/history block bounded by `` and ``. - Use a compact status block while the review is running. Rewrite only the content inside the hidden status markers on later updates, and keep the hidden checklist block and its contents intact until the final summary reconciliation. If `task_link_follow` is available, keep it inline on the in-progress status line; otherwise omit it. + Whenever you create or update the summary comment, keep this hidden marker as the first line: ``. Immediately after it, keep a hidden status block bounded by `` and ``, then a hidden checklist/history block bounded by `` and ``. End the comment with a small visible status footer as the final line: `Reviewing [SHORT_SHA](commit_url)` while the review is running, or with `Reviewed` instead of `Reviewing` once the summary is terminal. Use the current head SHA shortened to 7 characters, link it to the commit when a provider commit URL is available (otherwise keep the bare `SHORT_SHA`), and refresh the phase and SHA on every create or update. + Use a compact status block while the review is running. Rewrite only the content inside the hidden status markers on later updates, keep the hidden checklist block and its contents intact until the final summary reconciliation, and always refresh the trailing `Reviewing|Reviewed ...` footer to match the current phase and head SHA. If `task_link_follow` is available, keep it inline on the in-progress status line; otherwise omit it. The review has exactly one canonical top-level summary comment and it can be updated later in place. @@ -294,7 +294,7 @@ You are a pull request review workflow specialist. Review the assigned pull requ Never create a second top-level summary comment in this step. Keep the hidden marker as the first line and update its SHA value to the current PR head SHA: ``. - Use compact summary formatting with a hidden status block and a hidden checklist/history block. Later updates should rewrite only the content inside the status block unless checklist reconciliation is needed. + Use compact summary formatting with a hidden status block, a hidden checklist/history block, and a trailing `Reviewed|Reviewing [SHORT_SHA]` footer after the checklist block. Later updates should rewrite only the content inside the status block unless checklist reconciliation is needed, and should still refresh the footer phase and SHA. If unresolved code findings remain, write one short status line inside the hidden status block, such as `2 issues outstanding.` If `task_link_see` is available, keep it inline on that line. Add one unchecked markdown checkbox item (`- [ ]`) per actionable code finding inside the hidden checklist block. Treat only unchecked markdown checklist items (`- [ ]`) as unresolved actionable inventory. Keep genuinely fixed items as checked checklist lines (`- [x]`), and if a later linked implementation task dismisses a finding as invalid, stale, or out of scope, preserve it in place as a struck-through plain bullet like `- ~~Short finding text~~ — dismissed: brief factual reason.` so it no longer carries unresolved-checkbox semantics. If no actionable code issues remain, use a short status line in the hidden status block, such as `No code issues found.` If `task_link_see` is available, keep it inline on that line. If a checklist already exists, keep it and mark every resolved item as checked (`- [x]`) instead of removing the checklist. If no checklist was ever created, keep the hidden checklist block empty. @@ -527,8 +527,8 @@ You are a pull request review workflow specialist. Review the assigned pull requ If no marker-based summary comment exists, use a backward-compatible legacy fallback: find the latest Roomote-authored top-level PR comment that does not contain `` and `` markers when they exist, and otherwise normalize the comment into the hidden status/checklist block format before continuing. Do not replace the previous review inventory while the initial review is still running, and do not wait until the review is complete to make that status update. If no canonical summary comment exists yet, compose an initial body that contains the hidden summary marker, a hidden status block with a compact in-progress status line, and an empty hidden checklist block, then create the comment with `mcp__roomote__manage_source_control` `action: "create_pull_request_comment"` and capture the returned `commentId` (plus the `threadId` when the provider returns one, which Azure DevOps does for top-level comments) as `TOP_LEVEL_COMMENT_ID`. - Whenever you create or update the summary comment, keep this hidden marker as the first line: ``. Immediately after it, keep a hidden status block bounded by `` and ``, then a hidden checklist/history block bounded by `` and ``. - Use a compact status block while the review is running. Rewrite only the content inside the hidden status markers on later updates, and keep the hidden checklist block and its contents intact until the final summary reconciliation. If `task_link_follow` is available, keep it inline on the in-progress status line; otherwise omit it. + Whenever you create or update the summary comment, keep this hidden marker as the first line: ``. Immediately after it, keep a hidden status block bounded by `` and ``, then a hidden checklist/history block bounded by `` and ``. End the comment with a small visible status footer as the final line: `Reviewing [SHORT_SHA](commit_url)` while the review is running, or with `Reviewed` instead of `Reviewing` once the summary is terminal. Use the current head SHA shortened to 7 characters, link it to the commit when a provider commit URL is available (otherwise keep the bare `SHORT_SHA`), and refresh the phase and SHA on every create or update. + Use a compact status block while the review is running. Rewrite only the content inside the hidden status markers on later updates, keep the hidden checklist block and its contents intact until the final summary reconciliation, and always refresh the trailing `Reviewing|Reviewed ...` footer to match the current phase and head SHA. If `task_link_follow` is available, keep it inline on the in-progress status line; otherwise omit it. The review has exactly one canonical top-level summary comment and it can be updated later in place. @@ -569,7 +569,7 @@ You are a pull request review workflow specialist. Review the assigned pull requ Never create a second top-level summary comment in this step. Keep the hidden marker as the first line and update its SHA value to the current PR head SHA: ``. - Use compact summary formatting with a hidden status block and a hidden checklist/history block. Later updates should rewrite only the content inside the status block unless checklist reconciliation is needed. + Use compact summary formatting with a hidden status block, a hidden checklist/history block, and a trailing `Reviewed|Reviewing [SHORT_SHA]` footer after the checklist block. Later updates should rewrite only the content inside the status block unless checklist reconciliation is needed, and should still refresh the footer phase and SHA. If unresolved code findings remain, write one short status line inside the hidden status block, such as `2 issues outstanding.` If `task_link_see` is available, keep it inline on that line. Add one unchecked markdown checkbox item (`- [ ]`) per actionable code finding inside the hidden checklist block. Treat only unchecked markdown checklist items (`- [ ]`) as unresolved actionable inventory. Keep genuinely fixed items as checked checklist lines (`- [x]`), and if a later linked implementation task dismisses a finding as invalid, stale, or out of scope, preserve it in place as a struck-through plain bullet like `- ~~Short finding text~~ — dismissed: brief factual reason.` so it no longer carries unresolved-checkbox semantics. If no actionable code issues remain, use a short status line in the hidden status block, such as `No code issues found.` If `task_link_see` is available, keep it inline on that line. If a checklist already exists, keep it and mark every resolved item as checked (`- [x]`) instead of removing the checklist. If no checklist was ever created, keep the hidden checklist block empty. @@ -887,11 +887,11 @@ You are a sync-review workflow specialist. Re-review pull requests after new com Update the rolling summary so the current sync-review state is visible immediately. Never create a second top-level summary comment in this step. - Keep the hidden marker as the first line and update it to the new head SHA: ``. Immediately after it, keep a hidden status block bounded by `` and ``, then a hidden checklist/history block bounded by `` and ``. + Keep the hidden marker as the first line and update it to the new head SHA: ``. Immediately after it, keep a hidden status block bounded by `` and ``, then a hidden checklist/history block bounded by `` and ``. End the comment with a small visible status footer as the final line: `Reviewed [SHORT_SHA](commit_url)` for terminal sync results (or `Reviewing` while still in progress). Use the current head SHA shortened to 7 characters, link it to the commit when a provider commit URL is available (otherwise keep the bare `SHORT_SHA`), and refresh the phase and SHA on every create or update. Carry forward prior checklist items from `prior_summary_checklist` when it is available, or reconstruct them from `top_level_review_comment` when it is not, instead of restating them as new inline comments. Keep earlier checklist wording stable where possible. Check off earlier items only when the updated code clearly resolves them, and keep unresolved items unchecked. When an earlier item is checked off because the issue is clearly fixed, keep the summary state and provider thread state aligned: resolve the matching Roomote-authored review thread with `action: "resolve_pull_request_thread"` when it is still open, and leave the thread open when the issue remains unresolved or ambiguous. - Keep the summary structured as a hidden status block plus a hidden checklist/history block. Rewrite only the content inside the status block on each update, but keep the checklist history additive unless checklist reconciliation is required. + Keep the summary structured as a hidden status block plus a hidden checklist/history block, ending with the trailing `Reviewing|Reviewed [SHORT_SHA]` footer. Rewrite only the content inside the status block on each update, keep the checklist history additive unless checklist reconciliation is required, and always refresh the footer phase and SHA. If no surviving or net-new code issues remain, use a short status line in the hidden status block, such as `No new code issues found.` If `task_link_see` is available, keep it inline on that line. Keep the checklist history inside the hidden checklist block and mark all resolved items checked (`- [x]`) instead of removing the checklist. If no checklist history exists yet, keep the hidden checklist block empty. If surviving or net-new code issues remain, add one unchecked markdown checkbox item (`- [ ]`) for each actionable code issue that should remain open. Treat only unchecked markdown checklist items (`- [ ]`) as unresolved actionable inventory. Keep genuinely fixed items checked (`- [x]`). When a carried-forward item is dismissed as invalid, stale, or out of scope, convert that line from unresolved checklist form into a struck-through plain markdown bullet like `- ~~Short finding text~~ — dismissed: brief factual reason.` and leave it out of later actionable inventories. @@ -1198,11 +1198,11 @@ You are a sync-review workflow specialist. Re-review pull requests after new com Update the rolling summary so the current sync-review state is visible immediately. Never create a second top-level summary comment in this step. - Keep the hidden marker as the first line and update it to the new head SHA: ``. Immediately after it, keep a hidden status block bounded by `` and ``, then a hidden checklist/history block bounded by `` and ``. + Keep the hidden marker as the first line and update it to the new head SHA: ``. Immediately after it, keep a hidden status block bounded by `` and ``, then a hidden checklist/history block bounded by `` and ``. End the comment with a small visible status footer as the final line: `Reviewed [SHORT_SHA](commit_url)` for terminal sync results (or `Reviewing` while still in progress). Use the current head SHA shortened to 7 characters, link it to the commit when a provider commit URL is available (otherwise keep the bare `SHORT_SHA`), and refresh the phase and SHA on every create or update. Carry forward prior checklist items from `prior_summary_checklist` when it is available, or reconstruct them from `top_level_review_comment` when it is not, instead of restating them as new inline comments. Keep earlier checklist wording stable where possible. Check off earlier items only when the updated code clearly resolves them, and keep unresolved items unchecked. When an earlier item is checked off because the issue is clearly fixed, keep the summary state and provider thread state aligned: resolve the matching Roomote-authored review thread with `action: "resolve_pull_request_thread"` when it is still open, and leave the thread open when the issue remains unresolved or ambiguous. - Keep the summary structured as a hidden status block plus a hidden checklist/history block. Rewrite only the content inside the status block on each update, but keep the checklist history additive unless checklist reconciliation is required. + Keep the summary structured as a hidden status block plus a hidden checklist/history block, ending with the trailing `Reviewing|Reviewed [SHORT_SHA]` footer. Rewrite only the content inside the status block on each update, keep the checklist history additive unless checklist reconciliation is required, and always refresh the footer phase and SHA. If no surviving or net-new code issues remain, use a short status line in the hidden status block, such as `No new code issues found.` If `task_link_see` is available, keep it inline on that line. Keep the checklist history inside the hidden checklist block and mark all resolved items checked (`- [x]`) instead of removing the checklist. If no checklist history exists yet, keep the hidden checklist block empty. If surviving or net-new code issues remain, add one unchecked markdown checkbox item (`- [ ]`) for each actionable code issue that should remain open. Treat only unchecked markdown checklist items (`- [ ]`) as unresolved actionable inventory. Keep genuinely fixed items checked (`- [x]`). When a carried-forward item is dismissed as invalid, stale, or out of scope, convert that line from unresolved checklist form into a struck-through plain markdown bullet like `- ~~Short finding text~~ — dismissed: brief factual reason.` and leave it out of later actionable inventories. diff --git a/packages/compute-providers/src/__tests__/adapters.contract.test.ts b/packages/compute-providers/src/__tests__/adapters.contract.test.ts index 16cca4b10..8973a6ddc 100644 --- a/packages/compute-providers/src/__tests__/adapters.contract.test.ts +++ b/packages/compute-providers/src/__tests__/adapters.contract.test.ts @@ -65,6 +65,7 @@ vi.mock('e2b', () => ({ const { blaxelCreateMock, + blaxelCreateIfNotExistsMock, blaxelGetMock, blaxelListMock, blaxelDeleteMock, @@ -72,6 +73,7 @@ const { blaxelSettingsMock, } = vi.hoisted(() => ({ blaxelCreateMock: vi.fn(), + blaxelCreateIfNotExistsMock: vi.fn(), blaxelGetMock: vi.fn(), blaxelListMock: vi.fn(), blaxelDeleteMock: vi.fn(), @@ -83,6 +85,7 @@ vi.mock('@blaxel/core', () => ({ settings: { setConfig: blaxelSettingsMock }, SandboxInstance: { create: blaxelCreateMock, + createIfNotExists: blaxelCreateIfNotExistsMock, get: blaxelGetMock, list: blaxelListMock, delete: blaxelDeleteMock, @@ -225,7 +228,10 @@ describe('compute provider adapter contracts', () => { commandId: 'cmd-1', }), ).resolves.toEqual({ resumeHandle: created.instanceId }); - expect(blaxelUpdateTtlMock).toHaveBeenCalledWith(created.instanceId, '7d'); + expect(blaxelUpdateTtlMock).toHaveBeenCalledWith( + created.instanceId, + '604800s', + ); expect(sandbox.process.stop).toHaveBeenCalledWith('cmd-1'); await expect( @@ -246,10 +252,13 @@ describe('compute provider adapter contracts', () => { created.instanceId, '120s', ); - expect(sandbox.previews.delete).toHaveBeenCalledWith('port-3000'); - expect(sandbox.previews.delete).toHaveBeenCalledWith('port-4000'); - expect(sandbox.previews.create).toHaveBeenCalledWith( - expect.objectContaining({ metadata: { name: 'port-3000' } }), + expect(sandbox.previews.delete).not.toHaveBeenCalled(); + expect(sandbox.previews.create).not.toHaveBeenCalled(); + expect(sandbox.previews.createIfNotExists).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { name: 'port-3000' }, + spec: { port: 3000, public: true }, + }), ); await client.destroyInstance({ instanceId: created.instanceId }); expect(blaxelDeleteMock).toHaveBeenCalledWith(created.instanceId); @@ -280,6 +289,65 @@ describe('compute provider adapter contracts', () => { expect(blaxelDeleteMock).toHaveBeenCalledWith(createdName); }); + it('creates Blaxel sandboxes idempotently from a stable external key', async () => { + const sandbox = { + metadata: { name: 'roomote-stable' }, + wait: vi.fn().mockResolvedValue(undefined), + previews: { + createIfNotExists: vi.fn().mockResolvedValue({ + spec: { url: 'https://3000.blaxel.test' }, + }), + }, + }; + blaxelCreateIfNotExistsMock.mockResolvedValue(sandbox); + + const client = createComputeProviderClient({ + provider: 'blaxel', + config: { + apiKey: 'key', + workspace: 'workspace', + image: 'sandbox/roomote-worker:version', + timeoutMs: 120_000, + }, + }); + + const created = await client.createInstance({ + idempotencyKey: 'roomote-task-run:123', + ports: [3000], + }); + + expect(created.instanceId).toMatch(/^roomote-[a-f0-9]{32}$/); + expect(blaxelCreateMock).not.toHaveBeenCalled(); + expect(blaxelCreateIfNotExistsMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: created.instanceId, + externalId: 'roomote-task-run:123', + }), + ); + }); + + it('retains an idempotently-created Blaxel sandbox when readiness fails', async () => { + const sandbox = { + metadata: { name: 'roomote-stable' }, + wait: vi.fn().mockRejectedValue(new Error('deployment failed')), + }; + blaxelCreateIfNotExistsMock.mockResolvedValue(sandbox); + + const client = createComputeProviderClient({ + provider: 'blaxel', + config: { + apiKey: 'key', + workspace: 'workspace', + image: 'sandbox/roomote-worker:version', + }, + }); + + await expect( + client.createInstance({ idempotencyKey: 'roomote-task-run:123' }), + ).rejects.toThrow('deployment failed'); + expect(blaxelDeleteMock).not.toHaveBeenCalled(); + }); + it('cleans up a Blaxel sandbox that resolves after create is aborted', async () => { let resolveCreate!: (sandbox: { metadata: { name: string } }) => void; blaxelCreateMock.mockReturnValue( @@ -341,6 +409,95 @@ describe('compute provider adapter contracts', () => { } }); + it('retries Blaxel command launch while the workload data plane is starting', async () => { + vi.useFakeTimers(); + try { + const exec = vi + .fn() + .mockRejectedValueOnce( + new Error( + '404 {"error":{"code":"WORKLOAD_UNAVAILABLE","origin":"platform","retryable":true}}', + ), + ) + .mockResolvedValue({ + name: 'command-1', + status: 'running', + exitCode: null, + }); + blaxelGetMock.mockResolvedValue({ process: { exec } }); + const client = createComputeProviderClient({ + provider: 'blaxel', + config: { + apiKey: 'key', + workspace: 'workspace', + image: 'ghcr.io/roomote/worker:test', + }, + }); + + const commandPromise = client.runCommand({ + instanceId: 'sandbox-1', + cmd: 'worker', + args: ['resume', '123'], + detached: true, + }); + await vi.advanceTimersByTimeAsync(500); + + await expect(commandPromise).resolves.toMatchObject({ + commandId: 'command-1', + exitCode: null, + }); + expect(exec).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('retries Blaxel standby TTL and preview recovery while the workload is starting', async () => { + vi.useFakeTimers(); + try { + const unavailable = new Error( + '404 {"error":{"code":"WORKLOAD_UNAVAILABLE","origin":"platform","retryable":true}}', + ); + const createIfNotExists = vi + .fn() + .mockRejectedValueOnce(unavailable) + .mockResolvedValue({ spec: { url: 'https://3000.blaxel.test' } }); + const sandbox = { + metadata: { name: 'sandbox-1' }, + previews: { createIfNotExists }, + }; + blaxelUpdateTtlMock + .mockRejectedValueOnce(unavailable) + .mockResolvedValue(sandbox); + blaxelGetMock.mockResolvedValue(sandbox); + const client = createComputeProviderClient({ + provider: 'blaxel', + config: { + apiKey: 'key', + workspace: 'workspace', + image: 'ghcr.io/roomote/worker:test', + timeoutMs: 120_000, + }, + }); + + const resumePromise = client.resumeFromStandby?.({ + resumeHandle: 'sandbox-1', + ports: [3000], + }); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + + await expect(resumePromise).resolves.toMatchObject({ + instanceId: 'sandbox-1', + domains: { '3000': 'https://3000.blaxel.test' }, + }); + expect(blaxelUpdateTtlMock).toHaveBeenCalledTimes(2); + expect(createIfNotExists).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it('daytona adapter satisfies create/run/stream/status/destroy contract', async () => { const daytonaSandbox = { id: 'dtn-1', diff --git a/packages/compute-providers/src/adapters/blaxel.ts b/packages/compute-providers/src/adapters/blaxel.ts index 8c712884e..a4f7676e2 100644 --- a/packages/compute-providers/src/adapters/blaxel.ts +++ b/packages/compute-providers/src/adapters/blaxel.ts @@ -1,11 +1,9 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { SandboxInstance, settings } from '@blaxel/core'; import { BLAXEL_CAPABILITIES as BLAXEL_CAPABILITIES_VALUE, type ComputeProvider, - extractErrorDetails, - serializeError, } from '@roomote/types'; import { raceWithAbort, sleepWithSignal, throwIfAborted } from '../modal/abort'; @@ -36,12 +34,16 @@ import type { StreamCommandOutputInput, WriteFileInput, } from '../types'; +import { + isBlaxelResourceNotFound, + isBlaxelWorkloadUnavailable, +} from '../blaxel/errors'; const STREAM_POLL_INTERVAL_MS = 1_000; const WORKLOAD_READY_RETRY_BUDGET_MS = 60_000; const WORKLOAD_READY_INITIAL_DELAY_MS = 500; const WORKLOAD_READY_MAX_DELAY_MS = 30_000; -const BLAXEL_STANDBY_TTL = '7d'; +const DEFAULT_BLAXEL_STANDBY_TTL_MS = 7 * 24 * 60 * 60 * 1_000; export class BlaxelClient implements ComputeProviderClient { public readonly vendor: ComputeProvider = 'blaxel'; @@ -96,26 +98,37 @@ export class BlaxelClient implements ComputeProviderClient { input: CreateInstanceInput, ): Promise { throwIfAborted(input.signal); - const name = `roomote-${randomUUID()}`.slice(0, 49); + const isIdempotent = Boolean(input.idempotencyKey); + const name = input.idempotencyKey + ? deriveBlaxelSandboxName(input.idempotencyKey) + : `roomote-${randomUUID()}`.slice(0, 49); const ttl = this.ttl(); + const createInput = { + name, + image: this.config.image, + memory: this.config.memoryMiB, + region: this.config.region, + ttl, + ...(input.idempotencyKey ? { externalId: input.idempotencyKey } : {}), + labels: { ...(input.metadata ?? {}), ...(input.tags ?? {}) }, + ports: input.ports?.map((target) => ({ target, protocol: 'HTTP' })), + }; const sandbox = await raceWithAbort({ - promise: SandboxInstance.create({ - name, - image: this.config.image, - memory: this.config.memoryMiB, - region: this.config.region, - ttl, - labels: { ...(input.metadata ?? {}), ...(input.tags ?? {}) }, - ports: input.ports?.map((target) => ({ target, protocol: 'HTTP' })), - }), + promise: isIdempotent + ? SandboxInstance.createIfNotExists(createInput) + : SandboxInstance.create(createInput), signal: input.signal, abortMessage: `Creating Blaxel sandbox ${name} was aborted`, - onLateResolve: async (lateSandbox) => { - await this.cleanupSandboxAfterFailure( - lateSandbox.metadata.name || name, - 'create_instance_late_abort', - ); - }, + ...(!isIdempotent + ? { + onLateResolve: async (lateSandbox: SandboxInstance) => { + await this.cleanupSandboxAfterFailure( + lateSandbox.metadata.name || name, + 'create_instance_late_abort', + ); + }, + } + : {}), }); try { @@ -131,10 +144,14 @@ export class BlaxelClient implements ComputeProviderClient { ); return { instanceId: name, status: 'running', domains }; } catch (error) { - await this.cleanupSandboxAfterFailure( - name, - 'create_instance_post_create', - ); + // Idempotently-created sandboxes are retained for the controller's next + // attempt. Their TTL still bounds orphan lifetime if the task is canceled. + if (!isIdempotent) { + await this.cleanupSandboxAfterFailure( + name, + 'create_instance_post_create', + ); + } throw error; } } @@ -159,10 +176,21 @@ export class BlaxelClient implements ComputeProviderClient { ): Promise { throwIfAborted(input.signal); - await raceWithAbort({ - promise: SandboxInstance.updateTtl(input.instanceId, BLAXEL_STANDBY_TTL), + await retryWorkloadUnavailable({ + operation: () => + raceWithAbort({ + promise: SandboxInstance.updateTtl( + input.instanceId, + `${Math.ceil( + (this.config.standbyTtlMs ?? DEFAULT_BLAXEL_STANDBY_TTL_MS) / + 1_000, + )}s`, + ), + signal: input.signal, + abortMessage: `Extending standby TTL for Blaxel sandbox ${input.instanceId} was aborted`, + }), signal: input.signal, - abortMessage: `Extending standby TTL for Blaxel sandbox ${input.instanceId} was aborted`, + description: `extending standby TTL for Blaxel sandbox ${input.instanceId}`, }); if (input.commandId) { @@ -193,20 +221,24 @@ export class BlaxelClient implements ComputeProviderClient { ): Promise { throwIfAborted(input.signal); - await raceWithAbort({ - promise: SandboxInstance.updateTtl( - input.resumeHandle, - this.ttl() ?? null, - ), + await retryWorkloadUnavailable({ + operation: () => + raceWithAbort({ + promise: SandboxInstance.updateTtl( + input.resumeHandle, + this.ttl() ?? null, + ), + signal: input.signal, + abortMessage: `Restoring active TTL for Blaxel sandbox ${input.resumeHandle} was aborted`, + }), signal: input.signal, - abortMessage: `Restoring active TTL for Blaxel sandbox ${input.resumeHandle} was aborted`, + description: `restoring active TTL for Blaxel sandbox ${input.resumeHandle}`, }); const sandbox = await this.getSandbox(input.resumeHandle, input.signal); const domains = await this.createPreviewDomains( sandbox, input.ports ?? [], input.signal, - true, ); return { @@ -236,29 +268,34 @@ export class BlaxelClient implements ComputeProviderClient { const command = [input.cmd, ...(input.args ?? [])] .map(shellQuote) .join(' '); - const process = await raceWithAbort({ - promise: sandbox.process.exec({ - name, - command, - env: input.env, - workingDir: input.cwd, - waitForCompletion: !input.detached, - // Disable Blaxel's default provider-side timeout. Callers bound - // synchronous execution through their AbortSignal, while detached - // worker processes remain alive until Roomote tears down the sandbox. - timeout: 0, - ...(input.detached ? { keepAlive: true } : {}), - ...(input.onOutput - ? { - onStdout: (data: string) => - input.onOutput?.({ stream: 'stdout', data }), - onStderr: (data: string) => - input.onOutput?.({ stream: 'stderr', data }), - } - : {}), - }), + const process = await retryWorkloadUnavailable({ + operation: () => + raceWithAbort({ + promise: sandbox.process.exec({ + name, + command, + env: input.env, + workingDir: input.cwd, + waitForCompletion: !input.detached, + // Disable Blaxel's default provider-side timeout. Callers bound + // synchronous execution through their AbortSignal, while detached + // worker processes remain alive until Roomote tears down the sandbox. + timeout: 0, + ...(input.detached ? { keepAlive: true } : {}), + ...(input.onOutput + ? { + onStdout: (data: string) => + input.onOutput?.({ stream: 'stdout', data }), + onStderr: (data: string) => + input.onOutput?.({ stream: 'stderr', data }), + } + : {}), + }), + signal: input.signal, + abortMessage: `Running command in Blaxel sandbox ${input.instanceId} was aborted`, + }), signal: input.signal, - abortMessage: `Running command in Blaxel sandbox ${input.instanceId} was aborted`, + description: `running command ${name} in Blaxel sandbox ${input.instanceId}`, }); return { commandId: process.name || name, @@ -276,10 +313,15 @@ export class BlaxelClient implements ComputeProviderClient { let stderrLength = 0; while (true) { throwIfAborted(input.signal); - const process = await raceWithAbort({ - promise: sandbox.process.get(input.commandId), + const process = await retryWorkloadUnavailable({ + operation: () => + raceWithAbort({ + promise: sandbox.process.get(input.commandId), + signal: input.signal, + abortMessage: `Streaming Blaxel command ${input.commandId} was aborted`, + }), signal: input.signal, - abortMessage: `Streaming Blaxel command ${input.commandId} was aborted`, + description: `reading command ${input.commandId} in Blaxel sandbox ${input.instanceId}`, }); if (process.stdout.length > stdoutLength) { yield { stream: 'stdout', data: process.stdout.slice(stdoutLength) }; @@ -296,15 +338,20 @@ export class BlaxelClient implements ComputeProviderClient { public async getCommandOutput(input: GetCommandOutputInput): Promise { const sandbox = await this.getSandbox(input.instanceId, input.signal); - return raceWithAbort({ - promise: sandbox.process.logs( - input.commandId, - input.stream === 'both' || input.stream === undefined - ? 'all' - : input.stream, - ), + return retryWorkloadUnavailable({ + operation: () => + raceWithAbort({ + promise: sandbox.process.logs( + input.commandId, + input.stream === 'both' || input.stream === undefined + ? 'all' + : input.stream, + ), + signal: input.signal, + abortMessage: `Reading Blaxel command ${input.commandId} output was aborted`, + }), signal: input.signal, - abortMessage: `Reading Blaxel command ${input.commandId} output was aborted`, + description: `reading command ${input.commandId} output in Blaxel sandbox ${input.instanceId}`, }); } @@ -338,10 +385,15 @@ export class BlaxelClient implements ComputeProviderClient { } private getSandbox(instanceId: string, signal?: AbortSignal) { - return raceWithAbort({ - promise: SandboxInstance.get(instanceId), + return retryWorkloadUnavailable({ + operation: () => + raceWithAbort({ + promise: SandboxInstance.get(instanceId), + signal, + abortMessage: `Connecting to Blaxel sandbox ${instanceId} was aborted`, + }), signal, - abortMessage: `Connecting to Blaxel sandbox ${instanceId} was aborted`, + description: `connecting to Blaxel sandbox ${instanceId}`, }); } @@ -374,34 +426,24 @@ export class BlaxelClient implements ComputeProviderClient { sandbox: SandboxInstance, ports: number[], signal?: AbortSignal, - refresh = false, ): Promise> { const domains: Record = {}; for (const port of ports) { const previewName = `port-${port}`; - if (refresh) { - try { - await raceWithAbort({ - promise: sandbox.previews.delete(previewName), - signal, - abortMessage: `Deleting Blaxel preview for port ${port} was aborted`, - }); - } catch (error) { - if (!isNotFound(error)) throw error; - } - } - const preview = await raceWithAbort({ - promise: refresh - ? sandbox.previews.create({ - metadata: { name: previewName }, - spec: { port, public: true, ttl: this.ttl() }, - }) - : sandbox.previews.createIfNotExists({ + const preview = await retryWorkloadUnavailable({ + operation: () => + raceWithAbort({ + promise: sandbox.previews.createIfNotExists({ metadata: { name: previewName }, - spec: { port, public: true, ttl: this.ttl() }, + // The child preview follows the parent sandbox lifecycle. Giving + // it a shorter TTL forces destructive refreshes after standby. + spec: { port, public: true }, }), + signal, + abortMessage: `Creating Blaxel preview for port ${port} was aborted`, + }), signal, - abortMessage: `Creating Blaxel preview for port ${port} was aborted`, + description: `creating preview ${previewName} for Blaxel sandbox ${sandbox.metadata.name}`, }); if (!preview.spec.url) { throw new Error( @@ -455,11 +497,7 @@ function shellQuote(value: string): string { } function isNotFound(error: unknown): boolean { - const details = extractErrorDetails(error); - const value = serializeError(error).message.toLowerCase(); - return ( - details.code === 404 || value.includes('404') || value.includes('not found') - ); + return isBlaxelResourceNotFound(error); } async function retryWorkloadUnavailable(options: { @@ -475,7 +513,7 @@ async function retryWorkloadUnavailable(options: { try { return await options.operation(); } catch (error) { - if (!isWorkloadUnavailable(error)) throw error; + if (!isBlaxelWorkloadUnavailable(error)) throw error; const elapsedMs = Date.now() - startedAt; const remainingMs = WORKLOAD_READY_RETRY_BUDGET_MS - elapsedMs; @@ -491,10 +529,7 @@ async function retryWorkloadUnavailable(options: { } } -function isWorkloadUnavailable(error: unknown): boolean { - const value = serializeError(error).message.toLowerCase(); - return ( - value.includes('workload_unavailable') || - value.includes('currently not available') - ); +export function deriveBlaxelSandboxName(idempotencyKey: string): string { + const digest = createHash('sha256').update(idempotencyKey).digest('hex'); + return `roomote-${digest.slice(0, 32)}`; } diff --git a/packages/compute-providers/src/adapters/docker.ts b/packages/compute-providers/src/adapters/docker.ts index c967105f2..8f11196e0 100644 --- a/packages/compute-providers/src/adapters/docker.ts +++ b/packages/compute-providers/src/adapters/docker.ts @@ -1,3 +1,6 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + import { DOCKER_CAPABILITIES as DOCKER_CAPABILITIES_VALUE } from '@roomote/types'; import type { ComputeProvider } from '@roomote/types'; @@ -11,18 +14,46 @@ import type { CreatedInstance, DestroyInstanceInput, DestroyInstanceResult, + EnterStandbyInput, + EnterStandbyResult, GetCommandOutputInput, GetInstanceStatusInput, GetInstanceStatusResult, InstanceSummary, ListInstancesInput, ResumeInstanceInput, + ResumeFromStandbyInput, RunCommandInput, RunCommandResult, StreamCommandOutputInput, WriteFileInput, } from '../types'; +const execFileAsync = promisify(execFile); +const DOCKER_WORKER_CONTAINER_PREFIX = 'roomote-worker-'; + +async function docker( + args: string[], + options: { signal?: AbortSignal; allowFailure?: boolean } = {}, +): Promise { + try { + const { stdout } = await execFileAsync('docker', args, { + maxBuffer: 10 * 1024 * 1024, + signal: options.signal, + }); + return stdout.trim(); + } catch (error) { + if (options.allowFailure) return ''; + throw error; + } +} + +function getSourceRunId(instanceId: string): number | null { + if (!instanceId.startsWith(DOCKER_WORKER_CONTAINER_PREFIX)) return null; + const runId = Number(instanceId.slice(DOCKER_WORKER_CONTAINER_PREFIX.length)); + return Number.isInteger(runId) ? runId : null; +} + export class DockerClient implements ComputeProviderClient { public readonly vendor: ComputeProvider = 'docker'; public readonly capabilities = DOCKER_CAPABILITIES_VALUE; @@ -32,19 +63,66 @@ export class DockerClient implements ComputeProviderClient { } public getInstanceStatus( - _input: GetInstanceStatusInput, + input: GetInstanceStatusInput, ): Promise { - return Promise.resolve({ status: 'unknown' }); + return docker( + ['inspect', '--format', '{{.State.Status}}', input.instanceId], + { signal: input.signal, allowFailure: true }, + ).then((status) => ({ + status: + status === 'running' + ? 'running' + : status === 'created' || status === 'restarting' + ? 'pending' + : status === 'exited' || status === 'dead' + ? 'stopped' + : 'unknown', + })); } public createInstance(_input: CreateInstanceInput): Promise { return unsupported(this.vendor, 'createInstance'); } - public destroyInstance( - _input: DestroyInstanceInput, + public async destroyInstance( + input: DestroyInstanceInput, ): Promise { - return unsupported(this.vendor, 'destroyInstance'); + const sourceRunId = getSourceRunId(input.instanceId); + await docker(['rm', '-f', `${input.instanceId}-egress-policy`], { + signal: input.signal, + allowFailure: true, + }); + await docker(['rm', '-f', input.instanceId], { + signal: input.signal, + allowFailure: true, + }); + if (sourceRunId !== null) { + await docker(['network', 'rm', `roomote-task-${sourceRunId}`], { + signal: input.signal, + allowFailure: true, + }); + } + return {}; + } + + public async enterStandby( + input: EnterStandbyInput, + ): Promise { + await docker(['stop', '--time', '10', input.instanceId], { + signal: input.signal, + }); + return { resumeHandle: input.instanceId }; + } + + public async resumeFromStandby( + input: ResumeFromStandbyInput, + ): Promise { + await docker(['start', input.resumeHandle], { signal: input.signal }); + return { + instanceId: input.resumeHandle, + sourceSnapshotId: input.resumeHandle, + status: 'running', + }; } public runCommand(_input: RunCommandInput): Promise { diff --git a/packages/compute-providers/src/blaxel/create-blaxel-machine.test.ts b/packages/compute-providers/src/blaxel/create-blaxel-machine.test.ts index f9f795dac..6db1244d1 100644 --- a/packages/compute-providers/src/blaxel/create-blaxel-machine.test.ts +++ b/packages/compute-providers/src/blaxel/create-blaxel-machine.test.ts @@ -52,15 +52,47 @@ describe('createBlaxelMachine', () => { }); }); + it('forwards the stable provisioning key to fresh sandbox creation', async () => { + mockGetWorkerRelease.mockResolvedValue({ + archive: Buffer.from('worker'), + tag: 'worker-vtest', + version: 'test', + }); + const createInstance = vi.fn().mockResolvedValue({ + instanceId: 'roomote-stable', + domains: {}, + }); + const runCommand = vi.fn().mockResolvedValue({ exitCode: 0 }); + + await createBlaxelMachine({ + blaxelApiKey: 'key', + blaxelWorkspace: 'workspace', + blaxelImage: 'sandbox/roomote-worker:test', + idempotencyKey: 'roomote-task-run:42', + launchMode: 'fresh', + computeClient: { + vendor: 'blaxel', + createInstance, + resumeFromStandby: vi.fn(), + writeFiles: vi.fn(), + runCommand, + destroyInstance: vi.fn(), + }, + }); + + expect(createInstance).toHaveBeenCalledWith( + expect.objectContaining({ idempotencyKey: 'roomote-task-run:42' }), + ); + }); + it('preserves messages from plain Blaxel API errors', async () => { - vi.useFakeTimers(); - try { - const onMutation = vi.fn(); - const resumeFromStandby = vi - .fn() - .mockRejectedValue({ code: 409, error: 'Resource already exists' }); + const onMutation = vi.fn(); + const resumeFromStandby = vi + .fn() + .mockRejectedValue({ code: 409, error: 'Resource already exists' }); - const result = createBlaxelMachine({ + await expect( + createBlaxelMachine({ blaxelApiKey: 'key', blaxelWorkspace: 'workspace', blaxelImage: 'sandbox/roomote-worker:test', @@ -75,23 +107,45 @@ describe('createBlaxelMachine', () => { runCommand: vi.fn(), destroyInstance: vi.fn(), }, - }); - const rejection = expect(result).rejects.toThrow( - 'Resource already exists', - ); - - await vi.advanceTimersByTimeAsync(6_000); - await rejection; - expect(onMutation).toHaveBeenLastCalledWith( - expect.objectContaining({ - eventType: 'failed', - details: expect.objectContaining({ - error: 'Resource already exists', - }), + }), + ).rejects.toThrow('Resource already exists'); + expect(resumeFromStandby).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenLastCalledWith( + expect.objectContaining({ + eventType: 'failed', + details: expect.objectContaining({ + error: 'Resource already exists', }), + }), + ); + }); + + it('does not multiply an exhausted workload-unavailable retry budget', async () => { + const resumeFromStandby = vi + .fn() + .mockRejectedValue( + new Error( + '404 {"error":{"code":"WORKLOAD_UNAVAILABLE","origin":"platform","retryable":true}}', + ), ); - } finally { - vi.useRealTimers(); - } + + await expect( + createBlaxelMachine({ + blaxelApiKey: 'key', + blaxelWorkspace: 'workspace', + blaxelImage: 'sandbox/roomote-worker:test', + launchMode: 'task_standby', + resumeHandle: 'roomote-blaxel-standby', + computeClient: { + vendor: 'blaxel', + createInstance: vi.fn(), + resumeFromStandby, + writeFiles: vi.fn(), + runCommand: vi.fn(), + destroyInstance: vi.fn(), + }, + }), + ).rejects.toThrow('WORKLOAD_UNAVAILABLE'); + expect(resumeFromStandby).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/compute-providers/src/blaxel/create-blaxel-machine.ts b/packages/compute-providers/src/blaxel/create-blaxel-machine.ts index a74c07e2f..5e2f4c34b 100644 --- a/packages/compute-providers/src/blaxel/create-blaxel-machine.ts +++ b/packages/compute-providers/src/blaxel/create-blaxel-machine.ts @@ -15,6 +15,7 @@ import type { ComputeProviderClient, ComputeProviderMutationObserver, } from '../types'; +import { shouldRetryBlaxelLifecycleError } from './errors'; const MAX_RETRIES = 3; const INSTALL_SCRIPT_PATH = `${SANDBOX_FILES_DIR}/install-worker.sh`; @@ -34,6 +35,7 @@ export interface CreateBlaxelMachineOptions { blaxelApiKey: string; blaxelWorkspace: string; blaxelImage: string; + idempotencyKey?: string; blaxelRegion?: string; namedPorts?: NamedPort[]; proxyPorts?: Record; @@ -130,6 +132,7 @@ export async function createBlaxelMachine( }); } else { created = await computeClient.createInstance({ + idempotencyKey: options.idempotencyKey, ports, tags: options.tags, metadata: { @@ -179,7 +182,7 @@ export async function createBlaxelMachine( }, }); if (isAbortError(error)) throw error; - if (attempt === MAX_RETRIES) { + if (attempt === MAX_RETRIES || !shouldRetryBlaxelLifecycleError(error)) { throw error instanceof Error ? error : new Error(errorMessage); } await sleepWithSignal(2_000 * 2 ** (attempt - 1), createSignal); diff --git a/packages/compute-providers/src/blaxel/errors.test.ts b/packages/compute-providers/src/blaxel/errors.test.ts new file mode 100644 index 000000000..d52822ce7 --- /dev/null +++ b/packages/compute-providers/src/blaxel/errors.test.ts @@ -0,0 +1,105 @@ +import { + getBlaxelErrorDetails, + isBlaxelResourceNotFound, + isBlaxelWorkloadUnavailable, + shouldRetryBlaxelLifecycleError, +} from './errors'; + +describe('Blaxel structured errors', () => { + it('parses platform errors embedded in SDK error messages', () => { + const error = new Error( + '404 {"error":{"code":"WORKLOAD_UNAVAILABLE","message":"not ready","origin":"platform","retryable":true,"status":404}}', + ); + + expect(getBlaxelErrorDetails(error)).toMatchObject({ + code: 'WORKLOAD_UNAVAILABLE', + message: 'not ready', + origin: 'platform', + retryable: true, + status: 404, + }); + expect(isBlaxelWorkloadUnavailable(error)).toBe(true); + expect(shouldRetryBlaxelLifecycleError(error)).toBe(false); + }); + + it('recognizes a missing sandbox record', () => { + expect( + isBlaxelResourceNotFound({ + error: { + code: 'WORKLOAD_NOT_FOUND', + origin: 'platform', + status: 404, + }, + }), + ).toBe(true); + }); + + it('does not treat route or application 404s as missing sandboxes', () => { + expect( + isBlaxelResourceNotFound({ + error: { code: 'ROUTE_NOT_FOUND', origin: 'platform', status: 404 }, + }), + ).toBe(false); + expect( + isBlaxelResourceNotFound({ + error: { code: 404, origin: 'application', message: 'not found' }, + }), + ).toBe(false); + }); + + it('does not retry lifecycle operations for application-origin errors', () => { + expect( + shouldRetryBlaxelLifecycleError({ + error: { code: 404, origin: 'application', message: 'not found' }, + }), + ).toBe(false); + }); + + it('honors an explicit non-retryable workload response', () => { + expect( + isBlaxelWorkloadUnavailable({ + error: { + code: 'WORKLOAD_UNAVAILABLE', + origin: 'platform', + retryable: false, + }, + }), + ).toBe(false); + }); + + it('does not retry deterministic client errors', () => { + const error = new Error( + '400 {"error":{"status":400,"message":"metadata.externalId is invalid","origin":"platform"}}', + ); + + expect(shouldRetryBlaxelLifecycleError(error)).toBe(false); + }); + + it.each([408, 429])('allows retryable client status %s', (status) => { + const error = new Error( + `${status} {"error":{"status":${status},"message":"try again","origin":"platform","retryable":true}}`, + ); + + expect(shouldRetryBlaxelLifecycleError(error)).toBe(true); + }); + + it('reads structured details from a wrapped SDK error cause', () => { + expect( + getBlaxelErrorDetails( + new Error('request failed', { + cause: { + error: { + code: 'WORKLOAD_NOT_FOUND', + origin: 'platform', + status: 404, + }, + }, + }), + ), + ).toMatchObject({ + code: 'WORKLOAD_NOT_FOUND', + origin: 'platform', + status: 404, + }); + }); +}); diff --git a/packages/compute-providers/src/blaxel/errors.ts b/packages/compute-providers/src/blaxel/errors.ts new file mode 100644 index 000000000..f3fd3fd7e --- /dev/null +++ b/packages/compute-providers/src/blaxel/errors.ts @@ -0,0 +1,182 @@ +import { extractErrorDetails, serializeError } from '@roomote/types'; + +export interface BlaxelErrorDetails { + code?: string | number; + message: string; + origin?: string; + retryable?: boolean; + status?: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function parseEmbeddedJson(value: unknown): Record | null { + if (typeof value !== 'string') return null; + + const jsonStart = value.indexOf('{'); + if (jsonStart < 0) return null; + + try { + const parsed: unknown = JSON.parse(value.slice(jsonStart)); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function unwrapErrorPayload(value: unknown): Record | null { + if (!isRecord(value)) return null; + return isRecord(value.error) ? value.error : value; +} + +function readHeaderOrigin(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + + for (const [name, headerValue] of Object.entries(value)) { + if ( + name.toLowerCase() === 'x-blaxel-source' && + typeof headerValue === 'string' + ) { + return headerValue; + } + } + + return undefined; +} + +/** Normalizes Blaxel SDK errors, including JSON embedded in Error messages. */ +export function getBlaxelErrorDetails(error: unknown): BlaxelErrorDetails { + const extracted = extractErrorDetails(error); + const extractedCause = isRecord(extracted.cause) + ? extracted.cause + : undefined; + const directCause = isRecord(error) ? error.cause : undefined; + const serialized = serializeError(error); + const candidates = [ + unwrapErrorPayload(error), + unwrapErrorPayload(directCause), + unwrapErrorPayload(extracted.responseJson), + unwrapErrorPayload(extractedCause?.responseJson), + unwrapErrorPayload(extractedCause), + unwrapErrorPayload(parseEmbeddedJson(extracted.responseText)), + unwrapErrorPayload(parseEmbeddedJson(extractedCause?.responseText)), + unwrapErrorPayload(parseEmbeddedJson(extractedCause?.message)), + unwrapErrorPayload(parseEmbeddedJson(serialized.message)), + ].filter((candidate): candidate is Record => + Boolean(candidate), + ); + + let code: string | number | undefined; + let message = serialized.message; + let origin = + readHeaderOrigin(extracted.responseHeaders) ?? + readHeaderOrigin(extractedCause?.responseHeaders); + let retryable: boolean | undefined; + let status: number | undefined; + + for (const candidate of candidates) { + if ( + code === undefined && + (typeof candidate.code === 'string' || typeof candidate.code === 'number') + ) { + code = candidate.code; + } + if (typeof candidate.message === 'string') message = candidate.message; + if (origin === undefined && typeof candidate.origin === 'string') { + origin = candidate.origin; + } + if (retryable === undefined && typeof candidate.retryable === 'boolean') { + retryable = candidate.retryable; + } + if (status === undefined && typeof candidate.status === 'number') { + status = candidate.status; + } + } + + if (code === undefined) { + const extractedCode = extracted.code; + if ( + typeof extractedCode === 'string' || + typeof extractedCode === 'number' + ) { + code = extractedCode; + } + } + if (status === undefined && typeof extracted.responseStatus === 'number') { + status = extracted.responseStatus; + } + + return { code, message, origin, retryable, status }; +} + +function hasPlatformOrigin(details: BlaxelErrorDetails): boolean { + return ( + details.origin === undefined || details.origin.toLowerCase() === 'platform' + ); +} + +export function isBlaxelWorkloadUnavailable(error: unknown): boolean { + const details = getBlaxelErrorDetails(error); + return ( + details.code === 'WORKLOAD_UNAVAILABLE' && + details.retryable !== false && + hasPlatformOrigin(details) + ); +} + +export function isBlaxelResourceNotFound(error: unknown): boolean { + const details = getBlaxelErrorDetails(error); + + if (!hasPlatformOrigin(details)) return false; + if (details.code === 'WORKLOAD_NOT_FOUND') return true; + if (typeof details.code === 'string') return false; + + return ( + details.code === 404 || + details.status === 404 || + details.message.toLowerCase().includes('not found') + ); +} + +/** Whether the outer lifecycle retry may safely repeat this operation. */ +export function shouldRetryBlaxelLifecycleError(error: unknown): boolean { + const details = getBlaxelErrorDetails(error); + + if (details.retryable === false) return false; + + if ( + details.origin !== undefined && + details.origin.toLowerCase() !== 'platform' + ) { + return false; + } + + if ( + ['WORKLOAD_UNAVAILABLE', 'WORKLOAD_NOT_FOUND', 'ROUTE_NOT_FOUND'].includes( + String(details.code), + ) + ) { + return false; + } + + const httpStatus = + details.status ?? + (typeof details.code === 'number' && + details.code >= 100 && + details.code <= 599 + ? details.code + : undefined); + if ( + httpStatus !== undefined && + httpStatus >= 400 && + httpStatus < 500 && + httpStatus !== 408 && + httpStatus !== 429 + ) { + return false; + } + + return true; +} diff --git a/packages/compute-providers/src/blaxel/index.ts b/packages/compute-providers/src/blaxel/index.ts index 2ec026d11..997406609 100644 --- a/packages/compute-providers/src/blaxel/index.ts +++ b/packages/compute-providers/src/blaxel/index.ts @@ -1,2 +1,3 @@ export * from './build-blaxel-image'; export * from './create-blaxel-machine'; +export * from './errors'; diff --git a/packages/compute-providers/src/factory.ts b/packages/compute-providers/src/factory.ts index 231aad66c..f18016b6a 100644 --- a/packages/compute-providers/src/factory.ts +++ b/packages/compute-providers/src/factory.ts @@ -202,6 +202,9 @@ export function createComputeProviderClient( const workspace = options.config?.workspace ?? envValue('BL_WORKSPACE'); const image = options.config?.image ?? envValue('BLAXEL_IMAGE'); const region = envValue('BLAXEL_REGION'); + const standbyMaxAgeHours = Number( + envValue('BLAXEL_STANDBY_MAX_AGE_HOURS'), + ); assertDefined(apiKey, 'Missing BL_API_KEY'); assertDefined(workspace, 'Missing BL_WORKSPACE'); @@ -213,6 +216,11 @@ export function createComputeProviderClient( workspace, image, ...(options.config?.region === undefined && region ? { region } : {}), + ...(options.config?.standbyTtlMs === undefined && + Number.isFinite(standbyMaxAgeHours) && + standbyMaxAgeHours > 0 + ? { standbyTtlMs: standbyMaxAgeHours * 60 * 60 * 1_000 } + : {}), }; return new BlaxelClient(config); } diff --git a/packages/compute-providers/src/types.ts b/packages/compute-providers/src/types.ts index 6c05d8db5..654b5ccd4 100644 --- a/packages/compute-providers/src/types.ts +++ b/packages/compute-providers/src/types.ts @@ -20,6 +20,8 @@ import type { ComputeProviderCapabilities } from '@roomote/types'; export type { ComputeProviderCapabilities } from '@roomote/types'; export interface CreateInstanceInput { + /** Stable caller-owned key used by providers that support idempotent create. */ + idempotencyKey?: string; ports?: number[]; metadata?: Record; tags?: Record; @@ -293,6 +295,8 @@ export interface BlaxelConfig { memoryMiB?: number; /** Maximum sandbox lifetime in milliseconds. */ timeoutMs?: number; + /** How long a stopped sandbox remains available for task standby resume. */ + standbyTtlMs?: number; } export type ComputeProviderFactoryOptions = ( diff --git a/packages/db/drizzle/0009_abnormal_nuke.sql b/packages/db/drizzle/0009_abnormal_nuke.sql new file mode 100644 index 000000000..cebbba25e --- /dev/null +++ b/packages/db/drizzle/0009_abnormal_nuke.sql @@ -0,0 +1,6 @@ +DROP INDEX "task_runs_sleep_check_due_idx";--> statement-breakpoint +DROP INDEX "task_runs_sleep_check_stale_worker_idx";--> statement-breakpoint +DROP INDEX "task_runs_sleep_check_active_idx";--> statement-breakpoint +CREATE INDEX "task_runs_sleep_check_due_idx" ON "task_runs" USING btree ("sleep_at","created_at","vendor") WHERE "task_runs"."status" IN ('running', 'idle') AND "task_runs"."machine_id" IS NOT NULL AND "task_runs"."sleep_at" IS NOT NULL AND "task_runs"."sleep_requested_at" IS NULL AND "task_runs"."snapshot_id" IS NULL AND "task_runs"."snapshot_requested_at" IS NULL AND "task_runs"."vendor" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel');--> statement-breakpoint +CREATE INDEX "task_runs_sleep_check_stale_worker_idx" ON "task_runs" USING btree ("worker_heartbeat_at","created_at","vendor") WHERE "task_runs"."status" IN ('running', 'idle') AND "task_runs"."machine_id" IS NOT NULL AND "task_runs"."worker_heartbeat_at" IS NOT NULL AND "task_runs"."sleep_requested_at" IS NULL AND "task_runs"."snapshot_id" IS NULL AND "task_runs"."snapshot_requested_at" IS NULL AND "task_runs"."vendor" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel');--> statement-breakpoint +CREATE INDEX "task_runs_sleep_check_active_idx" ON "task_runs" USING btree ("vendor","created_at" DESC NULLS LAST) WHERE "task_runs"."status" IN ('running', 'idle') AND "task_runs"."machine_id" IS NOT NULL AND "task_runs"."sleep_requested_at" IS NULL AND "task_runs"."snapshot_id" IS NULL AND "task_runs"."snapshot_requested_at" IS NULL AND "task_runs"."vendor" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel'); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0009_snapshot.json b/packages/db/drizzle/meta/0009_snapshot.json new file mode 100644 index 000000000..4663e83c3 --- /dev/null +++ b/packages/db/drizzle/meta/0009_snapshot.json @@ -0,0 +1,9006 @@ +{ + "id": "42551663-36a5-4d39-bd96-a1e12800e1ae", + "prevId": "6324e185-5cef-4863-b16d-95dda1c80b17", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_accounts_user_id_idx": { + "name": "auth_accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_accounts_provider_account_unique": { + "name": "auth_accounts_provider_account_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_sessions_user_id_idx": { + "name": "auth_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_users_email_unique": { + "name": "auth_users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_users_created_at_idx": { + "name": "auth_users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verifications_identifier_idx": { + "name": "auth_verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal": { + "name": "internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_cursor": { + "name": "scan_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage": { + "name": "compute_provider_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_action": { + "name": "lifecycle_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measurement_source": { + "name": "measurement_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_clock_duration_ms": { + "name": "wall_clock_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "active_cpu_duration_ms": { + "name": "active_cpu_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "observed_memory_mib_milliseconds": { + "name": "observed_memory_mib_milliseconds", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_ingress_bytes": { + "name": "network_ingress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_egress_bytes": { + "name": "network_egress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "compute_provider_usage_provider_usage_id_unique": { + "name": "compute_provider_usage_provider_usage_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_run_id_idx": { + "name": "compute_provider_usage_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_task_id_idx": { + "name": "compute_provider_usage_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_created_at_idx": { + "name": "compute_provider_usage_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compute_provider_usage_task_id_tasks_id_fk": { + "name": "compute_provider_usage_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage_samples": { + "name": "compute_provider_usage_samples", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sampled_at": { + "name": "sampled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cpu_usage_ns_total": { + "name": "cpu_usage_ns_total", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_usage_bytes": { + "name": "memory_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_peak_usage_bytes": { + "name": "memory_peak_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compute_provider_usage_samples_provider_usage_sampled_at_unique": { + "name": "compute_provider_usage_samples_provider_usage_sampled_at_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sampled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_run_id_idx": { + "name": "compute_provider_usage_samples_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_task_id_idx": { + "name": "compute_provider_usage_samples_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_created_at_idx": { + "name": "compute_provider_usage_samples_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_samples_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_samples_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compute_provider_usage_samples_task_id_tasks_id_fk": { + "name": "compute_provider_usage_samples_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_mcp_enablements": { + "name": "deployment_mcp_enablements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_mcp_enablements_enabled_by_user_id_users_id_fk": { + "name": "deployment_mcp_enablements_enabled_by_user_id_users_id_fk", + "tableFrom": "deployment_mcp_enablements", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_mcp_enablements_mcp_unique": { + "name": "deployment_mcp_enablements_mcp_unique", + "nullsNotDistinct": false, + "columns": ["mcp_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_secrets": { + "name": "deployment_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deployment_secrets_name_unique": { + "name": "deployment_secrets_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_model_settings": { + "name": "task_model_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "router_debug_slack_channel_id": { + "name": "router_debug_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_model_config": { + "name": "runtime_model_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "runtime_compute_config": { + "name": "runtime_compute_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "access_policy": { + "name": "access_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "license_key": { + "name": "license_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_analytics_id": { + "name": "instance_analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_known_version": { + "name": "latest_known_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version_checked_at": { + "name": "latest_version_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_new_state": { + "name": "setup_new_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slack_onboarding_stage": { + "name": "slack_onboarding_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_slack_channel_id": { + "name": "manager_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "global_agent_instructions": { + "name": "global_agent_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorship_instructions": { + "name": "authorship_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiled_authorship_rules": { + "name": "compiled_authorship_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_issues": { + "name": "compiled_authorship_issues", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_at": { + "name": "compiled_authorship_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "style_guidance": { + "name": "style_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_summon_emoji": { + "name": "slack_summon_emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_ack_emoji": { + "name": "slack_ack_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eyes'" + }, + "slack_completion_emoji": { + "name": "slack_completion_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'white_check_mark'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_config_versions": { + "name": "environment_config_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_config_versions_environment_id_idx": { + "name": "environment_config_versions_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_config_versions_environment_version_unique": { + "name": "environment_config_versions_environment_version_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_config_versions_environment_id_environments_id_fk": { + "name": "environment_config_versions_environment_id_environments_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_config_versions_created_by_user_id_users_id_fk": { + "name": "environment_config_versions_created_by_user_id_users_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_repository_mappings": { + "name": "environment_repository_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "env_repo_mappings_env_id_idx": { + "name": "env_repo_mappings_env_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "env_repo_mappings_repo_id_idx": { + "name": "env_repo_mappings_repo_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_repository_mappings_environment_id_environments_id_fk": { + "name": "environment_repository_mappings_environment_id_environments_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_repository_mappings_repository_id_repositories_id_fk": { + "name": "environment_repository_mappings_repository_id_repositories_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "env_repo_mappings_unique": { + "name": "env_repo_mappings_unique", + "nullsNotDistinct": false, + "columns": ["environment_id", "repository_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_snapshots": { + "name": "environment_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_snapshots_environment_id_idx": { + "name": "environment_snapshots_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_snapshots_env_provider_unique": { + "name": "environment_snapshots_env_provider_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environment_snapshots\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_snapshots_environment_id_environments_id_fk": { + "name": "environment_snapshots_environment_id_environments_id_fk", + "tableFrom": "environment_snapshots", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_variables": { + "name": "environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_updated_by_user_id": { + "name": "last_updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_variables_user_id_idx": { + "name": "environment_variables_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_name_unique": { + "name": "environment_variables_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_variables_user_id_users_id_fk": { + "name": "environment_variables_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_variables_created_by_user_id_users_id_fk": { + "name": "environment_variables_created_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "environment_variables_last_updated_by_user_id_users_id_fk": { + "name": "environment_variables_last_updated_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["last_updated_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_eval": { + "name": "is_eval", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "declarative_source": { + "name": "declarative_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environments_user_id_idx": { + "name": "environments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_created_by_user_id_idx": { + "name": "environments_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_snapshot_expires_at_idx": { + "name": "environments_snapshot_expires_at_idx", + "columns": [ + { + "expression": "snapshot_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_name_unique": { + "name": "environments_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environments_user_id_users_id_fk": { + "name": "environments_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_created_by_user_id_users_id_fk": { + "name": "environments_created_by_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "members_count": { + "name": "members_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_deployment_installation_unique": { + "name": "github_installations_deployment_installation_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_installed_by_user_id_users_id_fk": { + "name": "github_installations_installed_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pending_installations": { + "name": "github_pending_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pending_installations_requested_by_user_id_idx": { + "name": "github_pending_installations_requested_by_user_id_idx", + "columns": [ + { + "expression": "requested_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pending_installations_user_id_users_id_fk": { + "name": "github_pending_installations_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pending_installations_requested_by_user_id_users_id_fk": { + "name": "github_pending_installations_requested_by_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_user_mappings": { + "name": "github_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_user_mappings_github_login_idx": { + "name": "github_user_mappings_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_user_mappings_user_id_idx": { + "name": "github_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_user_mappings_user_id_users_id_fk": { + "name": "github_user_mappings_user_id_users_id_fk", + "tableFrom": "github_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_user_mappings_unique": { + "name": "github_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["github_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique": { + "name": "invites_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_created_at_idx": { + "name": "invites_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_invited_by_user_id_users_id_fk": { + "name": "invites_invited_by_user_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": ["invited_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_pending_selections": { + "name": "linear_pending_selections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_workspace'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "selected_repo": { + "name": "selected_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_options": { + "name": "workspace_options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linear_pending_selections_expires_at_idx": { + "name": "linear_pending_selections_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linear_pending_selections_step_idx": { + "name": "linear_pending_selections_step_idx", + "columns": [ + { + "expression": "step", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_pending_selections_user_id_users_id_fk": { + "name": "linear_pending_selections_user_id_users_id_fk", + "tableFrom": "linear_pending_selections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "linear_pending_selections_session_id_unique": { + "name": "linear_pending_selections_session_id_unique", + "nullsNotDistinct": false, + "columns": ["session_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_connections": { + "name": "mcp_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "auth_config": { + "name": "auth_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_status": { + "name": "auth_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_connections_user_id_idx": { + "name": "mcp_connections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_role_idx": { + "name": "mcp_connections_role_idx", + "columns": [ + { + "expression": "mcp_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_connections_user_id_users_id_fk": { + "name": "mcp_connections_user_id_users_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_connections_user_mcp_id_unique": { + "name": "mcp_connections_user_mcp_id_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "mcp_id", "connection_role"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_replays": { + "name": "mcp_oauth_replays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "redirect_to": { + "name": "redirect_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_replays_connection_id_idx": { + "name": "mcp_oauth_replays_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_user_id_idx": { + "name": "mcp_oauth_replays_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_expires_at_idx": { + "name": "mcp_oauth_replays_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_replays_connection_id_mcp_connections_id_fk": { + "name": "mcp_oauth_replays_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_replays_user_id_users_id_fk": { + "name": "mcp_oauth_replays_user_id_users_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_replays_token_unique": { + "name": "mcp_oauth_replays_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microsoft_auth_user_mappings": { + "name": "microsoft_auth_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_tenant_id": { + "name": "microsoft_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_aad_object_id": { + "name": "microsoft_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "microsoft_auth_user_mappings_user_id_idx": { + "name": "microsoft_auth_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_account_id_idx": { + "name": "microsoft_auth_user_mappings_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_auth_account_idx": { + "name": "microsoft_auth_user_mappings_auth_account_idx", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_aad_object_unique": { + "name": "microsoft_auth_user_mappings_aad_object_unique", + "columns": [ + { + "expression": "microsoft_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "microsoft_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "microsoft_auth_user_mappings_user_id_auth_users_id_fk": { + "name": "microsoft_auth_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_state": { + "name": "oauth_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replay_token": { + "name": "replay_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_state_connection_id_idx": { + "name": "oauth_state_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_replay_token_idx": { + "name": "oauth_state_replay_token_idx", + "columns": [ + { + "expression": "replay_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_expires_at_idx": { + "name": "oauth_state_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_state_connection_id_mcp_connections_id_fk": { + "name": "oauth_state_connection_id_mcp_connections_id_fk", + "tableFrom": "oauth_state", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_request_facts": { + "name": "pull_request_facts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_full_name": { + "name": "repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "external_pull_request_id": { + "name": "external_pull_request_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_remote": { + "name": "created_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at_remote": { + "name": "updated_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "closed_at_remote": { + "name": "closed_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "merged_at_remote": { + "name": "merged_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_facts_deployment_repo_pr_unique": { + "name": "pull_request_facts_deployment_repo_pr_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_created_idx": { + "name": "pull_request_facts_deployment_created_idx", + "columns": [ + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_repo_created_idx": { + "name": "pull_request_facts_deployment_repo_created_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_state_created_idx": { + "name": "pull_request_facts_deployment_state_created_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_author_created_idx": { + "name": "pull_request_facts_deployment_author_created_idx", + "columns": [ + { + "expression": "author_login", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_updated_idx": { + "name": "pull_request_facts_deployment_updated_idx", + "columns": [ + { + "expression": "updated_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_facts_repository_id_repositories_id_fk": { + "name": "pull_request_facts_repository_id_repositories_id_fk", + "tableFrom": "pull_request_facts", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pull_request_facts_source_control_provider_check": { + "name": "pull_request_facts_source_control_provider_check", + "value": "\"pull_request_facts\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.pull_request_sync_states": { + "name": "pull_request_sync_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_incremental_updated_at": { + "name": "last_incremental_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_attempted_sync_at": { + "name": "last_attempted_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_sync_states_repo_unique": { + "name": "pull_request_sync_states_repo_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_deployment_updated_idx": { + "name": "pull_request_sync_states_deployment_updated_idx", + "columns": [ + { + "expression": "last_successful_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_cooldown_idx": { + "name": "pull_request_sync_states_cooldown_idx", + "columns": [ + { + "expression": "cooldown_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_sync_states_repository_id_repositories_id_fk": { + "name": "pull_request_sync_states_repository_id_repositories_id_fk", + "tableFrom": "pull_request_sync_states", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_source_control_provider_idx": { + "name": "repositories_source_control_provider_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_installation_id_idx": { + "name": "repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_full_name_idx": { + "name": "repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_idx": { + "name": "repositories_provider_host_full_name_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_active_installation_idx": { + "name": "repositories_deployment_active_installation_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_github_repo_unique": { + "name": "repositories_deployment_github_repo_unique", + "columns": [ + { + "expression": "github_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_external_repo_unique": { + "name": "repositories_provider_host_external_repo_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_unique": { + "name": "repositories_provider_host_full_name_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_installation_id_github_installations_id_fk": { + "name": "repositories_installation_id_github_installations_id_fk", + "tableFrom": "repositories", + "tableTo": "github_installations", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_linked_by_user_id_users_id_fk": { + "name": "repositories_linked_by_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["linked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "repositories_source_control_provider_check": { + "name": "repositories_source_control_provider_check", + "value": "\"repositories\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + }, + "repositories_github_shape_check": { + "name": "repositories_github_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'github' OR (\"repositories\".\"installation_id\" IS NOT NULL AND \"repositories\".\"github_repo_id\" IS NOT NULL)" + }, + "repositories_gitlab_shape_check": { + "name": "repositories_gitlab_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitlab' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_gitea_shape_check": { + "name": "repositories_gitea_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitea' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_ado_shape_check": { + "name": "repositories_ado_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'ado' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_bitbucket_shape_check": { + "name": "repositories_bitbucket_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'bitbucket' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.sandbox_oidc_targets": { + "name": "sandbox_oidc_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "compute_provider": { + "name": "compute_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compute_provider_id": { + "name": "compute_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_file": { + "name": "token_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aws_role_arn": { + "name": "aws_role_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aws_region": { + "name": "aws_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_at": { + "name": "refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_oidc_targets_environment_id_idx": { + "name": "sandbox_oidc_targets_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_run_id_idx": { + "name": "sandbox_oidc_targets_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_refresh_at_idx": { + "name": "sandbox_oidc_targets_refresh_at_idx", + "columns": [ + { + "expression": "refresh_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_provider_target_file_unique": { + "name": "sandbox_oidc_targets_provider_target_file_unique", + "columns": [ + { + "expression": "compute_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compute_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_file", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_oidc_targets_environment_id_environments_id_fk": { + "name": "sandbox_oidc_targets_environment_id_environments_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_oidc_targets_run_id_task_runs_id_fk": { + "name": "sandbox_oidc_targets_run_id_task_runs_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sandbox_oidc_targets_owner_required": { + "name": "sandbox_oidc_targets_owner_required", + "value": "run_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.setup_qualification_blocks": { + "name": "setup_qualification_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blocked'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_login": { + "name": "github_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_type": { + "name": "github_account_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_blocked_at": { + "name": "first_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_blocked_at": { + "name": "last_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_user_id": { + "name": "lifted_by_admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_email": { + "name": "lifted_by_admin_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "setup_qualification_blocks_deployment_user_reason_unique": { + "name": "setup_qualification_blocks_deployment_user_reason_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_deployment_status_idx": { + "name": "setup_qualification_blocks_deployment_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_user_status_idx": { + "name": "setup_qualification_blocks_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "setup_qualification_blocks_user_id_users_id_fk": { + "name": "setup_qualification_blocks_user_id_users_id_fk", + "tableFrom": "setup_qualification_blocks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_auth_tokens": { + "name": "slack_auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_auth_tokens_expires_at_idx": { + "name": "slack_auth_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_auth_tokens_token_unique": { + "name": "slack_auth_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_conversation_messages": { + "name": "slack_conversation_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_slack_user_id": { + "name": "subject_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_slack_user_id": { + "name": "sender_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_kind": { + "name": "conversation_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_at": { + "name": "message_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slack_quick_answer_id": { + "name": "slack_quick_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_conversation_messages_deployment_user_message_at_idx": { + "name": "slack_conversation_messages_deployment_user_message_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_deployment_user_thread_idx": { + "name": "slack_conversation_messages_deployment_user_thread_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_task_id_idx": { + "name": "slack_conversation_messages_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_run_id_idx": { + "name": "slack_conversation_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_team_channel_message_unique": { + "name": "slack_conversation_messages_team_channel_message_unique", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_conversation_messages_subject_user_id_users_id_fk": { + "name": "slack_conversation_messages_subject_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["subject_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_conversation_messages_sender_user_id_users_id_fk": { + "name": "slack_conversation_messages_sender_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_task_id_tasks_id_fk": { + "name": "slack_conversation_messages_task_id_tasks_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_run_id_task_runs_id_fk": { + "name": "slack_conversation_messages_run_id_task_runs_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_slack_quick_answer_id_slack_quick_answers_id_fk": { + "name": "slack_conversation_messages_slack_quick_answer_id_slack_quick_answers_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "slack_quick_answers", + "columnsFrom": ["slack_quick_answer_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installation_channels": { + "name": "slack_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_installation_id": { + "name": "slack_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installation_channels_installation_id_idx": { + "name": "slack_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "slack_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installation_channels_slack_installation_id_slack_installations_id_fk": { + "name": "slack_installation_channels_slack_installation_id_slack_installations_id_fk", + "tableFrom": "slack_installation_channels", + "tableTo": "slack_installations", + "columnsFrom": ["slack_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installation_channels_unique": { + "name": "slack_installation_channels_unique", + "nullsNotDistinct": false, + "columns": ["slack_installation_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installations": { + "name": "slack_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_domain": { + "name": "team_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_name": { + "name": "enterprise_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_access_token": { + "name": "user_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bot'" + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_count_snapshot": { + "name": "member_count_snapshot", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "member_count_snapshot_at": { + "name": "member_count_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installations_bot_user_id_idx": { + "name": "slack_installations_bot_user_id_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_installations_active_idx": { + "name": "slack_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installations_installed_by_user_id_users_id_fk": { + "name": "slack_installations_installed_by_user_id_users_id_fk", + "tableFrom": "slack_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installations_team_id_unique": { + "name": "slack_installations_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_quick_answers": { + "name": "slack_quick_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_channel": { + "name": "slack_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_quick_answers_deployment_channel_thread_unique": { + "name": "slack_quick_answers_deployment_channel_thread_unique", + "columns": [ + { + "expression": "slack_channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_thread_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_quick_answers_deployment_user_idx": { + "name": "slack_quick_answers_deployment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_quick_answers_user_id_users_id_fk": { + "name": "slack_quick_answers_user_id_users_id_fk", + "tableFrom": "slack_quick_answers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_user_mappings": { + "name": "slack_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_user_mappings_user_id_idx": { + "name": "slack_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_user_mappings_user_id_users_id_fk": { + "name": "slack_user_mappings_user_id_users_id_fk", + "tableFrom": "slack_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_user_mappings_unique": { + "name": "slack_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_artifacts": { + "name": "task_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uploaded": { + "name": "uploaded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_artifacts_task_id_idx": { + "name": "task_artifacts_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_run_id_idx": { + "name": "task_artifacts_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_uploaded_idx": { + "name": "task_artifacts_uploaded_idx", + "columns": [ + { + "expression": "uploaded", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_created_at_idx": { + "name": "task_artifacts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_path_idx": { + "name": "task_artifacts_path_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_artifacts_task_id_tasks_id_fk": { + "name": "task_artifacts_task_id_tasks_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_artifacts_run_id_task_runs_id_fk": { + "name": "task_artifacts_run_id_task_runs_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_artifacts_task_id_path_version_unique": { + "name": "task_artifacts_task_id_path_version_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "path", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_inference_usage_events": { + "name": "task_inference_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_tokens": { + "name": "context_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micro_usd": { + "name": "cost_micro_usd", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_created_at": { + "name": "message_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "message_completed_at": { + "name": "message_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_inference_usage_events_session_message_unique": { + "name": "task_inference_usage_events_session_message_unique", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_task_id_idx": { + "name": "task_inference_usage_events_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_run_id_idx": { + "name": "task_inference_usage_events_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_created_at_idx": { + "name": "task_inference_usage_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_inference_usage_events_task_id_tasks_id_fk": { + "name": "task_inference_usage_events_task_id_tasks_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_inference_usage_events_run_id_task_runs_id_fk": { + "name": "task_inference_usage_events_run_id_task_runs_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_messages": { + "name": "task_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_messages_task_id_ts_idx": { + "name": "task_messages_task_id_ts_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_run_id_idx": { + "name": "task_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_created_at_idx": { + "name": "task_messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_messages_run_id_task_runs_id_fk": { + "name": "task_messages_run_id_task_runs_id_fk", + "tableFrom": "task_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_task_id_tasks_id_fk": { + "name": "task_messages_task_id_tasks_id_fk", + "tableFrom": "task_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_user_id_users_id_fk": { + "name": "task_messages_user_id_users_id_fk", + "tableFrom": "task_messages", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_messages_task_protocol_ts_event_type_unique": { + "name": "task_messages_task_protocol_ts_event_type_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "protocol", "ts", "event_type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pins": { + "name": "task_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pins_deployment_user_task_unique": { + "name": "task_pins_deployment_user_task_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_deployment_user_updated_at_idx": { + "name": "task_pins_deployment_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_task_id_idx": { + "name": "task_pins_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pins_task_id_tasks_id_fk": { + "name": "task_pins_task_id_tasks_id_fk", + "tableFrom": "task_pins", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pins_user_id_users_id_fk": { + "name": "task_pins_user_id_users_id_fk", + "tableFrom": "task_pins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_platform_issue_reports": { + "name": "task_platform_issue_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_message_id": { + "name": "task_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "slack_posted_at": { + "name": "slack_posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_platform_issue_reports_created_at_idx": { + "name": "task_platform_issue_reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_id_created_at_idx": { + "name": "task_platform_issue_reports_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_run_id_created_at_idx": { + "name": "task_platform_issue_reports_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_message_id_unique": { + "name": "task_platform_issue_reports_task_message_id_unique", + "columns": [ + { + "expression": "task_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_platform_issue_reports_task_id_tasks_id_fk": { + "name": "task_platform_issue_reports_task_id_tasks_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_run_id_task_runs_id_fk": { + "name": "task_platform_issue_reports_run_id_task_runs_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_task_message_id_task_messages_id_fk": { + "name": "task_platform_issue_reports_task_message_id_task_messages_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_messages", + "columnsFrom": ["task_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pull_requests": { + "name": "task_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_sha": { + "name": "pr_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_ref": { + "name": "pr_base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_sha": { + "name": "pr_base_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_reaction_id": { + "name": "github_reaction_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_check_run_id": { + "name": "github_check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_review_comment_id": { + "name": "github_review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pull_requests_task_id_idx": { + "name": "task_pull_requests_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_repository_id_idx": { + "name": "task_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_provider_repository_pr_number_idx": { + "name": "task_pull_requests_provider_repository_pr_number_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pull_requests_task_id_tasks_id_fk": { + "name": "task_pull_requests_task_id_tasks_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pull_requests_repository_id_repositories_id_fk": { + "name": "task_pull_requests_repository_id_repositories_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_pull_requests_task_pr_unique": { + "name": "task_pull_requests_task_pr_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "pr_url"] + } + }, + "policies": {}, + "checkConstraints": { + "task_pull_requests_source_control_provider_check": { + "name": "task_pull_requests_source_control_provider_check", + "value": "\"task_pull_requests\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.task_run_events": { + "name": "task_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_run_events_run_id_created_at_idx": { + "name": "task_run_events_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_task_id_created_at_idx": { + "name": "task_run_events_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_created_at_idx": { + "name": "task_run_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_source_created_at_idx": { + "name": "task_run_events_source_created_at_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_run_events_run_id_task_runs_id_fk": { + "name": "task_run_events_run_id_task_runs_id_fk", + "tableFrom": "task_run_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_run_events_task_id_tasks_id_fk": { + "name": "task_run_events_task_id_tasks_id_fk", + "tableFrom": "task_run_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_runs": { + "name": "task_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "task_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fresh'" + }, + "source_run_id": { + "name": "source_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queue_scope": { + "name": "queue_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_phase": { + "name": "task_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log": { + "name": "log", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_cmd_id": { + "name": "sandbox_cmd_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domain": { + "name": "machine_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domains": { + "name": "machine_domains", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "initial_paths": { + "name": "initial_paths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_port_name": { + "name": "primary_port_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_server_url": { + "name": "sandbox_server_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proxy_ports": { + "name": "proxy_ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "worker_release_tag": { + "name": "worker_release_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_commit": { + "name": "worker_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_requested_at": { + "name": "snapshot_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_failed_at": { + "name": "snapshot_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "keepalive_ms": { + "name": "keepalive_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sleep_at": { + "name": "sleep_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sleep_requested_at": { + "name": "sleep_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "worker_heartbeat_at": { + "name": "worker_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_id": { + "name": "source_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_value": { + "name": "auth_bypass_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_header_name": { + "name": "auth_bypass_header_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dequeued_at": { + "name": "dequeued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_started_at": { + "name": "provision_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_ready_at": { + "name": "provision_ready_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "harness_started_at": { + "name": "harness_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "runtime_task_started_at": { + "name": "runtime_task_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_assistant_output_at": { + "name": "first_assistant_output_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "task_runs_task_id_idx": { + "name": "task_runs_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_queue_scope_idx": { + "name": "task_runs_queue_scope_idx", + "columns": [ + { + "expression": "queue_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_acting_user_id_idx": { + "name": "task_runs_acting_user_id_idx", + "columns": [ + { + "expression": "acting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_snapshot_id_idx": { + "name": "task_runs_snapshot_id_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_at_idx": { + "name": "task_runs_sleep_at_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_worker_heartbeat_at_idx": { + "name": "task_runs_worker_heartbeat_at_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_due_idx": { + "name": "task_runs_sleep_check_due_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_stale_worker_idx": { + "name": "task_runs_sleep_check_stale_worker_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"worker_heartbeat_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_active_idx": { + "name": "task_runs_sleep_check_active_idx", + "columns": [ + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_snapshot_id_idx": { + "name": "task_runs_source_snapshot_id_idx", + "columns": [ + { + "expression": "source_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_run_id_idx": { + "name": "task_runs_source_run_id_idx", + "columns": [ + { + "expression": "source_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_first_assistant_output_at_idx": { + "name": "task_runs_first_assistant_output_at_idx", + "columns": [ + { + "expression": "first_assistant_output_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_runs_task_id_tasks_id_fk": { + "name": "task_runs_task_id_tasks_id_fk", + "tableFrom": "task_runs", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_runs_source_run_id_task_runs_id_fk": { + "name": "task_runs_source_run_id_task_runs_id_fk", + "tableFrom": "task_runs", + "tableTo": "task_runs", + "columnsFrom": ["source_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_runs_acting_user_id_users_id_fk": { + "name": "task_runs_acting_user_id_users_id_fk", + "tableFrom": "task_runs", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "task_runs_kind_check": { + "name": "task_runs_kind_check", + "value": "\"task_runs\".\"kind\" in ('fresh', 'resume')" + }, + "task_runs_harness_check": { + "name": "task_runs_harness_check", + "value": "\"task_runs\".\"harness\" in ('opencode-server')" + } + }, + "isRLSEnabled": false + }, + "public.task_slack_reply_details": { + "name": "task_slack_reply_details", + "schema": "", + "columns": { + "detail_id": { + "name": "detail_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_slack_reply_details_task_id_idx": { + "name": "task_slack_reply_details_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_slack_reply_details_deployment_task_detail_unique": { + "name": "task_slack_reply_details_deployment_task_detail_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detail_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_slack_reply_details_task_id_tasks_id_fk": { + "name": "task_slack_reply_details_task_id_tasks_id_fk", + "tableFrom": "task_slack_reply_details", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_start_parallel_counts": { + "name": "task_start_parallel_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parallel_count": { + "name": "parallel_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_window_seconds": { + "name": "activity_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_start_parallel_counts_run_id_unique": { + "name": "task_start_parallel_counts_run_id_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_task_id_started_at_idx": { + "name": "task_start_parallel_counts_task_id_started_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_started_at_idx": { + "name": "task_start_parallel_counts_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_start_parallel_counts_task_id_tasks_id_fk": { + "name": "task_start_parallel_counts_task_id_tasks_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_start_parallel_counts_run_id_task_runs_id_fk": { + "name": "task_start_parallel_counts_run_id_task_runs_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow": { + "name": "workflow", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiator_user_id": { + "name": "initiator_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_automation": { + "name": "initiator_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_external_id": { + "name": "actor_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_display_name": { + "name": "actor_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_kind": { + "name": "commit_author_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_user_id": { + "name": "commit_author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_login": { + "name": "commit_author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_external_id": { + "name": "commit_author_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_assignee_login": { + "name": "pr_assignee_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_session_id": { + "name": "linear_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_issue_id": { + "name": "linear_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_provider": { + "name": "model_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft_prompt": { + "name": "draft_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_work_kind": { + "name": "requested_work_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "requested_work_kind_source": { + "name": "requested_work_kind_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system_default'" + }, + "requested_work_kind_confidence": { + "name": "requested_work_kind_confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "harness_instructions": { + "name": "harness_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compute_duration_ms": { + "name": "compute_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_at": { + "name": "activity_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_initiator_user_id_idx": { + "name": "tasks_initiator_user_id_idx", + "columns": [ + { + "expression": "initiator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_initiator_automation_idx": { + "name": "tasks_initiator_automation_idx", + "columns": [ + { + "expression": "initiator_automation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_workflow_idx": { + "name": "tasks_workflow_idx", + "columns": [ + { + "expression": "workflow", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_visibility_activity_at_idx": { + "name": "tasks_visibility_activity_at_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_harness_session_id_idx": { + "name": "tasks_harness_session_id_idx", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_timestamp_idx": { + "name": "tasks_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_deployment_activity_at_idx": { + "name": "tasks_deployment_activity_at_idx", + "columns": [ + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_initiator_user_id_users_id_fk": { + "name": "tasks_initiator_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["initiator_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_initiator_automation_automations_key_fk": { + "name": "tasks_initiator_automation_automations_key_fk", + "tableFrom": "tasks", + "tableTo": "automations", + "columnsFrom": ["initiator_automation"], + "columnsTo": ["key"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_commit_author_user_id_users_id_fk": { + "name": "tasks_commit_author_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["commit_author_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_initiator_shape_check": { + "name": "tasks_initiator_shape_check", + "value": "(\"tasks\".\"initiator_kind\" = 'user' AND \"tasks\".\"initiator_automation\" IS NULL AND (\"tasks\".\"initiator_user_id\" IS NOT NULL OR \"tasks\".\"actor_external_id\" IS NOT NULL)) OR (\"tasks\".\"initiator_kind\" = 'automation' AND \"tasks\".\"initiator_automation\" IS NOT NULL AND \"tasks\".\"initiator_user_id\" IS NULL)" + }, + "tasks_workflow_check": { + "name": "tasks_workflow_check", + "value": "\"tasks\".\"workflow\" in ('standard', 'pr_review', 'pr_conflict_resolve', 'scan', 'mcp_recommendations', 'setup_onboarding', 'env_snapshot', 'eval')" + }, + "tasks_surface_check": { + "name": "tasks_surface_check", + "value": "\"tasks\".\"surface\" in ('web', 'api', 'slack', 'teams', 'telegram', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system')" + }, + "tasks_trigger_check": { + "name": "tasks_trigger_check", + "value": "\"tasks\".\"trigger\" in ('message', 'webhook', 'schedule', 'manual')" + }, + "tasks_visibility_check": { + "name": "tasks_visibility_check", + "value": "\"tasks\".\"visibility\" in ('visible', 'hidden')" + }, + "tasks_state_check": { + "name": "tasks_state_check", + "value": "\"tasks\".\"state\" in ('active', 'completed', 'failed', 'canceled')" + }, + "tasks_harness_check": { + "name": "tasks_harness_check", + "value": "\"tasks\".\"harness\" in ('opencode-server')" + }, + "tasks_requested_work_kind_check": { + "name": "tasks_requested_work_kind_check", + "value": "\"tasks\".\"requested_work_kind\" in ('question', 'plan', 'implement', 'unknown')" + }, + "tasks_requested_work_kind_source_check": { + "name": "tasks_requested_work_kind_source_check", + "value": "\"tasks\".\"requested_work_kind_source\" in ('explicit_bootstrap', 'task_tool', 'llm_classifier', 'inherited', 'system_default')" + }, + "tasks_commit_author_kind_check": { + "name": "tasks_commit_author_kind_check", + "value": "\"tasks\".\"commit_author_kind\" IS NULL OR \"tasks\".\"commit_author_kind\" in ('roomote', 'user', 'external')" + } + }, + "isRLSEnabled": false + }, + "public.teams_installations": { + "name": "teams_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_key": { + "name": "installation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_type": { + "name": "conversation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_app_id": { + "name": "bot_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_installations_tenant_id_idx": { + "name": "teams_installations_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_team_id_idx": { + "name": "teams_installations_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_conversation_id_idx": { + "name": "teams_installations_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_active_idx": { + "name": "teams_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_installations_installation_key_unique": { + "name": "teams_installations_installation_key_unique", + "nullsNotDistinct": false, + "columns": ["installation_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams_user_mappings": { + "name": "teams_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "teams_user_id": { + "name": "teams_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_tenant_id": { + "name": "teams_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_aad_object_id": { + "name": "teams_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_user_mappings_aad_object_idx": { + "name": "teams_user_mappings_aad_object_idx", + "columns": [ + { + "expression": "teams_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "teams_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_user_mappings_user_id_idx": { + "name": "teams_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_user_mappings_user_id_users_id_fk": { + "name": "teams_user_mappings_user_id_users_id_fk", + "tableFrom": "teams_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_user_mappings_unique": { + "name": "teams_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["teams_user_id", "teams_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_user_mappings": { + "name": "telegram_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_user_mappings_user_id_idx": { + "name": "telegram_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_user_mappings_user_id_users_id_fk": { + "name": "telegram_user_mappings_user_id_users_id_fk", + "tableFrom": "telegram_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "telegram_user_mappings_unique": { + "name": "telegram_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["telegram_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_messages": { + "name": "tracked_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "work_item_id": { + "name": "work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_text": { + "name": "summary_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracked_messages_kind_dedupe_key_unique": { + "name": "tracked_messages_kind_dedupe_key_unique", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_work_item_id_idx": { + "name": "tracked_messages_work_item_id_idx", + "columns": [ + { + "expression": "work_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_channel_message_idx": { + "name": "tracked_messages_channel_message_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_automation_channel_posted_idx": { + "name": "tracked_messages_automation_channel_posted_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "posted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_messages_work_item_id_work_items_id_fk": { + "name": "tracked_messages_work_item_id_work_items_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "work_items", + "columnsFrom": ["work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_automation_key_automations_key_fk": { + "name": "tracked_messages_automation_key_automations_key_fk", + "tableFrom": "tracked_messages", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_created_by_user_id_users_id_fk": { + "name": "tracked_messages_created_by_user_id_users_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_keys": { + "name": "user_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_api_keys_user_id_idx": { + "name": "user_api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_api_keys_user_deployment_provider_unique": { + "name": "user_api_keys_user_deployment_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_keys_user_id_users_id_fk": { + "name": "user_api_keys_user_id_users_id_fk", + "tableFrom": "user_api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "analytics_id": { + "name": "analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by_invite_id": { + "name": "invited_by_invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_analytics_id_unique_idx": { + "name": "users_analytics_id_unique_idx", + "columns": [ + { + "expression": "analytics_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "succeeded_at": { + "name": "succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhooks_provider_delivery_id_unique": { + "name": "webhooks_provider_delivery_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_event_idx": { + "name": "webhooks_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_created_at_idx": { + "name": "webhooks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhooks_status_exclusive": { + "name": "webhooks_status_exclusive", + "value": "(\n (succeeded_at IS NOT NULL)::int +\n (failed_at IS NOT NULL)::int\n ) <= 1" + } + }, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selected_by_user_id": { + "name": "selected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_work_item_id": { + "name": "source_work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brief": { + "name": "brief", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_prompt": { + "name": "execution_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "investigation_context": { + "name": "investigation_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_kind": { + "name": "action_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "repository_ids": { + "name": "repository_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "target_repository_full_name": { + "name": "target_repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_environment_id": { + "name": "target_environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_readiness": { + "name": "workspace_readiness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readiness_message": { + "name": "readiness_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launched_task_id": { + "name": "launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launched_at": { + "name": "launched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_error": { + "name": "launch_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_source_task_idx": { + "name": "work_items_source_task_idx", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_kind_status_idx": { + "name": "work_items_kind_status_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_automation_key_fingerprint_idx": { + "name": "work_items_automation_key_fingerprint_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_fingerprint_idx": { + "name": "work_items_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_launched_task_id_idx": { + "name": "work_items_launched_task_id_idx", + "columns": [ + { + "expression": "launched_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_source_task_kind_sort_order_unique": { + "name": "work_items_source_task_kind_sort_order_unique", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_items_automation_key_automations_key_fk": { + "name": "work_items_automation_key_automations_key_fk", + "tableFrom": "work_items", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_task_id_tasks_id_fk": { + "name": "work_items_source_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_items_selected_by_user_id_users_id_fk": { + "name": "work_items_selected_by_user_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": ["selected_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_work_item_id_work_items_id_fk": { + "name": "work_items_source_work_item_id_work_items_id_fk", + "tableFrom": "work_items", + "tableTo": "work_items", + "columnsFrom": ["source_work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_target_environment_id_environments_id_fk": { + "name": "work_items_target_environment_id_environments_id_fk", + "tableFrom": "work_items", + "tableTo": "environments", + "columnsFrom": ["target_environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_launched_task_id_tasks_id_fk": { + "name": "work_items_launched_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index c3e164883..52376ce9f 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1783736898276, "tag": "0008_shiny_garia", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1783820516382, + "tag": "0009_abnormal_nuke", + "breakpoints": true } ] } diff --git a/packages/db/src/lib/__tests__/compute-runtime-config.test.ts b/packages/db/src/lib/__tests__/compute-runtime-config.test.ts index a5cb312a7..d12e74f14 100644 --- a/packages/db/src/lib/__tests__/compute-runtime-config.test.ts +++ b/packages/db/src/lib/__tests__/compute-runtime-config.test.ts @@ -153,6 +153,20 @@ describe('resolveComputeProviderEnvValues', () => { expect(values.MODAL_BASE_IMAGE_REF).toBeUndefined(); }); + + it('normalizes typed Docker standby settings with runtime values taking precedence', async () => { + const values = await resolveComputeProviderEnvValues('docker', { + runtimeEnv: { DOCKER_STANDBY_MAX_COUNT: 7 }, + executor: makeExecutor([ + { name: 'DOCKER_STANDBY_MAX_AGE_HOURS', value: '18' }, + ]), + }); + + expect(values).toEqual({ + DOCKER_STANDBY_MAX_COUNT: '7', + DOCKER_STANDBY_MAX_AGE_HOURS: '18', + }); + }); }); describe('listConfiguredComputeProviders', () => { diff --git a/packages/db/src/lib/compute-runtime-config.ts b/packages/db/src/lib/compute-runtime-config.ts index b6c9466cb..d8b668664 100644 --- a/packages/db/src/lib/compute-runtime-config.ts +++ b/packages/db/src/lib/compute-runtime-config.ts @@ -22,6 +22,21 @@ import { stringifyDecryptedEnvVarValue } from './environment-variables'; const DEFAULT_DEPLOYMENT_ID = 'default'; +type ComputeRuntimeEnv = Partial>; + +function normalizeComputeRuntimeEnvValue(value: unknown): string | undefined { + switch (typeof value) { + case 'string': + return value.trim() || undefined; + case 'number': + case 'boolean': + case 'bigint': + return String(value); + default: + return undefined; + } +} + async function loadPersistedRuntimeComputeConfig( executor: DatabaseOrTransaction = db, ) { @@ -159,11 +174,17 @@ export async function resolveDefaultComputeProvider( export async function resolveComputeProviderEnvValues( provider: ComputeProvider, options: { - runtimeEnv?: Partial>; + runtimeEnv?: ComputeRuntimeEnv; executor?: DatabaseOrTransaction; } = {}, ): Promise>> { - const runtimeEnv = options.runtimeEnv ?? process.env; + const rawRuntimeEnv = options.runtimeEnv ?? process.env; + const runtimeEnv = Object.fromEntries( + Object.entries(rawRuntimeEnv).flatMap(([name, value]) => { + const normalized = normalizeComputeRuntimeEnvValue(value); + return normalized === undefined ? [] : [[name, normalized]]; + }), + ); const executor = options.executor ?? db; const descriptor = getSetupComputeProvider(provider); const envVarNames = descriptor.fields.map((field) => field.envVarName); @@ -176,7 +197,7 @@ export async function resolveComputeProviderEnvValues( const missingEnvVarNames: string[] = []; for (const envVarName of envVarNames) { - const runtimeValue = runtimeEnv[envVarName]?.trim(); + const runtimeValue = runtimeEnv[envVarName]; if (runtimeValue) { resolvedValues[envVarName] = runtimeValue; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index dd99f7275..b57a52dd4 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1029,17 +1029,17 @@ export const taskRuns = pgTable( index('task_runs_sleep_check_due_idx') .using('btree', table.sleepAt, table.createdAt, table.vendor) .where( - sql`${table.status} IN ('running', 'idle') AND ${table.machineId} IS NOT NULL AND ${table.sleepAt} IS NOT NULL AND ${table.sleepRequestedAt} IS NULL AND ${table.snapshotId} IS NULL AND ${table.snapshotRequestedAt} IS NULL AND ${table.vendor} IN ('modal', 'daytona', 'e2b')`, + sql`${table.status} IN ('running', 'idle') AND ${table.machineId} IS NOT NULL AND ${table.sleepAt} IS NOT NULL AND ${table.sleepRequestedAt} IS NULL AND ${table.snapshotId} IS NULL AND ${table.snapshotRequestedAt} IS NULL AND ${table.vendor} IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel')`, ), index('task_runs_sleep_check_stale_worker_idx') .using('btree', table.workerHeartbeatAt, table.createdAt, table.vendor) .where( - sql`${table.status} IN ('running', 'idle') AND ${table.machineId} IS NOT NULL AND ${table.workerHeartbeatAt} IS NOT NULL AND ${table.sleepRequestedAt} IS NULL AND ${table.snapshotId} IS NULL AND ${table.snapshotRequestedAt} IS NULL AND ${table.vendor} IN ('modal', 'daytona', 'e2b')`, + sql`${table.status} IN ('running', 'idle') AND ${table.machineId} IS NOT NULL AND ${table.workerHeartbeatAt} IS NOT NULL AND ${table.sleepRequestedAt} IS NULL AND ${table.snapshotId} IS NULL AND ${table.snapshotRequestedAt} IS NULL AND ${table.vendor} IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel')`, ), index('task_runs_sleep_check_active_idx') .using('btree', table.vendor, table.createdAt.desc()) .where( - sql`${table.status} IN ('running', 'idle') AND ${table.machineId} IS NOT NULL AND ${table.sleepRequestedAt} IS NULL AND ${table.snapshotId} IS NULL AND ${table.snapshotRequestedAt} IS NULL AND ${table.vendor} IN ('modal', 'daytona', 'e2b')`, + sql`${table.status} IN ('running', 'idle') AND ${table.machineId} IS NOT NULL AND ${table.sleepRequestedAt} IS NULL AND ${table.snapshotId} IS NULL AND ${table.snapshotRequestedAt} IS NULL AND ${table.vendor} IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel')`, ), index('task_runs_source_snapshot_id_idx').on(table.sourceSnapshotId), index('task_runs_source_run_id_idx').on(table.sourceRunId), diff --git a/packages/env/src/__tests__/index.test.ts b/packages/env/src/__tests__/index.test.ts index c343df94c..956963efe 100644 --- a/packages/env/src/__tests__/index.test.ts +++ b/packages/env/src/__tests__/index.test.ts @@ -102,6 +102,10 @@ describe('Env', () => { expect(env.DOCKER_WORKER_LOG_MAX_SIZE).toBe('10m'); expect(env.DOCKER_WORKER_LOG_MAX_FILES).toBe(3); expect(env.DOCKER_WORKER_EGRESS_POLICY).toBe('internet'); + expect(env.DOCKER_STANDBY_MAX_COUNT).toBe(10); + expect(env.DOCKER_STANDBY_MAX_AGE_HOURS).toBe(24); + expect(env.BLAXEL_STANDBY_MAX_COUNT).toBe(25); + expect(env.BLAXEL_STANDBY_MAX_AGE_HOURS).toBe(168); expect(env.R_MODEL).toBeUndefined(); expect(env.R_SMALL_MODEL).toBeUndefined(); expect(env.R_VISION_MODEL).toBeUndefined(); diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 3ee705e0a..cfc7b8f60 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -87,6 +87,18 @@ const serverSchema = { DOCKER_WORKER_LOG_MAX_SIZE: dockerSize().default('10m'), DOCKER_WORKER_LOG_MAX_FILES: z.coerce.number().int().positive().default(3), DOCKER_WORKER_EGRESS_POLICY: z.enum(['internet', 'none']).default('internet'), + DOCKER_STANDBY_MAX_COUNT: z.coerce.number().int().nonnegative().default(10), + DOCKER_STANDBY_MAX_AGE_HOURS: z.coerce + .number() + .positive() + .max(168) + .default(24), + BLAXEL_STANDBY_MAX_COUNT: z.coerce.number().int().nonnegative().default(25), + BLAXEL_STANDBY_MAX_AGE_HOURS: z.coerce + .number() + .positive() + .max(168) + .default(168), R_PUBLIC_URL: z.string().url().optional(), R_APP_URL: z.string().min(1), // Anonymous telemetry + version checks (Ping service). diff --git a/packages/github/src/__tests__/task-run-token.test.ts b/packages/github/src/__tests__/task-run-token.test.ts index 53a2c909b..a9cc2647f 100644 --- a/packages/github/src/__tests__/task-run-token.test.ts +++ b/packages/github/src/__tests__/task-run-token.test.ts @@ -92,13 +92,13 @@ describe('createTaskRunGitHubToken', () => { it('uses the selected repositories installation for scoped multi-repo tasks', async () => { mockFindMany.mockResolvedValue([ { - fullName: 'LogSharpDoo/ViradaBMS-BE', - installationId: 'install-logsharpdoo', + fullName: 'ExampleOrg/example-backend', + installationId: 'install-exampleorg', githubRepoId: 101, }, { - fullName: 'LogSharpDoo/ViradaBMS-React', - installationId: 'install-logsharpdoo', + fullName: 'ExampleOrg/example-frontend', + installationId: 'install-exampleorg', githubRepoId: 102, }, ]); @@ -108,8 +108,8 @@ describe('createTaskRunGitHubToken', () => { buildTaskRun({ repo: '__all_repositories__', selectedRepositories: [ - 'LogSharpDoo/ViradaBMS-BE', - 'LogSharpDoo/ViradaBMS-React', + 'ExampleOrg/example-backend', + 'ExampleOrg/example-frontend', ], } as TaskRun['payload']), ), @@ -118,7 +118,7 @@ describe('createTaskRunGitHubToken', () => { expect(mockFindFirst).not.toHaveBeenCalled(); expect(mockCreateGitHubToken).toHaveBeenCalledWith({ type: 'installationId', - installationId: 'install-logsharpdoo', + installationId: 'install-exampleorg', repositoryIds: [101, 102], }); }); diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index e38c9b33a..507140909 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -15,6 +15,12 @@ export { export { finishRun } from './lib/task-runs/finish-run'; export { findTaskRunByRunTokenClaims } from './lib/task-runs/find-task-run'; export { createSnapshot } from './lib/task-runs/enqueue-snapshot'; +export { + enqueueTaskSleep, + TASK_SLEEP_QUEUE_NAME, + taskSleepRequestSchema, + type TaskSleepRequest, +} from './lib/task-runs/enqueue-sleep'; export { recordComputeProviderUsage } from './lib/task-runs/record-compute-provider-usage'; export { recordTaskMessageEnvelope, @@ -36,8 +42,11 @@ export { } from './lib/task-runs/stamp-milestone'; export { + ARTIFACT_RAW_URL_CLOCK_SKEW_SECONDS, + ARTIFACT_RAW_URL_MAX_AGE_SECONDS, buildSignedArtifactRawUrl, currentEpochSeconds, + isArtifactSignatureTimestampValid, signArtifactIdWithKey, verifyArtifactSignatureWithKeys, } from './lib/artifacts/raw-url'; diff --git a/packages/sdk/src/server/lib/artifacts/__tests__/raw-url.test.ts b/packages/sdk/src/server/lib/artifacts/__tests__/raw-url.test.ts new file mode 100644 index 000000000..20b6ccd90 --- /dev/null +++ b/packages/sdk/src/server/lib/artifacts/__tests__/raw-url.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import { + ARTIFACT_RAW_URL_MAX_AGE_SECONDS, + buildSignedArtifactRawUrl, + isArtifactSignatureTimestampValid, + signArtifactIdWithKey, + verifyArtifactSignatureWithKeys, +} from '../raw-url'; + +const CURRENT_KEY = 'current-artifact-signing-key-32chars!!'; +const PREVIOUS_KEY = 'previous-artifact-signing-key-32chars!'; +const ARTIFACT_ID = 'art_test_123'; + +describe('artifact raw URL signatures', () => { + const nowSeconds = 1_700_000_000; + + it('accepts a fresh signature signed with the current key', () => { + const ts = nowSeconds - 60; + const signature = signArtifactIdWithKey({ + artifactId: ARTIFACT_ID, + ts, + signingKey: CURRENT_KEY, + }); + + expect( + verifyArtifactSignatureWithKeys({ + artifactId: ARTIFACT_ID, + signature, + ts, + currentSigningKey: CURRENT_KEY, + previousSigningKey: PREVIOUS_KEY, + nowSeconds, + }), + ).toBe(true); + }); + + it('accepts a fresh signature signed with the previous key during rotation', () => { + const ts = nowSeconds - 120; + const signature = signArtifactIdWithKey({ + artifactId: ARTIFACT_ID, + ts, + signingKey: PREVIOUS_KEY, + }); + + expect( + verifyArtifactSignatureWithKeys({ + artifactId: ARTIFACT_ID, + signature, + ts, + currentSigningKey: CURRENT_KEY, + previousSigningKey: PREVIOUS_KEY, + nowSeconds, + }), + ).toBe(true); + }); + + it('rejects an expired timestamp even with a valid HMAC', () => { + const ts = nowSeconds - ARTIFACT_RAW_URL_MAX_AGE_SECONDS - 1; + const signature = signArtifactIdWithKey({ + artifactId: ARTIFACT_ID, + ts, + signingKey: CURRENT_KEY, + }); + + expect( + verifyArtifactSignatureWithKeys({ + artifactId: ARTIFACT_ID, + signature, + ts, + currentSigningKey: CURRENT_KEY, + previousSigningKey: PREVIOUS_KEY, + nowSeconds, + }), + ).toBe(false); + }); + + it('accepts a timestamp exactly at the max age boundary', () => { + const ts = nowSeconds - ARTIFACT_RAW_URL_MAX_AGE_SECONDS; + const signature = signArtifactIdWithKey({ + artifactId: ARTIFACT_ID, + ts, + signingKey: CURRENT_KEY, + }); + + expect( + verifyArtifactSignatureWithKeys({ + artifactId: ARTIFACT_ID, + signature, + ts, + currentSigningKey: CURRENT_KEY, + previousSigningKey: PREVIOUS_KEY, + nowSeconds, + }), + ).toBe(true); + }); + + it('rejects timestamps too far in the future', () => { + const ts = nowSeconds + 61; + expect( + isArtifactSignatureTimestampValid({ + ts, + nowSeconds, + }), + ).toBe(false); + + const signature = signArtifactIdWithKey({ + artifactId: ARTIFACT_ID, + ts, + signingKey: CURRENT_KEY, + }); + expect( + verifyArtifactSignatureWithKeys({ + artifactId: ARTIFACT_ID, + signature, + ts, + currentSigningKey: CURRENT_KEY, + nowSeconds, + }), + ).toBe(false); + }); + + it('rejects invalid signatures that do not match either key', () => { + const ts = nowSeconds; + expect( + verifyArtifactSignatureWithKeys({ + artifactId: ARTIFACT_ID, + signature: '0'.repeat(64), + ts, + currentSigningKey: CURRENT_KEY, + previousSigningKey: PREVIOUS_KEY, + nowSeconds, + }), + ).toBe(false); + }); + + it('builds a signed raw URL with sig and ts query params', () => { + const ts = nowSeconds; + const url = buildSignedArtifactRawUrl({ + artifactId: ARTIFACT_ID, + ts, + apiBaseUrl: 'https://example.com/', + signingKey: CURRENT_KEY, + }); + const signature = signArtifactIdWithKey({ + artifactId: ARTIFACT_ID, + ts, + signingKey: CURRENT_KEY, + }); + + expect(url).toBe( + `https://example.com/api/artifacts/${ARTIFACT_ID}/raw?sig=${signature}&ts=${ts}`, + ); + }); +}); diff --git a/packages/sdk/src/server/lib/artifacts/raw-url.ts b/packages/sdk/src/server/lib/artifacts/raw-url.ts index 912713497..0775dd664 100644 --- a/packages/sdk/src/server/lib/artifacts/raw-url.ts +++ b/packages/sdk/src/server/lib/artifacts/raw-url.ts @@ -1,5 +1,11 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; +/** Signed raw URLs fail closed after this many seconds from `ts`. */ +export const ARTIFACT_RAW_URL_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; + +/** Allow small positive clock skew when checking `ts`. */ +export const ARTIFACT_RAW_URL_CLOCK_SKEW_SECONDS = 60; + function signWithKey(key: string, artifactId: string, ts: number): string { const hmac = createHmac('sha256', key); hmac.update(`${artifactId}.${ts}`); @@ -22,13 +28,49 @@ function signaturesMatch(a: string, b: string): boolean { return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } +export function isArtifactSignatureTimestampValid(input: { + ts: number; + nowSeconds?: number; + maxAgeSeconds?: number; + clockSkewSeconds?: number; +}): boolean { + if (!Number.isFinite(input.ts) || input.ts <= 0) { + return false; + } + + const nowSeconds = input.nowSeconds ?? currentEpochSeconds(); + const maxAgeSeconds = input.maxAgeSeconds ?? ARTIFACT_RAW_URL_MAX_AGE_SECONDS; + const clockSkewSeconds = + input.clockSkewSeconds ?? ARTIFACT_RAW_URL_CLOCK_SKEW_SECONDS; + + if (input.ts > nowSeconds + clockSkewSeconds) { + return false; + } + + return nowSeconds - input.ts <= maxAgeSeconds; +} + export function verifyArtifactSignatureWithKeys(input: { artifactId: string; signature: string; ts: number; currentSigningKey: string; previousSigningKey?: string; + nowSeconds?: number; + maxAgeSeconds?: number; + clockSkewSeconds?: number; }): boolean { + if ( + !isArtifactSignatureTimestampValid({ + ts: input.ts, + nowSeconds: input.nowSeconds, + maxAgeSeconds: input.maxAgeSeconds, + clockSkewSeconds: input.clockSkewSeconds, + }) + ) { + return false; + } + const expected = signWithKey( input.currentSigningKey, input.artifactId, diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts index 37177f359..481932115 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts @@ -393,6 +393,39 @@ describe('dequeueResumeTaskRun', () => { expect(mockReleaseTaskRun).toHaveBeenCalledWith(resumeRun); }); + it.each(['docker', 'blaxel'] as const)( + 'resumes a retained %s environment before its first harness session', + async (vendor) => { + const resumeRun = makeSnapshotResumeRun( + { vendor }, + { harnessSessionId: null }, + ); + + mockTxExecute.mockResolvedValue([{ id: resumeRun.id }]); + mockTxFindFirstTaskRuns.mockResolvedValueOnce(resumeRun); + + const result = await dequeueResumeTaskRun({ orgId: 'org-1' } as never, { + runId: resumeRun.id, + }); + + expect(result?.harnessSessionId).toBeUndefined(); + expect(mockCancelTaskRun).not.toHaveBeenCalled(); + expect(mockRecordSnapshotResumeEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + runId: resumeRun.id, + eventType: 'started', + message: expect.stringContaining('before the first harness session'), + details: expect.objectContaining({ + harnessSessionId: undefined, + sourceRunId: 99, + sourceSnapshotId: 'snap-1', + }), + }), + ); + }, + ); + it('invokes the bootstrap failure callback when resume bootstrap fails before startup', async () => { const resumeRun = makeSnapshotResumeRun({}, { harnessSessionId: null }); diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/enqueue-sleep.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/enqueue-sleep.test.ts new file mode 100644 index 000000000..508e70941 --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/__tests__/enqueue-sleep.test.ts @@ -0,0 +1,89 @@ +import type { Mock } from 'vitest'; + +const { + queueAddMock, + queueGetJobMock, + queueConstructorMock, + queueEventsConstructorMock, + waitUntilFinishedMock, + mockGetRedis, +} = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type AnyMock = Mock<(...args: any[]) => any>; + const queueAddMock = vi.fn() as AnyMock; + const queueGetJobMock = vi.fn() as AnyMock; + const waitUntilFinishedMock = vi.fn() as AnyMock; + const queueConstructorMock = vi.fn(function Queue() { + return { add: queueAddMock, getJob: queueGetJobMock }; + }) as AnyMock; + + return { + queueAddMock, + queueGetJobMock, + queueConstructorMock, + queueEventsConstructorMock: vi.fn(function QueueEvents() { + return { kind: 'queue-events' }; + }) as AnyMock, + waitUntilFinishedMock, + mockGetRedis: vi.fn(() => ({ status: 'ready' })) as AnyMock, + }; +}); + +vi.mock('bullmq', () => ({ + Queue: queueConstructorMock, + QueueEvents: queueEventsConstructorMock, +})); +vi.mock('@roomote/redis', () => ({ getRedis: mockGetRedis })); + +describe('enqueueTaskSleep', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + queueAddMock.mockResolvedValue({ + id: 'task-sleep-123', + waitUntilFinished: waitUntilFinishedMock, + }); + queueGetJobMock.mockResolvedValue(null); + waitUntilFinishedMock.mockResolvedValue(undefined); + }); + + it('enqueues one immediate sleep action per task run', async () => { + const { enqueueTaskSleep } = await import('../enqueue-sleep'); + + await expect(enqueueTaskSleep({ runId: 123 })).resolves.toBe(true); + + expect(queueAddMock).toHaveBeenCalledWith( + 'sleep-task', + { runId: 123 }, + { jobId: 'task-sleep-123' }, + ); + expect(waitUntilFinishedMock).toHaveBeenCalledWith( + { kind: 'queue-events' }, + 60_000, + ); + }); + + it('deduplicates a sleep action that is still waiting', async () => { + queueGetJobMock.mockResolvedValue({ + getState: vi.fn().mockResolvedValue('waiting'), + remove: vi.fn(), + waitUntilFinished: waitUntilFinishedMock, + }); + const { enqueueTaskSleep } = await import('../enqueue-sleep'); + + await expect(enqueueTaskSleep({ runId: 123 })).resolves.toBe(false); + expect(queueAddMock).not.toHaveBeenCalled(); + expect(waitUntilFinishedMock).toHaveBeenCalledOnce(); + }); + + it('propagates an asynchronous sleep worker failure', async () => { + waitUntilFinishedMock.mockRejectedValue( + new Error('Docker instance is stopped'), + ); + const { enqueueTaskSleep } = await import('../enqueue-sleep'); + + await expect(enqueueTaskSleep({ runId: 123 })).rejects.toThrow( + 'Docker instance is stopped', + ); + }); +}); diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts index 6623288e9..4182d5b64 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts @@ -136,7 +136,9 @@ const eventsWithoutSelfReview: PrReviewActivityEvent[] = events.slice(0, 2); function mockGreenCiChecks() { mockCreateTaskRunGitHubToken.mockResolvedValue('github-token'); - mockPullsGet.mockResolvedValue({ data: { head: { sha: 'abc123' } } }); + mockPullsGet.mockResolvedValue({ + data: { head: { sha: 'abc123' }, mergeable: true }, + }); mockListCheckRunsForRef.mockResolvedValue({ data: { check_runs: [ @@ -219,6 +221,7 @@ describe('preparePrReviewNotificationDelivery', () => { expect(prompt).toContain('- CI / Lint: success'); expect(prompt).toContain('- CI / Tests: success'); + expect(prompt).not.toContain('- Merge conflicts: yes'); }); it('passes one line per check status into the triage LLM context', async () => { @@ -259,6 +262,22 @@ describe('preparePrReviewNotificationDelivery', () => { }); }); + it('passes merge conflicts into the triage LLM context', async () => { + mockPullsGet.mockResolvedValue({ + data: { head: { sha: 'abc123' }, mergeable: false }, + }); + + await preparePrReviewNotificationDelivery({ + taskRun, + request, + events, + }); + + const prompt = mockGenerateObject.mock.calls[0]?.[0]?.prompt as string; + + expect(prompt).toContain('- Merge conflicts: yes'); + }); + it('skips without triage when no conversation route can be resolved', async () => { mockResolveRoute.mockResolvedValue(null); @@ -353,6 +372,19 @@ describe('triagePrReviewActivity', () => { expect(system).toContain( 'when "Current pull request state" includes CI check lines', ); + expect(system).toContain( + 'when any CI check is listed as failure or error, treat it as high-signal', + ); + expect(system).toContain( + 'when "Current pull request state" includes "- Merge conflicts: yes"', + ); + expect(system).toContain( + 'current pull request state shows a CI check as failure or error', + ); + expect(system).toContain( + 'current pull request state includes "- Merge conflicts: yes"', + ); + expect(system).toContain('Do you want me to fix it?'); }); it('includes the latest Roomote review summary comment verbatim for self-review results', async () => { @@ -375,6 +407,7 @@ describe('triagePrReviewActivity', () => { { name: 'CI / Tests', status: 'success' }, ], }, + mergeable: true, }, }); @@ -389,6 +422,34 @@ describe('triagePrReviewActivity', () => { expect(prompt).toContain('Current pull request state:'); expect(prompt).toContain('- CI / Lint: success'); expect(prompt).toContain('- CI / Tests: success'); + expect(prompt).not.toContain('- Merge conflicts: yes'); + }); + + it('includes merge conflicts in the triage prompt for self-review results', async () => { + mockGenerateObject.mockResolvedValue({ + object: { worthNotifying: true, summary: 'ok.' }, + }); + + await triagePrReviewActivity({ + ...request, + events, + context: { + resolvedThreadCount: 0, + unresolvedThreadCount: 0, + latestReviewStatus: null, + latestReviewSummaryComment: null, + ciStatus: { + checks: [{ name: 'CI / Tests', status: 'failure' }], + }, + mergeable: false, + }, + }); + + const prompt = mockGenerateObject.mock.calls[0]?.[0]?.prompt as string; + + expect(prompt).toContain('Current pull request state:'); + expect(prompt).toContain('- Merge conflicts: yes'); + expect(prompt).toContain('- CI / Tests: failure'); }); it('returns a skip decision when the model says the activity is not worth notifying', async () => { @@ -475,7 +536,22 @@ describe('gatherPrReviewTriageContext', () => { { name: 'CI / Tests', status: 'success' }, ], }, + mergeable: true, + }); + }); + + it('includes mergeable false when the PR has conflicts', async () => { + mockPullsGet.mockResolvedValue({ + data: { head: { sha: 'abc123' }, mergeable: false }, + }); + + const context = await gatherPrReviewTriageContext({ + taskRun, + repository: request.repository, + prNumber: request.prNumber, }); + + expect(context.mergeable).toBe(false); }); it('includes pending CI status when checks are still running', async () => { @@ -537,6 +613,7 @@ describe('gatherPrReviewTriageContext', () => { }); expect(context.ciStatus).toBeNull(); + expect(context.mergeable).toBeNull(); expect(mockPullsGet).not.toHaveBeenCalled(); expect(mockListCheckRunsForRef).not.toHaveBeenCalled(); expect(mockGetCombinedStatusForRef).not.toHaveBeenCalled(); @@ -560,6 +637,7 @@ describe('gatherPrReviewTriageContext', () => { latestReviewStatus: null, latestReviewSummaryComment: null, ciStatus: null, + mergeable: null, }); }); }); diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts index b26c620bf..62837e422 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts @@ -5,6 +5,7 @@ import { type RequestedWorkKind, RunStatus, TaskPayloadKind, + isStandbyResumeCapableComputeProvider, resolveSourceControlProviderFromPayload, } from '@roomote/types'; import { @@ -52,11 +53,11 @@ type DequeueResumeTaskRunResult = setupOnboardingTask: boolean; gitAuthor: GitAuthor; /** - * The Roomote harness session ID that should be resumed. - * Resolved from tasks.harnessSessionId of the run's own task — resume - * runs share the task with the run they resume. + * The Roomote harness session ID that should be resumed, when one has + * started. Retained standby environments can be suspended before their + * first prompt creates a harness session. */ - harnessSessionId: string; + harnessSessionId?: string; /** * The repo from the source task run's payload. * Used to determine the correct workspace path. @@ -79,8 +80,9 @@ type DequeueResumeTaskRunResult = * * This function claims the run, validates it, and reads the persisted * harnessSessionId from the run's task (resume runs are rows on the same - * task). The worker uses that harness session ID to resume the existing task - * instead of starting a new one. + * task). The worker uses that harness session ID to resume an existing task. + * Retained standby environments may legitimately have no session yet when + * they were suspended before their first prompt. * * Unlike regular dequeue, this doesn't generate a prompt since we're resuming * an existing task with its own conversation history. @@ -130,7 +132,7 @@ export const dequeueResumeTaskRun = async ( orgAgentInstructions?: string; styleGuidance?: string; gitAuthor: GitAuthor; - harnessSessionId: string; + harnessSessionId?: string; sourceRepo?: string; sourceEnvironmentId?: string; sourceSelectedRepositories?: string[]; @@ -226,8 +228,10 @@ export const dequeueResumeTaskRun = async ( // Resume runs share their task with the run they resume, so the // harness session lives on the run's own task row. const harnessSessionId = taskRun.task.harnessSessionId ?? null; + const canResumeWithoutHarnessSession = + isStandbyResumeCapableComputeProvider(taskRun.vendor); - if (!harnessSessionId) { + if (!harnessSessionId && !canResumeWithoutHarnessSession) { console.error( `${tag} Task ${taskRun.taskId} for resume run ${taskRun.id} has no harnessSessionId`, ); @@ -274,7 +278,9 @@ export const dequeueResumeTaskRun = async ( const sourceEnvironmentId = resumePayload.environmentId; const sourceSelectedRepositories = resumePayload.selectedRepositories; console.log( - `${tag} Found harness session ID ${harnessSessionId} for resume run ${taskRun.id} (taskId=${taskRun.taskId}, sourceRunId=${sourceRunId}, repo=${sourceRepo}, environmentId=${sourceEnvironmentId})`, + harnessSessionId + ? `${tag} Found harness session ID ${harnessSessionId} for resume run ${taskRun.id} (taskId=${taskRun.taskId}, sourceRunId=${sourceRunId}, repo=${sourceRepo}, environmentId=${sourceEnvironmentId})` + : `${tag} Resuming retained ${taskRun.vendor} environment for run ${taskRun.id} before its first harness session (taskId=${taskRun.taskId}, sourceRunId=${sourceRunId}, repo=${sourceRepo}, environmentId=${sourceEnvironmentId})`, ); // Fetch environment variables @@ -335,7 +341,7 @@ export const dequeueResumeTaskRun = async ( orgAgentInstructions: settings?.globalAgentInstructions ?? undefined, styleGuidance: settings?.styleGuidance ?? undefined, gitAuthor, - harnessSessionId, + harnessSessionId: harnessSessionId ?? undefined, sourceRepo, sourceEnvironmentId, sourceSelectedRepositories, @@ -445,7 +451,9 @@ export const dequeueResumeTaskRun = async ( runId: result.taskRun.id, taskId: result.taskRun.taskId, eventType: 'started', - message: `Snapshot resume bootstrap started with source session ${result.harnessSessionId}.`, + message: result.harnessSessionId + ? `Snapshot resume bootstrap started with source session ${result.harnessSessionId}.` + : 'Standby resume bootstrap started before the first harness session.', details: { stage: 'bootstrap', sourceRunId: result.taskRun.sourceRunId ?? null, diff --git a/packages/sdk/src/server/lib/task-runs/enqueue-sleep.ts b/packages/sdk/src/server/lib/task-runs/enqueue-sleep.ts new file mode 100644 index 000000000..48f238ad8 --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/enqueue-sleep.ts @@ -0,0 +1,80 @@ +import { Queue, QueueEvents } from 'bullmq'; +import { z } from 'zod'; + +import { getRedis } from '@roomote/redis'; + +export const TASK_SLEEP_QUEUE_NAME = 'task-sleep-jobs'; +const TASK_SLEEP_RESULT_TIMEOUT_MS = 60_000; + +const BLOCKING_JOB_STATES = new Set([ + 'active', + 'delayed', + 'prioritized', + 'waiting', + 'waiting-children', +]); + +export const taskSleepRequestSchema = z.object({ + runId: z.number(), +}); + +export type TaskSleepRequest = z.infer; + +let taskSleepQueue: Queue | null = null; +let taskSleepQueueEvents: QueueEvents | null = null; + +function getTaskSleepQueue(): Queue { + if (!taskSleepQueue) { + taskSleepQueue = new Queue(TASK_SLEEP_QUEUE_NAME, { + connection: getRedis(), + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 1_000 }, + removeOnComplete: { age: 3_600, count: 100 }, + removeOnFail: { age: 24 * 3_600 }, + }, + }); + } + + return taskSleepQueue; +} + +function getTaskSleepQueueEvents(): QueueEvents { + if (!taskSleepQueueEvents) { + taskSleepQueueEvents = new QueueEvents(TASK_SLEEP_QUEUE_NAME, { + connection: getRedis(), + }); + } + + return taskSleepQueueEvents; +} + +/** Enqueue an immediate provider-neutral sleep request for a task run. */ +export async function enqueueTaskSleep( + request: TaskSleepRequest, +): Promise { + const queue = getTaskSleepQueue(); + const jobId = `task-sleep-${request.runId}`; + const existingJob = await queue.getJob(jobId); + + if (existingJob) { + const state = await existingJob.getState(); + + if (BLOCKING_JOB_STATES.has(state)) { + await existingJob.waitUntilFinished( + getTaskSleepQueueEvents(), + TASK_SLEEP_RESULT_TIMEOUT_MS, + ); + return false; + } + + await existingJob.remove(); + } + + const job = await queue.add('sleep-task', request, { jobId }); + await job.waitUntilFinished( + getTaskSleepQueueEvents(), + TASK_SLEEP_RESULT_TIMEOUT_MS, + ); + return true; +} diff --git a/packages/sdk/src/server/lib/task-runs/index.ts b/packages/sdk/src/server/lib/task-runs/index.ts index 135598812..6a5ba85ee 100644 --- a/packages/sdk/src/server/lib/task-runs/index.ts +++ b/packages/sdk/src/server/lib/task-runs/index.ts @@ -6,6 +6,7 @@ export * from './dequeue-task-run'; export * from './dequeue-resume-task-run'; export * from './finish-run'; export * from './enqueue-snapshot'; +export * from './enqueue-sleep'; export * from './revert-pr-commit'; export * from './refresh-github-token'; export * from './fetch-snapshot-env'; diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts index b616beb4f..7811f046b 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts @@ -68,6 +68,16 @@ export type PrReviewTriageContext = { * triage LLM so the chat message can mention CI naturally. */ ciStatus: PrReviewCiStatus | null; + /** + * Whether the PR is currently mergeable. `false` means live merge conflicts + * need resolution; `null` means unknown/unavailable. + */ + mergeable: boolean | null; +}; + +type PrReviewLiveHeadState = { + ciStatus: PrReviewCiStatus | null; + mergeable: boolean | null; }; type PrReviewTriageDecision = @@ -213,11 +223,12 @@ export function collectCiChecks({ } /** - * Resolve CI check state for the PR head using GitHub check runs and the - * classic combined commit status. Non-GitHub providers currently return null; - * failures to fetch status are treated as unavailable, not as red CI. + * Resolve live PR head state for triage: CI check runs / classic commit + * status (GitHub only) plus mergeability. Non-GitHub providers currently + * skip CI; failures to fetch status are treated as unavailable, not as red + * CI or as merge conflicts. */ -async function fetchPrReviewCiStatus({ +async function fetchPrReviewLiveHeadState({ taskRun, repository, prNumber, @@ -227,17 +238,17 @@ async function fetchPrReviewCiStatus({ repository: string; prNumber: number; sourceControlProvider?: SourceControlProvider; -}): Promise { +}): Promise { const provider = normalizeSourceControlProvider(sourceControlProvider); if (provider !== 'github') { - return null; + return { ciStatus: null, mergeable: null }; } const [owner, repo] = repository.split('/'); if (!owner || !repo) { - return null; + return { ciStatus: null, mergeable: null }; } try { @@ -251,9 +262,11 @@ async function fetchPrReviewCiStatus({ }); const headSha = typeof pullRequest.head?.sha === 'string' ? pullRequest.head.sha : null; + const mergeable = + typeof pullRequest.mergeable === 'boolean' ? pullRequest.mergeable : null; if (!headSha) { - return null; + return { ciStatus: null, mergeable }; } let checkRuns: Array<{ @@ -313,19 +326,18 @@ async function fetchPrReviewCiStatus({ const checks = collectCiChecks({ checkRuns, statusContexts }); - if (checks.length === 0) { - return null; - } - - return { checks }; + return { + ciStatus: checks.length > 0 ? { checks } : null, + mergeable, + }; } catch (error) { console.warn( - `[PrReviewNotification] Could not resolve CI status for ${repository}#${prNumber}: ${ + `[PrReviewNotification] Could not resolve live PR head state for ${repository}#${prNumber}: ${ error instanceof Error ? error.message : String(error) }`, ); - return null; + return { ciStatus: null, mergeable: null }; } } @@ -343,14 +355,19 @@ would plausibly want to know or act on, for example: - a human reviewer approved the PR, requested changes, or dismissed a review - a human reviewer left review comments - an automated review found concrete issues worth considering +- current pull request state shows a CI check as failure or error +- current pull request state includes "- Merge conflicts: yes" Set "worthNotifying" to false when the activity is only noise or is already handled, for example: -- automated or bot activity that found nothing actionable -- events with no substantive content for the user +- automated or bot activity that found nothing actionable and there is no + failing CI or known merge conflict +- events with no substantive content for the user (and no failing CI or + merge conflict in current state) - the feedback appears to already be fixed or addressed, for example the relevant review threads are resolved or the latest automated review status - reports the issues as addressed + reports the issues as addressed, and there is no failing CI or known + merge conflict Exception: events marked "(this is your own review)" are results of the agent's own automated review of the pull request, and the user always wants @@ -402,14 +419,27 @@ any feedback. Rules: use that as the single source of truth for what you flagged; when it stays short and concrete, mention one or two flagged items briefly instead of only giving a count -- when there is nothing actionable, do not add a question or call to action +- when there is nothing actionable (no open feedback, no failed CI, no merge + conflicts), do not add a question or call to action - never claim that any changes were made in response to the feedback, and do not promise follow-up actions - when "Current pull request state" includes CI check lines (for example - "- Lint: success"), use those live per-check statuses when mentioning CI; - keep it short (one brief sentence for green/pending/failed CI overall), - and name a specific failed check when one is listed as failure -- never invent CI status when those check lines are absent + "- Lint: success"), use those live per-check statuses when mentioning CI +- when any CI check is listed as failure or error, treat it as high-signal and + actionable: call the failure out clearly instead of burying it after a soft + "looked good" review wrap-up. Prefer a shape like "I reviewed + [owner/repo#42](pull request URL) on GitHub and the code looked good + overall, but a test is failing in CI. Do you want me to fix it?" Name the + failed check when one is listed. Pending checks may get a brief mention but + must not overshadow a hard failure. +- when "Current pull request state" includes "- Merge conflicts: yes", treat + conflicts as high-signal and actionable with the same weight as failed CI. + Call them out clearly and offer to resolve them, for example: "and the PR + also has merge conflicts — want me to resolve those?" +- unsuccessful CI and merge conflicts remain actionable even when the review + found no code issues; still end with a short question asking whether the + user wants you to fix or resolve them +- never invent CI status or merge-conflict state when those lines are absent - do not mention this triage step or that the input was parsed - if "worthNotifying" is false, "summary" may be an empty string @@ -455,7 +485,7 @@ async function fetchPrDiscussionSignals({ taskRun: TaskRun; repository: string; prNumber: number; -}): Promise> { +}): Promise> { const result = await readSourceControlPullRequestForTaskRun({ taskRun, input: { @@ -518,7 +548,7 @@ export async function gatherPrReviewTriageContext({ prNumber: number; sourceControlProvider?: SourceControlProvider; }): Promise { - const [discussionResult, ciStatus] = await Promise.all([ + const [discussionResult, liveHeadState] = await Promise.all([ (async () => { try { return await fetchPrDiscussionSignals({ @@ -541,7 +571,7 @@ export async function gatherPrReviewTriageContext({ }; } })(), - fetchPrReviewCiStatus({ + fetchPrReviewLiveHeadState({ taskRun, repository, prNumber, @@ -551,7 +581,8 @@ export async function gatherPrReviewTriageContext({ return { ...discussionResult, - ciStatus, + ciStatus: liveHeadState.ciStatus, + mergeable: liveHeadState.mergeable, }; } @@ -565,12 +596,29 @@ function buildCiContextLines(context: PrReviewTriageContext): string[] { ); } +function buildMergeConflictContextLines( + context: PrReviewTriageContext, +): string[] { + if (context.mergeable !== false) { + return []; + } + + return ['- Merge conflicts: yes']; +} + +function buildLiveStateContextLines(context: PrReviewTriageContext): string[] { + return [ + ...buildMergeConflictContextLines(context), + ...buildCiContextLines(context), + ]; +} + function buildContextLines( context: PrReviewTriageContext, options?: { containsSelfReviewResult?: boolean }, ): string[] { const lines: string[] = []; - const ciLines = buildCiContextLines(context); + const liveStateLines = buildLiveStateContextLines(context); if (options?.containsSelfReviewResult) { if (context.latestReviewSummaryComment) { @@ -581,8 +629,8 @@ function buildContextLines( ); } - if (ciLines.length > 0) { - lines.push('', 'Current pull request state:', ...ciLines); + if (liveStateLines.length > 0) { + lines.push('', 'Current pull request state:', ...liveStateLines); } return lines; @@ -602,7 +650,7 @@ function buildContextLines( ); } - lines.push(...ciLines); + lines.push(...liveStateLines); return lines.length > 0 ? ['', 'Current pull request state:', ...lines] : []; } diff --git a/packages/types/src/compute-providers/capabilities.ts b/packages/types/src/compute-providers/capabilities.ts index 2a24aabaf..5ee6d73c7 100644 --- a/packages/types/src/compute-providers/capabilities.ts +++ b/packages/types/src/compute-providers/capabilities.ts @@ -27,8 +27,8 @@ export const DOCKER_CAPABILITIES: ComputeProviderCapabilities = { supportsCommandOutputStreaming: false, supportsCommandOutputLookup: false, supportsSnapshots: false, - supportsStandbyResume: false, - supportsResume: false, + supportsStandbyResume: true, + supportsResume: true, supportsFileWrite: false, }; diff --git a/packages/types/src/compute-providers/compute-provider.ts b/packages/types/src/compute-providers/compute-provider.ts index 3503829a0..1f16cdf90 100644 --- a/packages/types/src/compute-providers/compute-provider.ts +++ b/packages/types/src/compute-providers/compute-provider.ts @@ -32,6 +32,7 @@ export const snapshotCapableComputeProviders = [ * use as reusable environment templates. */ export const standbyResumeCapableComputeProviders = [ + 'docker', 'blaxel', ] as const satisfies readonly ComputeProvider[]; @@ -42,6 +43,7 @@ export const standbyResumeCapableComputeProviders = [ */ export const sleepCheckManagedComputeProviders = [ ...snapshotCapableComputeProviders, + 'docker', 'blaxel', ] as const satisfies readonly ComputeProvider[]; diff --git a/packages/types/src/setup-compute-config.test.ts b/packages/types/src/setup-compute-config.test.ts index 3ce9399f4..830758d60 100644 --- a/packages/types/src/setup-compute-config.test.ts +++ b/packages/types/src/setup-compute-config.test.ts @@ -6,6 +6,7 @@ import { deriveModalBaseImageRefDefault, deriveWorkerImageFromReleaseVersion, getDefaultAvailableComputeProvider, + getComputeFieldValidationError, isAutoProvisionedComputeArtifactField, isComputeCredentialField, isComputeInfrastructureField, @@ -127,6 +128,16 @@ describe('buildSetupComputeStatus', () => { 'E2B_TEMPLATE_ID', 'E2B_DOMAIN', ]); + expect(infrastructureByProvider.blaxel).toEqual([ + 'BLAXEL_IMAGE', + 'BLAXEL_REGION', + 'BLAXEL_STANDBY_MAX_COUNT', + 'BLAXEL_STANDBY_MAX_AGE_HOURS', + ]); + expect(infrastructureByProvider.docker).toEqual([ + 'DOCKER_STANDBY_MAX_COUNT', + 'DOCKER_STANDBY_MAX_AGE_HOURS', + ]); // Advanced infrastructure fields are surfaced behind an advanced area. const modalBaseImage = status.providers @@ -206,6 +217,9 @@ describe('buildSetupComputeStatus', () => { 'blaxel', 'docker', ]); + expect( + status.providers.some((provider) => provider.comment === 'Recommended'), + ).toBe(false); // The shared worker image is not hosted-ready with no configuration. expect(status.workerImage.hostedReady).toBe(false); }); @@ -602,6 +616,33 @@ describe('buildSetupComputeStatus', () => { }); }); +describe('getComputeFieldValidationError', () => { + const field = { + envVarName: 'BLAXEL_STANDBY_MAX_AGE_HOURS', + label: 'Retention period (hours)', + required: false, + category: 'infrastructure' as const, + input: { type: 'number' as const, min: 1, max: 168, step: 1 }, + }; + + it('accepts blank and in-range whole-number values', () => { + expect(getComputeFieldValidationError(field, '')).toBeNull(); + expect(getComputeFieldValidationError(field, '72')).toBeNull(); + }); + + it('rejects values outside the configured constraints', () => { + expect(getComputeFieldValidationError(field, '0')).toBe( + 'Retention period (hours) must be at least 1.', + ); + expect(getComputeFieldValidationError(field, '169')).toBe( + 'Retention period (hours) must be at most 168.', + ); + expect(getComputeFieldValidationError(field, '1.5')).toBe( + 'Retention period (hours) must be a whole number.', + ); + }); +}); + describe('deriveWorkerImageFromReleaseVersion', () => { it('derives the published worker image from the baked release version', () => { expect( diff --git a/packages/types/src/setup-compute-config.ts b/packages/types/src/setup-compute-config.ts index d8d8d24d4..5b4e7b45f 100644 --- a/packages/types/src/setup-compute-config.ts +++ b/packages/types/src/setup-compute-config.ts @@ -34,8 +34,43 @@ export type SetupComputeFieldDescriptor = { * credentials. Managed worker artifacts are never form inputs. */ advanced?: boolean; + /** Optional presentation and validation metadata for operator inputs. */ + input?: { + type: 'number'; + min?: number; + max?: number; + step?: number; + placeholder?: string; + }; + /** Short guidance displayed with advanced provider settings. */ + helpText?: string; }; +export function getComputeFieldValidationError( + field: SetupComputeFieldDescriptor, + value: string, +): string | null { + if (!field.input || value.length === 0) { + return null; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return `${field.label} must be a number.`; + } + if (field.input.step === 1 && !Number.isInteger(parsed)) { + return `${field.label} must be a whole number.`; + } + if (field.input.min !== undefined && parsed < field.input.min) { + return `${field.label} must be at least ${field.input.min}.`; + } + if (field.input.max !== undefined && parsed > field.input.max) { + return `${field.label} must be at most ${field.input.max}.`; + } + + return null; +} + /** True for deployment-infrastructure fields (base images, template ids, snapshot names). */ export function isComputeInfrastructureField( field: Pick, @@ -199,7 +234,6 @@ export const SETUP_COMPUTE_PROVIDER_CATALOG = [ description: 'Hosted Modal sandboxes with snapshot support. Requires a Modal token pair.', supportsSnapshots: true, - comment: 'Recommended', fields: [ { envVarName: 'MODAL_TOKEN_ID', @@ -235,7 +269,6 @@ export const SETUP_COMPUTE_PROVIDER_CATALOG = [ description: 'Hosted E2B sandboxes with snapshot support and API-key-only onboarding.', supportsSnapshots: true, - comment: 'Recommended', fields: [ { envVarName: 'E2B_API_KEY', @@ -264,7 +297,6 @@ export const SETUP_COMPUTE_PROVIDER_CATALOG = [ description: 'Hosted Daytona sandboxes with snapshot support and API-key-only onboarding.', supportsSnapshots: true, - comment: 'Recommended', fields: [ { envVarName: 'DAYTONA_API_KEY', @@ -298,9 +330,8 @@ export const SETUP_COMPUTE_PROVIDER_CATALOG = [ provider: 'blaxel', label: 'Blaxel', description: - 'Hosted Blaxel perpetual sandboxes with automatic standby and fast resume.', + 'Hosted Blaxel sandboxes with bounded standby retention and fast resume.', supportsSnapshots: false, - comment: 'Recommended', fields: [ { envVarName: 'BL_API_KEY', @@ -325,6 +356,31 @@ export const SETUP_COMPUTE_PROVIDER_CATALOG = [ category: 'infrastructure', advanced: true, }, + { + envVarName: 'BLAXEL_STANDBY_MAX_COUNT', + label: 'Maximum retained tasks', + required: false, + category: 'infrastructure', + advanced: true, + input: { type: 'number', min: 0, step: 1, placeholder: '25' }, + helpText: 'Defaults to 25. Set to 0 to disable standby retention.', + }, + { + envVarName: 'BLAXEL_STANDBY_MAX_AGE_HOURS', + label: 'Retention period (hours)', + required: false, + category: 'infrastructure', + advanced: true, + input: { + type: 'number', + min: 1, + max: 168, + step: 1, + placeholder: '168', + }, + helpText: + 'Defaults to 168 hours (7 days), Blaxel’s maximum standby TTL.', + }, ], }, { @@ -332,9 +388,34 @@ export const SETUP_COMPUTE_PROVIDER_CATALOG = [ label: 'Local Docker', comment: 'Run on this host', description: - 'Runs each task in a Docker container on the host. No credentials needed, but the controller must have access to the Docker socket and tasks share the host with Roomote itself. No snapshot support.', + 'Runs each task in a Docker container on the host with bounded stopped-container resume. No credentials needed, but the controller must have access to the Docker socket and tasks share the host with Roomote itself.', supportsSnapshots: false, - fields: [], + 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' }, + helpText: 'Defaults to 10. Set to 0 to disable standby retention.', + }, + { + envVarName: 'DOCKER_STANDBY_MAX_AGE_HOURS', + label: 'Retention period (hours)', + required: false, + category: 'infrastructure', + advanced: true, + input: { + type: 'number', + min: 1, + max: 168, + step: 1, + placeholder: '24', + }, + helpText: 'Defaults to 24 hours.', + }, + ], }, ] as const satisfies readonly SetupComputeProviderDescriptor[];