diff --git a/.changeset/aca-sandboxes-compute-provider.md b/.changeset/aca-sandboxes-compute-provider.md new file mode 100644 index 000000000..bd84bd3b6 --- /dev/null +++ b/.changeset/aca-sandboxes-compute-provider.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': minor +--- + +Add Azure Container Apps Sandboxes (preview) as a compute provider: sandboxes run as hardware-isolated microVMs with memory+disk snapshots, sub-second suspend/resume standby, deterministic preview URLs with on-demand wake, and configurable sandbox size (XS–XL) and egress TLS inspection. The worker disk image can be provisioned automatically during setup, including from private registries. Auth via service principal, managed identity, or ambient az login. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 1d91d33ef..48d5b16d5 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -737,9 +737,47 @@ BL_API_KEY=... BL_WORKSPACE=... # Optional prebuilt Blaxel image override BLAXEL_IMAGE=sandbox/roomote-worker: -``` -`E2B_TEMPLATE_ID`, `DAYTONA_SNAPSHOT_NAME`, and `BLAXEL_IMAGE` can also be provisioned +# Azure Container Apps (preview) +DEFAULT_COMPUTE_PROVIDER=azure +AZURE_SUBSCRIPTION_ID=... +AZURE_RESOURCE_GROUP=... +AZURE_SANDBOX_GROUP=... +AZURE_SANDBOX_REGION=... +AZURE_SANDBOX_DISK_IMAGE=... +# Optional: client ID for a user-assigned managed identity (Azure-hosted +# controllers) OR the service principal app/client ID when paired with +# AZURE_TENANT_ID + AZURE_CLIENT_SECRET. Omit to use the ambient chain +# (az login locally, system-assigned identity deployed). +AZURE_CLIENT_ID=... +# Optional (recommended for Docker installs): service principal auth — +# all three together. az login inside containers is impractical, so +# Docker/compose deployments should use this or a managed identity. +AZURE_TENANT_ID=... +AZURE_CLIENT_SECRET=... +# Optional: pull credentials for baking the worker disk image from a private +# registry (GHCR: token owner's GitHub username + PAT with read:packages) +AZURE_SANDBOX_REGISTRY_USERNAME=... +AZURE_SANDBOX_REGISTRY_TOKEN=... +``` + +Azure auth uses the ambient Azure credential chain instead of an API key: +`az login` for local runs, or a managed identity (optionally user-assigned via +`AZURE_CLIENT_ID`) when the controller itself runs in Azure. Containerized +installs (docker compose, including `deploy/install.sh`) cannot use `az +login` inside the container — configure the service principal triple +(`AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET`), created with +`az ad sp create-for-rbac --name --skip-assignment` plus a +`Container Apps SandboxGroup Data Owner` role assignment on the sandbox +group, or run the controller on an Azure VM with a managed identity. One-time sandbox +group bootstrap: `aca sandboxgroup create --name --location +--set-config` — the calling principal is granted the Container Apps +SandboxGroup Data Owner role automatically; grant it explicitly to any +additional principal (for example a deployed controller's managed identity). +Azure Container Apps sandboxes are in public preview, so expect API drift. + +`E2B_TEMPLATE_ID`, `DAYTONA_SNAPSHOT_NAME`, `BLAXEL_IMAGE`, and +`AZURE_SANDBOX_DISK_IMAGE` can also be provisioned automatically during setup when a registry-qualified `DOCKER_WORKER_IMAGE` is configured — the setup wizard and the Settings → Sandboxes page build the worker base artifact in your provider account after credentials are saved. diff --git a/apps/bullmq/src/scheduled-jobs/sleep-check.ts b/apps/bullmq/src/scheduled-jobs/sleep-check.ts index b2486fa63..0cec98f68 100644 --- a/apps/bullmq/src/scheduled-jobs/sleep-check.ts +++ b/apps/bullmq/src/scheduled-jobs/sleep-check.ts @@ -167,6 +167,11 @@ async function createSleepCheckClient(provider: ComputeProvider) { provider: 'blaxel', envFallback: await resolveComputeProviderEnvValues('blaxel'), }); + case 'azure': + return createComputeProviderClient({ + provider: 'azure', + envFallback: await resolveComputeProviderEnvValues('azure'), + }); case 'docker': return createComputeProviderClient({ provider: 'docker' }); default: diff --git a/apps/bullmq/src/scheduled-jobs/standby-retention.ts b/apps/bullmq/src/scheduled-jobs/standby-retention.ts index 6bf2834d2..5f28419b8 100644 --- a/apps/bullmq/src/scheduled-jobs/standby-retention.ts +++ b/apps/bullmq/src/scheduled-jobs/standby-retention.ts @@ -13,7 +13,7 @@ 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; +const STANDBY_PROVIDERS = ['docker', 'blaxel', 'azure'] as const; type StandbyProvider = (typeof STANDBY_PROVIDERS)[number]; @@ -42,6 +42,8 @@ export function selectStandbyEvictions( const DEFAULT_POLICY = { docker: { maxCount: 10, maxAgeHours: 24 }, blaxel: { maxCount: 25, maxAgeHours: 168 }, + // Suspended ACA sandboxes cost almost nothing, so azure retention is generous. + azure: { maxCount: 50, maxAgeHours: 720 }, } as const; function parsePolicyInteger( @@ -63,7 +65,12 @@ export function resolveStandbyRetentionPolicy( maxCount: number; maxAgeMs: number; } { - const prefix = provider === 'docker' ? 'DOCKER' : 'BLAXEL'; + const prefix = + provider === 'docker' + ? 'DOCKER' + : provider === 'azure' + ? 'AZURE' + : 'BLAXEL'; const defaults = DEFAULT_POLICY[provider]; const maxCount = parsePolicyInteger( env[`${prefix}_STANDBY_MAX_COUNT`], @@ -74,7 +81,9 @@ export function resolveStandbyRetentionPolicy( env[`${prefix}_STANDBY_MAX_AGE_HOURS`], defaults.maxAgeHours, 1, - 168, + // Providers with a higher default keep their ceiling (azure: 720h); + // others stay capped at 168h as before. + Math.max(168, defaults.maxAgeHours), ); return { maxCount, maxAgeMs: maxAgeHours * MS_PER_HOUR }; @@ -85,6 +94,13 @@ async function createClient(provider: StandbyProvider) { return createComputeProviderClient({ provider: 'docker' }); } + if (provider === 'azure') { + return createComputeProviderClient({ + provider: 'azure', + envFallback: await resolveComputeProviderEnvValues('azure'), + }); + } + return createComputeProviderClient({ provider: 'blaxel', envFallback: await resolveComputeProviderEnvValues('blaxel'), diff --git a/apps/controller/src/RoomoteController.ts b/apps/controller/src/RoomoteController.ts index 3d946a764..dddd76dd8 100644 --- a/apps/controller/src/RoomoteController.ts +++ b/apps/controller/src/RoomoteController.ts @@ -21,6 +21,7 @@ import { DOCKER_SPAWN_TIMEOUT_MS, spawnE2bWorker, spawnBlaxelWorker, + spawnAzureWorker, spawnModalWorker, } from './compute-providers'; @@ -306,6 +307,60 @@ export class RoomoteController extends BaseController { }); return; } + case 'azure': { + const azureSubscriptionId = resolvedEnv.AZURE_SUBSCRIPTION_ID; + const azureResourceGroup = resolvedEnv.AZURE_RESOURCE_GROUP; + const azureSandboxGroup = resolvedEnv.AZURE_SANDBOX_GROUP; + const azureRegion = resolvedEnv.AZURE_SANDBOX_REGION; + const azureDiskImage = resolvedEnv.AZURE_SANDBOX_DISK_IMAGE; + + if (!azureSubscriptionId) { + throw new Error( + 'AZURE_SUBSCRIPTION_ID is required to spawn Azure workers', + ); + } + + if (!azureResourceGroup) { + throw new Error( + 'AZURE_RESOURCE_GROUP is required to spawn Azure workers', + ); + } + + if (!azureSandboxGroup) { + throw new Error( + 'AZURE_SANDBOX_GROUP is required to spawn Azure workers', + ); + } + + if (!azureRegion) { + throw new Error( + 'AZURE_SANDBOX_REGION is required to spawn Azure workers', + ); + } + + if (!azureDiskImage) { + throw new Error( + 'AZURE_SANDBOX_DISK_IMAGE is required to spawn Azure workers', + ); + } + + await spawnAzureWorker(taskRun, authToken, { + deploymentSlug, + azureTags: this.buildSandboxTags(), + azureSubscriptionId, + azureResourceGroup, + azureSandboxGroup, + azureRegion, + azureDiskImage, + azureClientId: resolvedEnv.AZURE_CLIENT_ID, + azureTenantId: resolvedEnv.AZURE_TENANT_ID, + azureClientSecret: resolvedEnv.AZURE_CLIENT_SECRET, + azureSize: resolvedEnv.AZURE_SANDBOX_SIZE, + azureTimeoutMs: timeoutMs, + localTarballPath: this.localWorkerReleasePath, + }); + return; + } default: { const _exhaustive: never = provider; throw new Error(`Unsupported compute provider: ${_exhaustive}`); diff --git a/apps/controller/src/compute-providers/index.ts b/apps/controller/src/compute-providers/index.ts index 8fc61618b..54a147137 100644 --- a/apps/controller/src/compute-providers/index.ts +++ b/apps/controller/src/compute-providers/index.ts @@ -7,3 +7,4 @@ export { cleanupStaleDockerSandboxes } from './docker-sandbox-security'; export { spawnDaytonaWorker } from './spawn-daytona-worker'; export { spawnE2bWorker } from './spawn-e2b-worker'; export { spawnBlaxelWorker } from './spawn-blaxel-worker'; +export { spawnAzureWorker } from './spawn-azure-worker'; diff --git a/apps/controller/src/compute-providers/spawn-azure-worker.ts b/apps/controller/src/compute-providers/spawn-azure-worker.ts new file mode 100644 index 000000000..d95aae643 --- /dev/null +++ b/apps/controller/src/compute-providers/spawn-azure-worker.ts @@ -0,0 +1,493 @@ +import { + TaskPayloadKind, + NonRetryableSpawnError, + getPrimaryPortFromConfig, +} from '@roomote/types'; +import { + type TaskRun, + createComputeProviderMutationEventRecorder, + db, + taskRuns, + eq, +} from '@roomote/db/server'; +import { stampTaskRunMilestone } from '@roomote/sdk/server'; +import { + buildComputeProviderMutationDetails, + buildAzureWorkerEnv, + cleanupAzureInstance, + createComputeProviderClient, + createAzureMachine, + resolveAuthBypassHeaderName, + resolveAuthBypassValue, + parseAzureSizePreset, + AZURE_SIZE_PRESETS, + type ComputeProviderClient, +} from '@roomote/compute-providers'; + +import { primeEnvironmentOidcForMachine } from '../sandbox-oidc'; +import { + getNamedPortsForTaskRun, + shouldEnableAuthBypassForTaskRun, + updateTaskRunMachine, +} from '../utils'; +import { resolveTaskSandboxMemoryMiB } from './task-sandbox-resources'; + +const AZURE_LAUNCH_OUTPUT_TEXT_LIMIT = 500; + +class DetachedWorkerLaunchError extends Error { + public readonly details: Record; + + public constructor(message: string, details: Record) { + super(message); + this.name = 'DetachedWorkerLaunchError'; + this.details = details; + } +} + +function truncateLaunchOutput(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + return trimmed.length > AZURE_LAUNCH_OUTPUT_TEXT_LIMIT + ? `${trimmed.slice(0, AZURE_LAUNCH_OUTPUT_TEXT_LIMIT)}...` + : trimmed; +} + +function buildDetachedWorkerExitError( + command: string, + result: { + exitCode: number | null; + commandId?: string; + stdout?: string; + stderr?: string; + }, +): DetachedWorkerLaunchError { + const stdout = truncateLaunchOutput(result.stdout); + const stderr = truncateLaunchOutput(result.stderr); + const message = `Detached "worker ${command}" exited immediately with code ${result.exitCode}`; + + return new DetachedWorkerLaunchError(message, { + commandId: result.commandId ?? null, + exitCode: result.exitCode, + ...(stdout ? { stdout } : {}), + ...(stderr ? { stderr } : {}), + }); +} + +async function resolveAzureResumeLaunchOptions( + snapshotId: string, + computeClient: Pick, +): Promise< + | { launchMode: 'task_standby'; resumeHandle: string } + | { launchMode: 'task_snapshot'; sourceSnapshotId: string } +> { + try { + const status = await computeClient.getInstanceStatus({ + instanceId: snapshotId, + }); + if (status.status === 'stopped' || status.status === 'running') { + return { launchMode: 'task_standby', resumeHandle: snapshotId }; + } + } catch { + // Not a live sandbox — treat as a genuine snapshot id. + } + + return { launchMode: 'task_snapshot', sourceSnapshotId: snapshotId }; +} + +function getWorkerLaunchCommand( + taskRun: TaskRun, +): 'snapshot' | 'resume' | 'run' { + return taskRun.payloadKind === TaskPayloadKind.SnapshotEnvironment + ? 'snapshot' + : taskRun.payloadKind === TaskPayloadKind.SnapshotResume + ? 'resume' + : 'run'; +} + +function getWorkerLaunchArgs(taskRun: TaskRun, machineId: string): string[] { + const command = getWorkerLaunchCommand(taskRun); + + return taskRun.payloadKind === TaskPayloadKind.SnapshotEnvironment + ? [ + 'snapshot', + '--task-run-id', + taskRun.id.toString(), + '--environment-id', + taskRun.payload.environmentId ?? '', + '--sandbox-id', + machineId, + ] + : [command, taskRun.id.toString()]; +} + +export async function spawnAzureWorker( + taskRun: TaskRun, + authToken: string, + config: { + azureSubscriptionId: string; + azureResourceGroup: string; + azureSandboxGroup: string; + azureRegion: string; + azureDiskImage: string; + azureClientId?: string; + azureTenantId?: string; + azureClientSecret?: string; + /** ACA size tier (S/M/L/XL...); sets the default sandbox size. */ + azureSize?: string; + azureTimeoutMs: number; + localTarballPath?: string; + deploymentSlug?: string; + azureTags?: Record; + }, +): Promise<{ + machineId: string; + sandboxCmdId?: string; +}> { + const { + azureSubscriptionId, + azureResourceGroup, + azureSandboxGroup, + azureRegion, + azureDiskImage, + azureClientId, + azureTenantId, + azureClientSecret, + azureSize, + azureTimeoutMs, + localTarballPath, + deploymentSlug, + azureTags, + } = config; + + const environmentId = taskRun.payload.environmentId; + + const { namedPorts, environmentSnapshotId, environmentConfig } = + await getNamedPortsForTaskRun(taskRun); + const sandboxResources = await resolveTaskSandboxMemoryMiB( + taskRun, + environmentConfig, + ); + + const shouldEnableAuthBypass = shouldEnableAuthBypassForTaskRun({ + environmentConfig, + namedPorts, + }); + + const authBypassValue = shouldEnableAuthBypass + ? resolveAuthBypassValue(environmentConfig) + : undefined; + + const authBypassHeaderName = shouldEnableAuthBypass + ? resolveAuthBypassHeaderName(environmentConfig) + : undefined; + + // Service principal auth only kicks in with the full triple; a lone + // AZURE_CLIENT_ID means user-assigned managed identity. + const azureServicePrincipal = + azureTenantId && azureClientId && azureClientSecret + ? { + tenantId: azureTenantId, + clientId: azureClientId, + clientSecret: azureClientSecret, + } + : undefined; + + // Size handling: the operator's AZURE_SANDBOX_SIZE picks the default + // tier (cpu/memory/disk). The task-side memory constant is only a + // platform default — not a user preference — so the provider size wins. + // Nested-Docker tasks bypass the preset entirely: their 8 GiB memory is + // hard, and a small preset's 20 GiB disk would otherwise cap image builds. + const azureSizePreset = parseAzureSizePreset(azureSize); + const effectiveSizePreset = sandboxResources.needsNestedDocker + ? undefined + : azureSizePreset; + const memoryMiB = + sandboxResources.needsNestedDocker || !effectiveSizePreset + ? sandboxResources.memoryMiB + : undefined; + // Record the size the provider will actually provision (preset when the + // override is omitted), not the task-side platform default. + const effectiveMemoryMiB = + memoryMiB ?? + (effectiveSizePreset + ? AZURE_SIZE_PRESETS[effectiveSizePreset].memoryMiB + : sandboxResources.memoryMiB); + + const computeClient = createComputeProviderClient({ + provider: 'azure', + config: { + subscriptionId: azureSubscriptionId, + resourceGroup: azureResourceGroup, + sandboxGroup: azureSandboxGroup, + region: azureRegion, + diskImage: azureDiskImage, + ...(azureServicePrincipal + ? { servicePrincipal: azureServicePrincipal } + : azureClientId + ? { managedIdentityClientId: azureClientId } + : {}), + ...(effectiveSizePreset ? { size: effectiveSizePreset } : {}), + ...(memoryMiB !== undefined ? { memoryMiB } : {}), + // Nested-Docker workloads get at least the L-tier disk for image + // builds; preset disks apply otherwise. + ...(sandboxResources.needsNestedDocker ? { diskSize: '40Gi' } : {}), + timeoutMs: azureTimeoutMs, + }, + }); + + let launchOptions: + | { launchMode: 'fresh' } + | { launchMode: 'environment_snapshot'; sourceSnapshotId: string } + | { launchMode: 'task_snapshot'; sourceSnapshotId: string } + | { launchMode: 'task_standby'; resumeHandle: string }; + + if (taskRun.payloadKind === TaskPayloadKind.SnapshotResume) { + const snapshotId = taskRun.sourceSnapshotId; + + if (!snapshotId) { + throw new NonRetryableSpawnError( + `SnapshotResume task run #${taskRun.id} missing sourceSnapshotId`, + ); + } + + // Azure is dual-capable: task sleeps always use standby (the sleep-check + // pipeline prefers standby for standby-capable vendors), so a + // SnapshotResume's id is usually a suspended-sandbox handle, not a + // snapshot. Discriminate by probing whether the id is a live sandbox; + // manual task snapshots land in the snapshot branch instead. + launchOptions = await resolveAzureResumeLaunchOptions( + snapshotId, + computeClient, + ); + } else if (taskRun.payloadKind === TaskPayloadKind.SnapshotEnvironment) { + // Environment snapshot refreshes must rebuild from the configured base + // worker disk image instead of inheriting the previous environment snapshot. + launchOptions = { launchMode: 'fresh' }; + } else { + const snapshotId = + taskRun.sourceSnapshotId ?? environmentSnapshotId ?? undefined; + + launchOptions = snapshotId + ? { launchMode: 'environment_snapshot', sourceSnapshotId: snapshotId } + : { launchMode: 'fresh' }; + } + + if ( + taskRun.payloadKind === TaskPayloadKind.SnapshotEnvironment && + !taskRun.payload.environmentId + ) { + throw new Error( + `SnapshotEnvironment task run #${taskRun.id} missing environmentId in payload`, + ); + } + + console.log( + `[spawnAzureWorker] Creating Azure instance for task run #${taskRun.id}... ${JSON.stringify( + { + ...launchOptions, + namedPorts: namedPorts.map((p) => p.name), + }, + )}`, + ); + + const createMachineStart = Date.now(); + + const mutationContext = { + launchMode: launchOptions.launchMode, + sourceSnapshotId: + 'resumeHandle' in launchOptions + ? launchOptions.resumeHandle + : 'sourceSnapshotId' in launchOptions + ? launchOptions.sourceSnapshotId + : null, + ports: namedPorts.map(({ port }) => port), + } as const; + + const recordMutation = createComputeProviderMutationEventRecorder( + db, + { + runId: taskRun.id, + taskId: taskRun.taskId, + }, + { logPrefix: 'spawnAzureWorker', logger: console }, + ); + + // Stamp provisionStartedAt + launchMode before the Azure API call. Only-if- + // null semantics preserve the earliest provision timestamp. + await stampTaskRunMilestone({ + runId: taskRun.id, + field: 'provisionStartedAt', + launchMode: launchOptions.launchMode, + }).catch((error) => { + console.warn( + `[spawnAzureWorker] Failed to stamp provisionStartedAt for task run #${taskRun.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + + const machine = await createAzureMachine({ + azureSubscriptionId, + azureResourceGroup, + azureSandboxGroup, + azureRegion, + azureDiskImage, + azureClientId, + azureTenantId, + azureClientSecret, + namedPorts, + tags: azureTags, + timeoutMs: azureTimeoutMs, + localTarballPath, + createInstanceTimeoutMs: 180_000, + bootstrapTimeoutMs: 120_000, + computeClient, + onMutation: recordMutation, + ...launchOptions, + }); + + const workerCommand = getWorkerLaunchCommand(taskRun); + const args = getWorkerLaunchArgs(taskRun, machine.machineId); + + try { + await updateTaskRunMachine({ + taskRun, + vendor: 'azure', + machineId: machine.machineId, + namedPorts, + domainFn: (port) => machine.domain(port), + proxyPorts: machine.proxyPorts ?? {}, + explicitPrimaryPortName: getPrimaryPortFromConfig( + environmentConfig?.ports, + )?.name, + sourceSnapshotId: mutationContext.sourceSnapshotId, + authBypassValue, + authBypassHeaderName, + configuredMemoryMiB: effectiveMemoryMiB, + }); + + // Infrastructure is usable; worker.js hand-off follows. + await stampTaskRunMilestone({ + runId: taskRun.id, + field: 'provisionReadyAt', + }).catch((error) => { + console.warn( + `[spawnAzureWorker] Failed to stamp provisionReadyAt for task run #${taskRun.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + + if (environmentId && environmentConfig) { + await primeEnvironmentOidcForMachine({ + taskId: taskRun.taskId, + environmentId, + environmentConfig, + computeProvider: 'azure', + computeProviderId: machine.machineId, + runId: taskRun.id, + context: 'Azure launch', + }); + } + + console.log( + `[spawnAzureWorker] Azure instance created for task run #${taskRun.id} in ${Date.now() - createMachineStart}ms ${JSON.stringify( + { machineId: machine.machineId, launchMode: launchOptions.launchMode }, + )}`, + ); + + await recordMutation({ + provider: 'azure', + operation: 'run_command', + eventType: 'started', + instanceId: machine.machineId, + message: `Calling runCommand to launch detached worker ${workerCommand} for Azure instance ${machine.machineId}.`, + details: buildComputeProviderMutationDetails(mutationContext, { + command: 'worker', + args, + detached: true, + phase: 'launch_worker', + }), + }); + + const result = await computeClient.runCommand({ + instanceId: machine.machineId, + cmd: 'worker', + args, + env: buildAzureWorkerEnv({ + authToken, + sandboxExpiresAtMs: Date.now() + azureTimeoutMs, + deploymentSlug, + environmentId, + diskImage: azureDiskImage, + extraEnv: { + SANDBOX_TIMEOUT_MS: String(azureTimeoutMs), + }, + }), + detached: true, + signal: AbortSignal.timeout(60_000), + }); + + if (result.exitCode !== null && result.exitCode !== 0) { + throw buildDetachedWorkerExitError(workerCommand, result); + } + + await recordMutation({ + provider: 'azure', + operation: 'run_command', + eventType: 'completed', + instanceId: machine.machineId, + message: `runCommand launched detached worker ${workerCommand} for Azure instance ${machine.machineId}.`, + details: buildComputeProviderMutationDetails(mutationContext, { + command: 'worker', + args, + detached: true, + phase: 'launch_worker', + commandId: result.commandId ?? null, + exitCode: result.exitCode, + }), + }); + + if (result.commandId) { + await db + .update(taskRuns) + .set({ sandboxCmdId: result.commandId }) + .where(eq(taskRuns.id, taskRun.id)); + } + + return { + machineId: machine.machineId, + ...(result.commandId ? { sandboxCmdId: result.commandId } : {}), + }; + } catch (error) { + await recordMutation({ + provider: 'azure', + operation: 'run_command', + eventType: 'failed', + instanceId: machine.machineId, + message: `runCommand failed while launching detached worker for Azure instance ${machine.machineId}.`, + details: buildComputeProviderMutationDetails(mutationContext, { + command: 'worker', + args, + detached: true, + phase: 'launch_worker', + ...(error instanceof DetachedWorkerLaunchError ? error.details : {}), + error: error instanceof Error ? error.message : String(error), + }), + }); + + await cleanupAzureInstance({ + computeClient, + instanceId: machine.machineId, + phase: 'spawn_worker', + error, + logPrefix: 'spawnAzureWorker', + onMutation: recordMutation, + ...mutationContext, + }); + throw error; + } +} diff --git a/apps/docs/compute.mdx b/apps/docs/compute.mdx index e1586fcca..b7520a1a3 100644 --- a/apps/docs/compute.mdx +++ b/apps/docs/compute.mdx @@ -44,6 +44,7 @@ Roomote supports local and hosted sandbox backends: | | Hosted task sandboxes with snapshot support | Runs task sandboxes on E2B-managed infrastructure. | | | Hosted task sandboxes with snapshot support | Supports environment and task-level snapshot flows. | | | Hosted perpetual task sandboxes | Uses automatic standby for resumable tasks. | +| | Hosted task sandboxes on Azure Container Apps (preview) | Supports memory+disk snapshots and sub-second standby resume. | Docker is the default because it works well for local development and simple self-hosted deployments. Hosted providers are useful when you want task work to diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 502470ce5..06772f3c2 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -101,6 +101,7 @@ "expanded": false, "pages": [ "compute", + "providers/compute/azure", "providers/compute/blaxel", "providers/compute/daytona", "providers/compute/docker", diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 5e6212e93..059453364 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -207,7 +207,7 @@ as per-task auth tokens or workspace paths. | 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`. | +| `DEFAULT_COMPUTE_PROVIDER` | Optional | Runtime default sandbox provider when no admin default is saved. Supported values are `docker`, `modal`, `e2b`, `daytona`, `blaxel`, and `azure`. | | `EXCLUDED_COMPUTE_PROVIDERS` | Optional | Comma-separated provider IDs to hide or exclude from default selection. | | `DOCKER_WORKER_IMAGE` | Optional | Worker image used by Docker. 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. | @@ -243,6 +243,18 @@ as per-task auth tokens or workspace paths. | `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`. | +| `AZURE_SUBSCRIPTION_ID` | Azure | Azure subscription ID hosting the sandbox group. Can also be saved from **Settings > Sandboxes**. | +| `AZURE_RESOURCE_GROUP` | Azure | Resource group containing the Azure sandbox group. | +| `AZURE_SANDBOX_GROUP` | Azure | Azure Container Apps sandbox group name. | +| `AZURE_SANDBOX_REGION` | Azure | Data-plane region for the sandbox group, for example `canadacentral`. | +| `AZURE_SANDBOX_DISK_IMAGE` | Azure | Worker disk image ID. Auto-provisioned from the worker OCI image during setup when unset. | +| `AZURE_CLIENT_ID` | Optional | User-assigned managed identity client ID — or the service principal's app ID when paired with `AZURE_TENANT_ID` + `AZURE_CLIENT_SECRET`. Unset uses `az login` locally or the system-assigned identity when deployed. | +| `AZURE_TENANT_ID` | Optional | Service principal tenant ID (with `AZURE_CLIENT_ID` + `AZURE_CLIENT_SECRET`). Recommended for containerized installs. | +| `AZURE_CLIENT_SECRET` | Optional | Service principal client secret (with `AZURE_TENANT_ID` + `AZURE_CLIENT_ID`). | +| `AZURE_SANDBOX_REGISTRY_USERNAME` | Optional | Username for pulling a private worker image during disk-image provisioning (GHCR: token owner's GitHub username). | +| `AZURE_SANDBOX_REGISTRY_TOKEN` | Optional | Token for pulling a private worker image during disk-image provisioning (GHCR: PAT with `read:packages`). | +| `AZURE_SANDBOX_SIZE` | Optional | Default sandbox size: `XS` (0.25/0.5 GiB/5 GiB disk), `S` (0.5/1 GiB/10 GiB disk), `M` (1/2 GiB/20 GiB, default), `L` (2/4/40 GiB), `XL` (4/8/80 GiB). ACA caps memory at cores×2Gi (CPU scales up to fit) and disk at cores×20Gi (clamped). | +| `AZURE_SANDBOX_EGRESS_INSPECTION` | Optional | Egress proxy TLS inspection: `Partial` (default — only rule-matched traffic inspected), `Full`, `Legacy`, `None`. Use `Full` only with egress rules/transforms; it TLS-resigns all traffic and blocks non-HTTP. | | `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. | diff --git a/apps/docs/logo/integrations/azure.svg b/apps/docs/logo/integrations/azure.svg new file mode 100644 index 000000000..362055115 --- /dev/null +++ b/apps/docs/logo/integrations/azure.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/providers/compute/azure.mdx b/apps/docs/providers/compute/azure.mdx new file mode 100644 index 000000000..1b41739a5 --- /dev/null +++ b/apps/docs/providers/compute/azure.mdx @@ -0,0 +1,114 @@ +--- +title: Azure Container Apps +icon: '/logo/integrations/azure.svg' +description: Run Roomote tasks on Azure Container Apps sandboxes. +--- + +Azure Container Apps sandboxes are hardware-isolated microVMs that run Roomote +tasks on your own Azure subscription. The provider supports memory+disk +snapshots and sub-second suspend/resume standby, so both environment setup +caching and task sleep/resume flows keep the full workspace state. + +Azure Container Apps sandboxes are in public preview; expect API drift. + +## When to use Azure Container Apps + +Use Azure Container Apps when: + +- task work should run inside your own Azure subscription +- you want hosted sandboxes without managing an API key (auth uses the + ambient Azure login or a managed identity) +- memory+disk snapshots or standby with sub-second resume is useful + +## Prerequisites + +1. An Azure subscription and resource group. +2. A sandbox group (one-time bootstrap): + + ```sh + aca sandboxgroup create --name --location --set-config + ``` + + The calling principal is granted the Container Apps SandboxGroup Data + Owner role automatically; grant it explicitly to any additional principal, + such as a deployed controller's managed identity. +3. Authentication: `az login` for local runs; a managed identity when the + Roomote controller itself runs in Azure (set `AZURE_CLIENT_ID` for a + user-assigned identity); or a service principal (recommended for + containerized installs, where `az login` is impractical) — + `az ad sp create-for-rbac --name --skip-assignment`, then grant the + app the Data Owner role from step 2 and set all three of + `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET`. + +## Configuration + +Add Azure Container Apps from **Settings > Sandboxes**, or provide the values +as deployment env vars: + +```sh +DEFAULT_COMPUTE_PROVIDER=azure +AZURE_SUBSCRIPTION_ID=... +AZURE_RESOURCE_GROUP=... +AZURE_SANDBOX_GROUP=... +AZURE_SANDBOX_REGION=... +``` + +Optional values: + +```sh +AZURE_CLIENT_ID=... +AZURE_SANDBOX_DISK_IMAGE=... +# Service principal auth (all three together; recommended for Docker installs) +AZURE_TENANT_ID=... +AZURE_CLIENT_SECRET=... +# Worker image registry pull credentials (only for private images) +AZURE_SANDBOX_REGISTRY_USERNAME=... +AZURE_SANDBOX_REGISTRY_TOKEN=... +# Sandbox size (XS/S/M/L/XL, default M) and egress TLS inspection (default Partial) +AZURE_SANDBOX_SIZE=... +AZURE_SANDBOX_EGRESS_INSPECTION=... +``` + +`AZURE_SANDBOX_DISK_IMAGE` selects the worker disk image. Leave it unset to +let Roomote provision it automatically: the setup wizard and the Settings > +Sandboxes page bake a disk image from the published worker OCI image after +the Azure settings are saved. Private registries (e.g. a private GHCR +worker image) need `AZURE_SANDBOX_REGISTRY_USERNAME` + +`AZURE_SANDBOX_REGISTRY_TOKEN`. + +## Snapshots and standby + +Azure snapshots capture memory and disk, so a restored sandbox resumes in +sub-second time with processes still alive. Task standby uses the same +memory-preserving suspend/resume, and sandboxes bill storage-only while +suspended. Preview port URLs do not survive a snapshot restore; Roomote +re-adds ports after resume. + +## Egress TLS inspection + +The ACA egress proxy can TLS-inspect (MITM) outbound traffic. Roomote creates +sandboxes with `trafficInspection: Partial`, so with no egress rules +configured nothing is inspected: package managers (npm, Maven/Gradle, pip) +see normal public certificate chains, and non-HTTP traffic such as SSH git +works. The service default (`Full`) resigns all TLS traffic with the proxy CA +(`/etc/ssl/certs/adc-egress-proxy-ca.crt`, preinstalled in the system store +and referenced by `NODE_EXTRA_CA_CERTS`/`SSL_CERT_FILE`) and blocks non-HTTP +traffic; choose `Full` only when wiring deny-default egress rules or header +transforms, and note Java's per-JDK cacerts still needs a manual import in +that mode. + +## Verify setup + +1. save the Azure subscription, resource group, sandbox group, and region +2. select Azure Container Apps as the default sandbox provider +3. start a small task from an environment +4. confirm the task starts, streams logs, and can run project commands +5. verify preview links if the task starts a web app + +## Common issues + +- **Authentication fails.** Run `az login` locally, or confirm the deployed + controller's managed identity holds the Container Apps SandboxGroup Data + Owner role on the sandbox group. +- **Sandbox creation is slow on first use.** Cold disk image pulls take + longer; the worker disk image bake is a one-time provisioning step. diff --git a/apps/docs/snippets/integration-name.jsx b/apps/docs/snippets/integration-name.jsx index 4b8b654af..8346ea341 100644 --- a/apps/docs/snippets/integration-name.jsx +++ b/apps/docs/snippets/integration-name.jsx @@ -3,6 +3,7 @@ export function IntegrationName({ href, icon, name }) { daytona: '/logo/integrations/daytona.svg', e2b: '/logo/integrations/e2b.svg', blaxel: '/logo/integrations/blaxel.svg', + azure: '/logo/integrations/azure.svg', monday: '/logo/integrations/monday.svg', }; const iconSrc = diff --git a/apps/web/src/app/(authenticated)/home/Home.tsx b/apps/web/src/app/(authenticated)/home/Home.tsx index e853fb513..d717dd5d8 100644 --- a/apps/web/src/app/(authenticated)/home/Home.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.tsx @@ -646,11 +646,7 @@ export function Home({ setSelectedComputeProvider(value as ComputeProvider) } > - + diff --git a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx index 3cd6491ea..e973958eb 100644 --- a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx @@ -21,6 +21,7 @@ const BRAND_ICON_BY_PROVIDER = { daytona: 'daytona', e2b: 'e2b', blaxel: 'blaxel', + azure: 'azure', roomote: 'roomote', } satisfies Record; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx index b2fbbb716..d4312e41c 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx @@ -72,6 +72,7 @@ const SANDBOX_PROVIDER_LABELS = { daytona: 'Daytona', e2b: 'E2B', blaxel: 'Blaxel', + azure: 'Azure', roomote: 'Roomote Cloud', } satisfies Record; @@ -81,6 +82,7 @@ const SANDBOX_PROVIDER_ICONS = { daytona: CloudIcon, e2b: CloudIcon, blaxel: CloudIcon, + azure: CloudIcon, roomote: CloudIcon, } satisfies Record; diff --git a/apps/web/src/components/settings/ComputeProviderSection.client.test.tsx b/apps/web/src/components/settings/ComputeProviderSection.client.test.tsx index 0427b6877..95c52d5a5 100644 --- a/apps/web/src/components/settings/ComputeProviderSection.client.test.tsx +++ b/apps/web/src/components/settings/ComputeProviderSection.client.test.tsx @@ -168,6 +168,65 @@ describe('ComputeProviderSection provisioning states', () => { }); describe('ComputeProviderSection advanced settings', () => { + const azureProvider: ComputeProviderStatus = { + provider: 'azure', + label: 'Azure Container Apps', + description: 'Azure sandboxes.', + supportsSnapshots: true, + fields: [ + { + envVarName: 'AZURE_SUBSCRIPTION_ID', + label: 'Azure Subscription ID', + category: 'credential', + runtimeSatisfied: false, + savedSatisfied: true, + savedValue: 'subscription-id', + defaultSatisfied: false, + setupProvisionable: false, + }, + { + envVarName: 'AZURE_CLIENT_ID', + label: 'Managed Identity / Service Principal Client ID', + required: false, + category: 'credential', + advanced: true, + helpText: + 'Set this for a user-assigned managed identity, or use it with the tenant ID and client secret for service principal authentication.', + runtimeSatisfied: false, + savedSatisfied: false, + defaultSatisfied: false, + setupProvisionable: false, + }, + { + envVarName: 'AZURE_TENANT_ID', + label: 'Service Principal Tenant ID', + required: false, + category: 'credential', + advanced: true, + runtimeSatisfied: false, + savedSatisfied: false, + defaultSatisfied: false, + setupProvisionable: false, + }, + { + envVarName: 'AZURE_CLIENT_SECRET', + label: 'Service Principal Client Secret', + required: false, + secret: true, + category: 'credential', + advanced: true, + runtimeSatisfied: false, + savedSatisfied: false, + defaultSatisfied: false, + setupProvisionable: false, + }, + ], + runtimeConfigSatisfied: false, + savedConfigSatisfied: true, + configSatisfied: true, + infrastructureSatisfied: true, + }; + const dockerProvider: ComputeProviderStatus = { provider: 'docker', label: 'Local Docker', @@ -193,6 +252,40 @@ describe('ComputeProviderSection advanced settings', () => { infrastructureSatisfied: true, }; + it('moves optional Azure authentication fields into advanced settings', () => { + renderSection(null, azureProvider); + + expect(screen.getByLabelText('Azure Subscription ID')).toBeInTheDocument(); + expect( + screen.queryByLabelText( + 'Managed Identity / Service Principal Client ID (optional)', + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('Service Principal Tenant ID (optional)'), + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('Service Principal Client Secret (optional)'), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Advanced settings' })); + + expect( + screen.getByLabelText( + 'Managed Identity / Service Principal Client ID (optional)', + ), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('Service Principal Tenant ID (optional)'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('Service Principal Client Secret (optional)'), + ).toBeInTheDocument(); + expect( + screen.getByText(/user-assigned managed identity/), + ).toBeInTheDocument(); + }); + it('keeps provider overrides collapsed until requested and saves edits', () => { const onSave = vi.fn(); render( diff --git a/apps/web/src/components/settings/ComputeProviderSection.tsx b/apps/web/src/components/settings/ComputeProviderSection.tsx index 8b197fcc9..c65c0168d 100644 --- a/apps/web/src/components/settings/ComputeProviderSection.tsx +++ b/apps/web/src/components/settings/ComputeProviderSection.tsx @@ -32,6 +32,11 @@ import { DialogTitle, EnvVarsInfoNote, Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, Spinner, Switch, Trash2, @@ -47,6 +52,7 @@ const BRAND_ICON_BY_PROVIDER: Record = { daytona: 'daytona', e2b: 'e2b', blaxel: 'blaxel', + azure: 'azure', roomote: 'roomote', }; @@ -120,15 +126,17 @@ export function ComputeProviderSection({ localDockerTogglePending = false, }: ComputeProviderSectionProps) { const isLocalDocker = provider.provider === 'docker'; - const inputFields = provider.fields.filter(isComputeCredentialField); - // 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( + const credentialFields = provider.fields.filter(isComputeCredentialField); + const inputFields = credentialFields.filter((field) => !field.advanced); + // Optional authentication, routing, endpoint, resource, 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 advancedFields = provider.fields.filter( (field) => - isComputeInfrastructureField(field) && isComputeOperatorEditableField(field) && - field.advanced, + field.advanced && + (isComputeCredentialField(field) || isComputeInfrastructureField(field)), ); const missingDefaultBlockingInfraFields = provider.fields.filter( (field) => @@ -141,8 +149,8 @@ export function ComputeProviderSection({ ); const hasMissingDefaultBlockingInfra = missingDefaultBlockingInfraFields.length > 0; - const hasNoInputFields = inputFields.length === 0; - const hasConfiguredValues = inputFields.some( + const hasNoInputFields = credentialFields.length === 0; + const hasConfiguredValues = credentialFields.some( (field) => field.runtimeSatisfied || field.savedSatisfied, ); const hasSavedValues = provider.fields.some((field) => field.savedSatisfied); @@ -212,15 +220,17 @@ export function ComputeProviderSection({ // Credentials are already satisfied when a save can still start or retry // auto-provisioning without retyping values (existing installs that later // gain a registry-qualified worker image). - const credentialsSatisfiedForProvisioning = inputFields.every((field) => { - const nextValue = values[field.envVarName]?.trim() ?? ''; - return ( - field.required === false || - field.runtimeSatisfied || - field.savedSatisfied || - nextValue.length > 0 - ); - }); + const credentialsSatisfiedForProvisioning = credentialFields.every( + (field) => { + const nextValue = values[field.envVarName]?.trim() ?? ''; + return ( + field.required === false || + field.runtimeSatisfied || + field.savedSatisfied || + nextValue.length > 0 + ); + }, + ); // A failed run is retried by saving again — even with no new values, as // long as the required credentials are already satisfied. const canRetryProvisioning = @@ -234,7 +244,7 @@ export function ComputeProviderSection({ credentialsSatisfiedForProvisioning && !provisioningRunning; - const hasPendingValueChanges = [...inputFields, ...advancedInfraFields].some( + const hasPendingValueChanges = [...inputFields, ...advancedFields].some( (field) => { if (field.runtimeSatisfied) { return false; @@ -253,7 +263,7 @@ export function ComputeProviderSection({ }, ); - const hasMissingRequiredValue = inputFields.some((field) => { + const hasMissingRequiredValue = credentialFields.some((field) => { const nextValue = values[field.envVarName]?.trim() ?? ''; return ( field.required !== false && @@ -270,10 +280,10 @@ export function ComputeProviderSection({ const hasEditableFields = inputFields.some((field) => !field.runtimeSatisfied) || - advancedInfraFields.some((field) => !field.runtimeSatisfied); + advancedFields.some((field) => !field.runtimeSatisfied); const runtimeConfigured = - inputFields.length > 0 && - inputFields.every((field) => field.runtimeSatisfied); + credentialFields.length > 0 && + credentialFields.every((field) => field.runtimeSatisfied); const handleSave = () => { onSave(provider.provider, values); @@ -306,54 +316,96 @@ export function ComputeProviderSection({ {field.required === false ? ' (optional)' : ''}
- { - if (shouldShowSavedValueMask) { - setEditingSavedValues((current) => ({ + {field.input?.type === 'select' ? ( + + ) : ( + { - if (isSecretField && field.savedSatisfied && value.length === 0) { - setEditingSavedValues((current) => ({ + onFocus={() => { + if (shouldShowSavedValueMask) { + setEditingSavedValues((current) => ({ + ...current, + [field.envVarName]: true, + })); + } + }} + onBlur={() => { + if ( + isSecretField && + field.savedSatisfied && + value.length === 0 + ) { + setEditingSavedValues((current) => ({ + ...current, + [field.envVarName]: false, + })); + } + }} + onChange={(event) => { + const nextValue = event.target.value; + setValues((current) => ({ ...current, - [field.envVarName]: false, + [field.envVarName]: nextValue, })); + }} + placeholder={ + field.runtimeSatisfied + ? 'Managed by environment variable' + : (field.input?.placeholder ?? field.label) } - }} - onChange={(event) => { - const nextValue = event.target.value; - setValues((current) => ({ - ...current, - [field.envVarName]: nextValue, - })); - }} - placeholder={ - field.runtimeSatisfied - ? 'Managed by environment variable' - : (field.input?.placeholder ?? field.label) - } - disabled={savePending || field.runtimeSatisfied} - data-1p-ignore - /> + disabled={savePending || field.runtimeSatisfied} + data-1p-ignore + /> + )} {(field.runtimeSatisfied || field.savedSatisfied) && }
{field.helpText ? ( @@ -487,7 +539,7 @@ export function ComputeProviderSection({ )} - {advancedInfraFields.length > 0 ? ( + {advancedFields.length > 0 ? (

- Provider routing, endpoint, and standby retention - overrides. Leave optional values blank to use provider - defaults. + Optional authentication, provider routing, resource, and + retention overrides. Leave optional values blank to use + provider defaults.

- {advancedInfraFields.map((field) => - renderFieldInput(field), - )} + {advancedFields.map((field) => renderFieldInput(field))}
) : null} - {(inputFields.length > 0 || advancedInfraFields.length > 0) && ( + {(inputFields.length > 0 || advancedFields.length > 0) && ( )} @@ -547,7 +597,7 @@ export function ComputeProviderSection({ canRetryProvisioning || canStartProvisioning) && (inputFields.length > 0 || - advancedInfraFields.length > 0 || + advancedFields.length > 0 || canRetryProvisioning || canStartProvisioning) && (
diff --git a/apps/web/src/components/system/custom/logos/brand-icon.tsx b/apps/web/src/components/system/custom/logos/brand-icon.tsx index 459193d39..0e6458265 100644 --- a/apps/web/src/components/system/custom/logos/brand-icon.tsx +++ b/apps/web/src/components/system/custom/logos/brand-icon.tsx @@ -233,6 +233,30 @@ function MicrosoftIcon({ ); } +function AzureIcon({ + name, + className, + isDecorative, +}: { + name: string; + className?: string; + isDecorative: boolean; +}) { + return ( + + + + ); +} + function DaytonaIcon({ name, className, @@ -545,6 +569,16 @@ export function BrandIcon({ icon, name, className }: BrandIconProps) { ); } + if (icon === 'azure') { + return ( + + ); + } + if (icon === 'daytona') { return ( ({ buildBlaxelWorkerImage: vi.fn(), buildE2bWorkerTemplate: mockBuildE2bWorkerTemplate, registerDaytonaWorkerSnapshot: vi.fn(), + registerAzureDiskImage: vi.fn(), deriveBlaxelWorkerImageName: (imageRef: string) => `roomote-worker-${imageRef.slice(imageRef.lastIndexOf(':') + 1)}`, deriveE2bWorkerTemplateRef: (imageRef: string) => `roomote-worker:${imageRef.slice(imageRef.lastIndexOf(':') + 1)}`, deriveDaytonaWorkerSnapshotName: (imageRef: string) => `roomote-worker-${imageRef.slice(imageRef.lastIndexOf(':') + 1)}`, + deriveAzureWorkerDiskImageName: (imageRef: string) => + `roomote-worker-${imageRef.slice(imageRef.lastIndexOf(':') + 1)}`, })); vi.mock('../environment-variables', () => ({ diff --git a/apps/web/src/trpc/commands/compute/compute-provisioning.ts b/apps/web/src/trpc/commands/compute/compute-provisioning.ts index 26fdea73b..3dd57e40c 100644 --- a/apps/web/src/trpc/commands/compute/compute-provisioning.ts +++ b/apps/web/src/trpc/commands/compute/compute-provisioning.ts @@ -12,9 +12,11 @@ import { queuePersistedTaskRun } from '@roomote/cloud-agents/server'; import { buildBlaxelWorkerImage, buildE2bWorkerTemplate, + deriveAzureWorkerDiskImageName, deriveBlaxelWorkerImageName, deriveDaytonaWorkerSnapshotName, deriveE2bWorkerTemplateRef, + registerAzureDiskImage, registerDaytonaWorkerSnapshot, } from '@roomote/compute-providers'; import { @@ -138,6 +140,73 @@ const PROVISIONING_PROVIDERS: Record< return { artifactRef: built.imageRef }; }, }, + azure: { + envVarName: 'AZURE_SANDBOX_DISK_IMAGE', + deriveArtifactRef: deriveAzureWorkerDiskImageName, + provision: async ({ resolvedEnv, imageRef, templateRef }) => { + const subscriptionId = resolvedEnv.AZURE_SUBSCRIPTION_ID; + const resourceGroup = resolvedEnv.AZURE_RESOURCE_GROUP; + const sandboxGroup = resolvedEnv.AZURE_SANDBOX_GROUP; + const region = resolvedEnv.AZURE_SANDBOX_REGION; + + if (!subscriptionId) { + throw new Error('AZURE_SUBSCRIPTION_ID is not configured'); + } + if (!resourceGroup) { + throw new Error('AZURE_RESOURCE_GROUP is not configured'); + } + if (!sandboxGroup) { + throw new Error('AZURE_SANDBOX_GROUP is not configured'); + } + if (!region) { + throw new Error('AZURE_SANDBOX_REGION is not configured'); + } + + // Private registries need pull credentials for the bake (GHCR: the + // token owner's GitHub username + a PAT with read:packages). + const registryUsername = resolvedEnv.AZURE_SANDBOX_REGISTRY_USERNAME; + const registryToken = resolvedEnv.AZURE_SANDBOX_REGISTRY_TOKEN; + if ( + (registryUsername && !registryToken) || + (!registryUsername && registryToken) + ) { + throw new Error( + 'AZURE_SANDBOX_REGISTRY_USERNAME and AZURE_SANDBOX_REGISTRY_TOKEN must be set together', + ); + } + + const spTenantId = resolvedEnv.AZURE_TENANT_ID; + const spClientId = resolvedEnv.AZURE_CLIENT_ID; + const spClientSecret = resolvedEnv.AZURE_CLIENT_SECRET; + const registered = await registerAzureDiskImage({ + subscriptionId, + resourceGroup, + sandboxGroup, + region, + ...(spTenantId && spClientId && spClientSecret + ? { + servicePrincipal: { + tenantId: spTenantId, + clientId: spClientId, + clientSecret: spClientSecret, + }, + } + : { managedIdentityClientId: resolvedEnv.AZURE_CLIENT_ID }), + ...(registryUsername && registryToken + ? { + registryCredentials: { + username: registryUsername, + token: registryToken, + }, + } + : {}), + imageRef, + name: templateRef, + }); + + return { artifactRef: registered.diskImageId }; + }, + }, }; async function getPersistedSetupNewStateValue(executor: DatabaseOrTransaction) { diff --git a/apps/web/src/trpc/commands/compute/index.ts b/apps/web/src/trpc/commands/compute/index.ts index 236626ab0..06a591407 100644 --- a/apps/web/src/trpc/commands/compute/index.ts +++ b/apps/web/src/trpc/commands/compute/index.ts @@ -124,6 +124,7 @@ export async function getComputeStatusCommand(auth: UserAuthSuccess): Promise< e2bProvisioning, daytonaProvisioning, blaxelProvisioning, + azureProvisioning, ] = await Promise.all([ getPersistedEnvironmentVariableNames(), getPersistedEnvironmentVariableValues([ @@ -133,6 +134,7 @@ export async function getComputeStatusCommand(auth: UserAuthSuccess): Promise< getPersistedComputeProvisioning('e2b'), getPersistedComputeProvisioning('daytona'), getPersistedComputeProvisioning('blaxel'), + getPersistedComputeProvisioning('azure'), ]); return { @@ -148,6 +150,7 @@ export async function getComputeStatusCommand(auth: UserAuthSuccess): Promise< e2b: presentSetupNewComputeProvisioning(e2bProvisioning), daytona: presentSetupNewComputeProvisioning(daytonaProvisioning), blaxel: presentSetupNewComputeProvisioning(blaxelProvisioning), + azure: presentSetupNewComputeProvisioning(azureProvisioning), }, }; } diff --git a/apps/web/src/trpc/commands/misc-settings/index.ts b/apps/web/src/trpc/commands/misc-settings/index.ts index edd6a0c6b..acd5b3fe3 100644 --- a/apps/web/src/trpc/commands/misc-settings/index.ts +++ b/apps/web/src/trpc/commands/misc-settings/index.ts @@ -489,7 +489,9 @@ async function collectDeploymentDiagnostics(): Promise { { label: 'Sandbox provider', value: providers.computeProvider }, { label: 'Snapshot support', - value: ['daytona', 'e2b', 'modal'].includes(providers.computeProvider) + value: ['daytona', 'e2b', 'modal', 'azure'].includes( + providers.computeProvider, + ) ? 'Supported' : 'Unknown', }, diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index d6e4ea740..120e0488d 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -1431,6 +1431,9 @@ export async function getSetupNewStatusCommand(auth: UserAuthSuccess) { blaxelImageBuild: presentSetupNewComputeProvisioning( setupNewState.blaxelImageBuild, ), + azureDiskImageBuild: presentSetupNewComputeProvisioning( + setupNewState.azureDiskImageBuild, + ), }; const sourceControlConnection = await getSourceControlConnectionSummary(); diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index bb2c147fd..cf0f9f75c 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -22,6 +22,22 @@ x-roomote-base-env: &roomote-base-env ROOMOTE_PUBLIC_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_INSTANCE_ID: ${R_INSTANCE_ID:-} TRPC_URL: ${TRPC_URL:-https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required}/_roomote-api} + # Azure Container Apps sandbox provider. In the base anchor so controller, + # api, and bullmq all see it — credentials can also come from the + # DB-persisted setup values instead of process env. + AZURE_SUBSCRIPTION_ID: ${AZURE_SUBSCRIPTION_ID:-} + AZURE_RESOURCE_GROUP: ${AZURE_RESOURCE_GROUP:-} + AZURE_SANDBOX_GROUP: ${AZURE_SANDBOX_GROUP:-} + AZURE_SANDBOX_REGION: ${AZURE_SANDBOX_REGION:-} + AZURE_SANDBOX_DISK_IMAGE: ${AZURE_SANDBOX_DISK_IMAGE:-} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} + AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + AZURE_SANDBOX_REGISTRY_USERNAME: ${AZURE_SANDBOX_REGISTRY_USERNAME:-} + AZURE_SANDBOX_REGISTRY_TOKEN: ${AZURE_SANDBOX_REGISTRY_TOKEN:-} + AZURE_SANDBOX_SIZE: ${AZURE_SANDBOX_SIZE:-} + AZURE_SANDBOX_EGRESS_INSPECTION: ${AZURE_SANDBOX_EGRESS_INSPECTION:-} + AZURE_HTTP_DEBUG: ${AZURE_HTTP_DEBUG:-} x-roomote-inference-env: &roomote-inference-env <<: *roomote-base-env diff --git a/docker-compose.production.yml b/docker-compose.production.yml index bb4d76510..f9c377755 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -87,6 +87,19 @@ x-roomote-production-env: &roomote-production-env BLAXEL_REGION: ${BLAXEL_REGION:-} BLAXEL_STANDBY_MAX_COUNT: ${BLAXEL_STANDBY_MAX_COUNT:-25} BLAXEL_STANDBY_MAX_AGE_HOURS: ${BLAXEL_STANDBY_MAX_AGE_HOURS:-168} + AZURE_SUBSCRIPTION_ID: ${AZURE_SUBSCRIPTION_ID:-} + AZURE_RESOURCE_GROUP: ${AZURE_RESOURCE_GROUP:-} + AZURE_SANDBOX_GROUP: ${AZURE_SANDBOX_GROUP:-} + AZURE_SANDBOX_REGION: ${AZURE_SANDBOX_REGION:-} + AZURE_SANDBOX_DISK_IMAGE: ${AZURE_SANDBOX_DISK_IMAGE:-} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} + AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + AZURE_SANDBOX_REGISTRY_USERNAME: ${AZURE_SANDBOX_REGISTRY_USERNAME:-} + AZURE_SANDBOX_REGISTRY_TOKEN: ${AZURE_SANDBOX_REGISTRY_TOKEN:-} + AZURE_SANDBOX_SIZE: ${AZURE_SANDBOX_SIZE:-} + AZURE_SANDBOX_EGRESS_INSPECTION: ${AZURE_SANDBOX_EGRESS_INSPECTION:-} + AZURE_HTTP_DEBUG: ${AZURE_HTTP_DEBUG:-} DOCKER_STANDBY_MAX_COUNT: ${DOCKER_STANDBY_MAX_COUNT:-10} DOCKER_STANDBY_MAX_AGE_HOURS: ${DOCKER_STANDBY_MAX_AGE_HOURS:-24} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 99050bb97..7565540c8 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -77,6 +77,19 @@ x-roomote-env: &roomote-env BLAXEL_REGION: ${BLAXEL_REGION:-} BLAXEL_STANDBY_MAX_COUNT: ${BLAXEL_STANDBY_MAX_COUNT:-25} BLAXEL_STANDBY_MAX_AGE_HOURS: ${BLAXEL_STANDBY_MAX_AGE_HOURS:-168} + AZURE_SUBSCRIPTION_ID: ${AZURE_SUBSCRIPTION_ID:-} + AZURE_RESOURCE_GROUP: ${AZURE_RESOURCE_GROUP:-} + AZURE_SANDBOX_GROUP: ${AZURE_SANDBOX_GROUP:-} + AZURE_SANDBOX_REGION: ${AZURE_SANDBOX_REGION:-} + AZURE_SANDBOX_DISK_IMAGE: ${AZURE_SANDBOX_DISK_IMAGE:-} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} + AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + AZURE_SANDBOX_REGISTRY_USERNAME: ${AZURE_SANDBOX_REGISTRY_USERNAME:-} + AZURE_SANDBOX_REGISTRY_TOKEN: ${AZURE_SANDBOX_REGISTRY_TOKEN:-} + AZURE_SANDBOX_SIZE: ${AZURE_SANDBOX_SIZE:-} + AZURE_SANDBOX_EGRESS_INSPECTION: ${AZURE_SANDBOX_EGRESS_INSPECTION:-} + AZURE_HTTP_DEBUG: ${AZURE_HTTP_DEBUG:-} 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 diff --git a/packages/compute-providers/package.json b/packages/compute-providers/package.json index 845816b5f..1e1c74917 100644 --- a/packages/compute-providers/package.json +++ b/packages/compute-providers/package.json @@ -38,6 +38,7 @@ "@daytonaio/sdk": "^0.190.1", "@roomote/env": "workspace:^", "@roomote/types": "workspace:^", + "@azure/identity": "^4.13.1", "e2b": "^2.31.0", "jsonwebtoken": "^9.0.3", "lru-cache": "^11.2.6", diff --git a/packages/compute-providers/src/__tests__/azure.contract.test.ts b/packages/compute-providers/src/__tests__/azure.contract.test.ts new file mode 100644 index 000000000..ff3eea822 --- /dev/null +++ b/packages/compute-providers/src/__tests__/azure.contract.test.ts @@ -0,0 +1,589 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createComputeProviderClient } from '../factory'; +import type { AzureConfig } from '../types'; + +const SANDBOX_ID = '11111111-2222-3333-4444-555555555555'; +const REGION = 'canadacentral'; +const ENDPOINT = `https://management.${REGION}.azuredevcompute.io`; +const GROUP_PATH = + '/subscriptions/sub-1/resourceGroups/rg-1/sandboxGroups/group-1'; + +interface RecordedRequest { + method: string; + url: string; + body?: unknown; + binaryBody?: Uint8Array; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function createFetchMock(options: { + states?: string[]; + onRequest?: (request: RecordedRequest) => Response | undefined; +}) { + const requests: RecordedRequest[] = []; + let stateIndex = 0; + const states = options.states ?? ['Running']; + + const fetchImpl = vi.fn( + async ( + url: string, + init?: { method?: string; headers?: unknown; body?: unknown }, + ): Promise => { + const method = init?.method ?? 'GET'; + const recorded: RecordedRequest = { + method, + url, + body: + typeof init?.body === 'string' + ? (JSON.parse(init.body) as unknown) + : undefined, + binaryBody: init?.body instanceof Uint8Array ? init.body : undefined, + }; + requests.push(recorded); + + const override = options.onRequest?.(recorded); + if (override) return override; + + const sbxPath = `${GROUP_PATH}/sandboxes/${SANDBOX_ID}`; + + if (method === 'PUT' && url.includes(`${GROUP_PATH}/sandboxes?`)) { + return jsonResponse({ id: SANDBOX_ID, state: 'Creating' }); + } + if (method === 'GET' && url.startsWith(`${ENDPOINT}${sbxPath}/files`)) { + return jsonResponse({ title: 'NotFound' }, 404); + } + if (method === 'GET' && url.startsWith(`${ENDPOINT}${sbxPath}/stats`)) { + return jsonResponse({ + cpu: { user: 50, system: 20 }, + network: { rxBytes: 1000, txBytes: 2000 }, + }); + } + if (method === 'GET' && url.startsWith(`${ENDPOINT}${sbxPath}?`)) { + const state = states[Math.min(stateIndex, states.length - 1)]; + stateIndex += 1; + return jsonResponse({ + id: SANDBOX_ID, + state, + createdAt: '2026-07-28T17:50:24Z', + }); + } + if (method === 'POST' && url.includes('/executeShellCommand')) { + return jsonResponse({ exitCode: 0, stdout: 'ok', stderr: '' }); + } + if (method === 'POST' && url.includes('/snapshot')) { + return jsonResponse({ + id: 'snap-1', + sandboxId: SANDBOX_ID, + createdAtUtc: '2026-07-28T18:00:00Z', + }); + } + if (method === 'GET' && url.includes(`${GROUP_PATH}/snapshots?`)) { + return jsonResponse({ + value: [ + { + id: 'snap-1', + sandboxId: SANDBOX_ID, + createdAtUtc: '2026-07-28T18:00:00Z', + }, + ], + }); + } + if (method === 'GET' && url.includes(`${GROUP_PATH}/sandboxes?`)) { + return jsonResponse({ value: [] }); + } + if (method === 'DELETE') { + return new Response(null, { status: 204 }); + } + // ports/add, stop, resume, lifecycle, etc. + return jsonResponse({}); + }, + ); + + return { fetchImpl: fetchImpl as unknown as typeof fetch, requests }; +} + +function createClient( + fetchImpl: typeof fetch, +): ReturnType { + const config: AzureConfig = { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + sandboxGroup: 'group-1', + region: REGION, + diskImage: 'disk-image-1', + timeoutMs: 3_600_000, + tokenProvider: { getToken: async () => 'test-token' }, + fetchImpl, + }; + return createComputeProviderClient({ provider: 'azure', config }); +} + +describe('azure adapter contract', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates an instance, waits for Running, and exposes deterministic port domains', async () => { + const { fetchImpl, requests } = createFetchMock({}); + const client = createClient(fetchImpl); + + const created = await client.createInstance({ + ports: [3000], + tags: { app_environment: 'env-1' }, + }); + + expect(created.instanceId).toBe(SANDBOX_ID); + expect(created.status).toBe('running'); + expect(created.domains?.['3000']).toBe( + `https://${SANDBOX_ID}--3000.${REGION}.adcproxy.io`, + ); + + const put = requests.find( + (r) => r.method === 'PUT' && r.url.includes('/sandboxes?'), + ); + expect(put?.body).toMatchObject({ + sourcesRef: { diskImage: { id: 'disk-image-1' } }, + resources: { cpu: '1000m', memory: '2048Mi' }, + lifecycle: { + autoSuspendPolicy: { enabled: false, interval: 0, mode: 'Memory' }, + // Backstop floors to 30d (suspension-anchored TTL); the 3600s + // task timeout stays Roomote-side. + autoDeletePolicy: { enabled: true, deleteIntervalInSeconds: 2592000 }, + }, + egressPolicy: { defaultAction: 'Allow', trafficInspection: 'Partial' }, + labels: { app_environment: 'env-1' }, + }); + + const portAdd = requests.find((r) => r.url.includes('/ports/add')); + expect(portAdd?.body).toMatchObject({ + port: 3000, + auth: { anonymous: true }, + activationMode: 'OnDemand', + }); + }); + + it('does not round small tier cpus up to whole cores', async () => { + const { fetchImpl, requests } = createFetchMock({}); + const client = createComputeProviderClient({ + provider: 'azure', + config: { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + sandboxGroup: 'group-1', + region: REGION, + diskImage: 'disk-image-1', + cpuMillicores: 250, + memoryMiB: 512, + tokenProvider: { getToken: async () => 'test-token' }, + fetchImpl, + }, + }); + + await client.createInstance({}); + + const put = requests.find( + (r) => r.method === 'PUT' && r.url.includes('/sandboxes?'), + ); + expect(put?.body).toMatchObject({ + resources: { cpu: '250m', memory: '512Mi' }, + }); + }); + + it('scales cpu to satisfy the ACA cores×2Gi memory tier cap', async () => { + const { fetchImpl, requests } = createFetchMock({}); + const client = createComputeProviderClient({ + provider: 'azure', + config: { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + sandboxGroup: 'group-1', + region: REGION, + diskImage: 'disk-image-1', + memoryMiB: 4096, + tokenProvider: { getToken: async () => 'test-token' }, + fetchImpl, + }, + }); + + await client.createInstance({}); + + const put = requests.find( + (r) => r.method === 'PUT' && r.url.includes('/sandboxes?'), + ); + expect(put?.body).toMatchObject({ + resources: { cpu: '2000m', memory: '4096Mi' }, + }); + }); + + it('treats a 409 PortAlreadyExists as success when exposing ports', async () => { + const { fetchImpl } = createFetchMock({ + onRequest: (request) => + request.url.includes('/ports/add') + ? jsonResponse({ title: 'PortAlreadyExists' }, 409) + : undefined, + }); + const client = createClient(fetchImpl); + + const result = await client.getInstanceDomains!({ + instanceId: SANDBOX_ID, + ports: [8080], + }); + expect(result.domains['8080']).toContain('--8080.'); + }); + + it('runs a blocking command with cwd and returns output', async () => { + const { fetchImpl, requests } = createFetchMock({}); + const client = createClient(fetchImpl); + + const result = await client.runCommand({ + instanceId: SANDBOX_ID, + cmd: 'echo', + args: ['hello world'], + cwd: '/sandbox', + env: { FOO: 'bar' }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('ok'); + + const exec = requests.find((r) => r.url.includes('/executeShellCommand')); + expect(exec?.body).toMatchObject({ workingDirectory: '/sandbox' }); + const command = (exec?.body as { command: string }).command; + expect(command).toContain('env FOO=bar'); + expect(command).toContain("echo 'hello world'"); + }); + + it('launches detached commands with log + exit-sentinel redirection', async () => { + const { fetchImpl, requests } = createFetchMock({}); + const client = createClient(fetchImpl); + + const result = await client.runCommand({ + instanceId: SANDBOX_ID, + cmd: 'node', + args: ['/sandbox/worker.js'], + detached: true, + }); + + expect(result.commandId).toMatch(/^azc-/); + expect(result.exitCode).toBeNull(); + + const exec = requests.find((r) => r.url.includes('/executeShellCommand')); + const command = (exec?.body as { command: string }).command; + expect(command).toContain('nohup bash -c'); + expect(command).toContain('.stdout.log'); + expect(command).toContain('.stderr.log'); + expect(command).toContain('.exit'); + }, 10_000); + + it('reads detached command output from log files', async () => { + const { fetchImpl } = createFetchMock({ + onRequest: (request) => { + if ( + request.url.includes('/files') && + request.url.includes('.stdout.log') + ) { + return new Response('line-1\nline-2\n', { status: 200 }); + } + if (request.url.includes('/files') && request.url.includes('.exit')) { + return new Response('0', { status: 200 }); + } + return undefined; + }, + }); + const client = createClient(fetchImpl); + + const output = await client.getCommandOutput({ + instanceId: SANDBOX_ID, + commandId: 'azc-test', + }); + expect(output).toBe('line-1\nline-2\n'); + + const events: { stream: string; data: string }[] = []; + for await (const event of client.streamCommandOutput({ + instanceId: SANDBOX_ID, + commandId: 'azc-test', + })) { + events.push(event); + } + expect(events).toEqual([{ stream: 'stdout', data: 'line-1\nline-2\n' }]); + }); + + it('writes files as octet-stream with createDirs', async () => { + const { fetchImpl, requests } = createFetchMock({}); + const client = createClient(fetchImpl); + + await client.writeFiles({ + instanceId: SANDBOX_ID, + files: [ + { + path: '/sandbox/.roomote/bootstrap.sh', + content: Buffer.from('#!/bin/bash\n'), + }, + ], + }); + + const put = requests.find( + (r) => r.method === 'PUT' && r.url.includes('/files'), + ); + expect(put?.url).toContain('createDirs=true'); + expect(put?.binaryBody).toBeDefined(); + expect(Buffer.from(put!.binaryBody!).toString()).toBe('#!/bin/bash\n'); + }); + + it('maps sandbox states to ComputeInstanceStatus', async () => { + const { fetchImpl } = createFetchMock({ states: ['Suspended'] }); + const client = createClient(fetchImpl); + const status = await client.getInstanceStatus({ instanceId: SANDBOX_ID }); + expect(status.status).toBe('stopped'); + }); + + it('reports no provider deadline while a sandbox is Running', async () => { + const { fetchImpl } = createFetchMock({ + states: ['Running'], + onRequest: (request) => + request.method === 'GET' && request.url.includes(`${SANDBOX_ID}?`) + ? jsonResponse({ + id: SANDBOX_ID, + state: 'Running', + createdAt: '2026-07-01T00:00:00Z', + lifecycle: { + autoDeletePolicy: { + enabled: true, + deleteIntervalInSeconds: 2_592_000, + }, + }, + }) + : undefined, + }); + const client = createClient(fetchImpl); + + const status = await client.getInstanceStatus({ instanceId: SANDBOX_ID }); + expect(status.status).toBe('running'); + // Auto-delete never fires on Running sandboxes (suspension-anchored), + // so no deadline is reported — even with an auto-delete policy present. + expect(status.timeoutRemainingMs).toBeUndefined(); + }); + + it('reports the suspension-anchored deadline for a stopped sandbox', async () => { + const stoppedAt = new Date(Date.now() - 60_000).toISOString(); + const { fetchImpl } = createFetchMock({ + onRequest: (request) => + request.method === 'GET' && request.url.includes(`${SANDBOX_ID}?`) + ? jsonResponse({ + id: SANDBOX_ID, + state: 'Stopped', + createdAt: '2026-07-01T00:00:00Z', + stateDetails: { stoppedReason: 'UserStopped', stoppedAt }, + lifecycle: { + autoDeletePolicy: { + enabled: true, + deleteIntervalInSeconds: 2_592_000, + }, + }, + }) + : undefined, + }); + const client = createClient(fetchImpl); + + const status = await client.getInstanceStatus({ instanceId: SANDBOX_ID }); + expect(status.status).toBe('stopped'); + // remaining = stoppedAt + 30d - now ≈ 30d - 60s + expect(status.timeoutRemainingMs).toBeGreaterThan( + 2_592_000_000 - 5 * 60_000, + ); + expect(status.timeoutRemainingMs).toBeLessThanOrEqual(2_592_000_000); + }); + + it('omits the deadline when the timestamp is malformed instead of leaking NaN', async () => { + const { fetchImpl } = createFetchMock({ + onRequest: (request) => + request.method === 'GET' && request.url.includes(`${SANDBOX_ID}?`) + ? jsonResponse({ + id: SANDBOX_ID, + state: 'Stopped', + createdAt: '2026-07-01T00:00:00Z', + stateDetails: { + stoppedReason: 'UserStopped', + stoppedAt: 'not-a-date', + }, + lifecycle: { + autoDeletePolicy: { + enabled: true, + deleteIntervalInSeconds: 2_592_000, + }, + }, + }) + : undefined, + }); + const client = createClient(fetchImpl); + + const status = await client.getInstanceStatus({ instanceId: SANDBOX_ID }); + expect(status.status).toBe('stopped'); + expect(status.timeoutRemainingMs).toBeUndefined(); + }); + + it('omits the deadline for a stopped sandbox without stateDetails', async () => { + const { fetchImpl } = createFetchMock({ + onRequest: (request) => + request.method === 'GET' && request.url.includes(`${SANDBOX_ID}?`) + ? jsonResponse({ + id: SANDBOX_ID, + state: 'Stopped', + createdAt: '2026-07-01T00:00:00Z', + lifecycle: { + autoDeletePolicy: { + enabled: true, + deleteIntervalInSeconds: 2_592_000, + }, + }, + }) + : undefined, + }); + const client = createClient(fetchImpl); + + const status = await client.getInstanceStatus({ instanceId: SANDBOX_ID }); + expect(status.status).toBe('stopped'); + // Legacy stopped documents have no trustworthy suspension anchor — + // omit rather than derive a phantom expiry from createdAt. + expect(status.timeoutRemainingMs).toBeUndefined(); + }); + + it('creates a snapshot synchronously, persists the id before teardown, then deletes the sandbox', async () => { + const { fetchImpl, requests } = createFetchMock({}); + const client = createClient(fetchImpl); + const callOrder: string[] = []; + + const result = await client.createSnapshot({ + instanceId: SANDBOX_ID, + onSnapshotCreated: async () => { + callOrder.push('persist'); + const deletedBeforePersist = requests.some( + (r) => r.method === 'DELETE' && r.url.includes(SANDBOX_ID), + ); + expect(deletedBeforePersist).toBe(false); + }, + }); + + expect(result.snapshotId).toBe('snap-1'); + expect( + requests.some((r) => r.method === 'DELETE' && r.url.includes(SANDBOX_ID)), + ).toBe(true); + expect(result.usageObservation?.activeCpuDurationMs).toBe(700); + expect(result.usageObservation?.networkTransfer).toEqual({ + ingress: 1000, + egress: 2000, + }); + + // Closed detached commands' logs are purged before the snapshot so they + // don't ride into restored sandboxes (in-flight command's files stay). + const execIndex = requests.findIndex((r) => + r.url.includes('/executeShellCommand'), + ); + const snapshotIndex = requests.findIndex((r) => + r.url.includes('/snapshot'), + ); + expect(execIndex).toBeGreaterThanOrEqual(0); + expect(snapshotIndex).toBeGreaterThan(execIndex); + const purgeCommand = (requests[execIndex]?.body as { command: string }) + .command; + expect(purgeCommand).toContain('find'); + expect(purgeCommand).toContain("-name '*.exit'"); + }); + + it('finds snapshots by source instance', async () => { + const { fetchImpl } = createFetchMock({}); + const client = createClient(fetchImpl); + + const found = await client.findSnapshotBySourceInstance?.({ + instanceId: SANDBOX_ID, + }); + expect(found?.snapshotId).toBe('snap-1'); + expect(found?.sourceInstanceId).toBe(SANDBOX_ID); + }); + + it('resumes from a snapshot and re-adds ports', async () => { + const { fetchImpl, requests } = createFetchMock({}); + const client = createClient(fetchImpl); + + const resumed = await client.resumeFromSnapshot({ + sourceSnapshotId: 'snap-1', + ports: [3000], + }); + + expect(resumed.instanceId).toBe(SANDBOX_ID); + expect(resumed.sourceSnapshotId).toBe('snap-1'); + expect(resumed.domains?.['3000']).toContain('--3000.'); + + const put = requests.find( + (r) => r.method === 'PUT' && r.url.includes('/sandboxes?'), + ); + expect(put?.body).toMatchObject({ + sourcesRef: { snapshot: { id: 'snap-1' } }, + }); + expect(requests.some((r) => r.url.includes('/ports/add'))).toBe(true); + }); + + it('enters standby via stop and resumes via resume', async () => { + const { fetchImpl, requests } = createFetchMock({ + // stop-poll, resume pre-check, post-resume poll + states: ['Stopped', 'Stopped', 'Running'], + }); + const client = createClient(fetchImpl); + + const standby = await client.enterStandby?.({ instanceId: SANDBOX_ID }); + expect(standby?.resumeHandle).toBe(SANDBOX_ID); + expect(requests.some((r) => r.url.includes('/stop'))).toBe(true); + expect(standby?.usageObservation?.activeCpuDurationMs).toBe(700); + + const resumed = await client.resumeFromStandby?.({ + resumeHandle: SANDBOX_ID, + ports: [3000], + }); + expect(resumed?.instanceId).toBe(SANDBOX_ID); + expect(resumed?.status).toBe('running'); + expect(requests.some((r) => r.url.includes('/resume'))).toBe(true); + + // No lifecycle refresh on resume: auto-delete is suspension-anchored, + // so each standby cycle re-anchors the TTL window by itself. + expect(requests.some((r) => r.url.includes('/lifecycle'))).toBe(false); + }); + + it('destroys an instance and reports usage', async () => { + const { fetchImpl, requests } = createFetchMock({}); + const client = createClient(fetchImpl); + + const result = await client.destroyInstance({ instanceId: SANDBOX_ID }); + expect( + requests.some((r) => r.method === 'DELETE' && r.url.includes(SANDBOX_ID)), + ).toBe(true); + expect(result.usageObservation?.networkTransfer?.egress).toBe(2000); + }); + + it('lists instances', async () => { + const { fetchImpl } = createFetchMock({ + onRequest: (request) => + request.method === 'GET' && request.url.includes('/sandboxes?') + ? jsonResponse({ + value: [ + { id: 'a', state: 'Running' }, + { id: 'b', state: 'Creating' }, + ], + }) + : undefined, + }); + const client = createClient(fetchImpl); + + const instances = await client.listInstances({}); + expect(instances).toEqual([ + expect.objectContaining({ instanceId: 'a', status: 'running' }), + expect.objectContaining({ instanceId: 'b', status: 'pending' }), + ]); + }); +}); diff --git a/packages/compute-providers/src/__tests__/factory.test.ts b/packages/compute-providers/src/__tests__/factory.test.ts index 2582dc299..6ebcd27e0 100644 --- a/packages/compute-providers/src/__tests__/factory.test.ts +++ b/packages/compute-providers/src/__tests__/factory.test.ts @@ -8,6 +8,7 @@ const { daytonaClientMock, e2bClientMock, blaxelClientMock, + azureClientMock, dockerCapabilities, daytonaCapabilities, e2bCapabilities, @@ -19,6 +20,7 @@ const { daytonaClientMock: vi.fn(), e2bClientMock: vi.fn(), blaxelClientMock: vi.fn(), + azureClientMock: vi.fn(), dockerCapabilities: { snapshots: false, detachedCommands: false, @@ -48,6 +50,7 @@ vi.mock('../adapters', () => ({ DaytonaClient: daytonaClientMock, E2bClient: e2bClientMock, BlaxelClient: blaxelClientMock, + AzureClient: azureClientMock, DOCKER_CAPABILITIES: dockerCapabilities, DAYTONA_CAPABILITIES: daytonaCapabilities, E2B_CAPABILITIES: e2bCapabilities, @@ -384,6 +387,133 @@ describe('createComputeProviderClient', () => { } }); + it('resolves Azure credentials and size/egress presets from env', () => { + process.env.AZURE_SUBSCRIPTION_ID = 'sub-1'; + process.env.AZURE_RESOURCE_GROUP = 'rg-1'; + process.env.AZURE_SANDBOX_GROUP = 'group-1'; + process.env.AZURE_SANDBOX_REGION = 'canadacentral'; + process.env.AZURE_SANDBOX_DISK_IMAGE = 'disk-1'; + process.env.AZURE_SANDBOX_SIZE = 'XL'; + process.env.AZURE_SANDBOX_EGRESS_INSPECTION = 'Full'; + + try { + createComputeProviderClient({ provider: 'azure' }); + + expect(azureClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + sandboxGroup: 'group-1', + region: 'canadacentral', + diskImage: 'disk-1', + cpuMillicores: 4000, + memoryMiB: 8192, + diskSize: '80Gi', + egressTrafficInspection: 'Full', + }), + ); + } finally { + delete process.env.AZURE_SUBSCRIPTION_ID; + delete process.env.AZURE_RESOURCE_GROUP; + delete process.env.AZURE_SANDBOX_GROUP; + delete process.env.AZURE_SANDBOX_REGION; + delete process.env.AZURE_SANDBOX_DISK_IMAGE; + delete process.env.AZURE_SANDBOX_SIZE; + delete process.env.AZURE_SANDBOX_EGRESS_INSPECTION; + } + }); + + it('honors explicit Azure size config over the env size preset', () => { + process.env.AZURE_SANDBOX_SIZE = 'XL'; + + try { + createComputeProviderClient({ + provider: 'azure', + config: { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + sandboxGroup: 'group-1', + region: 'canadacentral', + diskImage: 'disk-1', + size: 'S', + }, + }); + + expect(azureClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + cpuMillicores: 500, + memoryMiB: 1024, + diskSize: '10Gi', + }), + ); + } finally { + delete process.env.AZURE_SANDBOX_SIZE; + } + }); + + it('does not let ambient SP env vars override explicit Azure managed-identity config', () => { + process.env.AZURE_TENANT_ID = 'tenant-env'; + process.env.AZURE_CLIENT_ID = 'client-env'; + process.env.AZURE_CLIENT_SECRET = 'secret-env'; + + try { + createComputeProviderClient({ + provider: 'azure', + config: { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + sandboxGroup: 'group-1', + region: 'canadacentral', + diskImage: 'disk-1', + managedIdentityClientId: 'mi-1', + }, + }); + + expect(azureClientMock).toHaveBeenCalledWith( + expect.objectContaining({ managedIdentityClientId: 'mi-1' }), + ); + expect(azureClientMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + servicePrincipal: expect.anything(), + }), + ); + } finally { + delete process.env.AZURE_TENANT_ID; + delete process.env.AZURE_CLIENT_ID; + delete process.env.AZURE_CLIENT_SECRET; + } + }); + + it('prefers explicit Azure cpu/memory/disk config over the size preset', () => { + process.env.AZURE_SANDBOX_SIZE = 'XL'; + + try { + createComputeProviderClient({ + provider: 'azure', + config: { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + sandboxGroup: 'group-1', + region: 'canadacentral', + diskImage: 'disk-1', + cpuMillicores: 1500, + memoryMiB: 3072, + diskSize: '30Gi', + }, + }); + + expect(azureClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + cpuMillicores: 1500, + memoryMiB: 3072, + diskSize: '30Gi', + }), + ); + } finally { + delete process.env.AZURE_SANDBOX_SIZE; + } + }); + it('resolves Modal regions from env as a comma-separated list', () => { process.env.MODAL_TOKEN_ID = 'token-id'; process.env.MODAL_TOKEN_SECRET = 'token-secret'; diff --git a/packages/compute-providers/src/adapters/azure.ts b/packages/compute-providers/src/adapters/azure.ts new file mode 100644 index 000000000..e7d50715e --- /dev/null +++ b/packages/compute-providers/src/adapters/azure.ts @@ -0,0 +1,1338 @@ +import { randomUUID } from 'node:crypto'; + +import { + AZURE_CAPABILITIES as AZURE_CAPABILITIES_VALUE, + type ComputeProvider, +} from '@roomote/types'; + +import { raceWithAbort, sleepWithSignal, throwIfAborted } from '../modal/abort'; +import { + acquireAzureToken, + createAzureCredential, + type AzureTokenCredential, +} from '../azure/credentials'; +import type { + AzureConfig, + CommandOutputEvent, + ComputeProviderCapabilities, + ComputeProviderClient, + ComputeInstanceStatus, + CreateInstanceInput, + CreateSnapshotInput, + CreateSnapshotResult, + CreatedInstance, + DestroyInstanceInput, + DestroyInstanceResult, + EnterStandbyInput, + EnterStandbyResult, + FindSnapshotBySourceInstanceInput, + GetCommandOutputInput, + GetInstanceDomainsInput, + GetInstanceDomainsResult, + GetInstanceStatusInput, + GetInstanceStatusResult, + InstanceSummary, + ListInstancesInput, + ResumeFromStandbyInput, + ResumeInstanceInput, + RunCommandInput, + RunCommandResult, + SourceInstanceSnapshot, + StreamCommandOutputInput, + WriteFileInput, +} from '../types'; + +const API_VERSION = '2026-02-01-preview'; +const DATA_PLANE_SCOPE = 'https://dynamicsessions.io/.default'; + +const DEFAULT_CPU_MILLICORES = 1000; +const DEFAULT_MEMORY_MIB = 2048; + +/** + * Provider-side auto-delete backstop (30 days, matching the default azure + * standby-retention window in apps/bullmq). Auto-delete only fires on + * suspended sandboxes, so this bounds how long a standby sandbox survives + * without being woken; Roomote's own timeout stays the Roomote-side one. + */ +const AUTO_DELETE_BACKSTOP_MS = 30 * 24 * 60 * 60 * 1_000; + +const RUNNING_POLL_INTERVAL_MS = 1_000; +const RUNNING_POLL_TIMEOUT_MS = 5 * 60 * 1_000; +const STREAM_POLL_INTERVAL_MS = 1_000; +const DETACHED_START_GRACE_MS = 1_500; +const DETACHED_EXIT_POLL_INTERVAL_MS = 2_000; +const DETACHED_EXIT_POLL_MAX_MS = 12 * 60 * 60 * 1_000; + +const RETRY_MAX_ATTEMPTS = 8; +const RETRY_INITIAL_DELAY_MS = 1_000; +const RETRY_MAX_DELAY_MS = 10_000; + +const HTTP_DEBUG = + process.env.AZURE_HTTP_DEBUG === '1' || + process.env.AZURE_HTTP_DEBUG === 'true'; + +function logHttp(message: string, fields: Record): void { + if (HTTP_DEBUG) { + console.log(`[AzureClient:http] ${message} ${JSON.stringify(fields)}`); + } +} + +/** + * Root for detached-command state inside the sandbox. Mirrors the shared + * `/sandbox` worker layout; logs + exit sentinels for detached commands live + * here because ACA exec is one-shot (no sessions API), with a 1 MiB output + * cap and a ~60s wall-clock limit (measured 2026-07-28). + */ +const DETACHED_LOG_ROOT = '/sandbox/.roomote/logs'; + +const IDEMPOTENCY_LABEL = 'roomote-idempotency-key'; +const PRODUCT_SNAPSHOT_NAME_PREFIX = 'roomote-task'; + +interface AzureSandbox { + id: string; + state?: string; + stateDetails?: { stoppedReason?: string; stoppedAt?: string }; + labels?: Record; + lifecycle?: { + autoSuspendPolicy?: { enabled?: boolean; interval?: number; mode?: string }; + autoDeletePolicy?: { enabled?: boolean; deleteIntervalInSeconds?: number }; + }; + resources?: { cpu?: string; memory?: string; disk?: string }; + ports?: { + port: number; + url?: string; + auth?: Record; + activationMode?: string; + }[]; + createdAt?: string; + region?: string; +} + +interface AzureSnapshot { + id: string; + labels?: Record; + sandboxId?: string; + status?: string; + createdAtUtc?: string; + sizeInMB?: number; +} + +interface AzureExecResult { + exitCode?: number; + stdout?: string; + stderr?: string; +} + +interface AzureStats { + cpu?: { user?: number; system?: number }; + network?: { rxBytes?: number; txBytes?: number }; +} + +export class AzureDataPlaneError extends Error { + public constructor( + message: string, + public readonly status: number, + public readonly code?: string, + ) { + super(message); + this.name = 'AzureDataPlaneError'; + } +} + +function isNotFound(error: unknown): boolean { + return error instanceof AzureDataPlaneError && error.status === 404; +} + +function isPortConflict(error: unknown): boolean { + return error instanceof AzureDataPlaneError && error.status === 409; +} + +export class AzureClient implements ComputeProviderClient { + public readonly vendor: ComputeProvider = 'azure'; + public readonly capabilities: ComputeProviderCapabilities = + AZURE_CAPABILITIES_VALUE; + + private readonly endpoint: string; + private readonly groupPath: string; + private readonly fetchImpl: typeof fetch; + private credentialPromise?: Promise; + private cachedToken?: { token: string; expiresOnTimestamp: number }; + + public constructor(private readonly config: AzureConfig) { + if (!config.subscriptionId) + throw new Error('Azure requires a subscriptionId'); + if (!config.resourceGroup) + throw new Error('Azure requires a resourceGroup'); + if (!config.sandboxGroup) throw new Error('Azure requires a sandboxGroup'); + if (!config.region) throw new Error('Azure requires a region'); + if (!config.diskImage) throw new Error('Azure requires a diskImage'); + + this.endpoint = `https://management.${config.region}.azuredevcompute.io`; + this.groupPath = + `/subscriptions/${config.subscriptionId}` + + `/resourceGroups/${config.resourceGroup}` + + `/sandboxGroups/${config.sandboxGroup}`; + this.fetchImpl = config.fetchImpl ?? fetch; + } + + // ------------------------------------------------------------------------- + // Instances + // ------------------------------------------------------------------------- + + public async listInstances( + input: ListInstancesInput, + ): Promise { + throwIfAborted(input.signal); + + const sandboxes: AzureSandbox[] = []; + let nextLink: string | undefined; + do { + const page = ( + nextLink + ? await this.requestRaw('GET', nextLink, { signal: input.signal }) + : await this.request('GET', this.groupPath + '/sandboxes', { + signal: input.signal, + }) + ) as { value?: AzureSandbox[]; nextLink?: string } | AzureSandbox[]; + const items = Array.isArray(page) ? page : (page.value ?? []); + sandboxes.push(...items); + nextLink = Array.isArray(page) ? undefined : page.nextLink; + } while (nextLink); + + return sandboxes.map((sandbox) => this.summarize(sandbox)); + } + + public async getInstanceStatus( + input: GetInstanceStatusInput, + ): Promise { + throwIfAborted(input.signal); + const sandbox = await this.getSandbox(input.instanceId, input.signal); + const timeoutRemainingMs = this.timeoutRemainingMs(sandbox); + return { + status: this.mapState(sandbox.state), + // Omit when the sandbox has no auto-delete policy: sleep-check's + // provider-timeout backstop must only see real provider-side deadlines. + ...(timeoutRemainingMs !== undefined ? { timeoutRemainingMs } : {}), + }; + } + + public async getInstanceDomains( + input: GetInstanceDomainsInput, + ): Promise { + throwIfAborted(input.signal); + const domains = await this.ensurePorts( + input.instanceId, + input.ports, + input.signal, + ); + return { domains }; + } + + public async createInstance( + input: CreateInstanceInput, + ): Promise { + throwIfAborted(input.signal); + + if (input.idempotencyKey) { + const existing = await this.findByIdempotencyKey( + input.idempotencyKey, + input.signal, + ); + if (existing) { + const domains = await this.ensurePorts( + existing.id, + input.ports ?? [], + input.signal, + ); + return { + instanceId: existing.id, + status: this.mapState(existing.state), + ...(Object.keys(domains).length > 0 ? { domains } : {}), + }; + } + } + + const labels = normalizeLabels({ + ...(input.tags ?? {}), + ...(input.metadata ?? {}), + ...(input.idempotencyKey + ? { [IDEMPOTENCY_LABEL]: input.idempotencyKey } + : {}), + }); + + const body = this.buildCreateBody({ labels }); + const created = await this.createSandboxAndWait( + body, + input.signal, + 'create', + ); + const domains = await this.ensurePorts( + created.id, + input.ports ?? [], + input.signal, + ); + + return { + instanceId: created.id, + status: 'running', + ...(Object.keys(domains).length > 0 ? { domains } : {}), + }; + } + + public async destroyInstance( + input: DestroyInstanceInput, + ): Promise { + throwIfAborted(input.signal); + + // Best-effort usage observation before the sandbox disappears. + const usageObservation = await this.readUsageObservation(input.instanceId); + + try { + await this.request('DELETE', `${this.sandboxPath(input.instanceId)}`, { + signal: input.signal, + }); + } catch (error) { + if (!isNotFound(error)) throw error; + } + + return usageObservation ? { usageObservation } : {}; + } + + // ------------------------------------------------------------------------- + // Commands + // ------------------------------------------------------------------------- + + public async runCommand(input: RunCommandInput): Promise { + throwIfAborted(input.signal); + + if (input.detached) { + return this.runDetachedCommand(input); + } + + // NOTE: blocking exec is one-shot with a ~60s wall-clock limit and a + // 1 MiB stdout cap (the process is killed past either). Callers needing + // more must use `detached`. + const command = buildShellCommand(input); + const result = (await this.request( + 'POST', + `${this.sandboxPath(input.instanceId)}/executeShellCommand`, + { + body: { + command, + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + }, + signal: input.signal, + abortMessage: `Running command in Azure sandbox ${input.instanceId} was aborted`, + }, + )) as AzureExecResult; + + if (input.onOutput) { + if (result.stdout) { + input.onOutput({ stream: 'stdout', data: result.stdout }); + } + if (result.stderr) { + input.onOutput({ stream: 'stderr', data: result.stderr }); + } + } + + return { + exitCode: result.exitCode ?? 0, + stdout: result.stdout, + stderr: result.stderr, + }; + } + + private async runDetachedCommand( + input: RunCommandInput, + ): Promise { + const commandId = `azc-${randomUUID()}`; + const paths = detachedPaths(commandId); + + const inner = [ + input.cwd ? `cd ${shellQuote(input.cwd)} && ` : '', + buildShellCommand(input), + `; printf '%s' $? > ${shellQuote(paths.exit)}`, + ].join(''); + + const launch = + `mkdir -p ${shellQuote(DETACHED_LOG_ROOT)} && ` + + `nohup bash -c ${shellQuote(inner)} ` + + `> ${shellQuote(paths.stdout)} 2> ${shellQuote(paths.stderr)} ` + + `& echo $!`; + + const launchResult = (await this.request( + 'POST', + `${this.sandboxPath(input.instanceId)}/executeShellCommand`, + { + body: { command: launch }, + signal: input.signal, + abortMessage: `Launching detached command in Azure sandbox ${input.instanceId} was aborted`, + }, + )) as AzureExecResult; + + if (launchResult.exitCode !== 0) { + return { + commandId, + exitCode: launchResult.exitCode ?? 1, + stdout: launchResult.stdout, + stderr: launchResult.stderr, + }; + } + + // Give fast-failing commands a moment to land in the exit sentinel so + // callers see immediate failures synchronously. + await sleepWithSignal(DETACHED_START_GRACE_MS, input.signal); + const earlyExit = await this.readExitCode(input.instanceId, commandId); + + if (input.onExit) { + this.watchDetachedExit(input, commandId); + } + + if (earlyExit !== null) { + const [stdout, stderr] = await Promise.all([ + this.readFileText(input.instanceId, paths.stdout, input.signal), + this.readFileText(input.instanceId, paths.stderr, input.signal), + ]); + return { commandId, exitCode: earlyExit, stdout, stderr }; + } + + return { commandId, exitCode: null }; + } + + /** + * Background watcher: polls the exit sentinel and reports a detached + * command's exit through `onExit`. Fire-and-forget; errors are swallowed + * (a destroyed sandbox ends the watch via repeated read failures). + */ + private watchDetachedExit(input: RunCommandInput, commandId: string): void { + const onExit = input.onExit; + if (!onExit) return; + + const instanceId = input.instanceId; + void (async () => { + const deadline = Date.now() + DETACHED_EXIT_POLL_MAX_MS; + let consecutiveErrors = 0; + while (Date.now() < deadline) { + try { + const exitCode = await this.readExitCode(instanceId, commandId); + if (exitCode !== null) { + await onExit({ exitCode }); + return; + } + consecutiveErrors = 0; + } catch { + consecutiveErrors += 1; + if (consecutiveErrors >= 10) return; + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, DETACHED_EXIT_POLL_INTERVAL_MS); + // Let the process exit even if a watch is still pending. + (timer as { unref?: () => void }).unref?.(); + }); + } + })(); + } + + public async *streamCommandOutput( + input: StreamCommandOutputInput, + ): AsyncIterable { + const paths = detachedPaths(input.commandId); + let stdoutOffset = 0; + let stderrOffset = 0; + let exited = false; + + while (true) { + throwIfAborted(input.signal); + + const [stdout, stderr] = await Promise.all([ + this.readFileText(input.instanceId, paths.stdout, input.signal), + this.readFileText(input.instanceId, paths.stderr, input.signal), + ]); + + if (stdout.length > stdoutOffset) { + yield { stream: 'stdout', data: stdout.slice(stdoutOffset) }; + stdoutOffset = stdout.length; + } + if (stderr.length > stderrOffset) { + yield { stream: 'stderr', data: stderr.slice(stderrOffset) }; + stderrOffset = stderr.length; + } + + if (exited) return; + exited = + (await this.readExitCode(input.instanceId, input.commandId)) !== null; + if (!exited) { + await sleepWithSignal(STREAM_POLL_INTERVAL_MS, input.signal); + } + } + } + + public async getCommandOutput(input: GetCommandOutputInput): Promise { + const paths = detachedPaths(input.commandId); + const wantStdout = input.stream !== 'stderr'; + const wantStderr = input.stream !== 'stdout'; + + const [stdout, stderr] = await Promise.all([ + wantStdout + ? this.readFileText(input.instanceId, paths.stdout, input.signal) + : Promise.resolve(''), + wantStderr + ? this.readFileText(input.instanceId, paths.stderr, input.signal) + : Promise.resolve(''), + ]); + + // The detached scheme keeps stdout/stderr in separate files; "both" + // concatenates (matches the e2b adapter's combined-log behavior). + return stdout + stderr; + } + + // ------------------------------------------------------------------------- + // Files + // ------------------------------------------------------------------------- + + public async writeFiles(input: WriteFileInput): Promise { + throwIfAborted(input.signal); + await Promise.all( + input.files.map(async (file) => { + await this.request( + 'PUT', + `${this.sandboxPath(input.instanceId)}/files`, + { + query: { path: file.path, createDirs: 'true' }, + binaryBody: file.content, + signal: input.signal, + abortMessage: `Writing ${file.path} in Azure sandbox ${input.instanceId} was aborted`, + }, + ); + }), + ); + } + + // ------------------------------------------------------------------------- + // Snapshots + // ------------------------------------------------------------------------- + + public async createSnapshot( + input: CreateSnapshotInput, + ): Promise { + throwIfAborted(input.signal); + + const snapshotName = deriveAzureProductSnapshotName(input.instanceId); + const usageObservation = await this.readUsageObservation(input.instanceId); + + // ACA snapshots capture memory+disk and get restored into future + // sandboxes — purge CLOSED detached-command logs first so stale output + // (and anything it contains) doesn't ride into every restored sandbox. + // Only triples with an exit sentinel are removed: the in-flight worker's + // own logs stay readable after restore, and its exit watcher keeps + // working. Best effort; the snapshot is still valid without the purge. + await this.request( + 'POST', + `${this.sandboxPath(input.instanceId)}/executeShellCommand`, + { + body: { + command: + 'find ' + + shellQuote(DETACHED_LOG_ROOT) + + ' -name \'*.exit\' -exec sh -c \'p=${1%.exit}; rm -f "$p.exit" "$p.stdout.log" "$p.stderr.log"\' _ {} \\;', + }, + signal: input.signal, + abortMessage: `Purging detached logs before snapshotting Azure sandbox ${input.instanceId} was aborted`, + }, + ).catch(() => { + // Continue; stale logs are a hygiene issue, not a correctness one. + }); + + // The dataplane snapshot endpoint is synchronous: the returned body + // already carries the snapshot id. + const snapshot = (await this.request( + 'POST', + `${this.sandboxPath(input.instanceId)}/snapshot`, + { + body: { labels: { name: snapshotName } }, + signal: input.signal, + abortMessage: `Creating snapshot of Azure sandbox ${input.instanceId} was aborted`, + }, + )) as AzureSnapshot; + + // Persist the id before teardown: a crash between destroy and caller + // persistence would otherwise orphan the snapshot (see CreateSnapshotInput). + await input.onSnapshotCreated?.(snapshot.id); + + await this.destroySandboxAfterSnapshot(input.instanceId); + + return { + snapshotId: snapshot.id, + ...(usageObservation ? { usageObservation } : {}), + }; + } + + public async findSnapshotBySourceInstance( + input: FindSnapshotBySourceInstanceInput, + ): Promise { + throwIfAborted(input.signal); + + const snapshots: AzureSnapshot[] = []; + let nextLink: string | undefined; + do { + const page = ( + nextLink + ? await this.requestRaw('GET', nextLink, { signal: input.signal }) + : await this.request('GET', `${this.groupPath}/snapshots`, { + signal: input.signal, + }) + ) as { value?: AzureSnapshot[]; nextLink?: string } | AzureSnapshot[]; + const items = Array.isArray(page) ? page : (page.value ?? []); + snapshots.push(...items); + nextLink = Array.isArray(page) ? undefined : page.nextLink; + } while (nextLink); + + const matches = snapshots + .filter((snapshot) => snapshot.sandboxId === input.instanceId) + .filter((snapshot) => { + if (!snapshot.createdAtUtc) return true; + const createdAt = new Date(snapshot.createdAtUtc); + if (input.since && createdAt < input.since) return false; + if (input.until && createdAt > input.until) return false; + return true; + }) + .sort((a, b) => + (b.createdAtUtc ?? '').localeCompare(a.createdAtUtc ?? ''), + ); + + const match = matches[0]; + if (!match) return null; + + return { + snapshotId: match.id, + sourceInstanceId: input.instanceId, + status: 'created', + createdAt: match.createdAtUtc + ? new Date(match.createdAtUtc) + : new Date(0), + }; + } + + public async resumeFromSnapshot( + input: ResumeInstanceInput, + ): Promise { + throwIfAborted(input.signal); + + const labels = normalizeLabels({ + ...(input.tags ?? {}), + ...(input.metadata ?? {}), + }); + + const body: Record = { + sourcesRef: { snapshot: { id: input.sourceSnapshotId } }, + ...(labels ? { labels } : {}), + }; + + // Snapshot restore replays captured state; older previews rejected + // labels on restore, newer ones accept them. Fall back to a bare restore + // when the service rejects the labeled body. + let created: AzureSandbox; + try { + created = await this.createSandboxAndWait(body, input.signal, 'resume'); + } catch (error) { + if ( + labels && + error instanceof AzureDataPlaneError && + error.status === 400 + ) { + created = await this.createSandboxAndWait( + { sourcesRef: { snapshot: { id: input.sourceSnapshotId } } }, + input.signal, + 'resume', + ); + } else { + throw error; + } + } + + // Ports do NOT persist through snapshot/restore (measured) — re-add. + const domains = await this.ensurePorts( + created.id, + input.ports ?? [], + input.signal, + ); + + return { + instanceId: created.id, + status: 'running', + sourceSnapshotId: input.sourceSnapshotId, + ...(Object.keys(domains).length > 0 ? { domains } : {}), + }; + } + + // ------------------------------------------------------------------------- + // Standby (ACA suspend/resume preserves full memory+disk) + // ------------------------------------------------------------------------- + + public async enterStandby( + input: EnterStandbyInput, + ): Promise { + throwIfAborted(input.signal); + + const usageObservation = await this.readUsageObservation(input.instanceId); + + await this.request('POST', `${this.sandboxPath(input.instanceId)}/stop`, { + signal: input.signal, + abortMessage: `Suspending Azure sandbox ${input.instanceId} was aborted`, + }); + await this.waitForState( + input.instanceId, + ['Stopped', 'Suspended', 'Idle'], + input.signal, + ); + + return { + resumeHandle: input.instanceId, + ...(usageObservation ? { usageObservation } : {}), + }; + } + + public async resumeFromStandby( + input: ResumeFromStandbyInput, + ): Promise { + throwIfAborted(input.signal); + + // No-op when already Running (double-wake race): resuming a Running + // sandbox is rejected by the service. + const current = await this.getSandbox(input.resumeHandle, input.signal); + if (current.state !== 'Running') { + await this.request( + 'POST', + `${this.sandboxPath(input.resumeHandle)}/resume`, + { + signal: input.signal, + abortMessage: `Resuming Azure sandbox ${input.resumeHandle} was aborted`, + }, + ); + await this.waitForState(input.resumeHandle, ['Running'], input.signal); + } + + // NOTE: no lifecycle refresh here. Measured semantics (2026-07-30): + // auto-delete is a suspension-anchored TTL (stoppedAt + interval, + // laggy sweeper) and never fires on Running sandboxes, and each + // suspension re-anchors stoppedAt — so every Roomote standby cycle gets + // a fresh TTL window with no action needed from the adapter. + + // Ports persist through stop/resume (measured); ensure anyway so callers + // always get domains back. + const domains = await this.ensurePorts( + input.resumeHandle, + input.ports ?? [], + input.signal, + ); + + return { + instanceId: input.resumeHandle, + sourceSnapshotId: input.resumeHandle, + status: 'running', + ...(Object.keys(domains).length > 0 ? { domains } : {}), + }; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private sandboxPath(sandboxId: string): string { + return `${this.groupPath}/sandboxes/${sandboxId}`; + } + + private async getSandbox( + sandboxId: string, + signal?: AbortSignal, + ): Promise { + return (await this.request('GET', this.sandboxPath(sandboxId), { + signal, + })) as AzureSandbox; + } + + private async findByIdempotencyKey( + key: string, + signal?: AbortSignal, + ): Promise { + const page = (await this.request('GET', `${this.groupPath}/sandboxes`, { + query: { labels: `${IDEMPOTENCY_LABEL}=${key}` }, + signal, + })) as { value?: AzureSandbox[] } | AzureSandbox[]; + const items = Array.isArray(page) ? page : (page.value ?? []); + return ( + items.find( + (sandbox) => sandbox.state !== 'Deleting' && sandbox.state !== 'Failed', + ) ?? null + ); + } + + private buildCreateBody(options: { + labels?: Record; + }): Record> { + const diskImage = this.config.diskImage.startsWith('public:') + ? { + name: this.config.diskImage.slice('public:'.length), + isPublic: true, + } + : { id: this.config.diskImage }; + + const autoSuspendSeconds = this.config.autoSuspendSeconds ?? 0; + + // ACA tiers cap BOTH memory (cores × 2Gi) and disk (cores × 20Gi) at + // the CPU anchor (verified live: 400 InvalidResourceTier past either). + // Scale CPU up to fit the requested memory rather than failing — + // millicores directly, so XS/S presets (250m/500m) are not rounded up + // to whole cores. + const memoryMiB = this.config.memoryMiB ?? DEFAULT_MEMORY_MIB; + const minCpuMillicores = Math.ceil((memoryMiB / 2048) * 1000); + const cpuMillicores = Math.max( + this.config.cpuMillicores ?? DEFAULT_CPU_MILLICORES, + minCpuMillicores, + ); + + // Clamp an explicit disk request to the tier cap (cores × 20Gi). + const maxDiskGiB = Math.floor((cpuMillicores / 1000) * 20); + const requestedDiskGiB = this.config.diskSize + ? Number.parseInt(this.config.diskSize, 10) + : undefined; + const diskSize = + requestedDiskGiB !== undefined + ? `${Math.min(requestedDiskGiB, maxDiskGiB)}Gi` + : undefined; + + return { + sourcesRef: { diskImage }, + resources: { + cpu: `${cpuMillicores}m`, + memory: `${memoryMiB}Mi`, + ...(diskSize ? { disk: diskSize } : {}), + }, + lifecycle: { + autoSuspendPolicy: { + enabled: autoSuspendSeconds > 0, + interval: autoSuspendSeconds, + mode: 'Memory', + }, + // Measured (2026-07-30): auto-delete fires only on suspended + // sandboxes, at stoppedAt + interval. Make it a long backstop + // (>= standby retention) rather than the task timeout — the + // Roomote-side timeout is enforced by the worker/sleep-check. + ...(this.config.timeoutMs + ? { + autoDeletePolicy: { + enabled: true, + deleteIntervalInSeconds: Math.ceil( + Math.max(this.config.timeoutMs, AUTO_DELETE_BACKSTOP_MS) / + 1_000, + ), + }, + } + : {}), + }, + // Default Partial inspection: no rules configured means no TLS + // resigning at all (clean trust stores, SSH allowed). See AzureConfig. + egressPolicy: { + defaultAction: 'Allow', + trafficInspection: this.config.egressTrafficInspection ?? 'Partial', + }, + ...(options.labels ? { labels: options.labels } : {}), + }; + } + + /** + * PUT the create body and poll until Running. On abort, best-effort delete + * a late-created sandbox so it is not leaked (mirrors the daytona adapter's + * onLateResolve cleanup). + */ + private async createSandboxAndWait( + body: Record, + signal: AbortSignal | undefined, + operation: 'create' | 'resume', + ): Promise { + const created = await raceWithAbort({ + promise: this.request('PUT', `${this.groupPath}/sandboxes`, { + body, + }) as Promise, + signal, + abortMessage: `Azure sandbox ${operation} was aborted`, + onLateResolve: async (sandbox) => { + await this.cleanupSandboxAfterFailure(sandbox.id); + }, + }); + + try { + await this.waitForState(created.id, ['Running'], signal); + } catch (error) { + await this.cleanupSandboxAfterFailure(created.id); + throw error; + } + + return this.getSandbox(created.id, signal); + } + + private async waitForState( + sandboxId: string, + targetStates: string[], + signal?: AbortSignal, + ): Promise { + const deadline = Date.now() + RUNNING_POLL_TIMEOUT_MS; + while (true) { + throwIfAborted( + signal, + `Waiting for Azure sandbox ${sandboxId} was aborted`, + ); + const sandbox = await this.getSandbox(sandboxId, signal); + const state = sandbox.state ?? ''; + if (targetStates.includes(state)) { + return sandbox; + } + if (state === 'Failed' || state === 'Deleting') { + throw new Error( + `Azure sandbox ${sandboxId} entered terminal state '${state}'`, + ); + } + if (Date.now() > deadline) { + throw new Error( + `Azure sandbox ${sandboxId} did not reach [${targetStates.join(', ')}] within ${RUNNING_POLL_TIMEOUT_MS}ms (last state: '${state}')`, + ); + } + logHttp('waitForState poll', { sandboxId, state }); + await sleepWithSignal(RUNNING_POLL_INTERVAL_MS, signal); + } + } + + /** + * Add the requested ports (anonymous, OnDemand activation so inbound + * traffic wakes a suspended sandbox — measured 2026-07-28) and return + * deterministic per-port URLs. Re-adding an existing port is a 409, which + * is treated as success. + */ + private async ensurePorts( + sandboxId: string, + ports: number[], + signal?: AbortSignal, + ): Promise> { + const domains: Record = {}; + for (const port of ports) { + try { + await this.request('POST', `${this.sandboxPath(sandboxId)}/ports/add`, { + body: { + port, + auth: { anonymous: true }, + activationMode: 'OnDemand', + }, + signal, + abortMessage: `Exposing port ${port} on Azure sandbox ${sandboxId} was aborted`, + }); + } catch (error) { + if (!isPortConflict(error)) throw error; + } + domains[String(port)] = this.computePortUrl(sandboxId, port); + } + return domains; + } + + /** + * Port URLs are deterministic: {sandboxId}--{port}.{region}.adcproxy.io + * (measured 2026-07-28). + */ + private computePortUrl(sandboxId: string, port: number): string { + return `https://${sandboxId}--${port}.${this.config.region}.adcproxy.io`; + } + + private async readExitCode( + sandboxId: string, + commandId: string, + ): Promise { + // Missing sentinel (404 → '') means the command is still running. + const text = await this.readFileText( + sandboxId, + detachedPaths(commandId).exit, + ); + const parsed = Number.parseInt(text.trim(), 10); + return Number.isFinite(parsed) ? parsed : null; + } + + private async readFileText( + sandboxId: string, + path: string, + signal?: AbortSignal, + ): Promise { + try { + const buffer = (await this.request( + 'GET', + `${this.sandboxPath(sandboxId)}/files`, + { + query: { path }, + rawResponse: true, + signal, + }, + )) as ArrayBuffer; + return Buffer.from(buffer).toString('utf8'); + } catch (error) { + if (isNotFound(error)) return ''; + throw error; + } + } + + private async readUsageObservation(sandboxId: string) { + try { + const stats = (await this.request( + 'GET', + `${this.sandboxPath(sandboxId)}/stats`, + )) as AzureStats; + const user = stats.cpu?.user ?? 0; + const system = stats.cpu?.system ?? 0; + const rxBytes = stats.network?.rxBytes ?? 0; + const txBytes = stats.network?.txBytes ?? 0; + if (user + system + rxBytes + txBytes === 0) return undefined; + return { + // cpu user/system are USER_HZ jiffies (100/s → ×10ms). + activeCpuDurationMs: (user + system) * 10, + networkTransfer: { ingress: rxBytes, egress: txBytes }, + }; + } catch { + return undefined; + } + } + + private async destroySandboxAfterSnapshot(sandboxId: string): Promise { + try { + await this.request('DELETE', this.sandboxPath(sandboxId)); + } catch { + // Snapshot already captured; the caller's cleanup / sleep-check sweeps. + } + } + + private async cleanupSandboxAfterFailure(sandboxId: string): Promise { + try { + await this.request('DELETE', this.sandboxPath(sandboxId)); + } catch { + // Best effort. + } + } + + private summarize(sandbox: AzureSandbox): InstanceSummary { + return { + instanceId: sandbox.id, + status: this.mapState(sandbox.state), + // InstanceSummary requires a number; "no policy" reads as far-future. + timeoutRemainingMs: + this.timeoutRemainingMs(sandbox) ?? Number.MAX_SAFE_INTEGER, + ...(sandbox.createdAt ? { createdAt: new Date(sandbox.createdAt) } : {}), + }; + } + + /** + * Remaining lifetime from the sandbox's own auto-delete lifecycle policy, + * or undefined when there is no provider-side deadline. Measured semantics + * (2026-07-30): auto-delete is suspension-anchored — it fires at + * `stoppedAt + deleteInterval` and NEVER on Running sandboxes, so a + * Running sandbox has no provider deadline to report (returning a + * createdAt-derived value would feed sleep-check's hard_limit backstop a + * phantom expiry once a retained sandbox ages past the backstop interval). + */ + private timeoutRemainingMs(sandbox: AzureSandbox): number | undefined { + const autoDelete = sandbox.lifecycle?.autoDeletePolicy; + if (!autoDelete?.enabled || !autoDelete.deleteIntervalInSeconds) { + return undefined; + } + if ( + sandbox.state !== 'Stopped' && + sandbox.state !== 'Suspended' && + sandbox.state !== 'Idle' + ) { + return undefined; + } + // stateDetails is only populated for stops recorded after the field + // shipped; legacy stopped documents have no trustworthy anchor, so omit + // rather than invent one from createdAt (suspension-anchored semantics). + const anchor = sandbox.stateDetails?.stoppedAt; + if (!anchor) return undefined; + // Guard against malformed timestamps: Math.max(0, NaN) is NaN, which + // would otherwise propagate into InstanceSummary as a bogus number. + const anchorMs = Date.parse(anchor); + if (Number.isNaN(anchorMs)) return undefined; + const deadlineMs = anchorMs + autoDelete.deleteIntervalInSeconds * 1_000; + return Math.max(0, deadlineMs - Date.now()); + } + + private mapState(state: string | undefined): ComputeInstanceStatus { + switch (state) { + case 'Creating': + case 'Resuming': + return 'pending'; + case 'Running': + return 'running'; + case 'Stopping': + case 'Deleting': + return 'stopping'; + case 'Stopped': + case 'Suspended': + case 'Idle': + return 'stopped'; + case 'Failed': + return 'failed'; + default: + return 'unknown'; + } + } + + // ------------------------------------------------------------------------- + // HTTP transport + // ------------------------------------------------------------------------- + + private async getToken(signal?: AbortSignal): Promise { + if (this.config.tokenProvider) { + return this.config.tokenProvider.getToken(); + } + + const now = Date.now(); + if ( + this.cachedToken && + this.cachedToken.expiresOnTimestamp - 5 * 60 * 1_000 > now + ) { + return this.cachedToken.token; + } + + const tokenStart = Date.now(); + if (!this.credentialPromise) { + logHttp('credential init', { + kind: this.config.servicePrincipal + ? 'service-principal' + : this.config.managedIdentityClientId + ? 'user-assigned-mi' + : 'default-chain', + }); + this.credentialPromise = createAzureCredential({ + ...(this.config.servicePrincipal + ? { servicePrincipal: this.config.servicePrincipal } + : {}), + ...(this.config.managedIdentityClientId + ? { managedIdentityClientId: this.config.managedIdentityClientId } + : {}), + }); + } + const credential = await this.credentialPromise; + throwIfAborted(signal); + const token = await acquireAzureToken(credential, DATA_PLANE_SCOPE); + this.cachedToken = token; + logHttp('token acquired', { durationMs: Date.now() - tokenStart }); + return token.token; + } + + private async request( + method: string, + path: string, + options: { + query?: Record; + body?: Record; + binaryBody?: Buffer; + rawResponse?: boolean; + signal?: AbortSignal; + abortMessage?: string; + } = {}, + ): Promise { + const url = new URL(`${this.endpoint}${path}`); + url.searchParams.set('api-version', API_VERSION); + for (const [key, value] of Object.entries(options.query ?? {})) { + url.searchParams.set(key, value); + } + return this.requestRaw(method, url.toString(), options); + } + + private async requestRaw( + method: string, + url: string, + options: { + body?: Record; + binaryBody?: Buffer; + rawResponse?: boolean; + signal?: AbortSignal; + abortMessage?: string; + } = {}, + ): Promise { + let attempt = 0; + let delayMs = RETRY_INITIAL_DELAY_MS; + + while (true) { + attempt += 1; + throwIfAborted(options.signal, options.abortMessage); + + const attemptStart = Date.now(); + logHttp('request start', { method, url, attempt }); + + const token = await this.getToken(options.signal); + const headers: Record = { + authorization: `Bearer ${token}`, + }; + if (options.binaryBody) { + headers['content-type'] = 'application/octet-stream'; + } else if (options.body) { + headers['content-type'] = 'application/json'; + } + + let response: Response; + try { + response = await raceWithAbort({ + promise: this.fetchImpl(url, { + method, + headers, + body: options.binaryBody + ? new Uint8Array(options.binaryBody) + : options.body + ? JSON.stringify(options.body) + : undefined, + signal: options.signal, + }), + signal: options.signal, + abortMessage: options.abortMessage, + }); + } catch (error) { + logHttp('request error', { + method, + url, + attempt, + durationMs: Date.now() - attemptStart, + error: error instanceof Error ? error.message : String(error), + }); + if (attempt < RETRY_MAX_ATTEMPTS && !isAbortLike(error)) { + await sleepWithSignal(delayMs, options.signal); + delayMs = Math.min(delayMs * 2, RETRY_MAX_DELAY_MS); + continue; + } + throw error; + } + + logHttp('request end', { + method, + url, + attempt, + status: response.status, + durationMs: Date.now() - attemptStart, + }); + + if (response.status < 400) { + if (options.rawResponse) { + return response.arrayBuffer(); + } + if (response.status === 204) return {}; + const text = await response.text(); + if (!text) return {}; + return JSON.parse(text); + } + + const errorText = await response.text().catch(() => ''); + const { code, message } = parseAzureError(errorText); + const error = new AzureDataPlaneError( + message || + `Azure data plane ${method} ${url} failed with status ${response.status}`, + response.status, + code, + ); + + const retriableStatus = + response.status === 403 || // RBAC propagation after role assignment + response.status === 429 || + (response.status >= 500 && (method === 'GET' || method === 'DELETE')); + if (retriableStatus && attempt < RETRY_MAX_ATTEMPTS) { + logHttp('request retry', { + method, + url, + attempt, + status: response.status, + delayMs, + }); + await sleepWithSignal(delayMs, options.signal); + delayMs = Math.min(delayMs * 2, RETRY_MAX_DELAY_MS); + continue; + } + + throw error; + } + } +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +function detachedPaths(commandId: string): { + stdout: string; + stderr: string; + exit: string; +} { + return { + stdout: `${DETACHED_LOG_ROOT}/${commandId}.stdout.log`, + stderr: `${DETACHED_LOG_ROOT}/${commandId}.stderr.log`, + exit: `${DETACHED_LOG_ROOT}/${commandId}.exit`, + }; +} + +function buildShellCommand(input: RunCommandInput): string { + const envTokens = Object.entries(input.env ?? {}).map( + ([key, value]) => `${key}=${value}`, + ); + return shellJoin(['env', ...envTokens, input.cmd, ...(input.args ?? [])]); +} + +const SHELL_SAFE_PATTERN = /^[A-Za-z0-9_@%+=:,./-]+$/; + +function shellQuote(value: string): string { + if (value.length > 0 && SHELL_SAFE_PATTERN.test(value)) { + return value; + } + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function shellJoin(parts: string[]): string { + return parts.map(shellQuote).join(' '); +} + +function normalizeLabels( + labels: Record, +): Record | undefined { + const entries = Object.entries(labels).filter( + ([key, value]) => key.length > 0 && value.length > 0, + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +/** + * Product task/env snapshot names must not collide with the worker base disk + * image (`AZURE_SANDBOX_DISK_IMAGE`). + */ +export function deriveAzureProductSnapshotName(instanceId: string): string { + const sanitizedInstance = instanceId + .toLowerCase() + .replace(/[^a-z0-9._-]/g, '-') + .slice(0, 24); + const suffix = randomUUID().replace(/-/g, '').slice(0, 12); + return `${PRODUCT_SNAPSHOT_NAME_PREFIX}-${sanitizedInstance}-${suffix}`; +} + +function parseAzureError(body: string): { code?: string; message?: string } { + try { + const parsed = JSON.parse(body) as { + title?: string; + detail?: string; + errorCode?: number | string; + error?: { code?: string; message?: string }; + message?: string; + }; + return { + code: + parsed.error?.code ?? + parsed.title ?? + (parsed.errorCode !== undefined ? String(parsed.errorCode) : undefined), + message: parsed.error?.message ?? parsed.detail ?? parsed.message, + }; + } catch { + return {}; + } +} + +function isAbortLike(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} diff --git a/packages/compute-providers/src/adapters/index.ts b/packages/compute-providers/src/adapters/index.ts index 116bcd49b..fe20ac39e 100644 --- a/packages/compute-providers/src/adapters/index.ts +++ b/packages/compute-providers/src/adapters/index.ts @@ -4,3 +4,4 @@ export { DockerClient } from './docker'; export { DaytonaClient } from './daytona'; export { E2bClient } from './e2b'; export { BlaxelClient } from './blaxel'; +export { AzureClient } from './azure'; diff --git a/packages/compute-providers/src/azure/cleanup.ts b/packages/compute-providers/src/azure/cleanup.ts new file mode 100644 index 000000000..6ba5a255b --- /dev/null +++ b/packages/compute-providers/src/azure/cleanup.ts @@ -0,0 +1,106 @@ +import type { + ComputeProviderClient, + ComputeProviderMutationObserver, +} from '../types'; +import { buildComputeProviderMutationDetails } from '../mutation-events'; +import type { ComputeProviderLaunchMode } from '@roomote/types'; + +import { isAbortError } from '../modal/abort'; + +function resolveCleanupReason(error: unknown): 'abort' | 'error' { + return isAbortError(error) ? 'abort' : 'error'; +} + +export async function cleanupAzureInstance(options: { + computeClient: Pick; + instanceId: string; + phase: string; + error: unknown; + logPrefix: string; + onMutation?: ComputeProviderMutationObserver; + launchMode?: ComputeProviderLaunchMode | null; + sourceSnapshotId?: string | null; + ports?: number[]; +}): Promise { + const { computeClient, instanceId, phase, error, logPrefix, onMutation } = + options; + const reason = resolveCleanupReason(error); + const mutationDetails = buildComputeProviderMutationDetails( + { + launchMode: options.launchMode, + sourceSnapshotId: options.sourceSnapshotId, + ports: options.ports, + }, + { phase, reason }, + ); + + console.warn(`[${logPrefix}] Cleaning up Azure instance after ${reason}`, { + instanceId, + phase, + error: + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : { message: String(error) }, + }); + + try { + await onMutation?.({ + provider: 'azure', + operation: 'destroy_instance', + eventType: 'started', + instanceId, + message: `Calling destroyInstance for Azure instance ${instanceId}.`, + details: mutationDetails, + }); + + await computeClient.destroyInstance({ instanceId }); + + await onMutation?.({ + provider: 'azure', + operation: 'destroy_instance', + eventType: 'completed', + instanceId, + message: `destroyInstance completed for Azure instance ${instanceId}.`, + details: mutationDetails, + }); + + console.log(`[${logPrefix}] Cleaned up Azure instance`, { + instanceId, + phase, + reason, + }); + } catch (cleanupError) { + await onMutation?.({ + provider: 'azure', + operation: 'destroy_instance', + eventType: 'failed', + instanceId, + message: `destroyInstance failed for Azure instance ${instanceId}.`, + details: { + ...mutationDetails, + error: + cleanupError instanceof Error + ? cleanupError.message + : String(cleanupError), + }, + }); + + console.error(`[${logPrefix}] Failed to clean up Azure instance`, { + instanceId, + phase, + reason, + cleanupError: + cleanupError instanceof Error + ? { + name: cleanupError.name, + message: cleanupError.message, + stack: cleanupError.stack, + } + : { message: String(cleanupError) }, + }); + } +} diff --git a/packages/compute-providers/src/azure/create-azure-machine.ts b/packages/compute-providers/src/azure/create-azure-machine.ts new file mode 100644 index 000000000..fc5965889 --- /dev/null +++ b/packages/compute-providers/src/azure/create-azure-machine.ts @@ -0,0 +1,574 @@ +import { + type ComputeProviderLaunchMode, + type NamedPort, + SANDBOX_FILES_DIR, +} from '@roomote/types'; + +import { createComputeProviderClient } from '../factory'; +import { generateProxyPorts, getExposedPorts } from '../environment-machine'; +import { buildComputeProviderMutationDetails } from '../mutation-events'; +import type { + ComputeProviderClient, + ComputeProviderMutationObserver, +} from '../types'; +import { loadLocalWorkerReleaseWithVersion } from '../sandbox/utils'; +import { getWorkerRelease } from '../sandbox/worker-release-cache'; +import { + type LoadedSandboxBootstrapFiles, + loadSandboxBootstrapFiles, +} from '../sandbox/bootstrap-files'; +import { isAbortError, sleepWithSignal, throwIfAborted } from '../modal/abort'; + +import { cleanupAzureInstance } from './cleanup'; + +const MAX_RETRIES = 3; + +const INITIAL_DELAY_MS = 2_000; + +const AZURE_FILES_DIR = SANDBOX_FILES_DIR; + +const INSTALL_SCRIPT_PATH = `${AZURE_FILES_DIR}/install-worker.sh`; + +const WORKER_TARBALL_PATH = `${AZURE_FILES_DIR}/worker.tar.gz`; + +type AzureLifecycleClient = Pick< + ComputeProviderClient, + | 'vendor' + | 'createInstance' + | 'resumeFromSnapshot' + | 'resumeFromStandby' + | 'writeFiles' + | 'runCommand' + | 'destroyInstance' +>; + +export interface CreateAzureMachineOptions { + azureSubscriptionId: string; + azureResourceGroup: string; + azureSandboxGroup: string; + azureRegion: string; + azureDiskImage: string; + /** + * Optional client ID of a user-assigned managed identity. When omitted, + * auth falls back to the ambient Azure credential chain. + */ + azureClientId?: string; + /** Service principal tenant ID (with `azureClientId` + `azureClientSecret`). */ + azureTenantId?: string; + /** Service principal client secret (with `azureTenantId` + `azureClientId`). */ + azureClientSecret?: string; + ports?: number[]; + namedPorts?: NamedPort[]; + /** + * Optional proxy port mapping override. When omitted, proxy ports are generated. + */ + proxyPorts?: Record; + timeoutMs?: number; + localTarballPath?: string; + /** + * Timeout for sandbox creation (includes cold disk image pulls). + */ + createInstanceTimeoutMs?: number; + /** + * Timeout for file writes + install script after the instance is running. + */ + bootstrapTimeoutMs?: number; + tags?: Record; + signal?: AbortSignal; + computeClient?: AzureLifecycleClient; + onMutation?: ComputeProviderMutationObserver; +} + +export type AzureLaunchOptions = + | { launchMode: 'fresh'; sourceSnapshotId?: undefined } + | { launchMode: 'environment_snapshot'; sourceSnapshotId: string } + | { launchMode: 'task_snapshot'; sourceSnapshotId: string } + | { launchMode: 'task_standby'; resumeHandle: string }; + +export type CreateAzureMachineParams = CreateAzureMachineOptions & + AzureLaunchOptions; + +export interface AzureMachine { + machineId: string; + proxyPorts?: Record; + sourceSnapshotId?: string; + domain: (port: number) => string; +} + +export async function createAzureMachine( + options: CreateAzureMachineParams, +): Promise { + const { + azureSubscriptionId, + azureResourceGroup, + azureSandboxGroup, + azureRegion, + azureDiskImage, + azureClientId, + azureTenantId, + azureClientSecret, + ports, + namedPorts, + timeoutMs, + proxyPorts: proxyPortsOverride, + localTarballPath, + launchMode, + createInstanceTimeoutMs, + bootstrapTimeoutMs, + tags, + signal: legacySignal, + onMutation, + } = options; + + // The task_standby variant carries resumeHandle instead of sourceSnapshotId. + const sourceSnapshotId = + 'sourceSnapshotId' in options ? options.sourceSnapshotId : undefined; + + const createInstanceSignal = + createInstanceTimeoutMs != null + ? AbortSignal.timeout(createInstanceTimeoutMs) + : legacySignal; + + throwIfAborted(createInstanceSignal); + + let tarball: Buffer | undefined; + let version: string | undefined; + + // A task snapshot owns repository and harness-session state, but its worker + // must speak the current API/runtime protocol (for example inference-gateway + // env markers). Refresh only the shipped worker directory after restore. + if (localTarballPath) { + const localRelease = loadLocalWorkerReleaseWithVersion(localTarballPath); + tarball = localRelease.archive; + version = localRelease.version; + } else { + const release = await getWorkerRelease(); + tarball = release.archive; + version = release.version; + } + + const workerReleaseTag = version ? `worker-v${version}` : undefined; + + const proxyPorts = proxyPortsOverride ?? generateProxyPorts(namedPorts); + + const effectivePorts = + namedPorts && namedPorts.length > 0 + ? getExposedPorts(namedPorts, proxyPorts) + : ports; + + // Standby resumes carry resumeHandle, not sourceSnapshotId — record + // whichever is present so mutation events keep the resume reference for + // debugging/audit. + const resumeHandle = + 'resumeHandle' in options ? options.resumeHandle : undefined; + + const mutationContext = { + launchMode: launchMode as ComputeProviderLaunchMode, + sourceSnapshotId: sourceSnapshotId ?? resumeHandle ?? null, + ports: effectivePorts ?? [], + }; + + console.log( + `[createAzureMachine] Starting ${JSON.stringify({ + hasLocalTarball: !!localTarballPath, + hasSourceSnapshot: !!sourceSnapshotId, + launchMode, + workerReleaseTag, + effectivePorts, + proxyPorts, + azureResourceGroup, + azureSandboxGroup, + azureRegion, + azureDiskImage, + tags, + })}`, + ); + + // Service principal auth only kicks in with the full triple; a lone + // AZURE_CLIENT_ID means user-assigned managed identity. + const azureServicePrincipal = + azureTenantId && azureClientId && azureClientSecret + ? { + tenantId: azureTenantId, + clientId: azureClientId, + clientSecret: azureClientSecret, + } + : undefined; + + const computeClient = + options.computeClient ?? + createComputeProviderClient({ + provider: 'azure', + config: { + subscriptionId: azureSubscriptionId, + resourceGroup: azureResourceGroup, + sandboxGroup: azureSandboxGroup, + region: azureRegion, + diskImage: azureDiskImage, + ...(azureServicePrincipal + ? { servicePrincipal: azureServicePrincipal } + : azureClientId + ? { managedIdentityClientId: azureClientId } + : {}), + ...(timeoutMs ? { timeoutMs } : {}), + }, + }); + + if (computeClient.vendor !== 'azure') { + throw new Error('createAzureMachine requires an Azure compute client'); + } + + let createdMachine: + | { instanceId: string; domains?: Record } + | undefined; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + throwIfAborted(createInstanceSignal); + + console.log( + `[createAzureMachine] Attempt ${attempt}/${MAX_RETRIES}: ${resumeHandle ? 'resumeFromStandby' : sourceSnapshotId ? 'resumeFromSnapshot' : 'createInstance'}`, + ); + + try { + const operation = resumeHandle + ? 'resume_from_standby' + : sourceSnapshotId + ? 'resume_from_snapshot' + : 'create_instance'; + + await onMutation?.({ + provider: 'azure', + operation, + eventType: 'started', + message: + operation === 'resume_from_standby' + ? `Calling resumeFromStandby for Azure instance from standby handle ${resumeHandle}.` + : operation === 'resume_from_snapshot' + ? `Calling resumeFromSnapshot for Azure instance from snapshot ${sourceSnapshotId}.` + : 'Calling createInstance for Azure instance.', + details: buildComputeProviderMutationDetails( + { ...mutationContext, attempt }, + {}, + ), + }); + + const instance = resumeHandle + ? await computeClient.resumeFromStandby!({ + resumeHandle, + ports: effectivePorts, + signal: createInstanceSignal, + }) + : sourceSnapshotId + ? await computeClient.resumeFromSnapshot({ + sourceSnapshotId, + ports: effectivePorts, + tags, + metadata: { + ...(workerReleaseTag ? { workerReleaseTag } : {}), + ...(timeoutMs ? { timeoutMs: String(timeoutMs) } : {}), + }, + signal: createInstanceSignal, + }) + : await computeClient.createInstance({ + ports: effectivePorts, + tags, + metadata: { + ...(workerReleaseTag ? { workerReleaseTag } : {}), + ...(timeoutMs ? { timeoutMs: String(timeoutMs) } : {}), + }, + signal: createInstanceSignal, + }); + + await onMutation?.({ + provider: 'azure', + operation, + eventType: 'completed', + instanceId: instance.instanceId, + message: `${ + operation === 'resume_from_standby' + ? 'resumeFromStandby' + : operation === 'resume_from_snapshot' + ? 'resumeFromSnapshot' + : 'createInstance' + } completed for Azure instance ${instance.instanceId}.`, + details: buildComputeProviderMutationDetails( + { ...mutationContext, attempt }, + {}, + ), + }); + + createdMachine = { + instanceId: instance.instanceId, + domains: instance.domains, + }; + + console.log( + `[createAzureMachine] Instance created ${JSON.stringify({ + instanceId: instance.instanceId, + domains: instance.domains, + sourceSnapshotId: instance.sourceSnapshotId, + })}`, + ); + + break; + } catch (error) { + await onMutation?.({ + provider: 'azure', + operation: resumeHandle + ? 'resume_from_standby' + : sourceSnapshotId + ? 'resume_from_snapshot' + : 'create_instance', + eventType: 'failed', + message: `${ + resumeHandle + ? 'resumeFromStandby' + : sourceSnapshotId + ? 'resumeFromSnapshot' + : 'createInstance' + } failed for Azure instance.`, + details: buildComputeProviderMutationDetails( + { ...mutationContext, attempt }, + { + error: error instanceof Error ? error.message : String(error), + }, + ), + }); + + const errorInfo = + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : { message: String(error) }; + + if (isAbortError(error)) { + console.warn( + `[createAzureMachine] Aborting retries after cancellation ${JSON.stringify( + { + attempt, + error: errorInfo, + }, + )}`, + ); + + throw error; + } + + if (attempt === MAX_RETRIES) { + console.error( + `[createAzureMachine] Failed after ${MAX_RETRIES} attempts ${JSON.stringify(errorInfo)}`, + ); + + throw error; + } + + const delayMs = INITIAL_DELAY_MS * Math.pow(2, attempt - 1); + + console.warn( + `[createAzureMachine] Attempt ${attempt}/${MAX_RETRIES} failed, retrying in ${delayMs}ms ${JSON.stringify(errorInfo)}`, + ); + + await sleepWithSignal(delayMs, createInstanceSignal); + } + } + + if (!createdMachine) { + throw new Error('Failed to create Azure instance'); + } + + // Start the bootstrap timeout only after instance creation succeeds, so cold + // disk image pulls don't eat into the bootstrap budget. + const bootstrapSignal = + bootstrapTimeoutMs != null + ? AbortSignal.timeout(bootstrapTimeoutMs) + : legacySignal; + + let bootstrapPhase = 'load-files'; + + try { + const { files: filesToWrite } = loadAzureFiles(); + + if (tarball) { + filesToWrite.push({ path: WORKER_TARBALL_PATH, content: tarball }); + console.log( + `[createAzureMachine] Worker tarball added ${JSON.stringify({ + path: WORKER_TARBALL_PATH, + sizeBytes: tarball.byteLength, + })}`, + ); + } + + if (filesToWrite.length > 0) { + bootstrapPhase = 'write-files'; + + await onMutation?.({ + provider: 'azure', + operation: 'write_files', + eventType: 'started', + instanceId: createdMachine.instanceId, + message: `Calling writeFiles for Azure instance ${createdMachine.instanceId}.`, + details: buildComputeProviderMutationDetails(mutationContext, { + phase: 'bootstrap_upload', + fileCount: filesToWrite.length, + filePaths: filesToWrite.map((file) => file.path), + }), + }); + + await computeClient.writeFiles({ + instanceId: createdMachine.instanceId, + files: filesToWrite, + signal: bootstrapSignal, + }); + + await onMutation?.({ + provider: 'azure', + operation: 'write_files', + eventType: 'completed', + instanceId: createdMachine.instanceId, + message: `writeFiles completed for Azure instance ${createdMachine.instanceId}.`, + details: buildComputeProviderMutationDetails(mutationContext, { + phase: 'bootstrap_upload', + fileCount: filesToWrite.length, + filePaths: filesToWrite.map((file) => file.path), + }), + }); + } + + bootstrapPhase = 'install-worker'; + + console.log( + `[createAzureMachine] Running install script: bash ${INSTALL_SCRIPT_PATH}`, + ); + + await onMutation?.({ + provider: 'azure', + operation: 'run_command', + eventType: 'started', + instanceId: createdMachine.instanceId, + message: `Calling runCommand for Azure instance ${createdMachine.instanceId}.`, + details: buildComputeProviderMutationDetails(mutationContext, { + phase: 'install_worker', + command: 'bash', + args: [INSTALL_SCRIPT_PATH], + }), + }); + + const installResult = await computeClient.runCommand({ + instanceId: createdMachine.instanceId, + cmd: 'bash', + args: [INSTALL_SCRIPT_PATH], + ...(tarball + ? { + env: { + // Fresh Azure boots stage the worker release under the shared + // sandbox files directory so the install script can reuse the + // same default path as Vercel sandbox. + WORKER_RELEASE_ARCHIVE_PATH: WORKER_TARBALL_PATH, + }, + } + : {}), + signal: bootstrapSignal, + }); + + await onMutation?.({ + provider: 'azure', + operation: 'run_command', + eventType: 'completed', + instanceId: createdMachine.instanceId, + message: `runCommand completed for Azure instance ${createdMachine.instanceId}.`, + details: buildComputeProviderMutationDetails(mutationContext, { + phase: 'install_worker', + command: 'bash', + args: [INSTALL_SCRIPT_PATH], + exitCode: installResult.exitCode, + }), + }); + + if (installResult.exitCode !== 0) { + throw new Error( + `Azure worker install failed with exit code ${installResult.exitCode ?? 'null'}: ${installResult.stderr ?? installResult.stdout ?? 'no output'}`, + ); + } + } catch (error) { + if (bootstrapPhase === 'write-files') { + await onMutation?.({ + provider: 'azure', + operation: 'write_files', + eventType: 'failed', + instanceId: createdMachine.instanceId, + message: `writeFiles failed for Azure instance ${createdMachine.instanceId}.`, + details: buildComputeProviderMutationDetails(mutationContext, { + phase: 'bootstrap_upload', + error: error instanceof Error ? error.message : String(error), + }), + }); + } else if (bootstrapPhase === 'install-worker') { + await onMutation?.({ + provider: 'azure', + operation: 'run_command', + eventType: 'failed', + instanceId: createdMachine.instanceId, + message: `runCommand failed for Azure instance ${createdMachine.instanceId}.`, + details: buildComputeProviderMutationDetails(mutationContext, { + phase: 'install_worker', + command: 'bash', + args: [INSTALL_SCRIPT_PATH], + error: error instanceof Error ? error.message : String(error), + }), + }); + } + + await cleanupAzureInstance({ + computeClient, + instanceId: createdMachine.instanceId, + phase: bootstrapPhase, + error, + logPrefix: 'createAzureMachine', + onMutation, + ...mutationContext, + }); + + throw error; + } + + return { + machineId: createdMachine.instanceId, + proxyPorts, + ...(sourceSnapshotId + ? { sourceSnapshotId } + : 'resumeHandle' in options + ? { sourceSnapshotId: options.resumeHandle } + : {}), + domain: (port: number) => { + const fromResponse = createdMachine.domains?.[port.toString()]; + + if (fromResponse) { + return fromResponse; + } + + throw new Error( + `No Azure preview link resolved for port ${port} on ${createdMachine.instanceId}`, + ); + }, + }; +} + +function loadAzureFiles(): LoadedSandboxBootstrapFiles { + const loadedFiles = loadSandboxBootstrapFiles(AZURE_FILES_DIR); + + if (loadedFiles.ignoredFiles.length > 0) { + console.log( + `[createAzureMachine] Ignoring non-bootstrap Azure files ${JSON.stringify( + { + localDir: loadedFiles.localDir, + ignoredFiles: loadedFiles.ignoredFiles, + }, + )}`, + ); + } + + return loadedFiles; +} diff --git a/packages/compute-providers/src/azure/credentials.ts b/packages/compute-providers/src/azure/credentials.ts new file mode 100644 index 000000000..0c2a67180 --- /dev/null +++ b/packages/compute-providers/src/azure/credentials.ts @@ -0,0 +1,78 @@ +import { raceWithAbort } from '../modal/abort'; + +export const AZURE_CREDENTIAL_TIMEOUT_MS = 15_000; + +const AZURE_CREDENTIAL_TIMEOUT_MESSAGE = + `Azure credential acquisition timed out after ${AZURE_CREDENTIAL_TIMEOUT_MS / 1_000}s. ` + + 'If using managed identity, the controller must run in Azure with that identity assigned ' + + '(IMDS is unreachable outside Azure); otherwise configure the service principal triple ' + + '(AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET).'; + +export interface AzureCredentialOptions { + servicePrincipal?: { + tenantId: string; + clientId: string; + clientSecret: string; + }; + managedIdentityClientId?: string; +} + +export interface AzureTokenCredential { + // Matches @azure/identity TokenCredential: can resolve null when the + // chain cannot produce a token (acquireAzureToken turns that into an + // actionable error instead of a downstream crash). + getToken(scope: string): Promise<{ + token: string; + expiresOnTimestamp: number; + } | null>; +} + +/** + * Credential selection, deterministic order: explicit service principal > + * user-assigned managed identity > ambient chain (az login, system MI). + * Imported lazily so test seams never touch @azure/identity. + */ +export function createAzureCredential( + options: AzureCredentialOptions, +): Promise { + return import('@azure/identity').then( + ({ + ClientSecretCredential, + DefaultAzureCredential, + ManagedIdentityCredential, + }) => { + if (options.servicePrincipal) { + const { tenantId, clientId, clientSecret } = options.servicePrincipal; + return new ClientSecretCredential(tenantId, clientId, clientSecret); + } + return options.managedIdentityClientId + ? new ManagedIdentityCredential(options.managedIdentityClientId) + : new DefaultAzureCredential(); + }, + ); +} + +/** + * Fail fast with a readable error instead of hanging for minutes: + * ManagedIdentityCredential probes the IMDS endpoint (169.254.169.254), + * which silently blackholes on non-Azure hosts. + */ +export async function acquireAzureToken( + credential: AzureTokenCredential, + scope: string, +): Promise<{ token: string; expiresOnTimestamp: number }> { + const token = await raceWithAbort({ + promise: credential.getToken(scope), + signal: AbortSignal.timeout(AZURE_CREDENTIAL_TIMEOUT_MS), + abortMessage: AZURE_CREDENTIAL_TIMEOUT_MESSAGE, + }); + + if (!token) { + throw new Error( + 'Azure credential chain did not return an access token. Verify service ' + + 'principal or managed identity configuration and role assignment.', + ); + } + + return token; +} diff --git a/packages/compute-providers/src/azure/index.ts b/packages/compute-providers/src/azure/index.ts new file mode 100644 index 000000000..923916c40 --- /dev/null +++ b/packages/compute-providers/src/azure/index.ts @@ -0,0 +1,4 @@ +export * from './cleanup'; +export * from './credentials'; +export * from './register-azure-disk-image'; +export * from './create-azure-machine'; diff --git a/packages/compute-providers/src/azure/register-azure-disk-image.ts b/packages/compute-providers/src/azure/register-azure-disk-image.ts new file mode 100644 index 000000000..c35becae9 --- /dev/null +++ b/packages/compute-providers/src/azure/register-azure-disk-image.ts @@ -0,0 +1,259 @@ +import { WORKER_RUNTIME_SCHEMA_TAG } from '@roomote/types'; + +import { AzureDataPlaneError } from '../adapters/azure'; +import { + acquireAzureToken, + createAzureCredential, + type AzureTokenCredential, +} from './credentials'; + +const DATA_PLANE_SCOPE = 'https://dynamicsessions.io/.default'; +const API_VERSION = '2026-02-01-preview'; + +const DISK_IMAGE_POLL_INTERVAL_MS = 5_000; +const DISK_IMAGE_POLL_TIMEOUT_MS = 10 * 60 * 1_000; + +/** + * Default name prefix for the Roomote worker base disk image. The suffix is + * derived from the worker image tag so each disk image name identifies exactly + * one worker image build (Azure disk image names cannot contain colons). + */ +export const AZURE_WORKER_DISK_IMAGE_NAME_PREFIX = 'roomote-worker'; + +export function deriveAzureWorkerDiskImageName(imageRef: string): string { + const imageTag = imageRef.includes(':') + ? imageRef.slice(imageRef.lastIndexOf(':') + 1) + : 'latest'; + + const sanitizedTag = imageTag.toLowerCase().replace(/[^a-z0-9._-]/g, '-'); + + return `${AZURE_WORKER_DISK_IMAGE_NAME_PREFIX}-${sanitizedTag}-${WORKER_RUNTIME_SCHEMA_TAG}`; +} + +export interface RegisterAzureDiskImageOptions { + /** Azure subscription ID (`AZURE_SUBSCRIPTION_ID`). */ + subscriptionId: string; + /** Resource group containing the sandbox group (`AZURE_RESOURCE_GROUP`). */ + resourceGroup: string; + /** Sandbox group name (`AZURE_SANDBOX_GROUP`). */ + sandboxGroup: string; + /** Data-plane region (`AZURE_SANDBOX_REGION`), e.g. `canadacentral`. */ + region: string; + /** + * Registry-qualified worker image reference (e.g. `ghcr.io/...:tag`). + * Azure pulls this from the registry when baking the disk image; private + * registries require `registryCredentials`. + */ + imageRef: string; + /** + * Registry credentials for pulling private container images during the + * bake (`registryCredentials` on the data-plane PUT). For GHCR: the GitHub + * username that owns the token, and a PAT with `read:packages`. + */ + registryCredentials?: { username: string; token: string }; + /** Overrides `roomote-worker--r`. */ + name?: string; + /** + * Optional client ID of a user-assigned managed identity. When omitted, + * auth falls back to the ambient Azure credential chain (az login locally, + * system-assigned identity when deployed). + */ + managedIdentityClientId?: string; + /** + * Service principal credentials (`AZURE_TENANT_ID` + `AZURE_CLIENT_ID` + + * `AZURE_CLIENT_SECRET`). Preferred for containerized deployments where + * `az login` is impractical inside the container. + */ + servicePrincipal?: { + tenantId: string; + clientId: string; + clientSecret: string; + }; + /** Test seam; defaults to global fetch. */ + fetchImpl?: typeof fetch; +} + +export interface RegisteredAzureDiskImage { + /** Disk image id suitable for `AZURE_SANDBOX_DISK_IMAGE`. */ + diskImageId: string; +} + +interface AzureDiskImage { + id?: string; + labels?: Record; + status?: { state?: string; message?: string }; +} + +/** + * Registers the Roomote worker base disk image in the Azure sandbox group + * from a published worker OCI image. Shared by the setup-time provisioning + * flow; the PUT returns immediately and the bake finishes asynchronously, so + * the disk image is polled until its status reaches `Ready`/`Succeeded` + * (fail on `Failed`). + */ +export async function registerAzureDiskImage( + options: RegisterAzureDiskImageOptions, +): Promise { + const { + subscriptionId, + resourceGroup, + sandboxGroup, + region, + imageRef, + managedIdentityClientId, + registryCredentials, + } = options; + + if (!imageRef.includes('/')) { + throw new Error( + `Azure disk image registration needs a registry-qualified worker image; got "${imageRef}"`, + ); + } + + const name = options.name ?? deriveAzureWorkerDiskImageName(imageRef); + const fetchImpl = options.fetchImpl ?? fetch; + + const endpoint = `https://management.${region}.azuredevcompute.io`; + const collectionPath = + `/subscriptions/${subscriptionId}` + + `/resourceGroups/${resourceGroup}` + + `/sandboxGroups/${sandboxGroup}` + + `/diskimages`; + + console.log( + `[registerAzureDiskImage] Starting ${JSON.stringify({ + imageRef, + name, + region, + resourceGroup, + sandboxGroup, + })}`, + ); + + let credentialPromise: Promise | undefined; + let cachedToken: { token: string; expiresOnTimestamp: number } | undefined; + + const getToken = async (): Promise => { + const now = Date.now(); + if (cachedToken && cachedToken.expiresOnTimestamp - 5 * 60 * 1_000 > now) { + return cachedToken.token; + } + + if (!credentialPromise) { + credentialPromise = createAzureCredential({ + ...(options.servicePrincipal + ? { servicePrincipal: options.servicePrincipal } + : {}), + ...(managedIdentityClientId ? { managedIdentityClientId } : {}), + }); + } + const credential = await credentialPromise; + cachedToken = await acquireAzureToken(credential, DATA_PLANE_SCOPE); + return cachedToken.token; + }; + + const request = async ( + method: string, + path: string, + body?: Record, + ): Promise => { + const url = new URL(`${endpoint}${path}`); + url.searchParams.set('api-version', API_VERSION); + + const response = await fetchImpl(url.toString(), { + method, + headers: { + authorization: `Bearer ${await getToken()}`, + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + if (response.status < 400) { + if (response.status === 204) return {}; + const text = await response.text(); + return text ? JSON.parse(text) : {}; + } + + const errorText = await response.text().catch(() => ''); + throw new AzureDataPlaneError( + `Azure data plane ${method} ${path} failed with status ${response.status}: ${ + errorText || 'no response body' + }`, + response.status, + ); + }; + + const created = (await request('PUT', collectionPath, { + image: { base: imageRef }, + labels: { name }, + ...(registryCredentials + ? { + registryCredentials: { + username: registryCredentials.username, + token: registryCredentials.token, + }, + } + : {}), + })) as AzureDiskImage; + + if (!created.id) { + throw new Error('Azure disk image registration returned no disk image id'); + } + + const deadline = Date.now() + DISK_IMAGE_POLL_TIMEOUT_MS; + let lastLoggedState: string | undefined; + let diskImage = created; + + while (true) { + const state = diskImage.status?.state; + + if (state === 'Ready' || state === 'Succeeded') { + break; + } + + if (state === 'Failed') { + throw new Error( + `Azure disk image "${name}" failed to build${ + diskImage.status?.message ? `: ${diskImage.status.message}` : '' + }`, + ); + } + + if (Date.now() > deadline) { + throw new Error( + `Azure disk image "${name}" did not become Ready within ${ + DISK_IMAGE_POLL_TIMEOUT_MS / 60_000 + } minutes (last state: ${state ?? 'unknown'})`, + ); + } + + if (state !== lastLoggedState) { + console.log( + `[registerAzureDiskImage] Waiting for disk image ${JSON.stringify({ + id: created.id, + name, + state: state ?? 'unknown', + })}`, + ); + lastLoggedState = state; + } + + await new Promise((resolve) => + setTimeout(resolve, DISK_IMAGE_POLL_INTERVAL_MS), + ); + diskImage = (await request( + 'GET', + `${collectionPath}/${created.id}`, + )) as AzureDiskImage; + } + + console.log( + `[registerAzureDiskImage] Disk image registered ${JSON.stringify({ + diskImageId: created.id, + name, + })}`, + ); + + return { diskImageId: created.id }; +} diff --git a/packages/compute-providers/src/factory.ts b/packages/compute-providers/src/factory.ts index 3862bd574..12c7da61d 100644 --- a/packages/compute-providers/src/factory.ts +++ b/packages/compute-providers/src/factory.ts @@ -10,6 +10,7 @@ import { import type { ComputeProviderClient, ComputeProviderFactoryOptions, + AzureConfig, BlaxelConfig, DaytonaConfig, E2bConfig, @@ -23,10 +24,49 @@ import { DaytonaClient, E2bClient, BlaxelClient, + AzureClient, } from './adapters'; const MODAL_DEFAULT_MEMORY_LIMIT_MIB = SANDBOX_DEFAULT_MEMORY_MIB * 2; +/** + * ACA sandbox size presets. Disk values are the tier caps the service + * enforces (measured: `InvalidResourceTier` past cores × 20Gi — XS 5Gi, + * S 10Gi, M 20Gi, L 40Gi, XL 80Gi; the doc table's 20Gi for XS/S is wrong). + */ +export const AZURE_SIZE_PRESETS = { + XS: { cpuMillicores: 250, memoryMiB: 512, diskSize: '5Gi' }, + S: { cpuMillicores: 500, memoryMiB: 1024, diskSize: '10Gi' }, + M: { cpuMillicores: 1000, memoryMiB: 2048, diskSize: '20Gi' }, + L: { cpuMillicores: 2000, memoryMiB: 4096, diskSize: '40Gi' }, + XL: { cpuMillicores: 4000, memoryMiB: 8192, diskSize: '80Gi' }, +} as const; + +export type AzureSizePreset = keyof typeof AZURE_SIZE_PRESETS; + +export function parseAzureSizePreset( + value: string | undefined, +): AzureSizePreset | undefined { + return value === 'XS' || + value === 'S' || + value === 'M' || + value === 'L' || + value === 'XL' + ? value + : undefined; +} + +function parseAzureEgressInspection( + value: string | undefined, +): 'Legacy' | 'Full' | 'Partial' | 'None' | undefined { + return value === 'Legacy' || + value === 'Full' || + value === 'Partial' || + value === 'None' + ? value + : undefined; +} + export { getComputeProviderCapabilities } from '@roomote/types'; /** @@ -316,6 +356,93 @@ export function createComputeProviderClient( return new BlaxelClient(config); } + case 'azure': { + const subscriptionId = + options.config?.subscriptionId ?? envValue('AZURE_SUBSCRIPTION_ID'); + const resourceGroup = + options.config?.resourceGroup ?? envValue('AZURE_RESOURCE_GROUP'); + const sandboxGroup = + options.config?.sandboxGroup ?? envValue('AZURE_SANDBOX_GROUP'); + const region = options.config?.region ?? envValue('AZURE_SANDBOX_REGION'); + const diskImage = + options.config?.diskImage ?? envValue('AZURE_SANDBOX_DISK_IMAGE'); + // Each SP component follows the same config-then-env precedence as + // every other Azure field, so an explicit config never loses to + // ambient env. + const spTenantId = + options.config?.servicePrincipal?.tenantId ?? + envValue('AZURE_TENANT_ID'); + const spClientId = + options.config?.servicePrincipal?.clientId ?? + envValue('AZURE_CLIENT_ID'); + const spClientSecret = + options.config?.servicePrincipal?.clientSecret ?? + envValue('AZURE_CLIENT_SECRET'); + + // An explicit managedIdentityClientId is a deliberate choice of auth + // mode; ambient SP env vars must not silently override it. Service + // principal auth only kicks in when the full triple is present — a + // lone AZURE_CLIENT_ID still means user-assigned managed identity. + const callerChoseManagedIdentity = + options.config?.managedIdentityClientId !== undefined; + const servicePrincipal = + !callerChoseManagedIdentity && + spTenantId && + spClientId && + spClientSecret + ? { + tenantId: spTenantId, + clientId: spClientId, + clientSecret: spClientSecret, + } + : undefined; + const managedIdentityClientId = + options.config?.managedIdentityClientId ?? envValue('AZURE_CLIENT_ID'); + + assertDefined(subscriptionId, 'Missing AZURE_SUBSCRIPTION_ID'); + assertDefined(resourceGroup, 'Missing AZURE_RESOURCE_GROUP'); + assertDefined(sandboxGroup, 'Missing AZURE_SANDBOX_GROUP'); + assertDefined(region, 'Missing AZURE_SANDBOX_REGION'); + assertDefined(diskImage, 'Missing AZURE_SANDBOX_DISK_IMAGE'); + + // Size preset only fills values the caller didn't set explicitly. + const sizePreset = + options.config?.size ?? + parseAzureSizePreset(envValue('AZURE_SANDBOX_SIZE')); + const egressInspection = + options.config?.egressTrafficInspection ?? + parseAzureEgressInspection(envValue('AZURE_SANDBOX_EGRESS_INSPECTION')); + + const config: AzureConfig = { + ...(options.config ?? {}), + subscriptionId, + resourceGroup, + sandboxGroup, + region, + diskImage, + ...(servicePrincipal + ? { servicePrincipal } + : managedIdentityClientId + ? { managedIdentityClientId } + : {}), + ...(sizePreset && options.config?.cpuMillicores === undefined + ? { cpuMillicores: AZURE_SIZE_PRESETS[sizePreset].cpuMillicores } + : {}), + ...(sizePreset && options.config?.memoryMiB === undefined + ? { memoryMiB: AZURE_SIZE_PRESETS[sizePreset].memoryMiB } + : {}), + ...(sizePreset && options.config?.diskSize === undefined + ? { diskSize: AZURE_SIZE_PRESETS[sizePreset].diskSize } + : {}), + ...(egressInspection && + options.config?.egressTrafficInspection === undefined + ? { egressTrafficInspection: egressInspection } + : {}), + }; + + return new AzureClient(config); + } + default: { const _exhaustive: never = options; throw new Error(`Unsupported provider: ${String(_exhaustive)}`); diff --git a/packages/compute-providers/src/index.ts b/packages/compute-providers/src/index.ts index bca1d1a47..a531a8695 100644 --- a/packages/compute-providers/src/index.ts +++ b/packages/compute-providers/src/index.ts @@ -17,3 +17,5 @@ export * from './adapters/e2b'; export * from './e2b'; export * from './adapters/blaxel'; export * from './blaxel'; +export * from './adapters/azure'; +export * from './azure'; diff --git a/packages/compute-providers/src/types.ts b/packages/compute-providers/src/types.ts index 6dedffa7b..8fcd12df4 100644 --- a/packages/compute-providers/src/types.ts +++ b/packages/compute-providers/src/types.ts @@ -341,6 +341,81 @@ export interface E2bConfig { timeoutMs?: number; } +export interface AzureConfig { + /** Azure subscription ID (`AZURE_SUBSCRIPTION_ID`). */ + subscriptionId: string; + /** Azure resource group containing the sandbox group (`AZURE_RESOURCE_GROUP`). */ + resourceGroup: string; + /** Sandbox group name (`AZURE_SANDBOX_GROUP`). */ + sandboxGroup: string; + /** Data-plane region (`AZURE_SANDBOX_REGION`), e.g. `canadacentral`. */ + region: string; + /** + * Disk image used as the base for fresh sandboxes + * (`AZURE_SANDBOX_DISK_IMAGE`). Private disk image resource ID (baked from + * the Roomote worker OCI image), or `public:` for a public preset + * (e.g. `public:ubuntu`). + */ + diskImage: string; + /** + * Optional client ID of a user-assigned managed identity + * (`AZURE_CLIENT_ID`). When unset and no service principal is configured, + * auth falls back to the ambient Azure credential chain (az login locally, + * system-assigned identity when deployed). + */ + managedIdentityClientId?: string; + /** + * Service principal credentials (`AZURE_TENANT_ID` + `AZURE_CLIENT_ID` + + * `AZURE_CLIENT_SECRET`). Preferred for containerized deployments: unlike + * the ambient chain, these values come through Roomote's resolved-env + * channel (DB-persisted setup values or process env), not only process env. + */ + servicePrincipal?: { + tenantId: string; + clientId: string; + clientSecret: string; + }; + /** + * Maximum sandbox lifetime in milliseconds, enforced provider-side via the + * sandbox auto-delete lifecycle policy. + */ + timeoutMs?: number; + /** + * Idle auto-suspend interval in seconds. Defaults to 0 (disabled): Roomote + * drives suspend/resume explicitly via standby and idle workers must not + * suspend underneath the controller. + */ + autoSuspendSeconds?: number; + /** + * ACA size tier (`AZURE_SANDBOX_SIZE`). Sets cpu/memory/disk defaults; + * explicit cpuMillicores/memoryMiB/diskSize fields win over the preset. + */ + size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; + /** CPU request in millicores (default 1000 = 1 vCPU). */ + cpuMillicores?: number; + /** Memory request in MiB (default 2048). */ + memoryMiB?: number; + /** + * Base disk size as a storage quantity (e.g. `"80Gi"`). The service enforces + * disk <= cores × 20Gi; Roomote's built-in size presets use XS 5Gi, S 10Gi, + * M 20Gi, L 40Gi, XL 80Gi. + */ + diskSize?: string; + /** + * Egress proxy TLS inspection mode for new sandboxes. Defaults to + * `Partial`: Roomote configures no egress rules, and with the service + * default (`Full`) the proxy TLS-resigns ALL outbound traffic (breaking + * language-specific trust stores such as Java's cacerts) and blocks + * non-HTTP traffic (breaking SSH git). Set `Full` when wiring deny-default + * egress rules or header transforms. + */ + egressTrafficInspection?: 'Legacy' | 'Full' | 'Partial' | 'None'; + /** Test seam: token provider override (scope `https://dynamicsessions.io/.default`). */ + tokenProvider?: { getToken(): Promise }; + /** Test seam; defaults to global fetch. */ + fetchImpl?: typeof fetch; +} + export interface BlaxelConfig { /** Blaxel API key (`BL_API_KEY`). */ apiKey: string; @@ -388,6 +463,10 @@ export type ComputeProviderFactoryOptions = ( provider: 'blaxel'; config?: BlaxelConfig; } + | { + provider: 'azure'; + config?: AzureConfig; + } ) & { /** * Pre-resolved env values consulted before `process.env` when a config diff --git a/packages/compute-providers/src/worker-env/azure.ts b/packages/compute-providers/src/worker-env/azure.ts new file mode 100644 index 000000000..14c9dc108 --- /dev/null +++ b/packages/compute-providers/src/worker-env/azure.ts @@ -0,0 +1,34 @@ +import { buildBaseWorkerEnv } from './base'; +import { buildWorkerContextEnv } from './context'; + +import type { BuildWorkerEnvOptions } from './types'; + +export function buildAzureWorkerEnv({ + authToken, + sandboxExpiresAtMs, + extraEnv, + deploymentSlug, + environmentId, + diskImage, +}: BuildWorkerEnvOptions & { + deploymentSlug?: string; + environmentId?: string; + diskImage: string; +}): Record { + return { + ...buildBaseWorkerEnv({ + authToken, + sandboxExpiresAtMs, + extraEnv, + }), + ...buildWorkerContextEnv({ + provider: 'azure', + fingerprint: diskImage, + fingerprintKind: 'base-image', + deploymentSlug, + environmentId, + }), + MISE_DATA_DIR: '/opt/mise', + MISE_CACHE_DIR: '/opt/mise/cache', + }; +} diff --git a/packages/compute-providers/src/worker-env/index.ts b/packages/compute-providers/src/worker-env/index.ts index d9fadd907..03b65657c 100644 --- a/packages/compute-providers/src/worker-env/index.ts +++ b/packages/compute-providers/src/worker-env/index.ts @@ -3,3 +3,4 @@ export * from './docker'; export * from './daytona'; export * from './e2b'; export * from './blaxel'; +export * from './azure'; diff --git a/packages/db/drizzle/0024_azure_sleep_check_indexes.sql b/packages/db/drizzle/0024_azure_sleep_check_indexes.sql new file mode 100644 index 000000000..424467112 --- /dev/null +++ b/packages/db/drizzle/0024_azure_sleep_check_indexes.sql @@ -0,0 +1,14 @@ +-- Replace the 6-vendor sleep-check partial indexes on task_runs with +-- azure-inclusive *_v2 versions. Replacements are created BEFORE the +-- originals are dropped so sleep-check queries keep index coverage +-- throughout. Each plain CREATE INDEX takes a brief write lock on +-- task_runs; CREATE INDEX CONCURRENTLY is not used because drizzle wraps +-- migrations in a transaction (CONCURRENTLY cannot run inside one). +-- IF (NOT) EXISTS guards make this converge environments that applied +-- pre-merge variants of this change (e.g. an earlier *_v2 rename). +CREATE INDEX IF NOT EXISTS "task_runs_sleep_check_due_v2_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', 'roomote', 'azure');--> statement-breakpoint +DROP INDEX IF EXISTS "task_runs_sleep_check_due_idx";--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "task_runs_sleep_check_stale_worker_v2_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', 'roomote', 'azure');--> statement-breakpoint +DROP INDEX IF EXISTS "task_runs_sleep_check_stale_worker_idx";--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "task_runs_sleep_check_active_v2_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', 'roomote', 'azure');--> statement-breakpoint +DROP INDEX IF EXISTS "task_runs_sleep_check_active_idx"; diff --git a/packages/db/drizzle/meta/0024_snapshot.json b/packages/db/drizzle/meta/0024_snapshot.json new file mode 100644 index 000000000..6d60b8c96 --- /dev/null +++ b/packages/db/drizzle/meta/0024_snapshot.json @@ -0,0 +1,9897 @@ +{ + "id": "4594a16c-aab6-4bfe-aaf1-c1d04b3d145f", + "prevId": "0160cf6a-8185-4105-a4c4-76e8e1d120f1", + "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.custom_automations": { + "name": "custom_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule_mode": { + "name": "schedule_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "last_launched_task_id": { + "name": "last_launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_claimed_at": { + "name": "launch_claimed_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": { + "custom_automations_name_unique_idx": { + "name": "custom_automations_name_unique_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_enabled_idx": { + "name": "custom_automations_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_environment_id_idx": { + "name": "custom_automations_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_automations_environment_id_environments_id_fk": { + "name": "custom_automations_environment_id_environments_id_fk", + "tableFrom": "custom_automations", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_created_by_user_id_users_id_fk": { + "name": "custom_automations_created_by_user_id_users_id_fk", + "tableFrom": "custom_automations", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_last_launched_task_id_tasks_id_fk": { + "name": "custom_automations_last_launched_task_id_tasks_id_fk", + "tableFrom": "custom_automations", + "tableTo": "tasks", + "columnsFrom": ["last_launched_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_provider": { + "name": "router_debug_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_channel_id": { + "name": "router_debug_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_disabled": { + "name": "router_debug_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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.discord_gateway_sessions": { + "name": "discord_gateway_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resume_gateway_url": { + "name": "resume_gateway_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "shard_count": { + "name": "shard_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_ack_at": { + "name": "last_heartbeat_ack_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installation_channels": { + "name": "discord_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_installation_id": { + "name": "discord_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_type": { + "name": "channel_type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_available": { + "name": "is_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_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": { + "discord_installation_channels_installation_id_idx": { + "name": "discord_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installation_channels_unique": { + "name": "discord_installation_channels_unique", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installation_channels_discord_installation_id_discord_installations_id_fk": { + "name": "discord_installation_channels_discord_installation_id_discord_installations_id_fk", + "tableFrom": "discord_installation_channels", + "tableTo": "discord_installations", + "columnsFrom": ["discord_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installations": { + "name": "discord_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "guild_name": { + "name": "guild_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_id": { + "name": "default_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_name": { + "name": "default_channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_type": { + "name": "default_channel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": { + "discord_installations_guild_id_unique": { + "name": "discord_installations_guild_id_unique", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_active_idx": { + "name": "discord_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_default_channel_idx": { + "name": "discord_installations_default_channel_idx", + "columns": [ + { + "expression": "default_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installations_installed_by_user_id_users_id_fk": { + "name": "discord_installations_installed_by_user_id_users_id_fk", + "tableFrom": "discord_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_user_mappings": { + "name": "discord_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_dm_channel_id": { + "name": "discord_dm_channel_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": { + "discord_user_mappings_user_id_idx": { + "name": "discord_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_user_mappings_discord_user_id_unique": { + "name": "discord_user_mappings_discord_user_id_unique", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_user_mappings_user_id_users_id_fk": { + "name": "discord_user_mappings_user_id_users_id_fk", + "tableFrom": "discord_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verification_task_id": { + "name": "verification_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_error": { + "name": "verification_error", + "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.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'" + }, + "usage_type": { + "name": "usage_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inference'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "pricing_metadata": { + "name": "pricing_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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_event_key_unique": { + "name": "task_inference_usage_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "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_user_id_idx": { + "name": "task_inference_usage_events_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_environment_id_idx": { + "name": "task_inference_usage_events_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_provider_model_idx": { + "name": "task_inference_usage_events_provider_model_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_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" + }, + "task_inference_usage_events_user_id_users_id_fk": { + "name": "task_inference_usage_events_user_id_users_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_environment_id_environments_id_fk": { + "name": "task_inference_usage_events_environment_id_environments_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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_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 + }, + "created_by_roomote": { + "name": "created_by_roomote", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_handle_feedback_by_user_id": { + "name": "auto_handle_feedback_by_user_id", + "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" + }, + "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk": { + "name": "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "users", + "columnsFrom": ["auto_handle_feedback_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "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 + }, + "error_code": { + "name": "error_code", + "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 + }, + "environment_setup_state": { + "name": "environment_setup_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_setup_completed_at": { + "name": "environment_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_v2_idx": { + "name": "task_runs_sleep_check_due_v2_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', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_stale_worker_v2_idx": { + "name": "task_runs_sleep_check_stale_worker_v2_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', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_active_v2_idx": { + "name": "task_runs_sleep_check_active_v2_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', 'roomote', 'azure')", + "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_discord_source_event_unique": { + "name": "task_runs_discord_source_event_unique", + "columns": [ + { + "expression": "(\"payload\"->>'communicationSourceEventId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'communicationProvider' = 'discord' AND \"task_runs\".\"payload\"->>'communicationSourceEventId' IS NOT NULL AND \"task_runs\".\"canceled_at\" IS NULL", + "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', 'discord', '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 + }, + "cookie_consented_at": { + "name": "cookie_consented_at", + "type": "timestamp", + "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 8718e4371..62c6ddae1 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1785419452431, "tag": "0023_low_captain_midlands", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1785435030830, + "tag": "0024_azure_sleep_check_indexes", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 2ccca4a67..8558ae12d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1069,20 +1069,20 @@ export const taskRuns = pgTable( index('task_runs_snapshot_id_idx').on(table.snapshotId), index('task_runs_sleep_at_idx').on(table.sleepAt), index('task_runs_worker_heartbeat_at_idx').on(table.workerHeartbeatAt), - index('task_runs_sleep_check_due_idx') + index('task_runs_sleep_check_due_v2_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', 'docker', 'blaxel', 'roomote')`, + 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', 'roomote', 'azure')`, ), - index('task_runs_sleep_check_stale_worker_idx') + index('task_runs_sleep_check_stale_worker_v2_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', 'docker', 'blaxel', 'roomote')`, + 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', 'roomote', 'azure')`, ), - index('task_runs_sleep_check_active_idx') + index('task_runs_sleep_check_active_v2_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', 'docker', 'blaxel', 'roomote')`, + 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', 'roomote', 'azure')`, ), 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/index.ts b/packages/env/src/index.ts index 7b0471691..8e9519db8 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -65,7 +65,7 @@ const serverSchema = { R_APP_ENV: z.enum(['development', 'preview', 'production']).optional(), APP_ENV: z.enum(['development', 'preview', 'production']).optional(), DEFAULT_COMPUTE_PROVIDER: z - .enum(['modal', 'docker', 'daytona', 'e2b', 'roomote']) + .enum(['modal', 'docker', 'daytona', 'e2b', 'roomote', 'azure']) .default('docker'), EXCLUDED_COMPUTE_PROVIDERS: z.string().optional(), DOCKER_WORKER_IMAGE: z @@ -258,6 +258,20 @@ const serverSchema = { BLAXEL_REGION: z.string().optional(), E2B_DOMAIN: z.string().optional(), E2B_TEMPLATE_ID: z.string().optional(), + AZURE_SUBSCRIPTION_ID: z.string().optional(), + AZURE_RESOURCE_GROUP: z.string().optional(), + AZURE_SANDBOX_GROUP: z.string().optional(), + AZURE_SANDBOX_REGION: z.string().optional(), + AZURE_SANDBOX_DISK_IMAGE: z.string().optional(), + AZURE_CLIENT_ID: z.string().optional(), + AZURE_TENANT_ID: z.string().optional(), + AZURE_CLIENT_SECRET: z.string().optional(), + AZURE_SANDBOX_REGISTRY_USERNAME: z.string().optional(), + AZURE_SANDBOX_REGISTRY_TOKEN: z.string().optional(), + AZURE_SANDBOX_SIZE: z.enum(['XS', 'S', 'M', 'L', 'XL']).optional(), + AZURE_SANDBOX_EGRESS_INSPECTION: z + .enum(['Legacy', 'Full', 'Partial', 'None']) + .optional(), // E2B caps sandbox lifetime per plan (1 hour on Hobby, 24 hours on Pro); // requesting more fails sandbox creation with a 400, so the controller // clamps the provider-side timeout to this ceiling. Raise it only when the @@ -483,6 +497,18 @@ const OPTIONAL_NON_EMPTY_KEYS = new Set([ 'E2B_DOMAIN', 'E2B_TEMPLATE_ID', 'E2B_MAX_SANDBOX_TIMEOUT_MS', + 'AZURE_SUBSCRIPTION_ID', + 'AZURE_RESOURCE_GROUP', + 'AZURE_SANDBOX_GROUP', + 'AZURE_SANDBOX_REGION', + 'AZURE_SANDBOX_DISK_IMAGE', + 'AZURE_CLIENT_ID', + 'AZURE_TENANT_ID', + 'AZURE_CLIENT_SECRET', + 'AZURE_SANDBOX_REGISTRY_USERNAME', + 'AZURE_SANDBOX_REGISTRY_TOKEN', + 'AZURE_SANDBOX_SIZE', + 'AZURE_SANDBOX_EGRESS_INSPECTION', 'DOCKER_WORKER_NETWORK', 'DOCKER_WORKER_RELEASE_PATH', 'GITHUB_AUTOMATED_SKIP_REPOS', diff --git a/packages/sdk/src/server/lib/task-runs/record-compute-provider-usage.ts b/packages/sdk/src/server/lib/task-runs/record-compute-provider-usage.ts index 77fa4729b..8e4dc4aa1 100644 --- a/packages/sdk/src/server/lib/task-runs/record-compute-provider-usage.ts +++ b/packages/sdk/src/server/lib/task-runs/record-compute-provider-usage.ts @@ -315,6 +315,23 @@ const computeProviderUsagePolicies: Record< source: 'worker_blaxel_cgroup_poll', }); + return { + activeCpuDurationMs: context.inputActiveCpuDurationMs, + observedMemoryMibMilliseconds: + context.inputObservedMemoryMibMilliseconds, + detailPatch: {}, + preferredMeasurementSource: 'roomote_observation', + }; + }, + }, + azure: { + async deriveUsage(context) { + await recordComputeProviderUsageSampleIfPresent({ + context, + provider: 'azure', + source: 'worker_azure_cgroup_poll', + }); + return { activeCpuDurationMs: context.inputActiveCpuDurationMs, observedMemoryMibMilliseconds: diff --git a/packages/types/src/compute-provider-usage.ts b/packages/types/src/compute-provider-usage.ts index 5dba8a908..c0cea8dbc 100644 --- a/packages/types/src/compute-provider-usage.ts +++ b/packages/types/src/compute-provider-usage.ts @@ -76,6 +76,7 @@ export function resolveConfiguredComputeProviderResources(input: { case 'daytona': case 'e2b': case 'blaxel': + case 'azure': return { configuredVcpus: null, configuredCpuCores: null, diff --git a/packages/types/src/compute-providers/capabilities.ts b/packages/types/src/compute-providers/capabilities.ts index 817030ac6..138111400 100644 --- a/packages/types/src/compute-providers/capabilities.ts +++ b/packages/types/src/compute-providers/capabilities.ts @@ -87,6 +87,22 @@ export const BLAXEL_CAPABILITIES: ComputeProviderCapabilities = { supportsDockerProjects: true, }; +export const AZURE_CAPABILITIES: ComputeProviderCapabilities = { + supportsCreateInstance: true, + supportsDestroyInstance: true, + supportsCommandExecution: true, + // Poll-based streaming over detached-command log files. + supportsCommandOutputStreaming: true, + supportsCommandOutputLookup: true, + supportsSnapshots: true, + // ACA suspend/resume preserves full memory+disk with sub-second restore. + supportsStandbyResume: true, + supportsResume: true, + supportsFileWrite: true, + // dockerd runs inside the ACA microVM (verified against the worker image). + supportsDockerProjects: true, +}; + export function getComputeProviderCapabilities( provider: ComputeProvider, ): ComputeProviderCapabilities { @@ -104,6 +120,8 @@ export function getComputeProviderCapabilities( return E2B_CAPABILITIES; case 'blaxel': return BLAXEL_CAPABILITIES; + case 'azure': + return AZURE_CAPABILITIES; default: { const _exhaustive: never = provider; throw new Error(`Unsupported provider: ${_exhaustive}`); diff --git a/packages/types/src/compute-providers/compute-provider.ts b/packages/types/src/compute-providers/compute-provider.ts index 16bba38d4..8255a0c86 100644 --- a/packages/types/src/compute-providers/compute-provider.ts +++ b/packages/types/src/compute-providers/compute-provider.ts @@ -13,6 +13,7 @@ export const computeProviders = [ 'e2b', 'blaxel', 'roomote', + 'azure', ] as const; export type ComputeProvider = (typeof computeProviders)[number]; @@ -26,6 +27,7 @@ export const snapshotCapableComputeProviders = [ 'e2b', 'daytona', 'roomote', + 'azure', ] as const satisfies readonly ComputeProvider[]; /** @@ -36,6 +38,7 @@ export const snapshotCapableComputeProviders = [ export const standbyResumeCapableComputeProviders = [ 'docker', 'blaxel', + 'azure', ] as const satisfies readonly ComputeProvider[]; /** @@ -141,6 +144,16 @@ export const BLAXEL_WORKER_RUNTIME_PATHS: RuntimePathsWithoutEnvironment = ...SANDBOX_WORKER_RUNTIME_PATHS, }); +/** + * Azure Container Apps sandboxes boot from a disk image baked from the same + * worker OCI image as the other hosted providers, so they share the Vercel + * sandbox worker filesystem layout too. + */ +export const AZURE_WORKER_RUNTIME_PATHS: RuntimePathsWithoutEnvironment = + Object.freeze({ + ...SANDBOX_WORKER_RUNTIME_PATHS, + }); + export const LOCAL_WORKER_RUNTIME_PATHS: RuntimePathsWithoutEnvironment = Object.freeze({ ...SANDBOX_WORKER_RUNTIME_PATHS, @@ -157,6 +170,7 @@ const RUNTIME_PATHS_BY_ENVIRONMENT: Record< e2b: E2B_WORKER_RUNTIME_PATHS, blaxel: BLAXEL_WORKER_RUNTIME_PATHS, roomote: MODAL_WORKER_RUNTIME_PATHS, + azure: AZURE_WORKER_RUNTIME_PATHS, local: LOCAL_WORKER_RUNTIME_PATHS, }; diff --git a/packages/types/src/compute-providers/worker-context.ts b/packages/types/src/compute-providers/worker-context.ts index a213aad33..83a093e81 100644 --- a/packages/types/src/compute-providers/worker-context.ts +++ b/packages/types/src/compute-providers/worker-context.ts @@ -13,7 +13,8 @@ export type WorkerComputeProviderLabel = | 'modal' | 'daytona' | 'e2b' - | 'blaxel'; + | 'blaxel' + | 'azure'; export type WorkerComputeProviderFingerprintKind = 'base-image' | 'runtime'; @@ -26,6 +27,7 @@ export function getWorkerComputeProviderLabel( case 'daytona': case 'e2b': case 'blaxel': + case 'azure': return provider; // Roomote Cloud workers run inside Modal sandboxes, so worker-side // runtime handling (cgroup layout, usage polling) must match Modal. diff --git a/packages/types/src/setup-compute-config.test.ts b/packages/types/src/setup-compute-config.test.ts index 529c96e4f..2ddf8e29b 100644 --- a/packages/types/src/setup-compute-config.test.ts +++ b/packages/types/src/setup-compute-config.test.ts @@ -306,6 +306,7 @@ describe('buildSetupComputeStatus', () => { 'e2b', 'daytona', 'blaxel', + 'azure', 'docker', ]); expect( @@ -683,6 +684,9 @@ describe('buildSetupComputeStatus', () => { // Blaxel can build its sandbox image from the registry-qualified worker // image during setup, just like E2B and Daytona provision artifacts. blaxel: true, + // Azure bakes its sandbox disk image from the worker image during setup, + // the same provisioning story as Daytona/E2B. + azure: true, docker: true, // Roomote Cloud credentials are deployment-managed; a worker image // alone cannot satisfy them. @@ -714,6 +718,7 @@ describe('buildSetupComputeStatus', () => { daytona: false, e2b: false, blaxel: false, + azure: false, docker: true, roomote: false, }); @@ -739,6 +744,7 @@ describe('buildSetupComputeStatus', () => { e2b: true, roomote: false, blaxel: false, + azure: false, docker: true, }); }); @@ -995,7 +1001,7 @@ describe('getDefaultAvailableComputeProvider', () => { it('falls back to docker when every provider is excluded', () => { expect( getDefaultAvailableComputeProvider( - new Set(['docker', 'modal', 'daytona', 'e2b', 'blaxel']), + new Set(['docker', 'modal', 'daytona', 'e2b', 'blaxel', 'azure']), ), ).toBe('docker'); }); diff --git a/packages/types/src/setup-compute-config.ts b/packages/types/src/setup-compute-config.ts index b65f2b489..049672dbd 100644 --- a/packages/types/src/setup-compute-config.ts +++ b/packages/types/src/setup-compute-config.ts @@ -35,13 +35,19 @@ export type SetupComputeFieldDescriptor = { */ advanced?: boolean; /** Optional presentation and validation metadata for operator inputs. */ - input?: { - type: 'number'; - min?: number; - max?: number; - step?: number; - placeholder?: string; - }; + input?: + | { + type: 'number'; + min?: number; + max?: number; + step?: number; + placeholder?: string; + } + | { + type: 'select'; + options: readonly { value: string; label?: string }[]; + placeholder?: string; + }; /** Short guidance displayed with advanced provider settings. */ helpText?: string; }; @@ -54,6 +60,12 @@ export function getComputeFieldValidationError( return null; } + if (field.input.type === 'select') { + return field.input.options.some((option) => option.value === value) + ? null + : `${field.label} must be one of: ${field.input.options.map((option) => option.value).join(', ')}.`; + } + const parsed = Number(value); if (!Number.isFinite(parsed)) { return `${field.label} must be a number.`; @@ -502,6 +514,146 @@ export const SETUP_COMPUTE_PROVIDER_CATALOG = [ }, ], }, + { + provider: 'azure', + label: 'Azure Container Apps', + description: + 'Azure Container Apps sandboxes (preview) with memory+disk snapshots and sub-second suspend/resume standby. Auth via service principal, managed identity, or ambient az login — no API key. Requires an Azure sandbox group.', + supportsSnapshots: true, + fields: [ + { + envVarName: 'AZURE_SUBSCRIPTION_ID', + label: 'Azure Subscription ID', + category: 'credential', + }, + { + envVarName: 'AZURE_RESOURCE_GROUP', + label: 'Azure Resource Group', + category: 'credential', + }, + { + envVarName: 'AZURE_SANDBOX_GROUP', + label: 'Sandbox Group Name', + category: 'credential', + }, + { + envVarName: 'AZURE_SANDBOX_REGION', + label: 'Sandbox Region', + category: 'credential', + }, + { + // Provisioned by baking the Roomote worker OCI image into a sandbox + // disk image (or process env); not a Settings/setup form input. + envVarName: 'AZURE_SANDBOX_DISK_IMAGE', + label: 'Worker Disk Image', + category: 'infrastructure', + }, + { + // Optional user-assigned managed identity client id for Azure-hosted + // controllers — OR the service principal's client (app) id when paired + // with AZURE_TENANT_ID + AZURE_CLIENT_SECRET. Omit both paths to use + // az-login (local) or the system-assigned identity (deployed). + envVarName: 'AZURE_CLIENT_ID', + label: 'Managed Identity / Service Principal Client ID', + required: false, + category: 'credential', + advanced: true, + helpText: + 'Set this for a user-assigned managed identity, or use it with the tenant ID and client secret for service principal authentication. Leave blank for ambient az login or a system-assigned identity.', + }, + { + // Service principal auth for containerized/headless deployments where + // az login is impractical: all three SP values must be set together. + envVarName: 'AZURE_TENANT_ID', + label: 'Service Principal Tenant ID', + required: false, + category: 'credential', + advanced: true, + helpText: + 'Service principal authentication only. Provide this together with the client ID and client secret.', + }, + { + envVarName: 'AZURE_CLIENT_SECRET', + label: 'Service Principal Client Secret', + required: false, + secret: true, + category: 'credential', + advanced: true, + helpText: + 'Service principal authentication only. Provide this together with the client ID and tenant ID.', + }, + { + // Default sandbox size. Per-task memory sizing may override the + // memory side; CPU always scales up to satisfy ACA's cores×2Gi tier cap. + envVarName: 'AZURE_SANDBOX_SIZE', + label: 'Sandbox Size', + required: false, + category: 'infrastructure', + advanced: true, + input: { + type: 'select', + options: [ + { value: 'XS', label: 'XS — 0.25 vCPU / 0.5 GiB / 5 GiB disk' }, + { value: 'S', label: 'S — 0.5 vCPU / 1 GiB / 10 GiB disk' }, + { value: 'M', label: 'M — 1 vCPU / 2 GiB / 20 GiB disk (default)' }, + { value: 'L', label: 'L — 2 vCPU / 4 GiB / 40 GiB disk' }, + { value: 'XL', label: 'XL — 4 vCPU / 8 GiB / 80 GiB disk' }, + ], + }, + helpText: + "Default size for new sandboxes. Nested-Docker tasks always get 8 GiB; CPU is raised automatically when memory exceeds ACA's cores × 2Gi tier cap.", + }, + { + // Egress proxy TLS inspection mode (ACA portal exposes the same + // setting). Partial = only rule-matching traffic inspected — with no + // egress rules configured, nothing is TLS-resigned (Java/npm trust + // stores work) and non-HTTP traffic (SSH git) flows. + envVarName: 'AZURE_SANDBOX_EGRESS_INSPECTION', + label: 'Egress TLS Inspection', + required: false, + category: 'infrastructure', + advanced: true, + input: { + type: 'select', + options: [ + { + value: 'Partial', + label: 'Partial — inspect rule-matched only (default)', + }, + { + value: 'Full', + label: 'Full — inspect everything (TLS resign, no non-HTTP)', + }, + { + value: 'Legacy', + label: 'Legacy — inspect all, non-HTTP allowed', + }, + { value: 'None', label: 'None — no egress rules applied' }, + ], + }, + helpText: + 'Use Full only when wiring deny-default egress rules or header transforms; it TLS-resigns all traffic and blocks non-HTTP (SSH).', + }, + { + // Pull credentials for baking the worker disk image from a private + // registry (GHCR: token owner's GitHub username + PAT with + // read:packages). Only needed when the worker image is not public. + envVarName: 'AZURE_SANDBOX_REGISTRY_USERNAME', + label: 'Worker Registry Username', + required: false, + category: 'infrastructure', + advanced: true, + }, + { + envVarName: 'AZURE_SANDBOX_REGISTRY_TOKEN', + label: 'Worker Registry Token', + required: false, + secret: true, + category: 'infrastructure', + advanced: true, + }, + ], + }, { provider: 'docker', label: 'Local Docker', @@ -577,6 +729,7 @@ const SETUP_PROVISIONABLE_COMPUTE_ENV_VARS: ReadonlySet = new Set([ 'E2B_TEMPLATE_ID', 'DAYTONA_SNAPSHOT_NAME', 'BLAXEL_IMAGE', + 'AZURE_SANDBOX_DISK_IMAGE', ]); /** diff --git a/packages/types/src/setup-new.ts b/packages/types/src/setup-new.ts index f339bd1ec..26c0dcd4c 100644 --- a/packages/types/src/setup-new.ts +++ b/packages/types/src/setup-new.ts @@ -18,7 +18,8 @@ export const WAITING_FOR_SANDBOX_PROVIDER_TASK_PHASE = /** * Progress of a setup-time worker base-image provisioning run (an E2B - * template build, Daytona snapshot registration, or Blaxel image build). The run + * template build, Daytona snapshot registration, Blaxel image build, or Azure + * disk image bake). The run * executes detached in the web process after the provider's config is * saved, so a `building` entry older than * {@link SETUP_COMPUTE_PROVISIONING_STALE_MS} is treated as failed (the @@ -83,6 +84,7 @@ export const SETUP_COMPUTE_PROVISIONING_STATE_FIELDS = { e2b: 'e2bTemplateBuild', daytona: 'daytonaSnapshotBuild', blaxel: 'blaxelImageBuild', + azure: 'azureDiskImageBuild', } as const satisfies Partial>; export type SetupProvisionableComputeProvider = @@ -130,6 +132,7 @@ export type SetupNewState = { e2bTemplateBuild: SetupNewComputeProvisioningState | null; daytonaSnapshotBuild: SetupNewComputeProvisioningState | null; blaxelImageBuild: SetupNewComputeProvisioningState | null; + azureDiskImageBuild: SetupNewComputeProvisioningState | null; lastInteractedByUserId: string | null; }; @@ -158,6 +161,7 @@ export function createEmptySetupNewState(): SetupNewState { e2bTemplateBuild: null, daytonaSnapshotBuild: null, blaxelImageBuild: null, + azureDiskImageBuild: null, lastInteractedByUserId: null, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b663ad67..f4c93b5da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1207,6 +1207,9 @@ importers: packages/compute-providers: dependencies: + '@azure/identity': + specifier: ^4.13.1 + version: 4.13.1 '@blaxel/core': specifier: 0.3.0 version: 0.3.0(@cfworker/json-schema@4.1.1)(@hey-api/openapi-ts@0.99.0(typescript@5.9.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -7308,10 +7311,6 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.4.0: - resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==} - engines: {node: '>=18'} - default-browser@5.5.0: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} @@ -8120,8 +8119,8 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - giget@3.3.1: - resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + giget@3.3.0: + resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} hasBin: true github-from-package@0.0.0: @@ -17341,7 +17340,7 @@ snapshots: defu: 6.1.7 dotenv: 17.4.2 exsolve: 1.1.0 - giget: 3.3.1 + giget: 3.3.0 jiti: 2.7.0 ohash: 2.0.11 pathe: 2.0.3 @@ -17859,11 +17858,6 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.4.0: - dependencies: - bundle-name: 4.1.0 - default-browser-id: 5.0.1 - default-browser@5.5.0: dependencies: bundle-name: 4.1.0 @@ -18800,7 +18794,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - giget@3.3.1: {} + giget@3.3.0: {} github-from-package@0.0.0: optional: true @@ -20658,7 +20652,7 @@ snapshots: open@10.2.0: dependencies: - default-browser: 5.4.0 + default-browser: 5.5.0 define-lazy-prop: 3.0.0 is-inside-container: 1.0.0 wsl-utils: 0.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ef48c9585..b3abde392 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,4 +2,8 @@ packages: - "apps/*" - "packages/*" minimumReleaseAge: 10080 +# jose@6.2.4 is the version already pinned in the lockfile; allow +# lockfile-affecting changes while it finishes maturing. +minimumReleaseAgeExclude: + - jose blockExoticSubdeps: true