From 436d951b65f1ae4335afdfaa8a3f613955892a72 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 2 Sep 2025 16:35:33 +0200 Subject: [PATCH 01/10] feat(webapp): github app installation flow (#2463) * Add schemas for gh app installations * Implement gh app installation flow * Make the gh app configs optional * Add additional org check on gh app installation callback * Save account handle and repo default branch on install * Do repo hard deletes in favor of simplicity * Disable github app by default * Fix gh env schema union issue * Use octokit's iterator for paginating repos * Parse gh app install callback with a discriminated union * Remove duplicate env vars * Use bigint for github integer IDs * Sanitize redirect paths in the gh installation and auth flow * Regenerate migration after rebase on main to fix ordering * Handle gh install updates separately from new installs --- apps/webapp/app/env.server.ts | 2262 +++++++++-------- .../app/routes/_app.github.callback/route.tsx | 121 + .../app/routes/_app.github.install/route.tsx | 52 + .../app/routes/auth.github.callback.tsx | 3 +- apps/webapp/app/routes/auth.github.ts | 4 +- apps/webapp/app/services/gitHub.server.ts | 135 + .../app/services/gitHubSession.server.ts | 124 + apps/webapp/app/utils.ts | 30 +- apps/webapp/package.json | 1 + .../migration.sql | 46 + .../prisma/migrations/migration_lock.toml | 2 +- .../database/prisma/schema.prisma | 50 + pnpm-lock.yaml | 398 ++- 13 files changed, 2114 insertions(+), 1114 deletions(-) create mode 100644 apps/webapp/app/routes/_app.github.callback/route.tsx create mode 100644 apps/webapp/app/routes/_app.github.install/route.tsx create mode 100644 apps/webapp/app/services/gitHub.server.ts create mode 100644 apps/webapp/app/services/gitHubSession.server.ts create mode 100644 internal-packages/database/prisma/migrations/20250902133449_add_gh_installation_schema/migration.sql diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index f20c02f6a8f..350463a5b0d 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -3,1100 +3,1174 @@ import { BoolEnv } from "./utils/boolEnv"; import { isValidDatabaseUrl } from "./utils/db"; import { isValidRegex } from "./utils/regex"; -const EnvironmentSchema = z.object({ - NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]), - DATABASE_URL: z - .string() - .refine( - isValidDatabaseUrl, - "DATABASE_URL is invalid, for details please check the additional output above this message." - ), - DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10), - DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60), - DATABASE_CONNECTION_TIMEOUT: z.coerce.number().int().default(20), - DIRECT_URL: z - .string() - .refine( - isValidDatabaseUrl, - "DIRECT_URL is invalid, for details please check the additional output above this message." - ), - DATABASE_READ_REPLICA_URL: z.string().optional(), - SESSION_SECRET: z.string(), - MAGIC_LINK_SECRET: z.string(), - ENCRYPTION_KEY: z - .string() - .refine( - (val) => Buffer.from(val, "utf8").length === 32, - "ENCRYPTION_KEY must be exactly 32 bytes" - ), - WHITELISTED_EMAILS: z - .string() - .refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.") - .optional(), - ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(), - REMIX_APP_PORT: z.string().optional(), - LOGIN_ORIGIN: z.string().default("http://localhost:3030"), - APP_ORIGIN: z.string().default("http://localhost:3030"), - API_ORIGIN: z.string().optional(), - STREAM_ORIGIN: z.string().optional(), - ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"), - // A comma separated list of electric origins to shard into different electric instances by environmentId - // example: "http://localhost:3060,http://localhost:3061,http://localhost:3062" - ELECTRIC_ORIGIN_SHARDS: z.string().optional(), - APP_ENV: z.string().default(process.env.NODE_ENV), - SERVICE_NAME: z.string().default("trigger.dev webapp"), - POSTHOG_PROJECT_KEY: z.string().default("phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"), - TRIGGER_TELEMETRY_DISABLED: z.string().optional(), - AUTH_GITHUB_CLIENT_ID: z.string().optional(), - AUTH_GITHUB_CLIENT_SECRET: z.string().optional(), - EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(), - FROM_EMAIL: z.string().optional(), - REPLY_TO_EMAIL: z.string().optional(), - RESEND_API_KEY: z.string().optional(), - SMTP_HOST: z.string().optional(), - SMTP_PORT: z.coerce.number().optional(), - SMTP_SECURE: BoolEnv.optional(), - SMTP_USER: z.string().optional(), - SMTP_PASSWORD: z.string().optional(), - - PLAIN_API_KEY: z.string().optional(), - WORKER_SCHEMA: z.string().default("graphile_worker"), - WORKER_CONCURRENCY: z.coerce.number().int().default(10), - WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), - WORKER_ENABLED: z.string().default("true"), - GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().default(60000), - DISABLE_SSE: z.string().optional(), - OPENAI_API_KEY: z.string().optional(), - - // Redis options - REDIS_HOST: z.string().optional(), - REDIS_READER_HOST: z.string().optional(), - REDIS_READER_PORT: z.coerce.number().optional(), - REDIS_PORT: z.coerce.number().optional(), - REDIS_USERNAME: z.string().optional(), - REDIS_PASSWORD: z.string().optional(), - REDIS_TLS_DISABLED: z.string().optional(), - - RATE_LIMIT_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - RATE_LIMIT_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - RATE_LIMIT_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - RATE_LIMIT_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - RATE_LIMIT_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - RATE_LIMIT_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - RATE_LIMIT_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), - RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - CACHE_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - CACHE_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - CACHE_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - CACHE_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - CACHE_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - CACHE_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - CACHE_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), - CACHE_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - REALTIME_STREAMS_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - REALTIME_STREAMS_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - REALTIME_STREAMS_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - REALTIME_STREAMS_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - REALTIME_STREAMS_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - REALTIME_STREAMS_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - REALTIME_STREAMS_REDIS_TLS_DISABLED: z - .string() - .default(process.env.REDIS_TLS_DISABLED ?? "false"), - REALTIME_STREAMS_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS: z.coerce - .number() - .int() - .default(24 * 60 * 60 * 1000), // 1 day in milliseconds - - PUBSUB_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - PUBSUB_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - PUBSUB_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - PUBSUB_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - PUBSUB_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - PUBSUB_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - PUBSUB_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), - PUBSUB_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(100), - DEFAULT_ENV_EXECUTION_CONCURRENCY_BURST_FACTOR: z.coerce.number().default(1.0), - DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(300), - DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1), - - //API Rate limiting - /** - * @example "60s" - * @example "1m" - * @example "1h" - * @example "1d" - * @example "1000ms" - * @example "1000s" - */ - API_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"), // refill 250 tokens every 10 seconds - API_RATE_LIMIT_MAX: z.coerce.number().int().default(750), // allow bursts of 750 requests - API_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(250), // refix 250 tokens every 10 seconds - API_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"), - API_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"), - API_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"), - - API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"), - API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60), - - //v3 - PROVIDER_SECRET: z.string().default("provider-secret"), - COORDINATOR_SECRET: z.string().default("coordinator-secret"), - DEPOT_TOKEN: z.string().optional(), - DEPOT_ORG_ID: z.string().optional(), - DEPOT_REGION: z.string().default("us-east-1"), - - // Deployment registry (v3) - DEPLOY_REGISTRY_HOST: z.string().min(1), - DEPLOY_REGISTRY_USERNAME: z.string().optional(), - DEPLOY_REGISTRY_PASSWORD: z.string().optional(), - DEPLOY_REGISTRY_NAMESPACE: z.string().min(1).default("trigger"), - DEPLOY_REGISTRY_ECR_TAGS: z.string().optional(), // csv, for example: "key1=value1,key2=value2" - DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: z.string().optional(), - DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: z.string().optional(), - - // Deployment registry (v4) - falls back to v3 registry if not specified - V4_DEPLOY_REGISTRY_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.DEPLOY_REGISTRY_HOST) - .pipe(z.string().min(1)), // Ensure final type is required string - V4_DEPLOY_REGISTRY_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.DEPLOY_REGISTRY_USERNAME), - V4_DEPLOY_REGISTRY_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.DEPLOY_REGISTRY_PASSWORD), - V4_DEPLOY_REGISTRY_NAMESPACE: z - .string() - .optional() - .transform((v) => v ?? process.env.DEPLOY_REGISTRY_NAMESPACE) - .pipe(z.string().min(1).default("trigger")), // Ensure final type is required string - V4_DEPLOY_REGISTRY_ECR_TAGS: z - .string() - .optional() - .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_TAGS), - V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: z - .string() - .optional() - .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN), - V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: z - .string() - .optional() - .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID), - - DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"), - DEPLOY_TIMEOUT_MS: z.coerce - .number() - .int() - .default(60 * 1000 * 8), // 8 minutes - - OBJECT_STORE_BASE_URL: z.string().optional(), - OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(), - OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(), - OBJECT_STORE_REGION: z.string().optional(), - OBJECT_STORE_SERVICE: z.string().default("s3"), - EVENTS_BATCH_SIZE: z.coerce.number().int().default(100), - EVENTS_BATCH_INTERVAL: z.coerce.number().int().default(1000), - EVENTS_DEFAULT_LOG_RETENTION: z.coerce.number().int().default(7), - EVENTS_MIN_CONCURRENCY: z.coerce.number().int().default(1), - EVENTS_MAX_CONCURRENCY: z.coerce.number().int().default(10), - EVENTS_MAX_BATCH_SIZE: z.coerce.number().int().default(500), - EVENTS_MEMORY_PRESSURE_THRESHOLD: z.coerce.number().int().default(5000), - EVENTS_LOAD_SHEDDING_THRESHOLD: z.coerce.number().int().default(100000), - EVENTS_LOAD_SHEDDING_ENABLED: z.string().default("1"), - SHARED_QUEUE_CONSUMER_POOL_SIZE: z.coerce.number().int().default(10), - SHARED_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(100), - SHARED_QUEUE_CONSUMER_NEXT_TICK_INTERVAL_MS: z.coerce.number().int().default(100), - SHARED_QUEUE_CONSUMER_EMIT_RESUME_DEPENDENCY_TIMEOUT_MS: z.coerce.number().int().default(1000), - SHARED_QUEUE_CONSUMER_RESOLVE_PAYLOADS_BATCH_SIZE: z.coerce.number().int().default(25), - - MANAGED_WORKER_SECRET: z.string().default("managed-secret"), - - // Development OTEL environment variables - DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(), - // If this is set to 1, then the below variables are used to configure the batch processor for spans and logs - DEV_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"), - DEV_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"), - DEV_OTEL_SPAN_SCHEDULED_DELAY_MILLIS: z.string().default("200"), - DEV_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"), - DEV_OTEL_SPAN_MAX_QUEUE_SIZE: z.string().default("512"), - DEV_OTEL_LOG_MAX_EXPORT_BATCH_SIZE: z.string().default("64"), - DEV_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"), - DEV_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"), - DEV_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"), - - PROD_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"), - PROD_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"), - PROD_OTEL_SPAN_SCHEDULED_DELAY_MILLIS: z.string().default("200"), - PROD_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"), - PROD_OTEL_SPAN_MAX_QUEUE_SIZE: z.string().default("512"), - PROD_OTEL_LOG_MAX_EXPORT_BATCH_SIZE: z.string().default("64"), - PROD_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"), - PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"), - PROD_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"), - - TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"), - TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"), - TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT: z.string().default("131072"), - TRIGGER_OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT: z.string().default("131072"), - TRIGGER_OTEL_SPAN_EVENT_COUNT_LIMIT: z.string().default("10"), - TRIGGER_OTEL_LINK_COUNT_LIMIT: z.string().default("2"), - TRIGGER_OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT: z.string().default("10"), - TRIGGER_OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT: z.string().default("10"), - - CHECKPOINT_THRESHOLD_IN_MS: z.coerce.number().int().default(30000), - - // Internal OTEL environment variables - INTERNAL_OTEL_TRACE_EXPORTER_URL: z.string().optional(), - INTERNAL_OTEL_TRACE_EXPORTER_AUTH_HEADERS: z.string().optional(), - INTERNAL_OTEL_TRACE_LOGGING_ENABLED: z.string().default("1"), - // this means 1/20 traces or 5% of traces will be sampled (sampled = recorded) - INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"), - INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"), - INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"), - - INTERNAL_OTEL_LOG_EXPORTER_URL: z.string().optional(), - INTERNAL_OTEL_METRIC_EXPORTER_URL: z.string().optional(), - INTERNAL_OTEL_METRIC_EXPORTER_AUTH_HEADERS: z.string().optional(), - INTERNAL_OTEL_METRIC_EXPORTER_ENABLED: z.string().default("0"), - INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS: z.coerce.number().int().default(30_000), - INTERNAL_OTEL_HOST_METRICS_ENABLED: BoolEnv.default(true), - INTERNAL_OTEL_NODEJS_METRICS_ENABLED: BoolEnv.default(true), - INTERNAL_OTEL_ADDITIONAL_DETECTORS_ENABLED: BoolEnv.default(true), - - ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(), - ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(), - - /** These enable the alerts feature in v3 */ - ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(), - ALERT_FROM_EMAIL: z.string().optional(), - ALERT_REPLY_TO_EMAIL: z.string().optional(), - ALERT_RESEND_API_KEY: z.string().optional(), - ALERT_SMTP_HOST: z.string().optional(), - ALERT_SMTP_PORT: z.coerce.number().optional(), - ALERT_SMTP_SECURE: BoolEnv.optional(), - ALERT_SMTP_USER: z.string().optional(), - ALERT_SMTP_PASSWORD: z.string().optional(), - ALERT_RATE_LIMITER_EMISSION_INTERVAL: z.coerce.number().int().default(2_500), - ALERT_RATE_LIMITER_BURST_TOLERANCE: z.coerce.number().int().default(10_000), - ALERT_RATE_LIMITER_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - ALERT_RATE_LIMITER_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - ALERT_RATE_LIMITER_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - ALERT_RATE_LIMITER_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - ALERT_RATE_LIMITER_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - ALERT_RATE_LIMITER_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - ALERT_RATE_LIMITER_REDIS_TLS_DISABLED: z - .string() - .default(process.env.REDIS_TLS_DISABLED ?? "false"), - ALERT_RATE_LIMITER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - LOOPS_API_KEY: z.string().optional(), - MARQS_DISABLE_REBALANCING: BoolEnv.default(false), - MARQS_VISIBILITY_TIMEOUT_MS: z.coerce - .number() - .int() - .default(60 * 1000 * 15), - MARQS_SHARED_QUEUE_LIMIT: z.coerce.number().int().default(1000), - MARQS_MAXIMUM_QUEUE_PER_ENV_COUNT: z.coerce.number().int().default(50), - MARQS_DEV_QUEUE_LIMIT: z.coerce.number().int().default(1000), - MARQS_MAXIMUM_NACK_COUNT: z.coerce.number().int().default(64), - MARQS_CONCURRENCY_LIMIT_BIAS: z.coerce.number().default(0.75), - MARQS_AVAILABLE_CAPACITY_BIAS: z.coerce.number().default(0.3), - MARQS_QUEUE_AGE_RANDOMIZATION_BIAS: z.coerce.number().default(0.25), - MARQS_REUSE_SNAPSHOT_COUNT: z.coerce.number().int().default(0), - MARQS_MAXIMUM_ENV_COUNT: z.coerce.number().int().optional(), - MARQS_SHARED_WORKER_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(250), - MARQS_SHARED_WORKER_QUEUE_MAX_MESSAGE_COUNT: z.coerce.number().int().default(10), - - MARQS_SHARED_WORKER_QUEUE_EAGER_DEQUEUE_ENABLED: z.string().default("0"), - MARQS_WORKER_ENABLED: z.string().default("0"), - MARQS_WORKER_COUNT: z.coerce.number().int().default(2), - MARQS_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), - MARQS_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(5), - MARQS_WORKER_POLL_INTERVAL_MS: z.coerce.number().int().default(100), - MARQS_WORKER_IMMEDIATE_POLL_INTERVAL_MS: z.coerce.number().int().default(100), - MARQS_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), - MARQS_SHARED_WORKER_QUEUE_COOLOFF_COUNT_THRESHOLD: z.coerce.number().int().default(10), - MARQS_SHARED_WORKER_QUEUE_COOLOFF_PERIOD_MS: z.coerce.number().int().default(5_000), - - PROD_TASK_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(), - - VERBOSE_GRAPHILE_LOGGING: z.string().default("false"), - V2_MARQS_ENABLED: z.string().default("0"), - V2_MARQS_CONSUMER_POOL_ENABLED: z.string().default("0"), - V2_MARQS_CONSUMER_POOL_SIZE: z.coerce.number().int().default(10), - V2_MARQS_CONSUMER_POLL_INTERVAL_MS: z.coerce.number().int().default(1000), - V2_MARQS_QUEUE_SELECTION_COUNT: z.coerce.number().int().default(36), - V2_MARQS_VISIBILITY_TIMEOUT_MS: z.coerce - .number() - .int() - .default(60 * 1000 * 15), - V2_MARQS_DEFAULT_ENV_CONCURRENCY: z.coerce.number().int().default(100), - V2_MARQS_VERBOSE: z.string().default("0"), - V3_MARQS_CONCURRENCY_MONITOR_ENABLED: z.string().default("0"), - V2_MARQS_CONCURRENCY_MONITOR_ENABLED: z.string().default("0"), - /* Usage settings */ - USAGE_EVENT_URL: z.string().optional(), - PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(), - - CENTS_PER_RUN: z.coerce.number().default(0), - - EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"), - MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000), - MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000), - MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000), - TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB - TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB - BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB - TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB - - MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(), - MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(), - MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500), - MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500), - - REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"), - REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000), - REALTIME_STREAM_TTL: z.coerce - .number() - .int() - .default(60 * 60 * 24), // 1 day in seconds - BATCH_METADATA_OPERATIONS_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000), - BATCH_METADATA_OPERATIONS_FLUSH_ENABLED: z.string().default("1"), - BATCH_METADATA_OPERATIONS_FLUSH_LOGGING_ENABLED: z.string().default("1"), - - // Run Engine 2.0 - RUN_ENGINE_WORKER_COUNT: z.coerce.number().int().default(4), - RUN_ENGINE_TASKS_PER_WORKER: z.coerce.number().int().default(10), - RUN_ENGINE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(10), - RUN_ENGINE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(100), - RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(100), - RUN_ENGINE_TIMEOUT_PENDING_EXECUTING: z.coerce.number().int().default(60_000), - RUN_ENGINE_TIMEOUT_PENDING_CANCEL: z.coerce.number().int().default(60_000), - RUN_ENGINE_TIMEOUT_EXECUTING: z.coerce.number().int().default(60_000), - RUN_ENGINE_TIMEOUT_EXECUTING_WITH_WAITPOINTS: z.coerce.number().int().default(60_000), - RUN_ENGINE_TIMEOUT_SUSPENDED: z.coerce - .number() - .int() - .default(60_000 * 10), - RUN_ENGINE_DEBUG_WORKER_NOTIFICATIONS: BoolEnv.default(false), - RUN_ENGINE_PARENT_QUEUE_LIMIT: z.coerce.number().int().default(1000), - RUN_ENGINE_CONCURRENCY_LIMIT_BIAS: z.coerce.number().default(0.75), - RUN_ENGINE_AVAILABLE_CAPACITY_BIAS: z.coerce.number().default(0.3), - RUN_ENGINE_QUEUE_AGE_RANDOMIZATION_BIAS: z.coerce.number().default(0.25), - RUN_ENGINE_REUSE_SNAPSHOT_COUNT: z.coerce.number().int().default(0), - RUN_ENGINE_MAXIMUM_ENV_COUNT: z.coerce.number().int().optional(), - RUN_ENGINE_RUN_QUEUE_SHARD_COUNT: z.coerce.number().int().default(4), - RUN_ENGINE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), - RUN_ENGINE_RETRY_WARM_START_THRESHOLD_MS: z.coerce.number().int().default(30_000), - RUN_ENGINE_PROCESS_WORKER_QUEUE_DEBOUNCE_MS: z.coerce.number().int().default(200), - RUN_ENGINE_DEQUEUE_BLOCKING_TIMEOUT_SECONDS: z.coerce.number().int().default(10), - RUN_ENGINE_MASTER_QUEUE_CONSUMERS_INTERVAL_MS: z.coerce.number().int().default(1000), - RUN_ENGINE_MASTER_QUEUE_COOLOFF_PERIOD_MS: z.coerce.number().int().default(10_000), - RUN_ENGINE_MASTER_QUEUE_COOLOFF_COUNT_THRESHOLD: z.coerce.number().int().default(10), - RUN_ENGINE_MASTER_QUEUE_CONSUMER_DEQUEUE_COUNT: z.coerce.number().int().default(10), - RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_SCHEDULE: z.string().optional(), - RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_SCHEDULE: z.string().optional(), - RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_JITTER_IN_MS: z.coerce.number().int().optional(), - RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_JITTER_IN_MS: z.coerce.number().int().optional(), - - RUN_ENGINE_RUN_LOCK_DURATION: z.coerce.number().int().default(5000), - RUN_ENGINE_RUN_LOCK_AUTOMATIC_EXTENSION_THRESHOLD: z.coerce.number().int().default(1000), - RUN_ENGINE_RUN_LOCK_MAX_RETRIES: z.coerce.number().int().default(10), - RUN_ENGINE_RUN_LOCK_BASE_DELAY: z.coerce.number().int().default(100), - RUN_ENGINE_RUN_LOCK_MAX_DELAY: z.coerce.number().int().default(3000), - RUN_ENGINE_RUN_LOCK_BACKOFF_MULTIPLIER: z.coerce.number().default(1.8), - RUN_ENGINE_RUN_LOCK_JITTER_FACTOR: z.coerce.number().default(0.15), - RUN_ENGINE_RUN_LOCK_MAX_TOTAL_WAIT_TIME: z.coerce.number().int().default(15000), - - RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_MAX_COUNT: z.coerce.number().int().default(12), - RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_MAX_DELAY_MS: z.coerce - .number() - .int() - .default(60_000 * 60 * 6), - RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_INITIAL_DELAY_MS: z.coerce.number().int().default(60_000), - RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2), - - RUN_ENGINE_WORKER_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - RUN_ENGINE_WORKER_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - RUN_ENGINE_WORKER_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - RUN_ENGINE_WORKER_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - RUN_ENGINE_WORKER_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - RUN_ENGINE_WORKER_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - RUN_ENGINE_WORKER_REDIS_TLS_DISABLED: z - .string() - .default(process.env.REDIS_TLS_DISABLED ?? "false"), - - RUN_ENGINE_RUN_QUEUE_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - RUN_ENGINE_RUN_QUEUE_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - RUN_ENGINE_RUN_QUEUE_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - RUN_ENGINE_RUN_QUEUE_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - RUN_ENGINE_RUN_QUEUE_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - RUN_ENGINE_RUN_QUEUE_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - RUN_ENGINE_RUN_QUEUE_REDIS_TLS_DISABLED: z - .string() - .default(process.env.REDIS_TLS_DISABLED ?? "false"), - - RUN_ENGINE_RUN_LOCK_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - RUN_ENGINE_RUN_LOCK_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - RUN_ENGINE_RUN_LOCK_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - RUN_ENGINE_RUN_LOCK_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - RUN_ENGINE_RUN_LOCK_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - RUN_ENGINE_RUN_LOCK_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - RUN_ENGINE_RUN_LOCK_REDIS_TLS_DISABLED: z - .string() - .default(process.env.REDIS_TLS_DISABLED ?? "false"), - - RUN_ENGINE_DEV_PRESENCE_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - RUN_ENGINE_DEV_PRESENCE_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - RUN_ENGINE_DEV_PRESENCE_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - RUN_ENGINE_DEV_PRESENCE_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED: z - .string() - .default(process.env.REDIS_TLS_DISABLED ?? "false"), - - //API Rate limiting - /** - * @example "60s" - * @example "1m" - * @example "1h" - * @example "1d" - * @example "1000ms" - * @example "1000s" - */ - RUN_ENGINE_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"), // refill 250 tokens every 10 seconds - RUN_ENGINE_RATE_LIMIT_MAX: z.coerce.number().int().default(1200), // allow bursts of 750 requests - RUN_ENGINE_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(400), // refix 250 tokens every 10 seconds - RUN_ENGINE_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"), - RUN_ENGINE_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"), - RUN_ENGINE_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"), - - RUN_ENGINE_RELEASE_CONCURRENCY_ENABLED: z.string().default("0"), - RUN_ENGINE_RELEASE_CONCURRENCY_DISABLE_CONSUMERS: z.string().default("0"), - RUN_ENGINE_RELEASE_CONCURRENCY_MAX_TOKENS_RATIO: z.coerce.number().default(1), - RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_MAX_AGE: z.coerce - .number() - .int() - .default(60_000 * 30), - RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_POLL_INTERVAL: z.coerce.number().int().default(60_000), - RUN_ENGINE_RELEASE_CONCURRENCY_MAX_RETRIES: z.coerce.number().int().default(3), - RUN_ENGINE_RELEASE_CONCURRENCY_CONSUMERS_COUNT: z.coerce.number().int().default(1), - RUN_ENGINE_RELEASE_CONCURRENCY_POLL_INTERVAL: z.coerce.number().int().default(500), - RUN_ENGINE_RELEASE_CONCURRENCY_BATCH_SIZE: z.coerce.number().int().default(10), - - RUN_ENGINE_WORKER_ENABLED: z.string().default("1"), - RUN_ENGINE_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - RUN_ENGINE_RUN_QUEUE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - - /** How long should the presence ttl last */ - DEV_PRESENCE_SSE_TIMEOUT: z.coerce.number().int().default(30_000), - DEV_PRESENCE_TTL_MS: z.coerce.number().int().default(5_000), - DEV_PRESENCE_POLL_MS: z.coerce.number().int().default(1_000), - /** How many ms to wait until dequeuing again, if there was a run last time */ - DEV_DEQUEUE_INTERVAL_WITH_RUN: z.coerce.number().int().default(250), - /** How many ms to wait until dequeuing again, if there was no run last time */ - DEV_DEQUEUE_INTERVAL_WITHOUT_RUN: z.coerce.number().int().default(1_000), - /** The max number of runs per API call that we'll dequeue in DEV */ - DEV_DEQUEUE_MAX_RUNS_PER_PULL: z.coerce.number().int().default(10), - - /** The maximum concurrent local run processes executing at once in dev */ - DEV_MAX_CONCURRENT_RUNS: z.coerce.number().int().default(25), - - /** The CLI should connect to this for dev runs */ - DEV_ENGINE_URL: z.string().default(process.env.APP_ORIGIN ?? "http://localhost:3030"), - - LEGACY_RUN_ENGINE_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), - LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), - LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(1), - LEGACY_RUN_ENGINE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), - LEGACY_RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), - LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), - LEGACY_RUN_ENGINE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), - LEGACY_RUN_ENGINE_WORKER_LOG_LEVEL: z - .enum(["log", "error", "warn", "info", "debug"]) - .default("info"), - - LEGACY_RUN_ENGINE_WORKER_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - LEGACY_RUN_ENGINE_WORKER_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - LEGACY_RUN_ENGINE_WORKER_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - LEGACY_RUN_ENGINE_WORKER_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - LEGACY_RUN_ENGINE_WORKER_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - LEGACY_RUN_ENGINE_WORKER_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - LEGACY_RUN_ENGINE_WORKER_REDIS_TLS_DISABLED: z - .string() - .default(process.env.REDIS_TLS_DISABLED ?? "false"), - LEGACY_RUN_ENGINE_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - LEGACY_RUN_ENGINE_WAITING_FOR_DEPLOY_BATCH_SIZE: z.coerce.number().int().default(100), - LEGACY_RUN_ENGINE_WAITING_FOR_DEPLOY_BATCH_STAGGER_MS: z.coerce.number().int().default(1_000), - - COMMON_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), - COMMON_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), - COMMON_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), - COMMON_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), - COMMON_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), - COMMON_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), - COMMON_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), - COMMON_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - - COMMON_WORKER_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - COMMON_WORKER_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - COMMON_WORKER_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - COMMON_WORKER_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - COMMON_WORKER_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - COMMON_WORKER_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - COMMON_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), - COMMON_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - BATCH_TRIGGER_PROCESS_JOB_VISIBILITY_TIMEOUT_MS: z.coerce - .number() - .int() - .default(60_000 * 5), // 5 minutes - - BATCH_TRIGGER_CACHED_RUNS_CHECK_ENABLED: BoolEnv.default(false), - - BATCH_TRIGGER_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), - BATCH_TRIGGER_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), - BATCH_TRIGGER_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), - BATCH_TRIGGER_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), - BATCH_TRIGGER_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), - BATCH_TRIGGER_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(20), - BATCH_TRIGGER_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), - BATCH_TRIGGER_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - - BATCH_TRIGGER_WORKER_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - BATCH_TRIGGER_WORKER_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - BATCH_TRIGGER_WORKER_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - BATCH_TRIGGER_WORKER_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - BATCH_TRIGGER_WORKER_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - BATCH_TRIGGER_WORKER_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - BATCH_TRIGGER_WORKER_REDIS_TLS_DISABLED: z - .string() - .default(process.env.REDIS_TLS_DISABLED ?? "false"), - BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), - ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), - ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), - ADMIN_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), - ADMIN_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), - ADMIN_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(20), - ADMIN_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), - ADMIN_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - - ADMIN_WORKER_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - ADMIN_WORKER_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - ADMIN_WORKER_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - ADMIN_WORKER_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - ADMIN_WORKER_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - ADMIN_WORKER_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - ADMIN_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), - ADMIN_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - ALERTS_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), - ALERTS_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), - ALERTS_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), - ALERTS_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), - ALERTS_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(100), - ALERTS_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), - ALERTS_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), - ALERTS_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - - ALERTS_WORKER_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - ALERTS_WORKER_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - ALERTS_WORKER_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - ALERTS_WORKER_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - ALERTS_WORKER_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - ALERTS_WORKER_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - ALERTS_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), - ALERTS_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - SCHEDULE_ENGINE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - SCHEDULE_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), - SCHEDULE_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), - SCHEDULE_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), - SCHEDULE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), - SCHEDULE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), - SCHEDULE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), - SCHEDULE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(30_000), - SCHEDULE_WORKER_DISTRIBUTION_WINDOW_SECONDS: z.coerce.number().int().default(30), - - SCHEDULE_WORKER_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - SCHEDULE_WORKER_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - SCHEDULE_WORKER_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - SCHEDULE_WORKER_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - SCHEDULE_WORKER_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - SCHEDULE_WORKER_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - SCHEDULE_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), - SCHEDULE_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), - - TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"), - TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute - - QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000), - QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000), - - SLACK_BOT_TOKEN: z.string().optional(), - SLACK_SIGNUP_REASON_CHANNEL_ID: z.string().optional(), - - // kapa.ai - KAPA_AI_WEBSITE_ID: z.string().optional(), - - // BetterStack - BETTERSTACK_API_KEY: z.string().optional(), - BETTERSTACK_STATUS_PAGE_ID: z.string().optional(), - - RUN_REPLICATION_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - RUN_REPLICATION_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - RUN_REPLICATION_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - RUN_REPLICATION_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - RUN_REPLICATION_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - RUN_REPLICATION_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - RUN_REPLICATION_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), - - RUN_REPLICATION_CLICKHOUSE_URL: z.string().optional(), - RUN_REPLICATION_ENABLED: z.string().default("0"), - RUN_REPLICATION_SLOT_NAME: z.string().default("task_runs_to_clickhouse_v1"), - RUN_REPLICATION_PUBLICATION_NAME: z.string().default("task_runs_to_clickhouse_v1_publication"), - RUN_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(2), - RUN_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000), - RUN_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100), - RUN_REPLICATION_LEADER_LOCK_TIMEOUT_MS: z.coerce.number().int().default(30_000), - RUN_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS: z.coerce.number().int().default(10_000), - RUN_REPLICATION_ACK_INTERVAL_SECONDS: z.coerce.number().int().default(10), - RUN_REPLICATION_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - RUN_REPLICATION_CLICKHOUSE_LOG_LEVEL: z - .enum(["log", "error", "warn", "info", "debug"]) - .default("info"), - RUN_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS: z.coerce.number().int().default(10_000), - RUN_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS: z.coerce.number().int().default(500), - RUN_REPLICATION_WAIT_FOR_ASYNC_INSERT: z.string().default("0"), - RUN_REPLICATION_KEEP_ALIVE_ENABLED: z.string().default("0"), - RUN_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(), - RUN_REPLICATION_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10), - // Retry configuration for insert operations - RUN_REPLICATION_INSERT_MAX_RETRIES: z.coerce.number().int().default(3), - RUN_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100), - RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000), - RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"), - - // Clickhouse - CLICKHOUSE_URL: z.string(), - CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"), - CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(), - CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10), - CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"), - - // Bootstrap - TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"), - TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(), - TRIGGER_BOOTSTRAP_WORKER_TOKEN_PATH: z.string().optional(), - - // Machine presets - MACHINE_PRESETS_OVERRIDE_PATH: z.string().optional(), - - // CLI package tag (e.g. "latest", "v4-beta", "4.0.0") - used for setup commands - TRIGGER_CLI_TAG: z.string().default("latest"), - - HEALTHCHECK_DATABASE_DISABLED: z.string().default("0"), - - REQUEST_IDEMPOTENCY_REDIS_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_HOST), - REQUEST_IDEMPOTENCY_REDIS_READER_HOST: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_READER_HOST), - REQUEST_IDEMPOTENCY_REDIS_READER_PORT: z.coerce - .number() - .optional() - .transform( - (v) => - v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) - ), - REQUEST_IDEMPOTENCY_REDIS_PORT: z.coerce - .number() - .optional() - .transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)), - REQUEST_IDEMPOTENCY_REDIS_USERNAME: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_USERNAME), - REQUEST_IDEMPOTENCY_REDIS_PASSWORD: z - .string() - .optional() - .transform((v) => v ?? process.env.REDIS_PASSWORD), - REQUEST_IDEMPOTENCY_REDIS_TLS_DISABLED: z - .string() - .default(process.env.REDIS_TLS_DISABLED ?? "false"), - - REQUEST_IDEMPOTENCY_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), - - REQUEST_IDEMPOTENCY_TTL_IN_MS: z.coerce - .number() - .int() - .default(60_000 * 60 * 24), - - // Bulk action - BULK_ACTION_BATCH_SIZE: z.coerce.number().int().default(100), - BULK_ACTION_BATCH_DELAY_MS: z.coerce.number().int().default(200), - BULK_ACTION_SUBBATCH_CONCURRENCY: z.coerce.number().int().default(5), - - // AI Run Filter - AI_RUN_FILTER_MODEL: z.string().optional(), - - EVENT_LOOP_MONITOR_THRESHOLD_MS: z.coerce.number().int().default(100), - - VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(), -}); +const GithubAppEnvSchema = z.preprocess( + (val) => { + const obj = val as any; + if (!obj || !obj.GITHUB_APP_ENABLED) { + return { ...obj, GITHUB_APP_ENABLED: "0" }; + } + return obj; + }, + z.discriminatedUnion("GITHUB_APP_ENABLED", [ + z.object({ + GITHUB_APP_ENABLED: z.literal("1"), + GITHUB_APP_ID: z.string(), + GITHUB_APP_PRIVATE_KEY: z.string(), + GITHUB_APP_WEBHOOK_SECRET: z.string(), + GITHUB_APP_SLUG: z.string(), + }), + z.object({ + GITHUB_APP_ENABLED: z.literal("0"), + }), + ]) +); + +const EnvironmentSchema = z + .object({ + NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]), + DATABASE_URL: z + .string() + .refine( + isValidDatabaseUrl, + "DATABASE_URL is invalid, for details please check the additional output above this message." + ), + DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10), + DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60), + DATABASE_CONNECTION_TIMEOUT: z.coerce.number().int().default(20), + DIRECT_URL: z + .string() + .refine( + isValidDatabaseUrl, + "DIRECT_URL is invalid, for details please check the additional output above this message." + ), + DATABASE_READ_REPLICA_URL: z.string().optional(), + SESSION_SECRET: z.string(), + MAGIC_LINK_SECRET: z.string(), + ENCRYPTION_KEY: z + .string() + .refine( + (val) => Buffer.from(val, "utf8").length === 32, + "ENCRYPTION_KEY must be exactly 32 bytes" + ), + WHITELISTED_EMAILS: z + .string() + .refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.") + .optional(), + ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(), + REMIX_APP_PORT: z.string().optional(), + LOGIN_ORIGIN: z.string().default("http://localhost:3030"), + APP_ORIGIN: z.string().default("http://localhost:3030"), + API_ORIGIN: z.string().optional(), + STREAM_ORIGIN: z.string().optional(), + ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"), + // A comma separated list of electric origins to shard into different electric instances by environmentId + // example: "http://localhost:3060,http://localhost:3061,http://localhost:3062" + ELECTRIC_ORIGIN_SHARDS: z.string().optional(), + APP_ENV: z.string().default(process.env.NODE_ENV), + SERVICE_NAME: z.string().default("trigger.dev webapp"), + POSTHOG_PROJECT_KEY: z.string().default("phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"), + TRIGGER_TELEMETRY_DISABLED: z.string().optional(), + AUTH_GITHUB_CLIENT_ID: z.string().optional(), + AUTH_GITHUB_CLIENT_SECRET: z.string().optional(), + EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(), + FROM_EMAIL: z.string().optional(), + REPLY_TO_EMAIL: z.string().optional(), + RESEND_API_KEY: z.string().optional(), + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().optional(), + SMTP_SECURE: BoolEnv.optional(), + SMTP_USER: z.string().optional(), + SMTP_PASSWORD: z.string().optional(), + + PLAIN_API_KEY: z.string().optional(), + WORKER_SCHEMA: z.string().default("graphile_worker"), + WORKER_CONCURRENCY: z.coerce.number().int().default(10), + WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + WORKER_ENABLED: z.string().default("true"), + GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().default(60000), + DISABLE_SSE: z.string().optional(), + OPENAI_API_KEY: z.string().optional(), + + // Redis options + REDIS_HOST: z.string().optional(), + REDIS_READER_HOST: z.string().optional(), + REDIS_READER_PORT: z.coerce.number().optional(), + REDIS_PORT: z.coerce.number().optional(), + REDIS_USERNAME: z.string().optional(), + REDIS_PASSWORD: z.string().optional(), + REDIS_TLS_DISABLED: z.string().optional(), + + RATE_LIMIT_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + RATE_LIMIT_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + RATE_LIMIT_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + RATE_LIMIT_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + RATE_LIMIT_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + RATE_LIMIT_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + RATE_LIMIT_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), + RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + CACHE_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + CACHE_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + CACHE_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + CACHE_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + CACHE_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + CACHE_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + CACHE_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), + CACHE_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + REALTIME_STREAMS_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + REALTIME_STREAMS_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + REALTIME_STREAMS_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + REALTIME_STREAMS_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + REALTIME_STREAMS_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + REALTIME_STREAMS_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + REALTIME_STREAMS_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + REALTIME_STREAMS_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS: z.coerce + .number() + .int() + .default(24 * 60 * 60 * 1000), // 1 day in milliseconds + + PUBSUB_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + PUBSUB_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + PUBSUB_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + PUBSUB_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + PUBSUB_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + PUBSUB_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + PUBSUB_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), + PUBSUB_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(100), + DEFAULT_ENV_EXECUTION_CONCURRENCY_BURST_FACTOR: z.coerce.number().default(1.0), + DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(300), + DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1), + + //API Rate limiting + /** + * @example "60s" + * @example "1m" + * @example "1h" + * @example "1d" + * @example "1000ms" + * @example "1000s" + */ + API_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"), // refill 250 tokens every 10 seconds + API_RATE_LIMIT_MAX: z.coerce.number().int().default(750), // allow bursts of 750 requests + API_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(250), // refix 250 tokens every 10 seconds + API_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"), + API_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"), + API_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"), + + API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"), + API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60), + + //v3 + PROVIDER_SECRET: z.string().default("provider-secret"), + COORDINATOR_SECRET: z.string().default("coordinator-secret"), + DEPOT_TOKEN: z.string().optional(), + DEPOT_ORG_ID: z.string().optional(), + DEPOT_REGION: z.string().default("us-east-1"), + + // Deployment registry (v3) + DEPLOY_REGISTRY_HOST: z.string().min(1), + DEPLOY_REGISTRY_USERNAME: z.string().optional(), + DEPLOY_REGISTRY_PASSWORD: z.string().optional(), + DEPLOY_REGISTRY_NAMESPACE: z.string().min(1).default("trigger"), + DEPLOY_REGISTRY_ECR_TAGS: z.string().optional(), // csv, for example: "key1=value1,key2=value2" + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: z.string().optional(), + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: z.string().optional(), + + // Deployment registry (v4) - falls back to v3 registry if not specified + V4_DEPLOY_REGISTRY_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_HOST) + .pipe(z.string().min(1)), // Ensure final type is required string + V4_DEPLOY_REGISTRY_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_USERNAME), + V4_DEPLOY_REGISTRY_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_PASSWORD), + V4_DEPLOY_REGISTRY_NAMESPACE: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_NAMESPACE) + .pipe(z.string().min(1).default("trigger")), // Ensure final type is required string + V4_DEPLOY_REGISTRY_ECR_TAGS: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_TAGS), + V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN), + V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID), + + DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"), + DEPLOY_TIMEOUT_MS: z.coerce + .number() + .int() + .default(60 * 1000 * 8), // 8 minutes + + OBJECT_STORE_BASE_URL: z.string().optional(), + OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(), + OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(), + OBJECT_STORE_REGION: z.string().optional(), + OBJECT_STORE_SERVICE: z.string().default("s3"), + EVENTS_BATCH_SIZE: z.coerce.number().int().default(100), + EVENTS_BATCH_INTERVAL: z.coerce.number().int().default(1000), + EVENTS_DEFAULT_LOG_RETENTION: z.coerce.number().int().default(7), + EVENTS_MIN_CONCURRENCY: z.coerce.number().int().default(1), + EVENTS_MAX_CONCURRENCY: z.coerce.number().int().default(10), + EVENTS_MAX_BATCH_SIZE: z.coerce.number().int().default(500), + EVENTS_MEMORY_PRESSURE_THRESHOLD: z.coerce.number().int().default(5000), + EVENTS_LOAD_SHEDDING_THRESHOLD: z.coerce.number().int().default(100000), + EVENTS_LOAD_SHEDDING_ENABLED: z.string().default("1"), + SHARED_QUEUE_CONSUMER_POOL_SIZE: z.coerce.number().int().default(10), + SHARED_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(100), + SHARED_QUEUE_CONSUMER_NEXT_TICK_INTERVAL_MS: z.coerce.number().int().default(100), + SHARED_QUEUE_CONSUMER_EMIT_RESUME_DEPENDENCY_TIMEOUT_MS: z.coerce.number().int().default(1000), + SHARED_QUEUE_CONSUMER_RESOLVE_PAYLOADS_BATCH_SIZE: z.coerce.number().int().default(25), + + MANAGED_WORKER_SECRET: z.string().default("managed-secret"), + + // Development OTEL environment variables + DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(), + // If this is set to 1, then the below variables are used to configure the batch processor for spans and logs + DEV_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"), + DEV_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"), + DEV_OTEL_SPAN_SCHEDULED_DELAY_MILLIS: z.string().default("200"), + DEV_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"), + DEV_OTEL_SPAN_MAX_QUEUE_SIZE: z.string().default("512"), + DEV_OTEL_LOG_MAX_EXPORT_BATCH_SIZE: z.string().default("64"), + DEV_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"), + DEV_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"), + DEV_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"), + + PROD_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"), + PROD_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"), + PROD_OTEL_SPAN_SCHEDULED_DELAY_MILLIS: z.string().default("200"), + PROD_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"), + PROD_OTEL_SPAN_MAX_QUEUE_SIZE: z.string().default("512"), + PROD_OTEL_LOG_MAX_EXPORT_BATCH_SIZE: z.string().default("64"), + PROD_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"), + PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"), + PROD_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"), + + TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"), + TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"), + TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT: z.string().default("131072"), + TRIGGER_OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT: z.string().default("131072"), + TRIGGER_OTEL_SPAN_EVENT_COUNT_LIMIT: z.string().default("10"), + TRIGGER_OTEL_LINK_COUNT_LIMIT: z.string().default("2"), + TRIGGER_OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT: z.string().default("10"), + TRIGGER_OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT: z.string().default("10"), + + CHECKPOINT_THRESHOLD_IN_MS: z.coerce.number().int().default(30000), + + // Internal OTEL environment variables + INTERNAL_OTEL_TRACE_EXPORTER_URL: z.string().optional(), + INTERNAL_OTEL_TRACE_EXPORTER_AUTH_HEADERS: z.string().optional(), + INTERNAL_OTEL_TRACE_LOGGING_ENABLED: z.string().default("1"), + // this means 1/20 traces or 5% of traces will be sampled (sampled = recorded) + INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"), + INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"), + INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"), + + INTERNAL_OTEL_LOG_EXPORTER_URL: z.string().optional(), + INTERNAL_OTEL_METRIC_EXPORTER_URL: z.string().optional(), + INTERNAL_OTEL_METRIC_EXPORTER_AUTH_HEADERS: z.string().optional(), + INTERNAL_OTEL_METRIC_EXPORTER_ENABLED: z.string().default("0"), + INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS: z.coerce.number().int().default(30_000), + INTERNAL_OTEL_HOST_METRICS_ENABLED: BoolEnv.default(true), + INTERNAL_OTEL_NODEJS_METRICS_ENABLED: BoolEnv.default(true), + INTERNAL_OTEL_ADDITIONAL_DETECTORS_ENABLED: BoolEnv.default(true), + + ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(), + ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(), + + /** These enable the alerts feature in v3 */ + ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(), + ALERT_FROM_EMAIL: z.string().optional(), + ALERT_REPLY_TO_EMAIL: z.string().optional(), + ALERT_RESEND_API_KEY: z.string().optional(), + ALERT_SMTP_HOST: z.string().optional(), + ALERT_SMTP_PORT: z.coerce.number().optional(), + ALERT_SMTP_SECURE: BoolEnv.optional(), + ALERT_SMTP_USER: z.string().optional(), + ALERT_SMTP_PASSWORD: z.string().optional(), + ALERT_RATE_LIMITER_EMISSION_INTERVAL: z.coerce.number().int().default(2_500), + ALERT_RATE_LIMITER_BURST_TOLERANCE: z.coerce.number().int().default(10_000), + ALERT_RATE_LIMITER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + ALERT_RATE_LIMITER_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + ALERT_RATE_LIMITER_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + ALERT_RATE_LIMITER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + ALERT_RATE_LIMITER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + ALERT_RATE_LIMITER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + ALERT_RATE_LIMITER_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + ALERT_RATE_LIMITER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + LOOPS_API_KEY: z.string().optional(), + MARQS_DISABLE_REBALANCING: BoolEnv.default(false), + MARQS_VISIBILITY_TIMEOUT_MS: z.coerce + .number() + .int() + .default(60 * 1000 * 15), + MARQS_SHARED_QUEUE_LIMIT: z.coerce.number().int().default(1000), + MARQS_MAXIMUM_QUEUE_PER_ENV_COUNT: z.coerce.number().int().default(50), + MARQS_DEV_QUEUE_LIMIT: z.coerce.number().int().default(1000), + MARQS_MAXIMUM_NACK_COUNT: z.coerce.number().int().default(64), + MARQS_CONCURRENCY_LIMIT_BIAS: z.coerce.number().default(0.75), + MARQS_AVAILABLE_CAPACITY_BIAS: z.coerce.number().default(0.3), + MARQS_QUEUE_AGE_RANDOMIZATION_BIAS: z.coerce.number().default(0.25), + MARQS_REUSE_SNAPSHOT_COUNT: z.coerce.number().int().default(0), + MARQS_MAXIMUM_ENV_COUNT: z.coerce.number().int().optional(), + MARQS_SHARED_WORKER_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(250), + MARQS_SHARED_WORKER_QUEUE_MAX_MESSAGE_COUNT: z.coerce.number().int().default(10), + + MARQS_SHARED_WORKER_QUEUE_EAGER_DEQUEUE_ENABLED: z.string().default("0"), + MARQS_WORKER_ENABLED: z.string().default("0"), + MARQS_WORKER_COUNT: z.coerce.number().int().default(2), + MARQS_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), + MARQS_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(5), + MARQS_WORKER_POLL_INTERVAL_MS: z.coerce.number().int().default(100), + MARQS_WORKER_IMMEDIATE_POLL_INTERVAL_MS: z.coerce.number().int().default(100), + MARQS_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), + MARQS_SHARED_WORKER_QUEUE_COOLOFF_COUNT_THRESHOLD: z.coerce.number().int().default(10), + MARQS_SHARED_WORKER_QUEUE_COOLOFF_PERIOD_MS: z.coerce.number().int().default(5_000), + + PROD_TASK_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(), + + VERBOSE_GRAPHILE_LOGGING: z.string().default("false"), + V2_MARQS_ENABLED: z.string().default("0"), + V2_MARQS_CONSUMER_POOL_ENABLED: z.string().default("0"), + V2_MARQS_CONSUMER_POOL_SIZE: z.coerce.number().int().default(10), + V2_MARQS_CONSUMER_POLL_INTERVAL_MS: z.coerce.number().int().default(1000), + V2_MARQS_QUEUE_SELECTION_COUNT: z.coerce.number().int().default(36), + V2_MARQS_VISIBILITY_TIMEOUT_MS: z.coerce + .number() + .int() + .default(60 * 1000 * 15), + V2_MARQS_DEFAULT_ENV_CONCURRENCY: z.coerce.number().int().default(100), + V2_MARQS_VERBOSE: z.string().default("0"), + V3_MARQS_CONCURRENCY_MONITOR_ENABLED: z.string().default("0"), + V2_MARQS_CONCURRENCY_MONITOR_ENABLED: z.string().default("0"), + /* Usage settings */ + USAGE_EVENT_URL: z.string().optional(), + PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(), + + CENTS_PER_RUN: z.coerce.number().default(0), + + EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"), + MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000), + MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000), + MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000), + TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB + TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB + BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB + TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB + + MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(), + MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(), + MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500), + MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500), + + REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"), + REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000), + REALTIME_STREAM_TTL: z.coerce + .number() + .int() + .default(60 * 60 * 24), // 1 day in seconds + BATCH_METADATA_OPERATIONS_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000), + BATCH_METADATA_OPERATIONS_FLUSH_ENABLED: z.string().default("1"), + BATCH_METADATA_OPERATIONS_FLUSH_LOGGING_ENABLED: z.string().default("1"), + + // Run Engine 2.0 + RUN_ENGINE_WORKER_COUNT: z.coerce.number().int().default(4), + RUN_ENGINE_TASKS_PER_WORKER: z.coerce.number().int().default(10), + RUN_ENGINE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(10), + RUN_ENGINE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(100), + RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(100), + RUN_ENGINE_TIMEOUT_PENDING_EXECUTING: z.coerce.number().int().default(60_000), + RUN_ENGINE_TIMEOUT_PENDING_CANCEL: z.coerce.number().int().default(60_000), + RUN_ENGINE_TIMEOUT_EXECUTING: z.coerce.number().int().default(60_000), + RUN_ENGINE_TIMEOUT_EXECUTING_WITH_WAITPOINTS: z.coerce.number().int().default(60_000), + RUN_ENGINE_TIMEOUT_SUSPENDED: z.coerce + .number() + .int() + .default(60_000 * 10), + RUN_ENGINE_DEBUG_WORKER_NOTIFICATIONS: BoolEnv.default(false), + RUN_ENGINE_PARENT_QUEUE_LIMIT: z.coerce.number().int().default(1000), + RUN_ENGINE_CONCURRENCY_LIMIT_BIAS: z.coerce.number().default(0.75), + RUN_ENGINE_AVAILABLE_CAPACITY_BIAS: z.coerce.number().default(0.3), + RUN_ENGINE_QUEUE_AGE_RANDOMIZATION_BIAS: z.coerce.number().default(0.25), + RUN_ENGINE_REUSE_SNAPSHOT_COUNT: z.coerce.number().int().default(0), + RUN_ENGINE_MAXIMUM_ENV_COUNT: z.coerce.number().int().optional(), + RUN_ENGINE_RUN_QUEUE_SHARD_COUNT: z.coerce.number().int().default(4), + RUN_ENGINE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), + RUN_ENGINE_RETRY_WARM_START_THRESHOLD_MS: z.coerce.number().int().default(30_000), + RUN_ENGINE_PROCESS_WORKER_QUEUE_DEBOUNCE_MS: z.coerce.number().int().default(200), + RUN_ENGINE_DEQUEUE_BLOCKING_TIMEOUT_SECONDS: z.coerce.number().int().default(10), + RUN_ENGINE_MASTER_QUEUE_CONSUMERS_INTERVAL_MS: z.coerce.number().int().default(1000), + RUN_ENGINE_MASTER_QUEUE_COOLOFF_PERIOD_MS: z.coerce.number().int().default(10_000), + RUN_ENGINE_MASTER_QUEUE_COOLOFF_COUNT_THRESHOLD: z.coerce.number().int().default(10), + RUN_ENGINE_MASTER_QUEUE_CONSUMER_DEQUEUE_COUNT: z.coerce.number().int().default(10), + RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_SCHEDULE: z.string().optional(), + RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_SCHEDULE: z.string().optional(), + RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_JITTER_IN_MS: z.coerce.number().int().optional(), + RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_JITTER_IN_MS: z.coerce.number().int().optional(), + + RUN_ENGINE_RUN_LOCK_DURATION: z.coerce.number().int().default(5000), + RUN_ENGINE_RUN_LOCK_AUTOMATIC_EXTENSION_THRESHOLD: z.coerce.number().int().default(1000), + RUN_ENGINE_RUN_LOCK_MAX_RETRIES: z.coerce.number().int().default(10), + RUN_ENGINE_RUN_LOCK_BASE_DELAY: z.coerce.number().int().default(100), + RUN_ENGINE_RUN_LOCK_MAX_DELAY: z.coerce.number().int().default(3000), + RUN_ENGINE_RUN_LOCK_BACKOFF_MULTIPLIER: z.coerce.number().default(1.8), + RUN_ENGINE_RUN_LOCK_JITTER_FACTOR: z.coerce.number().default(0.15), + RUN_ENGINE_RUN_LOCK_MAX_TOTAL_WAIT_TIME: z.coerce.number().int().default(15000), + + RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_MAX_COUNT: z.coerce.number().int().default(12), + RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_MAX_DELAY_MS: z.coerce + .number() + .int() + .default(60_000 * 60 * 6), + RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_INITIAL_DELAY_MS: z.coerce + .number() + .int() + .default(60_000), + RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2), + + RUN_ENGINE_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + RUN_ENGINE_WORKER_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + RUN_ENGINE_WORKER_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + RUN_ENGINE_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + RUN_ENGINE_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + RUN_ENGINE_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + RUN_ENGINE_WORKER_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + + RUN_ENGINE_RUN_QUEUE_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + RUN_ENGINE_RUN_QUEUE_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + RUN_ENGINE_RUN_QUEUE_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + RUN_ENGINE_RUN_QUEUE_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + RUN_ENGINE_RUN_QUEUE_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + RUN_ENGINE_RUN_QUEUE_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + RUN_ENGINE_RUN_QUEUE_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + + RUN_ENGINE_RUN_LOCK_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + RUN_ENGINE_RUN_LOCK_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + RUN_ENGINE_RUN_LOCK_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + RUN_ENGINE_RUN_LOCK_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + RUN_ENGINE_RUN_LOCK_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + RUN_ENGINE_RUN_LOCK_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + RUN_ENGINE_RUN_LOCK_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + + RUN_ENGINE_DEV_PRESENCE_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + RUN_ENGINE_DEV_PRESENCE_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + RUN_ENGINE_DEV_PRESENCE_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + RUN_ENGINE_DEV_PRESENCE_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + + //API Rate limiting + /** + * @example "60s" + * @example "1m" + * @example "1h" + * @example "1d" + * @example "1000ms" + * @example "1000s" + */ + RUN_ENGINE_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"), // refill 250 tokens every 10 seconds + RUN_ENGINE_RATE_LIMIT_MAX: z.coerce.number().int().default(1200), // allow bursts of 750 requests + RUN_ENGINE_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(400), // refix 250 tokens every 10 seconds + RUN_ENGINE_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"), + RUN_ENGINE_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"), + RUN_ENGINE_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"), + + RUN_ENGINE_RELEASE_CONCURRENCY_ENABLED: z.string().default("0"), + RUN_ENGINE_RELEASE_CONCURRENCY_DISABLE_CONSUMERS: z.string().default("0"), + RUN_ENGINE_RELEASE_CONCURRENCY_MAX_TOKENS_RATIO: z.coerce.number().default(1), + RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_MAX_AGE: z.coerce + .number() + .int() + .default(60_000 * 30), + RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_POLL_INTERVAL: z.coerce + .number() + .int() + .default(60_000), + RUN_ENGINE_RELEASE_CONCURRENCY_MAX_RETRIES: z.coerce.number().int().default(3), + RUN_ENGINE_RELEASE_CONCURRENCY_CONSUMERS_COUNT: z.coerce.number().int().default(1), + RUN_ENGINE_RELEASE_CONCURRENCY_POLL_INTERVAL: z.coerce.number().int().default(500), + RUN_ENGINE_RELEASE_CONCURRENCY_BATCH_SIZE: z.coerce.number().int().default(10), + + RUN_ENGINE_WORKER_ENABLED: z.string().default("1"), + RUN_ENGINE_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + RUN_ENGINE_RUN_QUEUE_LOG_LEVEL: z + .enum(["log", "error", "warn", "info", "debug"]) + .default("info"), + + /** How long should the presence ttl last */ + DEV_PRESENCE_SSE_TIMEOUT: z.coerce.number().int().default(30_000), + DEV_PRESENCE_TTL_MS: z.coerce.number().int().default(5_000), + DEV_PRESENCE_POLL_MS: z.coerce.number().int().default(1_000), + /** How many ms to wait until dequeuing again, if there was a run last time */ + DEV_DEQUEUE_INTERVAL_WITH_RUN: z.coerce.number().int().default(250), + /** How many ms to wait until dequeuing again, if there was no run last time */ + DEV_DEQUEUE_INTERVAL_WITHOUT_RUN: z.coerce.number().int().default(1_000), + /** The max number of runs per API call that we'll dequeue in DEV */ + DEV_DEQUEUE_MAX_RUNS_PER_PULL: z.coerce.number().int().default(10), + + /** The maximum concurrent local run processes executing at once in dev */ + DEV_MAX_CONCURRENT_RUNS: z.coerce.number().int().default(25), + + /** The CLI should connect to this for dev runs */ + DEV_ENGINE_URL: z.string().default(process.env.APP_ORIGIN ?? "http://localhost:3030"), + + LEGACY_RUN_ENGINE_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), + LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), + LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(1), + LEGACY_RUN_ENGINE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + LEGACY_RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), + LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), + LEGACY_RUN_ENGINE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), + LEGACY_RUN_ENGINE_WORKER_LOG_LEVEL: z + .enum(["log", "error", "warn", "info", "debug"]) + .default("info"), + + LEGACY_RUN_ENGINE_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + LEGACY_RUN_ENGINE_WORKER_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + LEGACY_RUN_ENGINE_WORKER_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + LEGACY_RUN_ENGINE_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + LEGACY_RUN_ENGINE_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + LEGACY_RUN_ENGINE_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + LEGACY_RUN_ENGINE_WORKER_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + LEGACY_RUN_ENGINE_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + LEGACY_RUN_ENGINE_WAITING_FOR_DEPLOY_BATCH_SIZE: z.coerce.number().int().default(100), + LEGACY_RUN_ENGINE_WAITING_FOR_DEPLOY_BATCH_STAGGER_MS: z.coerce.number().int().default(1_000), + + COMMON_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), + COMMON_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), + COMMON_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), + COMMON_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + COMMON_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), + COMMON_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), + COMMON_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), + COMMON_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + + COMMON_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + COMMON_WORKER_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + COMMON_WORKER_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + COMMON_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + COMMON_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + COMMON_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + COMMON_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), + COMMON_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + BATCH_TRIGGER_PROCESS_JOB_VISIBILITY_TIMEOUT_MS: z.coerce + .number() + .int() + .default(60_000 * 5), // 5 minutes + + BATCH_TRIGGER_CACHED_RUNS_CHECK_ENABLED: BoolEnv.default(false), + + BATCH_TRIGGER_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), + BATCH_TRIGGER_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), + BATCH_TRIGGER_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), + BATCH_TRIGGER_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + BATCH_TRIGGER_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), + BATCH_TRIGGER_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(20), + BATCH_TRIGGER_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), + BATCH_TRIGGER_WORKER_LOG_LEVEL: z + .enum(["log", "error", "warn", "info", "debug"]) + .default("info"), + + BATCH_TRIGGER_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + BATCH_TRIGGER_WORKER_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + BATCH_TRIGGER_WORKER_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + BATCH_TRIGGER_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + BATCH_TRIGGER_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + BATCH_TRIGGER_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + BATCH_TRIGGER_WORKER_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), + ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), + ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), + ADMIN_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + ADMIN_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), + ADMIN_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(20), + ADMIN_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), + ADMIN_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + + ADMIN_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + ADMIN_WORKER_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + ADMIN_WORKER_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + ADMIN_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + ADMIN_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + ADMIN_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + ADMIN_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), + ADMIN_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + ALERTS_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), + ALERTS_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), + ALERTS_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), + ALERTS_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + ALERTS_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(100), + ALERTS_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), + ALERTS_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), + ALERTS_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + + ALERTS_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + ALERTS_WORKER_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + ALERTS_WORKER_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + ALERTS_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + ALERTS_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + ALERTS_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + ALERTS_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), + ALERTS_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + SCHEDULE_ENGINE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + SCHEDULE_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), + SCHEDULE_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), + SCHEDULE_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), + SCHEDULE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + SCHEDULE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), + SCHEDULE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50), + SCHEDULE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(30_000), + SCHEDULE_WORKER_DISTRIBUTION_WINDOW_SECONDS: z.coerce.number().int().default(30), + + SCHEDULE_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + SCHEDULE_WORKER_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + SCHEDULE_WORKER_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + SCHEDULE_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + SCHEDULE_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + SCHEDULE_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + SCHEDULE_WORKER_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + SCHEDULE_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + + TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"), + TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute + + QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000), + QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000), + + SLACK_BOT_TOKEN: z.string().optional(), + SLACK_SIGNUP_REASON_CHANNEL_ID: z.string().optional(), + + // kapa.ai + KAPA_AI_WEBSITE_ID: z.string().optional(), + + // BetterStack + BETTERSTACK_API_KEY: z.string().optional(), + BETTERSTACK_STATUS_PAGE_ID: z.string().optional(), + + RUN_REPLICATION_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + RUN_REPLICATION_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + RUN_REPLICATION_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + RUN_REPLICATION_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + RUN_REPLICATION_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + RUN_REPLICATION_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + RUN_REPLICATION_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + + RUN_REPLICATION_CLICKHOUSE_URL: z.string().optional(), + RUN_REPLICATION_ENABLED: z.string().default("0"), + RUN_REPLICATION_SLOT_NAME: z.string().default("task_runs_to_clickhouse_v1"), + RUN_REPLICATION_PUBLICATION_NAME: z.string().default("task_runs_to_clickhouse_v1_publication"), + RUN_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(2), + RUN_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000), + RUN_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100), + RUN_REPLICATION_LEADER_LOCK_TIMEOUT_MS: z.coerce.number().int().default(30_000), + RUN_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS: z.coerce.number().int().default(10_000), + RUN_REPLICATION_ACK_INTERVAL_SECONDS: z.coerce.number().int().default(10), + RUN_REPLICATION_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + RUN_REPLICATION_CLICKHOUSE_LOG_LEVEL: z + .enum(["log", "error", "warn", "info", "debug"]) + .default("info"), + RUN_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS: z.coerce.number().int().default(10_000), + RUN_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS: z.coerce.number().int().default(500), + RUN_REPLICATION_WAIT_FOR_ASYNC_INSERT: z.string().default("0"), + RUN_REPLICATION_KEEP_ALIVE_ENABLED: z.string().default("0"), + RUN_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(), + RUN_REPLICATION_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10), + // Retry configuration for insert operations + RUN_REPLICATION_INSERT_MAX_RETRIES: z.coerce.number().int().default(3), + RUN_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100), + RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000), + RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"), + + // Clickhouse + CLICKHOUSE_URL: z.string(), + CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"), + CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(), + CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10), + CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"), + + // Bootstrap + TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"), + TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(), + TRIGGER_BOOTSTRAP_WORKER_TOKEN_PATH: z.string().optional(), + + // Machine presets + MACHINE_PRESETS_OVERRIDE_PATH: z.string().optional(), + + // CLI package tag (e.g. "latest", "v4-beta", "4.0.0") - used for setup commands + TRIGGER_CLI_TAG: z.string().default("latest"), + + HEALTHCHECK_DATABASE_DISABLED: z.string().default("0"), + + REQUEST_IDEMPOTENCY_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + REQUEST_IDEMPOTENCY_REDIS_READER_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_READER_HOST), + REQUEST_IDEMPOTENCY_REDIS_READER_PORT: z.coerce + .number() + .optional() + .transform( + (v) => + v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined) + ), + REQUEST_IDEMPOTENCY_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + REQUEST_IDEMPOTENCY_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + REQUEST_IDEMPOTENCY_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + REQUEST_IDEMPOTENCY_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + + REQUEST_IDEMPOTENCY_LOG_LEVEL: z + .enum(["log", "error", "warn", "info", "debug"]) + .default("info"), + + REQUEST_IDEMPOTENCY_TTL_IN_MS: z.coerce + .number() + .int() + .default(60_000 * 60 * 24), + + // Bulk action + BULK_ACTION_BATCH_SIZE: z.coerce.number().int().default(100), + BULK_ACTION_BATCH_DELAY_MS: z.coerce.number().int().default(200), + BULK_ACTION_SUBBATCH_CONCURRENCY: z.coerce.number().int().default(5), + + // AI Run Filter + AI_RUN_FILTER_MODEL: z.string().optional(), + + EVENT_LOOP_MONITOR_THRESHOLD_MS: z.coerce.number().int().default(100), + + VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(), + }) + .and(GithubAppEnvSchema); export type Environment = z.infer; export const env = EnvironmentSchema.parse(process.env); diff --git a/apps/webapp/app/routes/_app.github.callback/route.tsx b/apps/webapp/app/routes/_app.github.callback/route.tsx new file mode 100644 index 00000000000..44c7f37c13a --- /dev/null +++ b/apps/webapp/app/routes/_app.github.callback/route.tsx @@ -0,0 +1,121 @@ +import { type LoaderFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server"; +import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server"; +import { logger } from "~/services/logger.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { tryCatch } from "@trigger.dev/core"; +import { $replica } from "~/db.server"; +import { requireUser } from "~/services/session.server"; +import { sanitizeRedirectPath } from "~/utils"; + +const QuerySchema = z.discriminatedUnion("setup_action", [ + z.object({ + setup_action: z.literal("install"), + installation_id: z.coerce.number(), + state: z.string(), + }), + z.object({ + setup_action: z.literal("update"), + installation_id: z.coerce.number(), + state: z.string(), + }), + z.object({ + setup_action: z.literal("request"), + state: z.string(), + }), +]); + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const queryParams = Object.fromEntries(url.searchParams); + const cookieHeader = request.headers.get("Cookie"); + + const result = QuerySchema.safeParse(queryParams); + + if (!result.success) { + logger.warn("GitHub App callback with invalid params", { + queryParams, + }); + return redirectWithErrorMessage("/", request, "Failed to install GitHub App"); + } + + const callbackData = result.data; + + const sessionResult = await validateGitHubAppInstallSession(cookieHeader, callbackData.state); + + if (!sessionResult.valid) { + logger.error("GitHub App callback with invalid session", { + callbackData, + error: sessionResult.error, + }); + + return redirectWithErrorMessage("/", request, "Failed to install GitHub App"); + } + + const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult; + const redirectTo = sanitizeRedirectPath(unsafeRedirectTo); + + const user = await requireUser(request); + const org = await $replica.organization.findFirst({ + where: { id: organizationId, members: { some: { userId: user.id } }, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + }, + }); + + if (!org) { + // the secure cookie approach should already protect against this + // just an additional check + logger.error("GitHub app installation attempt on unauthenticated org", { + userId: user.id, + organizationId, + }); + return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App"); + } + + switch (callbackData.setup_action) { + case "install": { + const [error] = await tryCatch( + linkGitHubAppInstallation(callbackData.installation_id, organizationId) + ); + + if (error) { + logger.error("Failed to link GitHub App installation", { + error, + }); + return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App"); + } + + return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully"); + } + + case "update": { + const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id)); + + if (error) { + logger.error("Failed to update GitHub App installation", { + error, + }); + return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App"); + } + + return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully"); + } + + case "request": { + // This happens when a non-admin user requests installation + // The installation_id won't be available until an admin approves + logger.info("GitHub App installation requested, awaiting approval", { + callbackData, + }); + + return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested"); + } + + default: + callbackData satisfies never; + return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App"); + } +} diff --git a/apps/webapp/app/routes/_app.github.install/route.tsx b/apps/webapp/app/routes/_app.github.install/route.tsx new file mode 100644 index 00000000000..42d68e5bec1 --- /dev/null +++ b/apps/webapp/app/routes/_app.github.install/route.tsx @@ -0,0 +1,52 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "remix-typedjson"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { createGitHubAppInstallSession } from "~/services/gitHubSession.server"; +import { requireUser } from "~/services/session.server"; +import { newOrganizationPath } from "~/utils/pathBuilder"; +import { logger } from "~/services/logger.server"; +import { sanitizeRedirectPath } from "~/utils"; + +const QuerySchema = z.object({ + org_slug: z.string(), + redirect_to: z.string().refine((value) => value === sanitizeRedirectPath(value), { + message: "Invalid redirect path", + }), +}); + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const searchParams = new URL(request.url).searchParams; + const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); + + if (!parsed.success) { + logger.warn("GitHub App installation redirect with invalid params", { + searchParams, + error: parsed.error, + }); + throw redirect("/"); + } + + const { org_slug, redirect_to } = parsed.data; + const user = await requireUser(request); + + const org = await $replica.organization.findFirst({ + where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + }, + }); + + if (!org) { + throw redirect(newOrganizationPath()); + } + + const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to); + + return redirect(url, { + headers: { + "Set-Cookie": cookieHeader, + }, + }); +}; diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index d85a68a4729..ee8776ae819 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -5,11 +5,12 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server"; import { authenticator } from "~/services/auth.server"; import { commitSession } from "~/services/sessionStorage.server"; import { redirectCookie } from "./auth.github"; +import { sanitizeRedirectPath } from "~/utils"; export let loader: LoaderFunction = async ({ request }) => { const cookie = request.headers.get("Cookie"); const redirectValue = await redirectCookie.parse(cookie); - const redirectTo = redirectValue ?? "/"; + const redirectTo = sanitizeRedirectPath(redirectValue); const auth = await authenticator.authenticate("github", request, { failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response diff --git a/apps/webapp/app/routes/auth.github.ts b/apps/webapp/app/routes/auth.github.ts index ec376153fc8..a4adc7f28d5 100644 --- a/apps/webapp/app/routes/auth.github.ts +++ b/apps/webapp/app/routes/auth.github.ts @@ -1,6 +1,4 @@ -import type { ActionFunction, LoaderFunction } from "@remix-run/node"; -import { createCookie } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node"; import { authenticator } from "~/services/auth.server"; export let loader: LoaderFunction = () => redirect("/login"); diff --git a/apps/webapp/app/services/gitHub.server.ts b/apps/webapp/app/services/gitHub.server.ts new file mode 100644 index 00000000000..9e4c26a5546 --- /dev/null +++ b/apps/webapp/app/services/gitHub.server.ts @@ -0,0 +1,135 @@ +import { App, type Octokit } from "octokit"; +import { env } from "../env.server"; +import { prisma } from "~/db.server"; +import { logger } from "./logger.server"; + +export const githubApp = + env.GITHUB_APP_ENABLED === "1" + ? new App({ + appId: env.GITHUB_APP_ID, + privateKey: env.GITHUB_APP_PRIVATE_KEY, + webhooks: { + secret: env.GITHUB_APP_WEBHOOK_SECRET, + }, + }) + : null; + +/** + * Links a GitHub App installation to a Trigger organization + */ +export async function linkGitHubAppInstallation( + installationId: number, + organizationId: string +): Promise { + if (!githubApp) { + throw new Error("GitHub App is not enabled"); + } + + const octokit = await githubApp.getInstallationOctokit(installationId); + const { data: installation } = await octokit.rest.apps.getInstallation({ + installation_id: installationId, + }); + + const repositories = await fetchInstallationRepositories(octokit, installationId); + + const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED"; + + await prisma.githubAppInstallation.create({ + data: { + appInstallationId: installationId, + organizationId, + targetId: installation.target_id, + targetType: installation.target_type, + accountHandle: installation.account + ? "login" in installation.account + ? installation.account.login + : "slug" in installation.account + ? installation.account.slug + : "-" + : "-", + permissions: installation.permissions, + repositorySelection, + repositories: { + create: repositories, + }, + }, + }); +} + +/** + * Links a GitHub App installation to a Trigger organization + */ +export async function updateGitHubAppInstallation(installationId: number): Promise { + if (!githubApp) { + throw new Error("GitHub App is not enabled"); + } + + const octokit = await githubApp.getInstallationOctokit(installationId); + const { data: installation } = await octokit.rest.apps.getInstallation({ + installation_id: installationId, + }); + + const existingInstallation = await prisma.githubAppInstallation.findFirst({ + where: { appInstallationId: installationId }, + }); + + if (!existingInstallation) { + throw new Error("GitHub App installation not found"); + } + + const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED"; + + // repos are updated asynchronously via webhook events + await prisma.githubAppInstallation.update({ + where: { id: existingInstallation?.id }, + data: { + appInstallationId: installationId, + targetId: installation.target_id, + targetType: installation.target_type, + accountHandle: installation.account + ? "login" in installation.account + ? installation.account.login + : "slug" in installation.account + ? installation.account.slug + : "-" + : "-", + permissions: installation.permissions, + suspendedAt: existingInstallation?.suspendedAt, + repositorySelection, + }, + }); +} + +async function fetchInstallationRepositories(octokit: Octokit, installationId: number) { + const iterator = octokit.paginate.iterator(octokit.rest.apps.listReposAccessibleToInstallation, { + installation_id: installationId, + per_page: 100, + }); + + const allRepos = []; + const maxPages = 3; + let pageCount = 0; + + for await (const { data } of iterator) { + pageCount++; + allRepos.push(...data); + + if (maxPages && pageCount >= maxPages) { + logger.warn("GitHub installation repository fetch truncated", { + installationId, + maxPages, + totalReposFetched: allRepos.length, + }); + break; + } + } + + return allRepos.map((repo) => ({ + githubId: repo.id, + name: repo.name, + fullName: repo.full_name, + htmlUrl: repo.html_url, + private: repo.private, + defaultBranch: repo.default_branch, + })); +} diff --git a/apps/webapp/app/services/gitHubSession.server.ts b/apps/webapp/app/services/gitHubSession.server.ts new file mode 100644 index 00000000000..499c1dff1ac --- /dev/null +++ b/apps/webapp/app/services/gitHubSession.server.ts @@ -0,0 +1,124 @@ +import { createCookieSessionStorage } from "@remix-run/node"; +import { randomBytes } from "crypto"; +import { env } from "../env.server"; +import { logger } from "./logger.server"; + +const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "__github_app_install", + httpOnly: true, + maxAge: 60 * 60, // 1 hour + path: "/", + sameSite: "lax", + secrets: [env.SESSION_SECRET], + secure: env.NODE_ENV === "production", + }, +}); + +/** + * Creates a secure session for GitHub App installation with organization tracking + */ +export async function createGitHubAppInstallSession( + organizationId: string, + redirectTo: string +): Promise<{ url: string; cookieHeader: string }> { + if (env.GITHUB_APP_ENABLED !== "1") { + throw new Error("GitHub App is not enabled"); + } + + const state = randomBytes(32).toString("hex"); + + const session = await sessionStorage.getSession(); + session.set("organizationId", organizationId); + session.set("redirectTo", redirectTo); + session.set("state", state); + session.set("createdAt", Date.now()); + + const githubAppSlug = env.GITHUB_APP_SLUG; + + // the state query param gets passed through to the installation callback + const url = `https://github.com/apps/${githubAppSlug}/installations/new?state=${state}`; + + const cookieHeader = await sessionStorage.commitSession(session); + + return { url, cookieHeader }; +} + +/** + * Validates and retrieves the GitHub App installation session + */ +export async function validateGitHubAppInstallSession( + cookieHeader: string | null, + state: string +): Promise< + { valid: true; organizationId: string; redirectTo: string } | { valid: false; error?: string } +> { + if (!cookieHeader) { + return { + valid: false, + error: "No installation session cookie found", + }; + } + + const session = await sessionStorage.getSession(cookieHeader); + + const sessionState = session.get("state"); + const organizationId = session.get("organizationId"); + const redirectTo = session.get("redirectTo"); + const createdAt = session.get("createdAt"); + + if (!sessionState || !organizationId || !createdAt || !redirectTo) { + logger.warn("GitHub App installation session missing required fields", { + hasState: !!sessionState, + hasOrgId: !!organizationId, + hasCreatedAt: !!createdAt, + hasRedirectTo: !!redirectTo, + }); + + return { + valid: false, + error: "invalid_session_data", + }; + } + + if (sessionState !== state) { + logger.warn("GitHub App installation state mismatch", { + expectedState: sessionState, + receivedState: state, + }); + return { + valid: false, + error: "state_mismatch", + }; + } + + const expirationTime = createdAt + 60 * 60 * 1000; + if (Date.now() > expirationTime) { + logger.warn("GitHub App installation session expired", { + createdAt: new Date(createdAt), + now: new Date(), + }); + return { + valid: false, + error: "session_expired", + }; + } + + return { + valid: true, + organizationId, + redirectTo, + }; +} + +/** + * Destroys the GitHub App installation cookie session + */ +export async function destroyGitHubAppInstallSession(cookieHeader: string | null): Promise { + if (!cookieHeader) { + return ""; + } + + const session = await sessionStorage.getSession(cookieHeader); + return await sessionStorage.destroySession(session); +} diff --git a/apps/webapp/app/utils.ts b/apps/webapp/app/utils.ts index 7dc407cec6c..adb18292811 100644 --- a/apps/webapp/app/utils.ts +++ b/apps/webapp/app/utils.ts @@ -7,22 +7,38 @@ const DEFAULT_REDIRECT = "/"; * This should be used any time the redirect path is user-provided * (Like the query string on our login/signup pages). This avoids * open-redirect vulnerabilities. - * @param {string} to The redirect destination + * @param {string} path The redirect destination * @param {string} defaultRedirect The redirect to use if the to is unsafe. */ -export function safeRedirect( - to: FormDataEntryValue | string | null | undefined, +export function sanitizeRedirectPath( + path: string | undefined | null, defaultRedirect: string = DEFAULT_REDIRECT -) { - if (!to || typeof to !== "string") { +): string { + if (!path || typeof path !== "string") { return defaultRedirect; } - if (!to.startsWith("/") || to.startsWith("//")) { + if (!path.startsWith("/") || path.startsWith("//")) { return defaultRedirect; } - return to; + try { + // should not parse as a full URL + new URL(path); + return defaultRedirect; + } catch {} + + try { + // ensure it's a valid relative path + const url = new URL(path, "https://example.com"); + if (url.hostname !== "example.com") { + return defaultRedirect; + } + } catch { + return defaultRedirect; + } + + return path; } /** diff --git a/apps/webapp/package.json b/apps/webapp/package.json index a09bcc5a66c..a0294942406 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -160,6 +160,7 @@ "morgan": "^1.10.0", "nanoid": "3.3.8", "non.geist": "^1.0.2", + "octokit": "^3.2.1", "ohash": "^1.1.3", "openai": "^4.33.1", "p-limit": "^6.2.0", diff --git a/internal-packages/database/prisma/migrations/20250902133449_add_gh_installation_schema/migration.sql b/internal-packages/database/prisma/migrations/20250902133449_add_gh_installation_schema/migration.sql new file mode 100644 index 00000000000..78b5e6bf2c2 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250902133449_add_gh_installation_schema/migration.sql @@ -0,0 +1,46 @@ +CREATE TYPE "public"."GithubRepositorySelection" AS ENUM ('ALL', 'SELECTED'); + +CREATE TABLE "public"."GithubAppInstallation" ( + "id" TEXT NOT NULL, + "appInstallationId" BIGINT NOT NULL, + "targetId" BIGINT NOT NULL, + "targetType" TEXT NOT NULL, + "accountHandle" TEXT NOT NULL, + "permissions" JSONB, + "repositorySelection" "public"."GithubRepositorySelection" NOT NULL, + "installedBy" TEXT, + "organizationId" TEXT NOT NULL, + "deletedAt" TIMESTAMP(3), + "suspendedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GithubAppInstallation_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "public"."GithubRepository" ( + "id" TEXT NOT NULL, + "githubId" BIGINT NOT NULL, + "name" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "htmlUrl" TEXT NOT NULL, + "private" BOOLEAN NOT NULL, + "defaultBranch" TEXT NOT NULL, + "installationId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GithubRepository_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "GithubAppInstallation_appInstallationId_key" ON "public"."GithubAppInstallation"("appInstallationId"); + +CREATE INDEX "GithubAppInstallation_organizationId_idx" ON "public"."GithubAppInstallation"("organizationId"); + +CREATE INDEX "GithubRepository_installationId_idx" ON "public"."GithubRepository"("installationId"); + +CREATE UNIQUE INDEX "GithubRepository_installationId_githubId_key" ON "public"."GithubRepository"("installationId", "githubId"); + +ALTER TABLE "public"."GithubAppInstallation" ADD CONSTRAINT "GithubAppInstallation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "public"."GithubRepository" ADD CONSTRAINT "GithubRepository_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "public"."GithubAppInstallation"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/migrations/migration_lock.toml b/internal-packages/database/prisma/migrations/migration_lock.toml index 99e4f200907..044d57cdb0d 100644 --- a/internal-packages/database/prisma/migrations/migration_lock.toml +++ b/internal-packages/database/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) +# It should be added in your version-control system (e.g., Git) provider = "postgresql" diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index be10d47f716..edcc82f535d 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -210,6 +210,7 @@ model Organization { workerGroups WorkerInstanceGroup[] workerInstances WorkerInstance[] executionSnapshots TaskRunExecutionSnapshot[] + githubAppInstallations GithubAppInstallation[] } model OrgMember { @@ -2238,3 +2239,52 @@ model TaskEventPartitioned { // Used for getting all logs for a run @@index([runId]) } + +enum GithubRepositorySelection { + ALL + SELECTED +} + +model GithubAppInstallation { + id String @id @default(cuid()) + + appInstallationId BigInt @unique + targetId BigInt + targetType String + accountHandle String + permissions Json? + repositorySelection GithubRepositorySelection + installedBy String? + + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId String + + repositories GithubRepository[] + + deletedAt DateTime? + suspendedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) +} + +model GithubRepository { + id String @id @default(cuid()) + + githubId BigInt + name String + fullName String + htmlUrl String + private Boolean + defaultBranch String + + installation GithubAppInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + installationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([installationId, githubId]) + @@index([installationId]) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4ddc729869..a2c6c795473 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,6 +587,9 @@ importers: non.geist: specifier: ^1.0.2 version: 1.0.2 + octokit: + specifier: ^3.2.1 + version: 3.2.2 ohash: specifier: ^1.1.3 version: 1.1.3 @@ -8546,6 +8549,262 @@ packages: which: 3.0.1 dev: true + /@octokit/app@14.1.0: + resolution: {integrity: sha512-g3uEsGOQCBl1+W1rgfwoRFUIR6PtvB2T1E4RpygeUU5LrLvlOqcxrt5lfykIeRpUPpupreGJUYl70fqMDXdTpw==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-app': 6.1.4 + '@octokit/auth-unauthenticated': 5.0.1 + '@octokit/core': 5.2.2 + '@octokit/oauth-app': 6.1.0 + '@octokit/plugin-paginate-rest': 9.2.2(@octokit/core@5.2.2) + '@octokit/types': 12.6.0 + '@octokit/webhooks': 12.3.2 + dev: false + + /@octokit/auth-app@6.1.4: + resolution: {integrity: sha512-QkXkSOHZK4dA5oUqY5Dk3S+5pN2s1igPjEASNQV8/vgJgW034fQWR16u7VsNOK/EljA00eyjYF5mWNxWKWhHRQ==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-oauth-app': 7.1.0 + '@octokit/auth-oauth-user': 4.1.0 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + lru-cache: /@wolfy1339/lru-cache@11.0.2-patch.1 + universal-github-app-jwt: 1.2.0 + universal-user-agent: 6.0.1 + dev: false + + /@octokit/auth-oauth-app@7.1.0: + resolution: {integrity: sha512-w+SyJN/b0l/HEb4EOPRudo7uUOSW51jcK1jwLa+4r7PA8FPFpoxEnHBHMITqCsc/3Vo2qqFjgQfz/xUUvsSQnA==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-oauth-device': 6.1.0 + '@octokit/auth-oauth-user': 4.1.0 + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + '@types/btoa-lite': 1.0.2 + btoa-lite: 1.0.0 + universal-user-agent: 6.0.1 + dev: false + + /@octokit/auth-oauth-device@6.1.0: + resolution: {integrity: sha512-FNQ7cb8kASufd6Ej4gnJ3f1QB5vJitkoV1O0/g6e6lUsQ7+VsSNRHRmFScN2tV4IgKA12frrr/cegUs0t+0/Lw==} + engines: {node: '>= 18'} + dependencies: + '@octokit/oauth-methods': 4.1.0 + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + dev: false + + /@octokit/auth-oauth-user@4.1.0: + resolution: {integrity: sha512-FrEp8mtFuS/BrJyjpur+4GARteUCrPeR/tZJzD8YourzoVhRics7u7we/aDcKv+yywRNwNi/P4fRi631rG/OyQ==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-oauth-device': 6.1.0 + '@octokit/oauth-methods': 4.1.0 + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + btoa-lite: 1.0.0 + universal-user-agent: 6.0.1 + dev: false + + /@octokit/auth-token@4.0.0: + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + dev: false + + /@octokit/auth-unauthenticated@5.0.1: + resolution: {integrity: sha512-oxeWzmBFxWd+XolxKTc4zr+h3mt+yofn4r7OfoIkR/Cj/o70eEGmPsFbueyJE2iBAGpjgTnEOKM3pnuEGVmiqg==} + engines: {node: '>= 18'} + dependencies: + '@octokit/request-error': 5.1.1 + '@octokit/types': 12.6.0 + dev: false + + /@octokit/core@5.2.2: + resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.1 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + dev: false + + /@octokit/endpoint@9.0.6: + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + dependencies: + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + dev: false + + /@octokit/graphql@7.1.1: + resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} + engines: {node: '>= 18'} + dependencies: + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + dev: false + + /@octokit/oauth-app@6.1.0: + resolution: {integrity: sha512-nIn/8eUJ/BKUVzxUXd5vpzl1rwaVxMyYbQkNZjHrF7Vk/yu98/YDF/N2KeWO7uZ0g3b5EyiFXFkZI8rJ+DH1/g==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-oauth-app': 7.1.0 + '@octokit/auth-oauth-user': 4.1.0 + '@octokit/auth-unauthenticated': 5.0.1 + '@octokit/core': 5.2.2 + '@octokit/oauth-authorization-url': 6.0.2 + '@octokit/oauth-methods': 4.1.0 + '@types/aws-lambda': 8.10.152 + universal-user-agent: 6.0.1 + dev: false + + /@octokit/oauth-authorization-url@6.0.2: + resolution: {integrity: sha512-CdoJukjXXxqLNK4y/VOiVzQVjibqoj/xHgInekviUJV73y/BSIcwvJ/4aNHPBPKcPWFnd4/lO9uqRV65jXhcLA==} + engines: {node: '>= 18'} + dev: false + + /@octokit/oauth-methods@4.1.0: + resolution: {integrity: sha512-4tuKnCRecJ6CG6gr0XcEXdZtkTDbfbnD5oaHBmLERTjTMZNi2CbfEHZxPU41xXLDG4DfKf+sonu00zvKI9NSbw==} + engines: {node: '>= 18'} + dependencies: + '@octokit/oauth-authorization-url': 6.0.2 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + btoa-lite: 1.0.0 + dev: false + + /@octokit/openapi-types@20.0.0: + resolution: {integrity: sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==} + dev: false + + /@octokit/openapi-types@24.2.0: + resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + dev: false + + /@octokit/plugin-paginate-graphql@4.0.1(@octokit/core@5.2.2): + resolution: {integrity: sha512-R8ZQNmrIKKpHWC6V2gum4x9LG2qF1RxRjo27gjQcG3j+vf2tLsEfE7I/wRWEPzYMaenr1M+qDAtNcwZve1ce1A==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=5' + dependencies: + '@octokit/core': 5.2.2 + dev: false + + /@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2): + resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + dev: false + + /@octokit/plugin-paginate-rest@9.2.2(@octokit/core@5.2.2): + resolution: {integrity: sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 12.6.0 + dev: false + + /@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2): + resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^5 + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + dev: false + + /@octokit/plugin-retry@6.1.0(@octokit/core@5.2.2): + resolution: {integrity: sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + dependencies: + '@octokit/core': 5.2.2 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + bottleneck: 2.19.5 + dev: false + + /@octokit/plugin-throttling@8.2.0(@octokit/core@5.2.2): + resolution: {integrity: sha512-nOpWtLayKFpgqmgD0y3GqXafMFuKcA4tRPZIfu7BArd2lEZeb1988nhWhwx4aZWmjDmUfdgVf7W+Tt4AmvRmMQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^5.0.0 + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 12.6.0 + bottleneck: 2.19.5 + dev: false + + /@octokit/request-error@5.1.1: + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + dependencies: + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + once: 1.4.0 + dev: false + + /@octokit/request@8.4.1: + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + dependencies: + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + dev: false + + /@octokit/types@12.6.0: + resolution: {integrity: sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==} + dependencies: + '@octokit/openapi-types': 20.0.0 + dev: false + + /@octokit/types@13.10.0: + resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + dependencies: + '@octokit/openapi-types': 24.2.0 + dev: false + + /@octokit/webhooks-methods@4.1.0: + resolution: {integrity: sha512-zoQyKw8h9STNPqtm28UGOYFE7O6D4Il8VJwhAtMHFt2C4L0VQT1qGKLeefUOqHNs1mNRYSadVv7x0z8U2yyeWQ==} + engines: {node: '>= 18'} + dev: false + + /@octokit/webhooks-types@7.6.1: + resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==} + dev: false + + /@octokit/webhooks@12.3.2: + resolution: {integrity: sha512-exj1MzVXoP7xnAcAB3jZ97pTvVPkQF9y6GA/dvYC47HV7vLv+24XRS6b/v/XnyikpEuvMhugEXdGtAlU086WkQ==} + engines: {node: '>= 18'} + dependencies: + '@octokit/request-error': 5.1.1 + '@octokit/webhooks-methods': 4.1.0 + '@octokit/webhooks-types': 7.6.1 + aggregate-error: 3.1.0 + dev: false + /@one-ini/wasm@0.1.1: resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} dev: false @@ -17538,6 +17797,10 @@ packages: resolution: {integrity: sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==} dev: true + /@types/aws-lambda@8.10.152: + resolution: {integrity: sha512-soT/c2gYBnT5ygwiHPmd9a1bftj462NWVk2tKCc1PYHSIacB2UwbTS2zYG4jzag1mRDuzg/OjtxQjQ2NKRB6Rw==} + dev: false + /@types/bcryptjs@2.4.2: resolution: {integrity: sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ==} dev: true @@ -17549,6 +17812,10 @@ packages: '@types/node': 20.14.14 dev: true + /@types/btoa-lite@1.0.2: + resolution: {integrity: sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==} + dev: false + /@types/bun@1.1.6: resolution: {integrity: sha512-uJgKjTdX0GkWEHZzQzFsJkWp5+43ZS7HC8sZPFnOwnSo1AsNl2q9o2bFeS23disNDqbggEgyFkKCHl/w8iZsMA==} dependencies: @@ -17808,6 +18075,13 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/jsonwebtoken@9.0.10: + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + dependencies: + '@types/ms': 0.7.31 + '@types/node': 20.14.14 + dev: false + /@types/keyv@3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: @@ -17926,7 +18200,6 @@ packages: resolution: {integrity: sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==} dependencies: undici-types: 6.20.0 - dev: false /@types/nodemailer@6.4.17: resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} @@ -19012,6 +19285,11 @@ packages: xstate: 5.18.1 dev: false + /@wolfy1339/lru-cache@11.0.2-patch.1: + resolution: {integrity: sha512-BgYZfL2ADCXKOw2wJtkM3slhHotawWkgIRRxq4wEybnZQPjvAp71SPX35xepMykTw8gXlzWcWPTY31hlbnRsDA==} + engines: {node: 18 >=18.20 || 20 || >=22} + dev: false + /@xobotyi/scrollbar-width@1.9.5: resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} dev: false @@ -19181,7 +19459,6 @@ packages: dependencies: clean-stack: 2.2.0 indent-string: 4.0.0 - dev: true /aggregate-error@4.0.1: resolution: {integrity: sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==} @@ -19878,6 +20155,10 @@ packages: dependencies: tweetnacl: 0.14.5 + /before-after-hook@2.2.3: + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + dev: false + /better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -19958,6 +20239,10 @@ packages: - supports-color dev: false + /bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + dev: false + /bowser@2.11.0: resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} dev: false @@ -20023,6 +20308,10 @@ packages: update-browserslist-db: 1.1.3(browserslist@4.25.0) dev: true + /btoa-lite@1.0.0: + resolution: {integrity: sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==} + dev: false + /buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} dev: false @@ -20032,6 +20321,10 @@ packages: engines: {node: '>=8.0.0'} dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -20453,7 +20746,6 @@ packages: /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} - dev: true /clean-stack@4.2.0: resolution: {integrity: sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg==} @@ -21440,6 +21732,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + /deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + dev: false + /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -21702,6 +21998,12 @@ packages: safer-buffer: 2.1.2 dev: false + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /editorconfig@1.0.4: resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} engines: {node: '>=14'} @@ -21834,7 +22136,7 @@ packages: engines: {node: '>=10.13.0'} dependencies: graceful-fs: 4.2.11 - tapable: 2.2.1 + tapable: 2.2.2 /enquirer@2.3.6: resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} @@ -25124,7 +25426,7 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 20.14.14 + '@types/node': 22.13.9 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -25338,6 +25640,22 @@ packages: engines: {node: '>=0.10.0'} dev: false + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.2 + dev: false + /jsprim@1.4.2: resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} engines: {node: '>=0.6.0'} @@ -25361,6 +25679,21 @@ packages: engines: {node: '>=12.20'} dev: true + /jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + dev: false + /keyv@3.1.0: resolution: {integrity: sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==} dependencies: @@ -25742,21 +26075,40 @@ packages: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} dev: false + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: false + /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} dev: false + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: false + /lodash.isfunction@3.0.9: resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} dev: false + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: false + /lodash.isnil@4.0.0: resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} dev: false + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: false + /lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - dev: true + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: false /lodash.isundefined@3.0.1: resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} @@ -25769,6 +26121,10 @@ packages: resolution: {integrity: sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==} dev: false + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: false + /lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} dev: true @@ -27631,6 +27987,23 @@ packages: /obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + /octokit@3.2.2: + resolution: {integrity: sha512-7Abo3nADdja8l/aglU6Y3lpnHSfv0tw7gFPiqzry/yCU+2gTAX7R1roJ8hJrxIK+S1j+7iqRJXtmuHJ/UDsBhQ==} + engines: {node: '>= 18'} + dependencies: + '@octokit/app': 14.1.0 + '@octokit/core': 5.2.2 + '@octokit/oauth-app': 6.1.0 + '@octokit/plugin-paginate-graphql': 4.0.1(@octokit/core@5.2.2) + '@octokit/plugin-paginate-rest': 11.4.4-cjs.2(@octokit/core@5.2.2) + '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) + '@octokit/plugin-retry': 6.1.0(@octokit/core@5.2.2) + '@octokit/plugin-throttling': 8.2.0(@octokit/core@5.2.2) + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + '@octokit/webhooks': 12.3.2 + dev: false + /ohash@1.1.3: resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} dev: false @@ -32014,7 +32387,6 @@ packages: /tapable@2.2.2: resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} engines: {node: '>=6'} - dev: true /tar-fs@2.1.3: resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==} @@ -33000,7 +33372,6 @@ packages: /undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} - dev: false /undici@5.28.4: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} @@ -33152,6 +33523,17 @@ packages: cookie: 0.6.0 dev: false + /universal-github-app-jwt@1.2.0: + resolution: {integrity: sha512-dncpMpnsKBk0eetwfN8D8OUHGfiDhhJ+mtsbMl+7PfW7mYjiH8LIcqRmYMtzYLgSh47HjfdBtrBwIQ/gizKR3g==} + dependencies: + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.2 + dev: false + + /universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + dev: false + /universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} From ed23615aa444212affae9004356bbd92f34f7090 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 3 Sep 2025 11:39:03 +0100 Subject: [PATCH 02/10] perf(webapp): add event loop utilization metric (#2471) * perf(webapp): add event loop utilization metric * add event loop utilization logging as well --- apps/webapp/app/env.server.ts | 2 ++ apps/webapp/app/eventLoopMonitor.server.ts | 32 ++++++++++++++++++++++ apps/webapp/app/v3/tracer.server.ts | 21 ++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 350463a5b0d..059195d5430 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1167,6 +1167,8 @@ const EnvironmentSchema = z AI_RUN_FILTER_MODEL: z.string().optional(), EVENT_LOOP_MONITOR_THRESHOLD_MS: z.coerce.number().int().default(100), + EVENT_LOOP_MONITOR_UTILIZATION_INTERVAL_MS: z.coerce.number().int().default(1000), + EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE: z.coerce.number().default(0.05), VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(), }) diff --git a/apps/webapp/app/eventLoopMonitor.server.ts b/apps/webapp/app/eventLoopMonitor.server.ts index 42e982bdb99..1d8603a4af0 100644 --- a/apps/webapp/app/eventLoopMonitor.server.ts +++ b/apps/webapp/app/eventLoopMonitor.server.ts @@ -3,6 +3,8 @@ import { singleton } from "./utils/singleton"; import { tracer } from "./v3/tracer.server"; import { env } from "./env.server"; import { context, Context } from "@opentelemetry/api"; +import { performance } from "node:perf_hooks"; +import { logger } from "./services/logger.server"; const THRESHOLD_NS = env.EVENT_LOOP_MONITOR_THRESHOLD_MS * 1e6; @@ -69,16 +71,46 @@ function after(asyncId: number) { export const eventLoopMonitor = singleton("eventLoopMonitor", () => { const hook = createHook({ init, before, after, destroy }); + let stopEventLoopUtilizationMonitoring: () => void; + return { enable: () => { console.log("πŸ₯Έ Initializing event loop monitor"); hook.enable(); + + stopEventLoopUtilizationMonitoring = startEventLoopUtilizationMonitoring(); }, disable: () => { console.log("πŸ₯Έ Disabling event loop monitor"); hook.disable(); + + stopEventLoopUtilizationMonitoring?.(); }, }; }); + +function startEventLoopUtilizationMonitoring() { + let lastEventLoopUtilization = performance.eventLoopUtilization(); + + const interval = setInterval(() => { + const currentEventLoopUtilization = performance.eventLoopUtilization(); + + const diff = performance.eventLoopUtilization( + currentEventLoopUtilization, + lastEventLoopUtilization + ); + const utilization = Number.isFinite(diff.utilization) ? diff.utilization : 0; + + if (Math.random() < env.EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE) { + logger.info("nodejs.event_loop.utilization", { utilization }); + } + + lastEventLoopUtilization = currentEventLoopUtilization; + }, env.EVENT_LOOP_MONITOR_UTILIZATION_INTERVAL_MS); + + return () => { + clearInterval(interval); + }; +} diff --git a/apps/webapp/app/v3/tracer.server.ts b/apps/webapp/app/v3/tracer.server.ts index d084f78a7fc..71e14521e50 100644 --- a/apps/webapp/app/v3/tracer.server.ts +++ b/apps/webapp/app/v3/tracer.server.ts @@ -57,6 +57,7 @@ import { flattenAttributes } from "@trigger.dev/core/v3"; import { prisma } from "~/db.server"; import { metricsRegister } from "~/metrics.server"; import type { Prisma } from "@trigger.dev/database"; +import { performance } from "node:perf_hooks"; export const SEMINTATTRS_FORCE_RECORDING = "forceRecording"; @@ -602,10 +603,17 @@ function configureNodejsMetrics({ meter }: { meter: Meter }) { description: "Event loop 99th percentile delay", unit: "s", }); + // ELU observable gauge (unit is a ratio, 0..1) + const eluGauge = meter.createObservableGauge("nodejs.event_loop.utilization", { + description: "Event loop utilization over the last collection interval", + unit: "1", // OpenTelemetry convention for ratios + }); // Get UV threadpool size (defaults to 4 if not set) const uvThreadpoolSize = parseInt(process.env.UV_THREADPOOL_SIZE || "4", 10); + let lastEventLoopUtilization = performance.eventLoopUtilization(); + // Single helper to read metrics from prom-client async function readNodeMetrics() { const metrics = await metricsRegister.getMetricsAsJSON(); @@ -648,6 +656,16 @@ function configureNodejsMetrics({ meter }: { meter: Meter }) { } } + const currentEventLoopUtilization = performance.eventLoopUtilization(); + // Diff over [lastSnapshot, current] + const diff = performance.eventLoopUtilization( + currentEventLoopUtilization, + lastEventLoopUtilization + ); + + // diff.utilization is between 0 and 1 (fraction of time "active") + const utilization = Number.isFinite(diff.utilization) ? diff.utilization : 0; + return { threadpoolSize: uvThreadpoolSize, handlesByType, @@ -661,6 +679,7 @@ function configureNodejsMetrics({ meter }: { meter: Meter }) { p50: eventLoopLagP50?.values?.[0]?.value ?? 0, p90: eventLoopLagP90?.values?.[0]?.value ?? 0, p99: eventLoopLagP99?.values?.[0]?.value ?? 0, + utilization, }, }; } @@ -698,6 +717,7 @@ function configureNodejsMetrics({ meter }: { meter: Meter }) { res.observe(eventLoopLagP50Gauge, eventLoop.p50); res.observe(eventLoopLagP90Gauge, eventLoop.p90); res.observe(eventLoopLagP99Gauge, eventLoop.p99); + res.observe(eluGauge, eventLoop.utilization); }, [ uvThreadpoolSizeGauge, @@ -711,6 +731,7 @@ function configureNodejsMetrics({ meter }: { meter: Meter }) { eventLoopLagP50Gauge, eventLoopLagP90Gauge, eventLoopLagP99Gauge, + eluGauge, ] ); } From 59c17e04e95d4014a24ca8c194a73609b43ca59f Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:27:06 +0100 Subject: [PATCH 03/10] feat(run-engine): worker queue resolver (#2476) --- .../run-engine/src/run-queue/index.ts | 19 +- .../tests/workerQueueResolver.test.ts | 484 ++++++++++++++++++ .../src/run-queue/workerQueueResolver.ts | 100 ++++ 3 files changed, 590 insertions(+), 13 deletions(-) create mode 100644 internal-packages/run-engine/src/run-queue/tests/workerQueueResolver.test.ts create mode 100644 internal-packages/run-engine/src/run-queue/workerQueueResolver.ts diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index ec12c19f1d6..81cbb0379cb 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -42,6 +42,7 @@ import { RunQueueKeyProducer, RunQueueSelectionStrategy, } from "./types.js"; +import { WorkerQueueResolver } from "./workerQueueResolver.js"; const SemanticAttributes = { QUEUE: "runqueue.queue", @@ -169,6 +170,7 @@ export class RunQueue { private shardCount: number; private abortController: AbortController; private worker: Worker; + private workerQueueResolver: WorkerQueueResolver; private _observableWorkerQueues: Set = new Set(); private _meter: Meter; private _queueCooloffStates: Map = new Map(); @@ -185,6 +187,8 @@ export class RunQueue { }, }); this.logger = options.logger ?? new Logger("RunQueue", options.logLevel ?? "info"); + + this.workerQueueResolver = new WorkerQueueResolver({ logger: this.logger }); this._meter = options.meter ?? getMeter("run-queue"); const workerQueueObservableGauge = this._meter.createObservableGauge( @@ -1845,19 +1849,8 @@ export class RunQueue { ); } - #getWorkerQueueFromMessage(message: OutputPayload) { - if (message.version === "2") { - return message.workerQueue; - } - - // In v2, if the environment is development, the worker queue is the environment id. - if (message.environmentType === "DEVELOPMENT") { - return message.environmentId; - } - - // In v1, the master queue is something like us-nyc-3, - // which in v2 is the worker queue. - return message.masterQueues[0]; + #getWorkerQueueFromMessage(message: OutputPayload): string { + return this.workerQueueResolver.getWorkerQueueFromMessage(message); } #createBlockingDequeueClient() { diff --git a/internal-packages/run-engine/src/run-queue/tests/workerQueueResolver.test.ts b/internal-packages/run-engine/src/run-queue/tests/workerQueueResolver.test.ts new file mode 100644 index 00000000000..3ea8f0ce714 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/workerQueueResolver.test.ts @@ -0,0 +1,484 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Logger } from "@trigger.dev/core/logger"; +import { WorkerQueueResolver, type WorkerQueueOverrides } from "../workerQueueResolver.js"; +import { OutputPayload, OutputPayloadV1, OutputPayloadV2 } from "../types.js"; +import { RuntimeEnvironmentType } from "@trigger.dev/core/v3"; + +vi.setConfig({ testTimeout: 5_000 }); + +describe("WorkerQueueOverrideResolver", () => { + const createTestMessage = (overrides?: Partial): OutputPayloadV2 => ({ + version: "2", + runId: "run_123", + taskIdentifier: "task_123", + orgId: "org_123", + projectId: "proj_123", + environmentId: "env_123", + environmentType: RuntimeEnvironmentType.PRODUCTION, + queue: "test-queue", + timestamp: Date.now(), + attempt: 0, + workerQueue: "default-queue", + ...overrides, + }); + + describe("No overrides", () => { + it("should return original workerQueue when no overrides are set", () => { + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger }); + const message = createTestMessage({ workerQueue: "original-queue" }); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("original-queue"); + }); + }); + + describe("Environment ID overrides", () => { + it("should override based on environmentId", () => { + const overrideConfig = JSON.stringify({ + environmentId: { + env_special: "special-env-queue", + }, + } satisfies WorkerQueueOverrides); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage({ + environmentId: "env_special", + workerQueue: "original-queue", + }); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("special-env-queue"); + }); + + it("should not override when environmentId doesn't match", () => { + const overrideConfig = JSON.stringify({ + environmentId: { + env_other: "other-queue", + }, + } satisfies WorkerQueueOverrides); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage({ + environmentId: "env_123", + workerQueue: "original-queue", + }); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("original-queue"); + }); + }); + + describe("Project ID overrides", () => { + it("should override based on projectId", () => { + const overrideConfig = JSON.stringify({ + projectId: { + proj_special: "special-project-queue", + }, + } satisfies WorkerQueueOverrides); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage({ + projectId: "proj_special", + workerQueue: "original-queue", + }); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("special-project-queue"); + }); + }); + + describe("Organization ID overrides", () => { + it("should override based on orgId", () => { + const overrideConfig = JSON.stringify({ + orgId: { + org_special: "special-org-queue", + }, + } satisfies WorkerQueueOverrides); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage({ + orgId: "org_special", + workerQueue: "original-queue", + }); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("special-org-queue"); + }); + }); + + describe("Worker Queue overrides", () => { + it("should override based on workerQueue", () => { + const overrideConfig = JSON.stringify({ + workerQueue: { + "us-east-1": "us-west-1", + }, + } satisfies WorkerQueueOverrides); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage({ + workerQueue: "us-east-1", + }); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("us-west-1"); + }); + }); + + describe("Priority order", () => { + it("should prioritize environmentId over projectId", () => { + const overrideConfig = JSON.stringify({ + environmentId: { + env_123: "env-queue", + }, + projectId: { + proj_123: "project-queue", + }, + } satisfies WorkerQueueOverrides); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage(); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("env-queue"); + }); + + it("should prioritize projectId over orgId", () => { + const overrideConfig = JSON.stringify({ + projectId: { + proj_123: "project-queue", + }, + orgId: { + org_123: "org-queue", + }, + } satisfies WorkerQueueOverrides); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage(); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("project-queue"); + }); + + it("should prioritize orgId over workerQueue", () => { + const overrideConfig = JSON.stringify({ + orgId: { + org_123: "org-queue", + }, + workerQueue: { + "default-queue": "worker-override-queue", + }, + } satisfies WorkerQueueOverrides); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage(); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("org-queue"); + }); + }); + + describe("Configuration parsing", () => { + it("should handle invalid JSON gracefully", () => { + const loggerSpy = vi.spyOn(Logger.prototype, "error"); + + const overrideConfig = "invalid json {"; + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage(); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("default-queue"); + expect(loggerSpy).toHaveBeenCalledWith( + "Failed to parse worker queue overrides json", + expect.any(Object) + ); + + loggerSpy.mockRestore(); + }); + + it("should handle non-object JSON gracefully", () => { + const loggerSpy = vi.spyOn(Logger.prototype, "error"); + + const overrideConfig = JSON.stringify("not an object"); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage(); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("default-queue"); + expect(loggerSpy).toHaveBeenCalledWith( + "Invalid worker queue overrides format", + expect.any(Object) + ); + + loggerSpy.mockRestore(); + }); + + it("should handle null JSON gracefully", () => { + const loggerSpy = vi.spyOn(Logger.prototype, "error"); + + const overrideConfig = JSON.stringify(null); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage(); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("default-queue"); + expect(loggerSpy).toHaveBeenCalledWith( + "Invalid worker queue overrides format", + expect.any(Object) + ); + + loggerSpy.mockRestore(); + }); + + it("should log when overrides are enabled", () => { + const loggerSpy = vi.spyOn(Logger.prototype, "info"); + + const overrides: WorkerQueueOverrides = { + orgId: { org_123: "dedicated-queue" }, + }; + + const overrideConfig = JSON.stringify(overrides); + + const logger = new Logger("test", "info"); + new WorkerQueueResolver({ logger, overrideConfig }); + + expect(loggerSpy).toHaveBeenCalledWith("🎯 Worker queue overrides enabled", { overrides }); + + loggerSpy.mockRestore(); + }); + + it("should validate schema and reject invalid structure", () => { + const loggerSpy = vi.spyOn(Logger.prototype, "error"); + + // Invalid structure - numbers instead of strings in the record + const overrideConfig = JSON.stringify({ + orgId: { + org_123: 12345, // Should be string, not number + }, + }); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage(); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("default-queue"); + expect(loggerSpy).toHaveBeenCalledWith( + "Invalid worker queue overrides format", + expect.any(Object) + ); + + loggerSpy.mockRestore(); + }); + }); + + describe("Complex scenarios", () => { + it("should handle multiple override types simultaneously", () => { + const overrideConfig = JSON.stringify({ + environmentId: { + env_special: "special-env-queue", + }, + projectId: { + proj_other: "other-project-queue", + }, + orgId: { + org_123: "org-queue", + }, + workerQueue: { + "fallback-queue": "redirected-queue", + }, + }); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + + // Should use orgId override since env and project don't match + const message1 = createTestMessage({ + environmentId: "env_123", + projectId: "proj_123", + orgId: "org_123", + workerQueue: "original-queue", + }); + + const result1 = resolver.getWorkerQueueFromMessage(message1); + expect(result1).toBe("org-queue"); + + // Should use environmentId override since it matches + const message2 = createTestMessage({ + environmentId: "env_special", + projectId: "proj_123", + orgId: "org_456", + workerQueue: "original-queue", + }); + + const result2 = resolver.getWorkerQueueFromMessage(message2); + expect(result2).toBe("special-env-queue"); + + // Should use workerQueue override as fallback + const message3 = createTestMessage({ + environmentId: "env_unknown", + projectId: "proj_unknown", + orgId: "org_unknown", + workerQueue: "fallback-queue", + }); + + const result3 = resolver.getWorkerQueueFromMessage(message3); + expect(result3).toBe("redirected-queue"); + }); + + it("should handle empty override sections", () => { + const overrideConfig = JSON.stringify({ + environmentId: {}, + projectId: {}, + orgId: { + org_123: "org-queue", + }, + workerQueue: {}, + }); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage(); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("org-queue"); + }); + }); + + describe("V1 message handling", () => { + it("should handle v1 development messages", () => { + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger }); + + const v1DevMessage: OutputPayloadV1 = { + version: "1", + runId: "run_123", + taskIdentifier: "task_123", + orgId: "org_123", + projectId: "proj_123", + environmentId: "env_dev", + environmentType: RuntimeEnvironmentType.DEVELOPMENT, + queue: "test-queue", + timestamp: Date.now(), + attempt: 0, + masterQueues: ["us-east-1", "us-west-1"], + }; + + const result = resolver.getWorkerQueueFromMessage(v1DevMessage); + + expect(result).toBe("env_dev"); + }); + + it("should handle v1 production messages", () => { + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger }); + + const v1ProdMessage: OutputPayloadV1 = { + version: "1", + runId: "run_123", + taskIdentifier: "task_123", + orgId: "org_123", + projectId: "proj_123", + environmentId: "env_prod", + environmentType: RuntimeEnvironmentType.PRODUCTION, + queue: "test-queue", + timestamp: Date.now(), + attempt: 0, + masterQueues: ["us-east-1", "us-west-1"], + }; + + const result = resolver.getWorkerQueueFromMessage(v1ProdMessage); + + expect(result).toBe("us-east-1"); + }); + }); + + describe("Environment variable fallback", () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env.RUN_ENGINE_WORKER_QUEUE_OVERRIDES; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.RUN_ENGINE_WORKER_QUEUE_OVERRIDES; + } else { + process.env.RUN_ENGINE_WORKER_QUEUE_OVERRIDES = originalEnv; + } + }); + + it("should fall back to environment variable when no overrideConfig provided", () => { + // Set environment variable + process.env.RUN_ENGINE_WORKER_QUEUE_OVERRIDES = JSON.stringify({ + orgId: { + org_from_env: "env-based-queue", + }, + }); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger }); // No overrideConfig + const message = createTestMessage({ + orgId: "org_from_env", + }); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("env-based-queue"); + }); + + it("should prioritize overrideConfig over environment variable", () => { + // Set environment variable + process.env.RUN_ENGINE_WORKER_QUEUE_OVERRIDES = JSON.stringify({ + orgId: { + org_123: "env-queue", + }, + }); + + // Pass config directly (should take precedence) + const overrideConfig = JSON.stringify({ + orgId: { + org_123: "config-queue", + }, + }); + + const logger = new Logger("test", "error"); + const resolver = new WorkerQueueResolver({ logger, overrideConfig }); + const message = createTestMessage({ + orgId: "org_123", + }); + + const result = resolver.getWorkerQueueFromMessage(message); + + expect(result).toBe("config-queue"); + }); + }); +}); diff --git a/internal-packages/run-engine/src/run-queue/workerQueueResolver.ts b/internal-packages/run-engine/src/run-queue/workerQueueResolver.ts new file mode 100644 index 00000000000..5c83d3de6ff --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/workerQueueResolver.ts @@ -0,0 +1,100 @@ +import type { Logger } from "@trigger.dev/core/logger"; +import type { OutputPayload, OutputPayloadV2 } from "./types.js"; +import { z } from "zod"; + +const WorkerQueueOverrides = z.object({ + environmentId: z.record(z.string(), z.string()).optional(), + projectId: z.record(z.string(), z.string()).optional(), + orgId: z.record(z.string(), z.string()).optional(), + workerQueue: z.record(z.string(), z.string()).optional(), +}); + +export type WorkerQueueOverrides = z.infer; + +export type WorkerQueueResolverOptions = { + logger: Logger; + overrideConfig?: string; +}; + +export class WorkerQueueResolver { + private overrides: WorkerQueueOverrides | null; + private logger: Logger; + + constructor(opts: WorkerQueueResolverOptions) { + this.logger = opts.logger; + this.overrides = this.parseOverrides(opts.overrideConfig); + } + + private parseOverrides(overrideConfig?: string): WorkerQueueOverrides | null { + const overridesJson = overrideConfig ?? process.env.RUN_ENGINE_WORKER_QUEUE_OVERRIDES; + + if (!overridesJson) { + return null; + } + + try { + const parsed = JSON.parse(overridesJson); + const result = WorkerQueueOverrides.safeParse(parsed); + + if (!result.success) { + this.logger.error("Invalid worker queue overrides format", { + error: result.error.format(), + }); + return null; + } + + this.logger.info("🎯 Worker queue overrides enabled", { overrides: result.data }); + + return result.data; + } catch (error) { + this.logger.error("Failed to parse worker queue overrides json", { + error, + }); + return null; + } + } + + public getWorkerQueueFromMessage(message: OutputPayload): string { + if (message.version === "2") { + // Check overrides in priority order + const override = this.#getOverride(message); + if (override) return override; + + return message.workerQueue; + } + + // In v2, if the environment is development, the worker queue is the environment id. + if (message.environmentType === "DEVELOPMENT") { + return message.environmentId; + } + + // In v1, the master queue is something like us-nyc-3, + // which in v2 is the worker queue. + return message.masterQueues[0]; + } + + #getOverride(message: OutputPayloadV2): string | null { + if (!this.overrides) { + return null; + } + + // Priority: environmentId > projectId > orgId > workerQueue + if (this.overrides.environmentId?.[message.environmentId]) { + return this.overrides.environmentId[message.environmentId]; + } + + if (this.overrides.projectId?.[message.projectId]) { + return this.overrides.projectId[message.projectId]; + } + + if (this.overrides.orgId?.[message.orgId]) { + return this.overrides.orgId[message.orgId]; + } + + if (this.overrides.workerQueue?.[message.workerQueue]) { + return this.overrides.workerQueue[message.workerQueue]; + } + + return null; + } +} From a1e9738faaa3341c87861418214a01a9af5a20fd Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:37:22 +0100 Subject: [PATCH 04/10] fix(webapp): prevent duplicate preview env image tags (#2475) * fix(webapp): prevent duplicate preview env image tags * use deploy shortcode instead of new nanoid * replace regexps in tests --- .../app/v3/getDeploymentImageRef.server.ts | 10 +- .../services/initializeDeployment.server.ts | 7 +- .../webapp/test/getDeploymentImageRef.test.ts | 149 +++++++++++------- 3 files changed, 104 insertions(+), 62 deletions(-) diff --git a/apps/webapp/app/v3/getDeploymentImageRef.server.ts b/apps/webapp/app/v3/getDeploymentImageRef.server.ts index e01f340a26a..a456791e970 100644 --- a/apps/webapp/app/v3/getDeploymentImageRef.server.ts +++ b/apps/webapp/app/v3/getDeploymentImageRef.server.ts @@ -11,6 +11,7 @@ import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts"; import { tryCatch } from "@trigger.dev/core"; import { logger } from "~/services/logger.server"; import { type RegistryConfig } from "./registryConfig.server"; +import type { EnvironmentType } from "@trigger.dev/core/v3"; // Optional configuration for cross-account access export type AssumeRoleConfig = { @@ -101,19 +102,22 @@ export async function getDeploymentImageRef({ registry, projectRef, nextVersion, - environmentSlug, + environmentType, + deploymentShortCode, }: { registry: RegistryConfig; projectRef: string; nextVersion: string; - environmentSlug: string; + environmentType: EnvironmentType; + deploymentShortCode: string; }): Promise<{ imageRef: string; isEcr: boolean; repoCreated: boolean; }> { const repositoryName = `${registry.namespace}/${projectRef}`; - const imageRef = `${registry.host}/${repositoryName}:${nextVersion}.${environmentSlug}`; + const envType = environmentType.toLowerCase(); + const imageRef = `${registry.host}/${repositoryName}:${nextVersion}.${envType}.${deploymentShortCode}`; if (!isEcrRegistry(registry.host)) { return { diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index cdd174ed922..234cfa12706 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -73,12 +73,15 @@ export class InitializeDeploymentService extends BaseService { const isV4Deployment = payload.type === "MANAGED"; const registryConfig = getRegistryConfig(isV4Deployment); + const deploymentShortCode = nanoid(8); + const [imageRefError, imageRefResult] = await tryCatch( getDeploymentImageRef({ registry: registryConfig, projectRef: environment.project.externalRef, nextVersion, - environmentSlug: environment.slug, + environmentType: environment.type, + deploymentShortCode, }) ); @@ -111,7 +114,7 @@ export class InitializeDeploymentService extends BaseService { data: { friendlyId: generateFriendlyId("deployment"), contentHash: payload.contentHash, - shortCode: nanoid(8), + shortCode: deploymentShortCode, version: nextVersion, status: "BUILDING", environmentId: environment.id, diff --git a/apps/webapp/test/getDeploymentImageRef.test.ts b/apps/webapp/test/getDeploymentImageRef.test.ts index 2810c144351..a05236596f2 100644 --- a/apps/webapp/test/getDeploymentImageRef.test.ts +++ b/apps/webapp/test/getDeploymentImageRef.test.ts @@ -8,7 +8,7 @@ import { } from "../app/v3/getDeploymentImageRef.server"; import { DeleteRepositoryCommand } from "@aws-sdk/client-ecr"; -describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", () => { +describe("getDeploymentImageRef", () => { const testHost = process.env.DEPLOY_REGISTRY_HOST || "123456789012.dkr.ecr.us-east-1.amazonaws.com"; const testNamespace = process.env.DEPLOY_REGISTRY_NAMESPACE || "test-namespace"; @@ -25,7 +25,7 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", // Clean up test repository after tests afterAll(async () => { - if (process.env.KEEP_TEST_REPO === "1") { + if (process.env.KEEP_TEST_REPO === "1" || process.env.RUN_ECR_TESTS !== "1") { return; } @@ -57,7 +57,7 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", it("should return the correct image ref for non-ECR registry", async () => { const imageRef = await getDeploymentImageRef({ registry: { - host: "registry.digitalocean.com", + host: "registry.example.com", namespace: testNamespace, username: "test-user", password: "test-pass", @@ -67,17 +67,69 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", }, projectRef: testProjectRef, nextVersion: "20250630.1", - environmentSlug: "test", + environmentType: "DEVELOPMENT", + deploymentShortCode: "test1234", }); + // Check the image ref structure and that it contains expected parts expect(imageRef.imageRef).toBe( - `registry.digitalocean.com/${testNamespace}/${testProjectRef}:20250630.1.test` + `registry.example.com/${testNamespace}/${testProjectRef}:20250630.1.development.test1234` ); expect(imageRef.isEcr).toBe(false); }); - it("should create ECR repository and return correct image ref", async () => { - const imageRef1 = await getDeploymentImageRef({ + it.skipIf(process.env.RUN_ECR_TESTS !== "1")( + "should create ECR repository and return correct image ref", + async () => { + const imageRef1 = await getDeploymentImageRef({ + registry: { + host: testHost, + namespace: testNamespace, + username: "test-user", + password: "test-pass", + ecrTags: registryTags, + ecrAssumeRoleArn: roleArn, + ecrAssumeRoleExternalId: externalId, + }, + projectRef: testProjectRef2, + nextVersion: "20250630.1", + environmentType: "DEVELOPMENT", + deploymentShortCode: "test1234", + }); + + expect(imageRef1.imageRef).toBe( + `${testHost}/${testNamespace}/${testProjectRef2}:20250630.1.development.test1234` + ); + expect(imageRef1.isEcr).toBe(true); + expect(imageRef1.repoCreated).toBe(true); + + const imageRef2 = await getDeploymentImageRef({ + registry: { + host: testHost, + namespace: testNamespace, + username: "test-user", + password: "test-pass", + ecrTags: registryTags, + ecrAssumeRoleArn: roleArn, + ecrAssumeRoleExternalId: externalId, + }, + projectRef: testProjectRef2, + nextVersion: "20250630.2", + environmentType: "DEVELOPMENT", + deploymentShortCode: "test1234", + }); + + expect(imageRef2.imageRef).toBe( + `${testHost}/${testNamespace}/${testProjectRef2}:20250630.2.development.test1234` + ); + expect(imageRef2.isEcr).toBe(true); + expect(imageRef2.repoCreated).toBe(false); + } + ); + + it.skipIf(process.env.RUN_ECR_TESTS !== "1")("should reuse existing ECR repository", async () => { + // This should use the repository created in the previous test + const imageRef = await getDeploymentImageRef({ registry: { host: testHost, namespace: testNamespace, @@ -87,20 +139,26 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", ecrAssumeRoleArn: roleArn, ecrAssumeRoleExternalId: externalId, }, - projectRef: testProjectRef2, - nextVersion: "20250630.1", - environmentSlug: "test", + projectRef: testProjectRef, + nextVersion: "20250630.2", + environmentType: "PRODUCTION", + deploymentShortCode: "test1234", }); - expect(imageRef1.imageRef).toBe( - `${testHost}/${testNamespace}/${testProjectRef2}:20250630.1.test` + expect(imageRef.imageRef).toBe( + `${testHost}/${testNamespace}/${testProjectRef}:20250630.2.production.test1234` ); - expect(imageRef1.isEcr).toBe(true); - expect(imageRef1.repoCreated).toBe(true); + expect(imageRef.isEcr).toBe(true); + }); + + it("should generate unique image tags for different deployments with same environment type", async () => { + // Simulates the scenario where multiple deployments happen to the same environment type + const sameEnvironmentType = "PREVIEW"; + const sameVersion = "20250630.1"; - const imageRef2 = await getDeploymentImageRef({ + const firstImageRef = await getDeploymentImageRef({ registry: { - host: testHost, + host: "registry.example.com", namespace: testNamespace, username: "test-user", password: "test-pass", @@ -108,23 +166,15 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", ecrAssumeRoleArn: roleArn, ecrAssumeRoleExternalId: externalId, }, - projectRef: testProjectRef2, - nextVersion: "20250630.2", - environmentSlug: "test", + projectRef: testProjectRef, + nextVersion: sameVersion, + environmentType: sameEnvironmentType, + deploymentShortCode: "test1234", }); - expect(imageRef2.imageRef).toBe( - `${testHost}/${testNamespace}/${testProjectRef2}:20250630.2.test` - ); - expect(imageRef2.isEcr).toBe(true); - expect(imageRef2.repoCreated).toBe(false); - }); - - it("should reuse existing ECR repository", async () => { - // This should use the repository created in the previous test - const imageRef = await getDeploymentImageRef({ + const secondImageRef = await getDeploymentImageRef({ registry: { - host: testHost, + host: "registry.example.com", namespace: testNamespace, username: "test-user", password: "test-pass", @@ -133,37 +183,23 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", ecrAssumeRoleExternalId: externalId, }, projectRef: testProjectRef, - nextVersion: "20250630.2", - environmentSlug: "prod", + nextVersion: sameVersion, + environmentType: sameEnvironmentType, + deploymentShortCode: "test4321", }); - expect(imageRef.imageRef).toBe( - `${testHost}/${testNamespace}/${testProjectRef}:20250630.2.prod` + // Even with the same environment type and version, the image refs should be different due to deployment short codes + expect(firstImageRef.imageRef).toBe( + `registry.example.com/${testNamespace}/${testProjectRef}:${sameVersion}.preview.test1234` ); - expect(imageRef.isEcr).toBe(true); - }); - - it("should throw error for invalid ECR host", async () => { - await expect( - getDeploymentImageRef({ - registry: { - host: "invalid.ecr.amazonaws.com", - namespace: testNamespace, - username: "test-user", - password: "test-pass", - ecrTags: registryTags, - ecrAssumeRoleArn: roleArn, - ecrAssumeRoleExternalId: externalId, - }, - projectRef: testProjectRef, - nextVersion: "20250630.1", - environmentSlug: "test", - }) - ).rejects.toThrow("Invalid ECR registry host: invalid.ecr.amazonaws.com"); + expect(secondImageRef.imageRef).toBe( + `registry.example.com/${testNamespace}/${testProjectRef}:${sameVersion}.preview.test4321` + ); + expect(firstImageRef.imageRef).not.toBe(secondImageRef.imageRef); }); }); -describe.skipIf(process.env.RUN_REGISTRY_AUTH_TESTS !== "1")("getEcrAuthToken", () => { +describe.skipIf(process.env.RUN_ECR_TESTS !== "1")("getEcrAuthToken", () => { const testHost = process.env.DEPLOY_REGISTRY_HOST || "123456789012.dkr.ecr.us-east-1.amazonaws.com"; @@ -188,8 +224,7 @@ describe.skipIf(process.env.RUN_REGISTRY_AUTH_TESTS !== "1")("getEcrAuthToken", expect(auth.password.length).toBeGreaterThan(0); // Verify the token format (should be a base64-encoded string) - const base64Regex = /^[A-Za-z0-9+/=]+$/; - expect(base64Regex.test(auth.password)).toBe(true); + expect(auth.password).toMatch(/^[A-Za-z0-9+/=]+$/); }); it("should throw error for invalid region", async () => { From 00d32ed4eeda732e3a2aaf886e52145801e40ca2 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 4 Sep 2025 09:53:15 +0100 Subject: [PATCH 05/10] feat(webapp): add support for running web services (api, engine, webapp) in cluster mode for better perf (#2472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(webapp): add support for running web services (api, engine, webapp) in cluster mode for better perf * cleaned up signal handling and resolved some valid πŸ‡ issues --- apps/webapp/app/eventLoopMonitor.server.ts | 8 + .../realtime/relayRealtimeStreams.server.ts | 8 +- .../runsReplicationInstance.server.ts | 6 +- .../services/runsReplicationService.server.ts | 2 + apps/webapp/app/services/signals.server.ts | 32 ++ .../app/v3/dynamicFlushScheduler.server.ts | 40 +- apps/webapp/app/v3/marqs/index.server.ts | 9 +- apps/webapp/app/v3/tracing.server.ts | 33 +- apps/webapp/server.ts | 341 +++++++++++------- 9 files changed, 317 insertions(+), 162 deletions(-) create mode 100644 apps/webapp/app/services/signals.server.ts diff --git a/apps/webapp/app/eventLoopMonitor.server.ts b/apps/webapp/app/eventLoopMonitor.server.ts index 1d8603a4af0..b86ea3d31ae 100644 --- a/apps/webapp/app/eventLoopMonitor.server.ts +++ b/apps/webapp/app/eventLoopMonitor.server.ts @@ -5,6 +5,7 @@ import { env } from "./env.server"; import { context, Context } from "@opentelemetry/api"; import { performance } from "node:perf_hooks"; import { logger } from "./services/logger.server"; +import { signalsEmitter } from "./services/signals.server"; const THRESHOLD_NS = env.EVENT_LOOP_MONITOR_THRESHOLD_MS * 1e6; @@ -110,6 +111,13 @@ function startEventLoopUtilizationMonitoring() { lastEventLoopUtilization = currentEventLoopUtilization; }, env.EVENT_LOOP_MONITOR_UTILIZATION_INTERVAL_MS); + signalsEmitter.on("SIGTERM", () => { + clearInterval(interval); + }); + signalsEmitter.on("SIGINT", () => { + clearInterval(interval); + }); + return () => { clearInterval(interval); }; diff --git a/apps/webapp/app/services/realtime/relayRealtimeStreams.server.ts b/apps/webapp/app/services/realtime/relayRealtimeStreams.server.ts index 46e16ff5e95..99a82199d02 100644 --- a/apps/webapp/app/services/realtime/relayRealtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/relayRealtimeStreams.server.ts @@ -1,5 +1,6 @@ import { AuthenticatedEnvironment } from "../apiAuth.server"; import { logger } from "../logger.server"; +import { signalsEmitter } from "../signals.server"; import { StreamIngestor, StreamResponder } from "./types"; import { LineTransformStream } from "./utils.server"; import { v1RealtimeStreams } from "./v1StreamsGlobal.server"; @@ -243,12 +244,17 @@ export class RelayRealtimeStreams implements StreamIngestor, StreamResponder { } function initializeRelayRealtimeStreams() { - return new RelayRealtimeStreams({ + const service = new RelayRealtimeStreams({ ttl: 1000 * 60 * 5, // 5 minutes cleanupInterval: 1000 * 60, // 1 minute fallbackIngestor: v1RealtimeStreams, fallbackResponder: v1RealtimeStreams, }); + + signalsEmitter.on("SIGTERM", service.close.bind(service)); + signalsEmitter.on("SIGINT", service.close.bind(service)); + + return service; } export const relayRealtimeStreams = singleton( diff --git a/apps/webapp/app/services/runsReplicationInstance.server.ts b/apps/webapp/app/services/runsReplicationInstance.server.ts index 45b7b7a971f..2c9aafb1c0f 100644 --- a/apps/webapp/app/services/runsReplicationInstance.server.ts +++ b/apps/webapp/app/services/runsReplicationInstance.server.ts @@ -3,8 +3,8 @@ import invariant from "tiny-invariant"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; import { provider } from "~/v3/tracer.server"; -import { logger } from "./logger.server"; import { RunsReplicationService } from "./runsReplicationService.server"; +import { signalsEmitter } from "./signals.server"; export const runsReplicationInstance = singleton( "runsReplicationInstance", @@ -80,8 +80,8 @@ function initializeRunsReplicationInstance() { }); }); - process.on("SIGTERM", service.shutdown.bind(service)); - process.on("SIGINT", service.shutdown.bind(service)); + signalsEmitter.on("SIGTERM", service.shutdown.bind(service)); + signalsEmitter.on("SIGINT", service.shutdown.bind(service)); } return service; diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index b9eeeab2562..41170fadc60 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -204,6 +204,8 @@ export class RunsReplicationService { } public async shutdown() { + if (this._isShuttingDown) return; + this._isShuttingDown = true; this.logger.info("Initiating shutdown of runs replication service"); diff --git a/apps/webapp/app/services/signals.server.ts b/apps/webapp/app/services/signals.server.ts new file mode 100644 index 00000000000..308f16fdeaa --- /dev/null +++ b/apps/webapp/app/services/signals.server.ts @@ -0,0 +1,32 @@ +import { EventEmitter } from "events"; +import { singleton } from "~/utils/singleton"; + +export type SignalsEvents = { + SIGTERM: [ + { + time: Date; + signal: NodeJS.Signals; + } + ]; + SIGINT: [ + { + time: Date; + signal: NodeJS.Signals; + } + ]; +}; + +export type SignalsEventArgs = SignalsEvents[T]; + +export type SignalsEmitter = EventEmitter; + +function initializeSignalsEmitter() { + const emitter = new EventEmitter(); + + process.on("SIGTERM", () => emitter.emit("SIGTERM", { time: new Date(), signal: "SIGTERM" })); + process.on("SIGINT", () => emitter.emit("SIGINT", { time: new Date(), signal: "SIGINT" })); + + return emitter; +} + +export const signalsEmitter = singleton("signalsEmitter", initializeSignalsEmitter); diff --git a/apps/webapp/app/v3/dynamicFlushScheduler.server.ts b/apps/webapp/app/v3/dynamicFlushScheduler.server.ts index 88e6a102485..30c508d0371 100644 --- a/apps/webapp/app/v3/dynamicFlushScheduler.server.ts +++ b/apps/webapp/app/v3/dynamicFlushScheduler.server.ts @@ -1,6 +1,7 @@ import { Logger } from "@trigger.dev/core/logger"; import { nanoid } from "nanoid"; import pLimit from "p-limit"; +import { signalsEmitter } from "~/services/signals.server"; export type DynamicFlushSchedulerConfig = { batchSize: number; @@ -22,6 +23,7 @@ export class DynamicFlushScheduler { private readonly BATCH_SIZE: number; private readonly FLUSH_INTERVAL: number; private flushTimer: NodeJS.Timeout | null; + private metricsReporterTimer: NodeJS.Timeout | undefined; private readonly callback: (flushId: string, batch: T[]) => Promise; // New properties for dynamic scaling @@ -41,6 +43,7 @@ export class DynamicFlushScheduler { droppedEvents: 0, droppedEventsByKind: new Map(), }; + private isShuttingDown: boolean = false; // New properties for load shedding private readonly loadSheddingThreshold: number; @@ -75,6 +78,7 @@ export class DynamicFlushScheduler { this.startFlushTimer(); this.startMetricsReporter(); + this.setupShutdownHandlers(); } addToBatch(items: T[]): void { @@ -119,8 +123,8 @@ export class DynamicFlushScheduler { this.currentBatch.push(...itemsToAdd); this.totalQueuedItems += itemsToAdd.length; - // Check if we need to create a batch - if (this.currentBatch.length >= this.currentBatchSize) { + // Check if we need to create a batch (if we are shutting down, create a batch immediately because the flush timer is stopped) + if (this.currentBatch.length >= this.currentBatchSize || this.isShuttingDown) { this.createBatch(); } @@ -137,6 +141,23 @@ export class DynamicFlushScheduler { this.resetFlushTimer(); } + private setupShutdownHandlers(): void { + signalsEmitter.on("SIGTERM", () => + this.shutdown().catch((error) => { + this.logger.error("Error shutting down dynamic flush scheduler", { + error, + }); + }) + ); + signalsEmitter.on("SIGINT", () => + this.shutdown().catch((error) => { + this.logger.error("Error shutting down dynamic flush scheduler", { + error, + }); + }) + ); + } + private startFlushTimer(): void { this.flushTimer = setInterval(() => this.checkAndFlush(), this.FLUSH_INTERVAL); } @@ -145,6 +166,9 @@ export class DynamicFlushScheduler { if (this.flushTimer) { clearInterval(this.flushTimer); } + + if (this.isShuttingDown) return; + this.startFlushTimer(); } @@ -226,7 +250,7 @@ export class DynamicFlushScheduler { } private lastConcurrencyAdjustment: number = Date.now(); - + private adjustConcurrency(backOff: boolean = false): void { const currentConcurrency = this.limiter.concurrency; let newConcurrency = currentConcurrency; @@ -281,7 +305,7 @@ export class DynamicFlushScheduler { private startMetricsReporter(): void { // Report metrics every 30 seconds - setInterval(() => { + this.metricsReporterTimer = setInterval(() => { const droppedByKind: Record = {}; this.metrics.droppedEventsByKind.forEach((count, kind) => { droppedByKind[kind] = count; @@ -356,10 +380,18 @@ export class DynamicFlushScheduler { // Graceful shutdown async shutdown(): Promise { + if (this.isShuttingDown) return; + + this.isShuttingDown = true; + if (this.flushTimer) { clearInterval(this.flushTimer); } + if (this.metricsReporterTimer) { + clearInterval(this.metricsReporterTimer); + } + // Flush any remaining items if (this.currentBatch.length > 0) { this.createBatch(); diff --git a/apps/webapp/app/v3/marqs/index.server.ts b/apps/webapp/app/v3/marqs/index.server.ts index 89dfa1e3ffd..5348f228ae1 100644 --- a/apps/webapp/app/v3/marqs/index.server.ts +++ b/apps/webapp/app/v3/marqs/index.server.ts @@ -24,6 +24,7 @@ import z from "zod"; import { env } from "~/env.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { signalsEmitter } from "~/services/signals.server"; import { singleton } from "~/utils/singleton"; import { legacyRunEngineWorker } from "../legacyRunEngineWorker.server"; import { concurrencyTracker } from "../services/taskRunConcurrencyTracker.server"; @@ -112,6 +113,7 @@ export class MarQS { private queueDequeueCooloffPeriod: Map = new Map(); private queueDequeueCooloffCounts: Map = new Map(); private clearCooloffPeriodInterval: NodeJS.Timeout; + isShuttingDown: boolean = false; constructor(private readonly options: MarQSOptions) { this.redis = options.redis; @@ -151,11 +153,14 @@ export class MarQS { } #setupShutdownHandlers() { - process.on("SIGTERM", () => this.shutdown("SIGTERM")); - process.on("SIGINT", () => this.shutdown("SIGINT")); + signalsEmitter.on("SIGTERM", () => this.shutdown("SIGTERM")); + signalsEmitter.on("SIGINT", () => this.shutdown("SIGINT")); } async shutdown(signal: NodeJS.Signals) { + if (this.isShuttingDown) return; + this.isShuttingDown = true; + console.log("πŸ‘‡ Shutting down marqs", this.name, signal); clearInterval(this.clearCooloffPeriodInterval); this.#rebalanceWorkers.forEach((worker) => worker.stop()); diff --git a/apps/webapp/app/v3/tracing.server.ts b/apps/webapp/app/v3/tracing.server.ts index 936cf9a572d..b02fc5ec696 100644 --- a/apps/webapp/app/v3/tracing.server.ts +++ b/apps/webapp/app/v3/tracing.server.ts @@ -41,33 +41,14 @@ export async function startSpanWithEnv( fn: (span: Span) => Promise, options?: SpanOptions ): Promise { - return startSpan( - tracer, - name, - async (span) => { - try { - return await fn(span); - } catch (e) { - if (e instanceof Error) { - span.recordException(e); - } else { - span.recordException(new Error(String(e))); - } - - throw e; - } finally { - span.end(); - } + return startSpan(tracer, name, fn, { + ...options, + attributes: { + ...attributesFromAuthenticatedEnv(env), + ...options?.attributes, }, - { - attributes: { - ...attributesFromAuthenticatedEnv(env), - ...options?.attributes, - }, - kind: SpanKind.SERVER, - ...options, - } - ); + kind: SpanKind.SERVER, + }); } export async function emitDebugLog( diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index 455fded7a37..b2cc9387332 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -12,168 +12,257 @@ import type { Server as IoServer } from "socket.io"; import { WebSocketServer } from "ws"; import { RateLimitMiddleware } from "~/services/apiRateLimit.server"; import { type RunWithHttpContextFunction } from "~/services/httpAsyncStorage.server"; +import cluster from "node:cluster"; +import os from "node:os"; -const app = express(); +const ENABLE_CLUSTER = process.env.ENABLE_CLUSTER === "1"; +const cpuCount = os.availableParallelism(); +const WORKERS = + Number.parseInt(process.env.WEB_CONCURRENCY || process.env.CLUSTER_WORKERS || "", 10) || cpuCount; -if (process.env.DISABLE_COMPRESSION !== "1") { - app.use(compression()); +function forkWorkers() { + for (let i = 0; i < WORKERS; i++) { + cluster.fork(); + } } -// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header -app.disable("x-powered-by"); +function installPrimarySignalHandlers() { + let didHandleSigterm = false; + let didHandleSigint = false; + let didGracefulExit = false; + + const forward = (signal: NodeJS.Signals) => { + for (const id in cluster.workers) { + const w = cluster.workers[id]; + if (w?.process?.pid) { + try { + process.kill(w.process.pid, signal); + } catch {} + } + } + }; + + const gracefulExit = () => { + if (didGracefulExit) return; + didGracefulExit = true; + + const timeoutMs = Number(process.env.GRACEFUL_SHUTDOWN_TIMEOUT || 30_000); + // wait for workers to exit, then exit the primary too + const maybeExit = () => { + const alive = Object.values(cluster.workers || {}).some((w) => w && !w.isDead()); + if (!alive) process.exit(0); + }; + setInterval(maybeExit, 1000); + setTimeout(() => process.exit(0), timeoutMs); + }; -// Remix fingerprints its assets so we can cache forever. -app.use("/build", express.static("public/build", { immutable: true, maxAge: "1y" })); + process.on("SIGTERM", () => { + if (didHandleSigterm) return; + didHandleSigterm = true; + forward("SIGTERM"); + gracefulExit(); + }); + process.on("SIGINT", () => { + if (didHandleSigint) return; + didHandleSigint = true; + forward("SIGINT"); + gracefulExit(); + }); +} -// Everything else (like favicon.ico) is cached for an hour. You may want to be -// more aggressive with this caching. -app.use(express.static("public", { maxAge: "1h" })); +if (ENABLE_CLUSTER && cluster.isPrimary) { + process.title = `node webapp-server primary`; + console.log(`[cluster] Primary ${process.pid} is starting with ${WORKERS} workers`); + forkWorkers(); -app.use(morgan("tiny")); + cluster.on("exit", (worker, code, signal) => { + const intentional = + // If we sent "shutdown", the worker will exit with code 0 after closing. + code === 0 || worker.exitedAfterDisconnect; + console.log( + `[cluster] worker ${worker.process.pid} exited (code=${code}, signal=${signal}, intentional=${intentional})` + ); + // If it wasn't during a shutdown, replace the worker. + if (!intentional) cluster.fork(); + }); -process.title = "node webapp-server"; + installPrimarySignalHandlers(); +} else { + const app = express(); -const MODE = process.env.NODE_ENV; -const BUILD_DIR = path.join(process.cwd(), "build"); -const build = require(BUILD_DIR); + if (process.env.DISABLE_COMPRESSION !== "1") { + app.use(compression()); + } -const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000; + // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header + app.disable("x-powered-by"); -if (process.env.HTTP_SERVER_DISABLED !== "true") { - const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo; - const wss: WebSocketServer | undefined = build.entry.module.wss; - const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter; - const engineRateLimiter: RateLimitMiddleware = build.entry.module.engineRateLimiter; - const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext; + // Remix fingerprints its assets so we can cache forever. + app.use("/build", express.static("public/build", { immutable: true, maxAge: "1y" })); - app.use((req, res, next) => { - // helpful headers: - res.set("Strict-Transport-Security", `max-age=${60 * 60 * 24 * 365 * 100}`); + // Everything else (like favicon.ico) is cached for an hour. You may want to be + // more aggressive with this caching. + app.use(express.static("public", { maxAge: "1h" })); - // Add X-Robots-Tag header for test-cloud.trigger.dev - if (req.hostname !== "cloud.trigger.dev") { - res.set("X-Robots-Tag", "noindex, nofollow"); - } + app.use(morgan("tiny")); - // /clean-urls/ -> /clean-urls - if (req.path.endsWith("/") && req.path.length > 1) { - const query = req.url.slice(req.path.length); - const safepath = req.path.slice(0, -1).replace(/\/+/g, "/"); - res.redirect(301, safepath + query); - return; - } - next(); - }); + process.title = ENABLE_CLUSTER + ? `node webapp-worker-${cluster.isWorker ? cluster.worker?.id : "solo"}` + : "node webapp-server"; - app.use((req, res, next) => { - // Generate a unique request ID for each request - const requestId = nanoid(); + const MODE = process.env.NODE_ENV; + const BUILD_DIR = path.join(process.cwd(), "build"); + const build = require(BUILD_DIR); - runWithHttpContext({ requestId, path: req.url, host: req.hostname, method: req.method }, next); - }); + const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000; - if (process.env.DASHBOARD_AND_API_DISABLED !== "true") { - if (process.env.ALLOW_ONLY_REALTIME_API === "true") { - // Block all requests that do not start with /realtime - app.use((req, res, next) => { - // Make sure /healthcheck is still accessible - if (!req.url.startsWith("/realtime") && req.url !== "/healthcheck") { - res.status(404).send("Not Found"); - return; - } + if (process.env.HTTP_SERVER_DISABLED !== "true") { + const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo; + const wss: WebSocketServer | undefined = build.entry.module.wss; + const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter; + const engineRateLimiter: RateLimitMiddleware = build.entry.module.engineRateLimiter; + const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext; - next(); - }); - } + app.use((req, res, next) => { + // helpful headers: + res.set("Strict-Transport-Security", `max-age=${60 * 60 * 24 * 365 * 100}`); - app.use(apiRateLimiter); - app.use(engineRateLimiter); + // Add X-Robots-Tag header for test-cloud.trigger.dev + if (req.hostname !== "cloud.trigger.dev") { + res.set("X-Robots-Tag", "noindex, nofollow"); + } - app.all( - "*", - // @ts-ignore - createRequestHandler({ - build, - mode: MODE, - }) - ); - } else { - // we need to do the health check here at /healthcheck - app.get("/healthcheck", (req, res) => { - res.status(200).send("OK"); + // /clean-urls/ -> /clean-urls + if (req.path.endsWith("/") && req.path.length > 1) { + const query = req.url.slice(req.path.length); + const safepath = req.path.slice(0, -1).replace(/\/+/g, "/"); + res.redirect(301, safepath + query); + return; + } + next(); }); - } - const server = app.listen(port, () => { - console.log(`βœ… server ready: http://localhost:${port} [NODE_ENV: ${MODE}]`); + app.use((req, res, next) => { + // Generate a unique request ID for each request + const requestId = nanoid(); + + runWithHttpContext( + { requestId, path: req.url, host: req.hostname, method: req.method }, + next + ); + }); + + if (process.env.DASHBOARD_AND_API_DISABLED !== "true") { + if (process.env.ALLOW_ONLY_REALTIME_API === "true") { + // Block all requests that do not start with /realtime + app.use((req, res, next) => { + // Make sure /healthcheck is still accessible + if (!req.url.startsWith("/realtime") && req.url !== "/healthcheck") { + res.status(404).send("Not Found"); + return; + } + + next(); + }); + } + + app.use(apiRateLimiter); + app.use(engineRateLimiter); - if (MODE === "development") { - broadcastDevReady(build) - .then(() => logDevReady(build)) - .catch(console.error); + app.all( + "*", + // @ts-ignore + createRequestHandler({ + build, + mode: MODE, + }) + ); + } else { + // we need to do the health check here at /healthcheck + app.get("/healthcheck", (req, res) => { + res.status(200).send("OK"); + }); } - }); - server.keepAliveTimeout = 65 * 1000; - // Mitigate against https://github.com/triggerdotdev/trigger.dev/security/dependabot/128 - // by not allowing 2000+ headers to be sent and causing a DoS - // headers will instead be limited by the maxHeaderSize - server.maxHeadersCount = 0; + const server = app.listen(port, () => { + console.log( + `βœ… server ready: http://localhost:${port} [NODE_ENV: ${MODE}]${ + ENABLE_CLUSTER && cluster.isWorker ? ` [worker ${cluster.worker?.id}/${process.pid}]` : "" + }` + ); - process.on("SIGTERM", () => { - server.close((err) => { - if (err) { - console.error("Error closing express server:", err); - } else { - console.log("Express server closed gracefully."); + if (MODE === "development") { + broadcastDevReady(build) + .then(() => logDevReady(build)) + .catch(console.error); } }); - }); - socketIo?.io.attach(server); - server.removeAllListeners("upgrade"); // prevent duplicate upgrades from listeners created by io.attach() + server.keepAliveTimeout = 65 * 1000; + // Mitigate against https://github.com/triggerdotdev/trigger.dev/security/dependabot/128 + // by not allowing 2000+ headers to be sent and causing a DoS + // headers will instead be limited by the maxHeaderSize + server.maxHeadersCount = 0; - server.on("upgrade", async (req, socket, head) => { - console.log( - `Attemping to upgrade connection at url ${req.url} with headers: ${JSON.stringify( - req.headers - )}` - ); + let didCloseServer = false; - socket.on("error", (err) => { - console.error("Connection upgrade error:", err); - }); + function closeServer(signal: NodeJS.Signals) { + if (didCloseServer) return; + didCloseServer = true; - const url = new URL(req.url ?? "", "http://localhost"); + server.close((err) => { + if (err) { + console.error("Error closing express server:", err); + } else { + console.log("Express server closed gracefully."); + } + }); + } - // Upgrade socket.io connection - if (url.pathname.startsWith("/socket.io/")) { - console.log(`Socket.io client connected, upgrading their connection...`); + process.on("SIGTERM", closeServer); + process.on("SIGINT", closeServer); - // https://github.com/socketio/socket.io/issues/4693 - (socketIo?.io.engine as EngineServer).handleUpgrade(req, socket, head); - return; - } + socketIo?.io.attach(server); + server.removeAllListeners("upgrade"); // prevent duplicate upgrades from listeners created by io.attach() - // Only upgrade the connecting if the path is `/ws` - if (url.pathname !== "/ws") { - // Setting the socket.destroy() error param causes an error event to be emitted which needs to be handled with socket.on("error") to prevent uncaught exceptions. - socket.destroy( - new Error( - "Cannot connect because of invalid path: Please include `/ws` in the path of your upgrade request." - ) - ); - return; - } + server.on("upgrade", async (req, socket, head) => { + console.log(`Attemping to upgrade connection at url ${req.url}`); + + socket.on("error", (err) => { + console.error("Connection upgrade error:", err); + }); + + const url = new URL(req.url ?? "", "http://localhost"); + + // Upgrade socket.io connection + if (url.pathname.startsWith("/socket.io/")) { + console.log(`Socket.io client connected, upgrading their connection...`); + + // https://github.com/socketio/socket.io/issues/4693 + (socketIo?.io.engine as EngineServer).handleUpgrade(req, socket, head); + return; + } - console.log(`Client connected, upgrading their connection...`); + // Only upgrade the connecting if the path is `/ws` + if (url.pathname !== "/ws") { + // Setting the socket.destroy() error param causes an error event to be emitted which needs to be handled with socket.on("error") to prevent uncaught exceptions. + socket.destroy( + new Error( + "Cannot connect because of invalid path: Please include `/ws` in the path of your upgrade request." + ) + ); + return; + } + + console.log(`Client connected, upgrading their connection...`); - // Handle the WebSocket connection - wss?.handleUpgrade(req, socket, head, (ws) => { - wss?.emit("connection", ws, req); + // Handle the WebSocket connection + wss?.handleUpgrade(req, socket, head, (ws) => { + wss?.emit("connection", ws, req); + }); }); - }); -} else { - require(BUILD_DIR); - console.log(`βœ… app ready (skipping http server)`); + } else { + require(BUILD_DIR); + console.log(`βœ… app ready (skipping http server)`); + } } From e6586d3c1a140a2f08555456d5c2218bd0036aaf Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 4 Sep 2025 14:57:17 +0100 Subject: [PATCH 06/10] Remove deprecated releaseConcurrency from wait.forToken() docs (#2477) --- docs/wait-for-token.mdx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/docs/wait-for-token.mdx b/docs/wait-for-token.mdx index 4f5a8faedda..9e050a3430e 100644 --- a/docs/wait-for-token.mdx +++ b/docs/wait-for-token.mdx @@ -263,24 +263,6 @@ Wait for a token to be completed. The token to wait for. - - Options for the wait. - - - - If set to true, this will cause the waitpoint to release the current run from the queue's concurrency. - - This is useful if you want to allow other runs to execute while waiting - - Note: It's possible that this run will not be able to resume when the waitpoint is complete if this is set to true. - It will go back in the queue and will resume once concurrency becomes available. - - The default is `false`. - - - - - ### returns The `forToken` function returns a result object with the following properties: From 99660112bd6494211b17be6cdbf216d9a2b48588 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 4 Sep 2025 17:09:34 +0100 Subject: [PATCH 07/10] feat(supervisor): add configurable resource requests (#2474) --- apps/supervisor/src/env.ts | 4 ++++ .../src/workloadManager/kubernetes.ts | 21 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts index 074c18ad742..99cb16425b1 100644 --- a/apps/supervisor/src/env.ts +++ b/apps/supervisor/src/env.ts @@ -85,6 +85,10 @@ const Env = z.object({ KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"), KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"), KUBERNETES_STRIP_IMAGE_DIGEST: BoolEnv.default(false), + KUBERNETES_CPU_REQUEST_MIN_CORES: z.coerce.number().min(0).default(0), + KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit + KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0), + KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit // Placement tags settings PLACEMENT_TAGS_ENABLED: BoolEnv.default(false), diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index b38e6c5b46f..55281c56a67 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -20,6 +20,12 @@ export class KubernetesWorkloadManager implements WorkloadManager { private namespace = env.KUBERNETES_NAMESPACE; private placementTagProcessor: PlacementTagProcessor; + // Resource settings + private readonly cpuRequestMinCores = env.KUBERNETES_CPU_REQUEST_MIN_CORES; + private readonly cpuRequestRatio = env.KUBERNETES_CPU_REQUEST_RATIO; + private readonly memoryRequestMinGb = env.KUBERNETES_MEMORY_REQUEST_MIN_GB; + private readonly memoryRequestRatio = env.KUBERNETES_MEMORY_REQUEST_RATIO; + constructor(private opts: WorkloadManagerOptions) { this.k8s = createK8sApi(); this.placementTagProcessor = new PlacementTagProcessor({ @@ -63,6 +69,10 @@ export class KubernetesWorkloadManager implements WorkloadManager { return imageRef.substring(0, atIndex); } + private clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } + async create(opts: WorkloadManagerCreateOptions) { this.logger.log("[KubernetesWorkloadManager] Creating container", { opts }); @@ -295,9 +305,16 @@ export class KubernetesWorkloadManager implements WorkloadManager { } #getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities { + const cpuRequest = preset.cpu * this.cpuRequestRatio; + const memoryRequest = preset.memory * this.memoryRequestRatio; + + // Clamp between min and max + const clampedCpu = this.clamp(cpuRequest, this.cpuRequestMinCores, preset.cpu); + const clampedMemory = this.clamp(memoryRequest, this.memoryRequestMinGb, preset.memory); + return { - cpu: `${preset.cpu * 0.75}`, - memory: `${preset.memory}G`, + cpu: `${clampedCpu}`, + memory: `${clampedMemory}G`, }; } From 97015ba8c81ccf71f25ce5cabac4b6465d610348 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 5 Sep 2025 11:13:13 +0100 Subject: [PATCH 08/10] fix(docs): update sentry error tracking guide for v4 (#2479) --- docs/guides/examples/sentry-error-tracking.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/examples/sentry-error-tracking.mdx b/docs/guides/examples/sentry-error-tracking.mdx index 04eef6bf89a..d861e93e71a 100644 --- a/docs/guides/examples/sentry-error-tracking.mdx +++ b/docs/guides/examples/sentry-error-tracking.mdx @@ -56,7 +56,7 @@ export default defineConfig({ environment: process.env.NODE_ENV === "production" ? "production" : "development", }); }, - onFailure: async (payload, error, { ctx }) => { + onFailure: async ({ payload, error, ctx }) => { Sentry.captureException(error, { extra: { payload, From 89b1d8ba139efe67cf0e24c3308be324f0ce41b2 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 5 Sep 2025 11:17:38 +0100 Subject: [PATCH 09/10] fix all lifecycle hooks (#2480) --- docs/config/config-file.mdx | 8 ++++---- docs/errors-retrying.mdx | 4 ++-- docs/migrating-from-v3.mdx | 2 +- docs/tasks/overview.mdx | 29 +++++++++++++++-------------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/config/config-file.mdx b/docs/config/config-file.mdx index d11704d3033..ef79bc86200 100644 --- a/docs/config/config-file.mdx +++ b/docs/config/config-file.mdx @@ -99,16 +99,16 @@ import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ project: "", // Your other config settings... - onSuccess: async (payload, output, { ctx }) => { + onSuccess: async ({ payload, output, ctx }) => { console.log("Task succeeded", ctx.task.id); }, - onFailure: async (payload, error, { ctx }) => { + onFailure: async ({ payload, error, ctx }) => { console.log("Task failed", ctx.task.id); }, - onStart: async (payload, { ctx }) => { + onStart: async ({ payload, ctx }) => { console.log("Task started", ctx.task.id); }, - init: async (payload, { ctx }) => { + init: async ({ payload, ctx }) => { console.log("I run before any task is run"); }, }); diff --git a/docs/errors-retrying.mdx b/docs/errors-retrying.mdx index 7bd2deae3ce..246db2b82af 100644 --- a/docs/errors-retrying.mdx +++ b/docs/errors-retrying.mdx @@ -180,7 +180,7 @@ export const taskWithFetchRetries = task({ ## Advanced error handling and retrying -We provide a `handleError` callback on the task and in your `trigger.config` file. This gets called when an uncaught error is thrown in your task. +We provide a `catchError` callback on the task and in your `trigger.config` file. This gets called when an uncaught error is thrown in your task. You can @@ -219,7 +219,7 @@ export const openaiTask = task({ return chatCompletion.choices[0].message.content; }, - handleError: async (payload, error, { ctx, retryAt }) => { + catchError: async ({ payload, error, ctx, retryAt }) => { if (error instanceof OpenAI.APIError) { if (!error.status) { return { diff --git a/docs/migrating-from-v3.mdx b/docs/migrating-from-v3.mdx index e06cd08064a..03f0cae5776 100644 --- a/docs/migrating-from-v3.mdx +++ b/docs/migrating-from-v3.mdx @@ -232,7 +232,7 @@ import { task } from "@trigger.dev/sdk"; export const myTask = task({ id: "my-task", - onStart: (payload, { ctx }) => {}, + onStart: ({ payload, ctx }) => {}, run: async (payload, { ctx }) => {}, }); ``` diff --git a/docs/tasks/overview.mdx b/docs/tasks/overview.mdx index aa305f2f80c..2b3452c87a4 100644 --- a/docs/tasks/overview.mdx +++ b/docs/tasks/overview.mdx @@ -181,7 +181,7 @@ This function is called before a run attempt: ```ts /trigger/init.ts export const taskWithInit = task({ id: "task-with-init", - init: async (payload, { ctx }) => { + init: async ({ payload, ctx }) => { //... }, run: async (payload: any, { ctx }) => { @@ -195,7 +195,7 @@ You can also return data from the `init` function that will be available in the ```ts /trigger/init-return.ts export const taskWithInitReturn = task({ id: "task-with-init-return", - init: async (payload, { ctx }) => { + init: async ({ payload, ctx }) => { return { someData: "someValue" }; }, run: async (payload: any, { ctx, init }) => { @@ -213,7 +213,7 @@ This function is called after the `run` function is executed, regardless of whet ```ts /trigger/cleanup.ts export const taskWithCleanup = task({ id: "task-with-cleanup", - cleanup: async (payload, { ctx }) => { + cleanup: async ({ payload, ctx }) => { //... }, run: async (payload: any, { ctx }) => { @@ -230,7 +230,7 @@ Our task middleware system runs at the top level, executing before and after all An error thrown in `middleware` is just like an uncaught error in the run function: it will - propagate through to `handleError()` and then will fail the attempt (causing a retry). + propagate through to `catchError()` function and then will fail the attempt (causing a retry). The `locals` API allows you to share data between middleware and hooks. @@ -303,7 +303,7 @@ When a task run starts, the `onStart` function is called. It's useful for sendin ```ts /trigger/on-start.ts export const taskWithOnStart = task({ id: "task-with-on-start", - onStart: async (payload, { ctx }) => { + onStart: async ({ payload, ctx }) => { //... }, run: async (payload: any, { ctx }) => { @@ -319,7 +319,7 @@ import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ project: "proj_1234", - onStart: async (payload, { ctx }) => { + onStart: async ({ payload, ctx }) => { console.log("Task started", ctx.task.id); }, }); @@ -357,7 +357,7 @@ When a task run succeeds, the `onSuccess` function is called. It's useful for se ```ts /trigger/on-success.ts export const taskWithOnSuccess = task({ id: "task-with-on-success", - onSuccess: async (payload, output, { ctx }) => { + onSuccess: async ({ payload, output, ctx }) => { //... }, run: async (payload: any, { ctx }) => { @@ -373,7 +373,7 @@ import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ project: "proj_1234", - onSuccess: async (payload, output, { ctx }) => { + onSuccess: async ({ payload, output, ctx }) => { console.log("Task succeeded", ctx.task.id); }, }); @@ -388,7 +388,7 @@ This hook is executed when a run completes, regardless of whether it succeeded o ```ts /trigger/on-complete.ts export const taskWithOnComplete = task({ id: "task-with-on-complete", - onComplete: async (payload, output, { ctx }) => { + onComplete: async ({ payload, output, ctx }) => { if (result.ok) { console.log("Run succeeded", result.data); } else { @@ -404,7 +404,7 @@ When a task run fails, the `onFailure` function is called. It's useful for sendi ```ts /trigger/on-failure.ts export const taskWithOnFailure = task({ id: "task-with-on-failure", - onFailure: async (payload, error, { ctx }) => { + onFailure: async ({ payload, error, ctx }) => { //... }, run: async (payload: any, { ctx }) => { @@ -420,7 +420,7 @@ import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ project: "proj_1234", - onFailure: async (payload, error, { ctx }) => { + onFailure: async ({ payload, error, ctx }) => { console.log("Task failed", ctx.task.id); }, }); @@ -429,14 +429,15 @@ export default defineConfig({ Errors thrown in the `onFailure` function are ignored. - `onFailure` doesn’t fire for some of the run statuses like `Crashed`, `System failures`, and `Canceled`. + `onFailure` doesn’t fire for some of the run statuses like `Crashed`, `System failures`, and + `Canceled`. -### `handleError` functions +### `catchError` functions You can define a function that will be called when an error is thrown in the `run` function, that allows you to control how the error is handled and whether the task should be retried. -Read more about `handleError` in our [Errors and Retrying guide](/errors-retrying). +Read more about `catchError` in our [Errors and Retrying guide](/errors-retrying). Uncaught errors will throw a special internal error of the type `HANDLE_ERROR_ERROR`. From 71060d93b1fab0411b2154b9e16308a31527344a Mon Sep 17 00:00:00 2001 From: Dan <8297864+D-K-P@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:01:09 +0100 Subject: [PATCH 10/10] =?UTF-8?q?Removed=20some=20remaining=20/v3=E2=80=99?= =?UTF-8?q?s=20from=20the=20docs=20(#2478)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Removed /v3 from numerous files * Fix AWS SDK v3 documentation link for S3 uploads --- .../supabase-edge-functions-basic.mdx | 2 +- ...abase-edge-functions-database-webhooks.mdx | 2 +- docs/guides/python/python-crawl4ai.mdx | 2 +- docs/how-to-reduce-your-spend.mdx | 25 +++---- docs/realtime/auth.mdx | 24 +++---- docs/realtime/backend/overview.mdx | 2 +- docs/realtime/backend/streams.mdx | 14 ++-- docs/realtime/backend/subscribe.mdx | 16 ++--- docs/realtime/how-it-works.mdx | 6 +- docs/realtime/run-object.mdx | 4 +- docs/runs/metadata.mdx | 6 +- docs/snippets/node-versions.mdx | 2 +- docs/troubleshooting.mdx | 3 +- docs/v3-openapi.yaml | 66 +++++++++---------- 14 files changed, 88 insertions(+), 86 deletions(-) diff --git a/docs/guides/frameworks/supabase-edge-functions-basic.mdx b/docs/guides/frameworks/supabase-edge-functions-basic.mdx index db050292ffd..26087401607 100644 --- a/docs/guides/frameworks/supabase-edge-functions-basic.mdx +++ b/docs/guides/frameworks/supabase-edge-functions-basic.mdx @@ -77,7 +77,7 @@ Replace the placeholder code in your `edge-function-trigger/index.ts` file with // Setup type definitions for built-in Supabase Runtime APIs import "jsr:@supabase/functions-js/edge-runtime.d.ts"; // Import the Trigger.dev SDK - replace "" with the version of the SDK you are using, e.g. "3.0.0". You can find this in your package.json file. -import { tasks } from "npm:@trigger.dev/sdk@3.0.0/v3"; +import { tasks } from "npm:@trigger.dev/sdk@3.0.0"; // Import your task type from your /trigger folder import type { helloWorldTask } from "../../../src/trigger/example.ts"; // πŸ‘† **type-only** import diff --git a/docs/guides/frameworks/supabase-edge-functions-database-webhooks.mdx b/docs/guides/frameworks/supabase-edge-functions-database-webhooks.mdx index 81b04cae6d3..4114e95ef9c 100644 --- a/docs/guides/frameworks/supabase-edge-functions-database-webhooks.mdx +++ b/docs/guides/frameworks/supabase-edge-functions-database-webhooks.mdx @@ -330,7 +330,7 @@ supabase functions new video-processing-handler ```ts functions/video-processing-handler/index.ts // Setup type definitions for built-in Supabase Runtime APIs import "jsr:@supabase/functions-js/edge-runtime.d.ts"; -import { tasks } from "npm:@trigger.dev/sdk@latest/v3"; +import { tasks } from "npm:@trigger.dev/sdk@latest"; // Import the videoProcessAndUpdate task from the trigger folder import type { videoProcessAndUpdate } from "../../../src/trigger/videoProcessAndUpdate.ts"; // πŸ‘† type only import diff --git a/docs/guides/python/python-crawl4ai.mdx b/docs/guides/python/python-crawl4ai.mdx index 8cb0d7f2104..d66aeb1e2bb 100644 --- a/docs/guides/python/python-crawl4ai.mdx +++ b/docs/guides/python/python-crawl4ai.mdx @@ -62,7 +62,7 @@ After you've initialized your project with Trigger.dev, add these build settings ```ts trigger.config.ts import { defineConfig } from "@trigger.dev/sdk"; import { pythonExtension } from "@trigger.dev/python/extension"; -import type { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build"; +import type { BuildContext, BuildExtension } from "@trigger.dev/core/build"; export default defineConfig({ project: "", diff --git a/docs/how-to-reduce-your-spend.mdx b/docs/how-to-reduce-your-spend.mdx index a65af5ce470..0fc8949fe90 100644 --- a/docs/how-to-reduce-your-spend.mdx +++ b/docs/how-to-reduce-your-spend.mdx @@ -6,6 +6,7 @@ description: "Tips and best practices to reduce your costs on Trigger.dev" ## Check out your usage page regularly Monitor your usage dashboard to understand your spending patterns. You can see: + - Your most expensive tasks - Your total duration by task - Number of runs by task @@ -18,6 +19,7 @@ You can view your usage page by clicking the "Organization" menu in the top left ## Create billing alerts Configure billing alerts in your dashboard to get notified when you approach spending thresholds. This helps you: + - Catch unexpected cost increases early - Identify runaway tasks before they become expensive @@ -43,7 +45,7 @@ export const lightTask = task({ // Only use larger machines when necessary export const heavyTask = task({ - id: "heavy-task", + id: "heavy-task", machine: "medium-1x", // 1 vCPU, 2 GB RAM run: async (payload) => { // CPU/memory intensive operations @@ -64,11 +66,14 @@ export const expensiveApiCall = task({ id: "expensive-api-call", run: async (payload: { userId: string }) => { // This expensive operation will only run once per user - await wait.for({ seconds: 30 }, { - idempotencyKey: `user-processing-${payload.userId}`, - idempotencyKeyTTL: "1h" - }); - + await wait.for( + { seconds: 30 }, + { + idempotencyKey: `user-processing-${payload.userId}`, + idempotencyKeyTTL: "1h", + } + ); + const result = await processUserData(payload.userId); return result; }, @@ -105,7 +110,7 @@ export const processItems = task({ id: "process-items", run: async (payload: { items: string[] }) => { // Process all items in parallel - const promises = payload.items.map(item => processItem(item)); + const promises = payload.items.map((item) => processItem(item)); // This works very well for API calls await Promise.all(promises); }, @@ -133,7 +138,7 @@ export const apiTask = task({ This is very useful for intermittent errors, but if there's a permanent error you don't want to retry because you will just keep failing and waste compute. Use [AbortTaskRunError](/errors-retrying#using-aborttaskrunerror) to prevent a retry: ```ts -import { task, AbortTaskRunError } from "@trigger.dev/sdk/v3"; +import { task, AbortTaskRunError } from "@trigger.dev/sdk"; export const someTask = task({ id: "some-task", @@ -145,13 +150,11 @@ export const someTask = task({ throw new AbortTaskRunError(result.error); } - return result + return result; }, }); ``` - - ## Use appropriate maxDuration settings Set realistic maxDurations to prevent runs from executing for too long: diff --git a/docs/realtime/auth.mdx b/docs/realtime/auth.mdx index e574d07e6bd..80634b82a8a 100644 --- a/docs/realtime/auth.mdx +++ b/docs/realtime/auth.mdx @@ -23,7 +23,7 @@ You can create a Public Access Token using the `auth.createPublicToken` function ```tsx // Somewhere in your backend code -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; const publicToken = await auth.createPublicToken(); // πŸ‘ˆ this public access token has no permissions, so is pretty useless! ``` @@ -33,7 +33,7 @@ const publicToken = await auth.createPublicToken(); // πŸ‘ˆ this public access t By default a Public Access Token has no permissions. You must specify the scopes you need when creating a Public Access Token: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; const publicToken = await auth.createPublicToken({ scopes: { @@ -47,7 +47,7 @@ const publicToken = await auth.createPublicToken({ This will allow the token to read all runs, which is probably not what you want. You can specify only certain runs by passing an array of run IDs: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; const publicToken = await auth.createPublicToken({ scopes: { @@ -61,7 +61,7 @@ const publicToken = await auth.createPublicToken({ You can scope the token to only read certain tasks: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; const publicToken = await auth.createPublicToken({ scopes: { @@ -75,7 +75,7 @@ const publicToken = await auth.createPublicToken({ Or tags: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; const publicToken = await auth.createPublicToken({ scopes: { @@ -89,7 +89,7 @@ const publicToken = await auth.createPublicToken({ Or a specific batch of runs: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; const publicToken = await auth.createPublicToken({ scopes: { @@ -103,7 +103,7 @@ const publicToken = await auth.createPublicToken({ You can also combine scopes. For example, to read runs with specific tags and for specific tasks: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; const publicToken = await auth.createPublicToken({ scopes: { @@ -120,7 +120,7 @@ const publicToken = await auth.createPublicToken({ By default, Public Access Token's expire after 15 minutes. You can specify a different expiration time when creating a Public Access Token: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; const publicToken = await auth.createPublicToken({ expirationTime: "1hr", @@ -156,7 +156,7 @@ For triggering tasks from your frontend, you need special "trigger" tokens. Thes ### Creating Trigger Tokens ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; // Somewhere in your backend code const triggerToken = await auth.createTriggerPublicToken("my-task"); @@ -167,7 +167,7 @@ const triggerToken = await auth.createTriggerPublicToken("my-task"); You can pass multiple tasks to create a token that can trigger multiple tasks: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; // Somewhere in your backend code const triggerToken = await auth.createTriggerPublicToken(["my-task-1", "my-task-2"]); @@ -178,7 +178,7 @@ const triggerToken = await auth.createTriggerPublicToken(["my-task-1", "my-task- You can also create tokens that can be used multiple times: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; // Somewhere in your backend code const triggerToken = await auth.createTriggerPublicToken("my-task", { @@ -191,7 +191,7 @@ const triggerToken = await auth.createTriggerPublicToken("my-task", { These tokens also expire, with the default expiration time being 15 minutes. You can specify a custom expiration time: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; // Somewhere in your backend code const triggerToken = await auth.createTriggerPublicToken("my-task", { diff --git a/docs/realtime/backend/overview.mdx b/docs/realtime/backend/overview.mdx index 079004e9231..de13fd70ec2 100644 --- a/docs/realtime/backend/overview.mdx +++ b/docs/realtime/backend/overview.mdx @@ -30,7 +30,7 @@ See our [authentication guide](/realtime/auth) for detailed information on creat Subscribe to a run: ```ts -import { runs, tasks } from "@trigger.dev/sdk/v3"; +import { runs, tasks } from "@trigger.dev/sdk"; // Trigger a task const handle = await tasks.trigger("my-task", { some: "data" }); diff --git a/docs/realtime/backend/streams.mdx b/docs/realtime/backend/streams.mdx index 7a5ae28d511..9593578da1d 100644 --- a/docs/realtime/backend/streams.mdx +++ b/docs/realtime/backend/streams.mdx @@ -20,7 +20,7 @@ Streams use the metadata system to send data chunks in real-time. You register a Here's how to stream data from OpenAI in your task: ```ts -import { task, metadata } from "@trigger.dev/sdk/v3"; +import { task, metadata } from "@trigger.dev/sdk"; import OpenAI from "openai"; const openai = new OpenAI({ @@ -64,7 +64,7 @@ export const myTask = task({ You can subscribe to the stream using the `runs.subscribeToRun` method with `.withStreams()`: ```ts -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; import type { myTask, STREAMS } from "./trigger/my-task"; // Somewhere in your backend @@ -91,7 +91,7 @@ async function subscribeToStream(runId: string) { You can register and subscribe to multiple streams in the same task: ```ts -import { task, metadata } from "@trigger.dev/sdk/v3"; +import { task, metadata } from "@trigger.dev/sdk"; import OpenAI from "openai"; const openai = new OpenAI({ @@ -138,7 +138,7 @@ export const myTask = task({ Then subscribe to both streams: ```ts -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; import type { myTask, STREAMS } from "./trigger/my-task"; // Somewhere in your backend @@ -170,7 +170,7 @@ The [AI SDK](https://sdk.vercel.ai/docs/introduction) provides a higher-level AP ```ts import { openai } from "@ai-sdk/openai"; -import { logger, metadata, runs, schemaTask } from "@trigger.dev/sdk/v3"; +import { logger, metadata, runs, schemaTask } from "@trigger.dev/sdk"; import { streamText } from "ai"; import { z } from "zod"; @@ -215,7 +215,7 @@ When using tools with the AI SDK, you can access tool calls and results using th ```ts import { openai } from "@ai-sdk/openai"; -import { logger, metadata, runs, schemaTask } from "@trigger.dev/sdk/v3"; +import { logger, metadata, runs, schemaTask } from "@trigger.dev/sdk"; import { streamText, tool, type TextStreamPart } from "ai"; import { z } from "zod"; @@ -283,7 +283,7 @@ You can define a Trigger.dev task that can be used as a tool, and will automatic ```ts import { openai } from "@ai-sdk/openai"; -import { logger, metadata, runs, schemaTask, toolTask } from "@trigger.dev/sdk/v3"; +import { logger, metadata, runs, schemaTask, toolTask } from "@trigger.dev/sdk"; import { streamText, tool, type TextStreamPart } from "ai"; import { z } from "zod"; diff --git a/docs/realtime/backend/subscribe.mdx b/docs/realtime/backend/subscribe.mdx index 973ce1414c8..ffa46532bf6 100644 --- a/docs/realtime/backend/subscribe.mdx +++ b/docs/realtime/backend/subscribe.mdx @@ -11,7 +11,7 @@ These functions allow you to subscribe to run updates from your backend code. Ea Subscribes to all changes to a specific run. ```ts Example -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; for await (const run of runs.subscribeToRun("run_1234")) { console.log(run); @@ -29,7 +29,7 @@ This function subscribes to all changes to a run. It returns an async iterator t Subscribes to all changes to runs with a specific tag. ```ts Example -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; for await (const run of runs.subscribeToRunsWithTag("user:1234")) { console.log(run); @@ -47,7 +47,7 @@ This function subscribes to all changes to runs with a specific tag. It returns Subscribes to all changes for runs in a batch. ```ts Example -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; for await (const run of runs.subscribeToBatch("batch_1234")) { console.log(run); @@ -65,7 +65,7 @@ This function subscribes to all changes for runs in a batch. It returns an async You can infer the types of the run's payload and output by passing the type of the task to the subscribe functions: ```ts -import { runs, tasks } from "@trigger.dev/sdk/v3"; +import { runs, tasks } from "@trigger.dev/sdk"; import type { myTask } from "./trigger/my-task"; async function myBackend() { @@ -85,7 +85,7 @@ async function myBackend() { When using `subscribeToRunsWithTag`, you can pass a union of task types: ```ts -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; import type { myTask, myOtherTask } from "./trigger/my-task"; for await (const run of runs.subscribeToRunsWithTag("my-tag")) { @@ -130,7 +130,7 @@ This example task updates the progress of a task as it processes items. ```ts // Your task code -import { task, metadata } from "@trigger.dev/sdk/v3"; +import { task, metadata } from "@trigger.dev/sdk"; export const progressTask = task({ id: "progress-task", @@ -165,7 +165,7 @@ We can now subscribe to the runs and receive real-time metadata updates. ```ts // Somewhere in your backend code -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; import type { progressTask } from "./trigger/progress-task"; async function monitorProgress(runId: string) { @@ -199,7 +199,7 @@ For more information on how to write tasks that use the metadata API, as well as You can get type safety for your metadata by defining types: ```ts -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; import type { progressTask } from "./trigger/progress-task"; interface ProgressMetadata { diff --git a/docs/realtime/how-it-works.mdx b/docs/realtime/how-it-works.mdx index 594b9da926e..04dd13dadad 100644 --- a/docs/realtime/how-it-works.mdx +++ b/docs/realtime/how-it-works.mdx @@ -25,7 +25,7 @@ The run object returned by Realtime subscriptions is optimized for streaming upd After you trigger a task, you can subscribe to the run using the `runs.subscribeToRun` function. This function returns an async iterator that you can use to get updates on the run status. ```ts -import { runs, tasks } from "@trigger.dev/sdk/v3"; +import { runs, tasks } from "@trigger.dev/sdk"; // Somewhere in your backend code async function myBackend() { @@ -43,7 +43,7 @@ Every time the run changes, the async iterator will yield the updated run. You c Alternatively, you can subscribe to changes to any run that includes a specific tag (or tags) using the `runs.subscribeToRunsWithTag` function. ```ts -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; // Somewhere in your backend code for await (const run of runs.subscribeToRunsWithTag("user:1234")) { @@ -55,7 +55,7 @@ for await (const run of runs.subscribeToRunsWithTag("user:1234")) { If you've used `batchTrigger` to trigger multiple runs, you can also subscribe to changes to all the runs triggered in the batch using the `runs.subscribeToBatch` function. ```ts -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; // Somewhere in your backend code for await (const run of runs.subscribeToBatch("batch-id")) { diff --git a/docs/realtime/run-object.mdx b/docs/realtime/run-object.mdx index e5a7b654d7f..a7b16447e3d 100644 --- a/docs/realtime/run-object.mdx +++ b/docs/realtime/run-object.mdx @@ -126,7 +126,7 @@ Type-safety is supported for the run object, so you can infer the types of the r You can infer the types of the run's payload and output by passing the type of the task to the `subscribeToRun` function. This will give you type-safe access to the run's payload and output. ```ts -import { runs, tasks } from "@trigger.dev/sdk/v3"; +import { runs, tasks } from "@trigger.dev/sdk"; import type { myTask } from "./trigger/my-task"; // Somewhere in your backend code @@ -148,7 +148,7 @@ async function myBackend() { When using `subscribeToRunsWithTag`, you can pass a union of task types for all the possible tasks that can have the tag. ```ts -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; import type { myTask, myOtherTask } from "./trigger/my-task"; // Somewhere in your backend code diff --git a/docs/runs/metadata.mdx b/docs/runs/metadata.mdx index 04542dfed43..2b6f6955a76 100644 --- a/docs/runs/metadata.mdx +++ b/docs/runs/metadata.mdx @@ -523,7 +523,7 @@ Using metadata updates in conjunction with our [Realtime React hooks](/realtime/ Track progress with percentage and current step: ```ts -import { task, metadata } from "@trigger.dev/sdk/v3"; +import { task, metadata } from "@trigger.dev/sdk"; export const batchProcessingTask = task({ id: "batch-processing", @@ -550,7 +550,7 @@ export const batchProcessingTask = task({ Append log entries while maintaining status: ```ts -import { task, metadata } from "@trigger.dev/sdk/v3"; +import { task, metadata } from "@trigger.dev/sdk"; export const deploymentTask = task({ id: "deployment", @@ -584,7 +584,7 @@ export const deploymentTask = task({ Store user information and notification preferences: ```ts -import { task, metadata } from "@trigger.dev/sdk/v3"; +import { task, metadata } from "@trigger.dev/sdk"; export const userTask = task({ id: "user-task", diff --git a/docs/snippets/node-versions.mdx b/docs/snippets/node-versions.mdx index 70a173d5a2d..b82e0a9c59a 100644 --- a/docs/snippets/node-versions.mdx +++ b/docs/snippets/node-versions.mdx @@ -13,7 +13,7 @@ Trigger.dev runs your tasks on specific Node.js versions: You can change the runtime by setting the `runtime` field in your `trigger.config.ts` file. ```ts -import { defineConfig } from "@trigger.dev/sdk/v3"; +import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ // "node", "node-22" or "bun" diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 30a5fe526fb..d8ed3d095af 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -39,7 +39,7 @@ Ensure you have stopped your local dev server then locate the hidden `.trigger` If you see errors like this when running `trigger.dev dev`: ``` -Could not resolve "@trigger.dev/core/v3" +Could not resolve "@trigger.dev/core" The Yarn Plug'n'Play manifest forbids importing "@trigger.dev/core" here because it's not listed as a dependency of this package ``` @@ -118,7 +118,6 @@ export default defineConfig({ - ## Project setup issues ### `The requested module 'node:events' does not provide an export named 'addAbortListener'` diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index b94280419b3..acc40088a85 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -43,7 +43,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { schedules } from "@trigger.dev/sdk/v3"; + import { schedules } from "@trigger.dev/sdk"; const schedule = await schedules.create({ task: 'my-task', @@ -85,7 +85,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { schedules } from "@trigger.dev/sdk/v3"; + import { schedules } from "@trigger.dev/sdk"; const allSchedules = await schedules.list(); @@ -120,7 +120,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { schedules } from "@trigger.dev/sdk/v3"; + import { schedules } from "@trigger.dev/sdk"; const schedule = await schedules.retrieve(scheduleId); @@ -163,7 +163,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { schedules } from "@trigger.dev/sdk/v3"; + import { schedules } from "@trigger.dev/sdk"; const updatedSchedule = await schedules.update(scheduleId, { task: 'my-updated-task', @@ -195,7 +195,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { schedules } from "@trigger.dev/sdk/v3"; + import { schedules } from "@trigger.dev/sdk"; await schedules.del(scheduleId); @@ -229,7 +229,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { schedules } from "@trigger.dev/sdk/v3"; + import { schedules } from "@trigger.dev/sdk"; const schedule = await schedules.deactivate(scheduleId); @@ -263,7 +263,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { schedules } from "@trigger.dev/sdk/v3"; + import { schedules } from "@trigger.dev/sdk"; const schedule = await schedules.activate(scheduleId); @@ -292,7 +292,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { schedules } from "@trigger.dev/sdk/v3"; + import { schedules } from "@trigger.dev/sdk"; const { timezones } = await schedules.timezones(); @@ -356,7 +356,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { runs } from "@trigger.dev/sdk/v3"; + import { runs } from "@trigger.dev/sdk"; const handle = await runs.replay("run_1234"); @@ -431,7 +431,7 @@ paths: - lang: typescript label: Save metadata source: |- - import { metadata, task } from "@trigger.dev/sdk/v3"; + import { metadata, task } from "@trigger.dev/sdk"; export const myTask = task({ id: "my-task", @@ -501,7 +501,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { runs } from "@trigger.dev/sdk/v3"; + import { runs } from "@trigger.dev/sdk"; await runs.cancel("run_1234"); @@ -566,11 +566,11 @@ paths: x-codeSamples: - lang: typescript source: |- - import { runs } from "@trigger.dev/sdk/v3"; + import { runs } from "@trigger.dev/sdk"; const handle = await runs.reschedule("run_1234", { delay: new Date("2024-06-29T20:45:56.340Z") }); - "/api/v3/runs/{runId}": + "/api/runs/{runId}": parameters: - $ref: "#/components/parameters/runId" get: @@ -625,7 +625,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { runs } from "@trigger.dev/sdk/v3"; + import { runs } from "@trigger.dev/sdk"; const result = await runs.retrieve("run_1234"); @@ -680,7 +680,7 @@ paths: - lang: typescript label: List runs source: |- - import { runs } from "@trigger.dev/sdk/v3"; + import { runs } from "@trigger.dev/sdk"; // Get the first page of runs let page = await runs.list({ limit: 20 }); @@ -704,7 +704,7 @@ paths: - lang: typescript label: Filter runs source: |- - import { runs } from "@trigger.dev/sdk/v3"; + import { runs } from "@trigger.dev/sdk"; const response = await runs.list({ status: ["QUEUED", "EXECUTING"], @@ -750,7 +750,7 @@ paths: - lang: typescript label: List runs source: |- - import { runs, configure } from "@trigger.dev/sdk/v3"; + import { runs, configure } from "@trigger.dev/sdk"; configure({ accessToken: "tr_pat_1234" // always use an environment variable for this @@ -778,7 +778,7 @@ paths: - lang: typescript label: Filter runs source: |- - import { runs, configure } from "@trigger.dev/sdk/v3"; + import { runs, configure } from "@trigger.dev/sdk"; configure({ accessToken: "tr_pat_1234" // always use an environment variable for this @@ -838,7 +838,7 @@ paths: - lang: typescript label: Outside of a task source: |- - import { envvars, configure } from "@trigger.dev/sdk/v3"; + import { envvars, configure } from "@trigger.dev/sdk"; const variables = await envvars.list("proj_yubjwjsfkxnylobaqvqz", "dev"); @@ -848,7 +848,7 @@ paths: - lang: typescript label: Inside a task source: |- - import { envvars, task } from "@trigger.dev/sdk/v3"; + import { envvars, task } from "@trigger.dev/sdk"; export const myTask = task({ id: "my-task", @@ -911,7 +911,7 @@ paths: - lang: typescript label: Outside of a task source: |- - import { envvars } from "@trigger.dev/sdk/v3"; + import { envvars } from "@trigger.dev/sdk"; await envvars.create("proj_yubjwjsfkxnylobaqvqz", "dev", { name: "SLACK_API_KEY", @@ -920,7 +920,7 @@ paths: - lang: typescript label: Inside a task source: |- - import { envvars, task } from "@trigger.dev/sdk/v3"; + import { envvars, task } from "@trigger.dev/sdk"; export const myTask = task({ id: "my-task", @@ -998,7 +998,7 @@ paths: - lang: typescript label: Import variables from an array source: |- - import { envvars } from "@trigger.dev/sdk/v3"; + import { envvars } from "@trigger.dev/sdk"; await envvars.upload("proj_yubjwjsfkxnylobaqvqz", "dev", { variables: { SLACK_API_KEY: "slack_key_1234" }, @@ -1048,7 +1048,7 @@ paths: - lang: typescript label: Outside of a task source: |- - import { envvars } from "@trigger.dev/sdk/v3"; + import { envvars } from "@trigger.dev/sdk"; const variable = await envvars.retrieve("proj_yubjwjsfkxnylobaqvqz", "dev", "SLACK_API_KEY"); @@ -1056,7 +1056,7 @@ paths: - lang: typescript label: Inside a task source: |- - import { envvars, task } from "@trigger.dev/sdk/v3"; + import { envvars, task } from "@trigger.dev/sdk"; export const myTask = task({ id: "my-task", @@ -1106,13 +1106,13 @@ paths: - lang: typescript label: Outside of a task source: |- - import { envvars } from "@trigger.dev/sdk/v3"; + import { envvars } from "@trigger.dev/sdk"; await envvars.del("proj_yubjwjsfkxnylobaqvqz", "dev", "SLACK_API_KEY"); - lang: typescript label: Inside a task source: |- - import { envvars, task } from "@trigger.dev/sdk/v3"; + import { envvars, task } from "@trigger.dev/sdk"; export const myTask = task({ id: "my-task", @@ -1165,7 +1165,7 @@ paths: - lang: typescript label: Outside of a task source: |- - import { envvars } from "@trigger.dev/sdk/v3"; + import { envvars } from "@trigger.dev/sdk"; await envvars.update("proj_yubjwjsfkxnylobaqvqz", "dev", "SLACK_API_KEY", { value: "slack_123456" @@ -1173,7 +1173,7 @@ paths: - lang: typescript label: Inside a task source: |- - import { envvars, task } from "@trigger.dev/sdk/v3"; + import { envvars, task } from "@trigger.dev/sdk"; export const myTask = task({ id: "my-task", @@ -1229,7 +1229,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { task } from "@trigger.dev/sdk/v3"; + import { task } from "@trigger.dev/sdk"; export const myTask = await task({ id: "my-task", @@ -1340,7 +1340,7 @@ paths: x-codeSamples: - lang: typescript source: |- - import { task } from "@trigger.dev/sdk/v3"; + import { task } from "@trigger.dev/sdk"; export const myTask = await task({ id: "my-task", @@ -1498,7 +1498,7 @@ components: Our TypeScript SDK will default to using the value of the `TRIGGER_SECRET_KEY` environment variable if it is set. If you are using the SDK in a different environment, you can set the key using the `configure` function. ```typescript - import { configure } from "@trigger.dev/sdk/v3"; + import { configure } from "@trigger.dev/sdk"; configure({ accessToken: "tr_dev_1234" }); ``` @@ -1512,7 +1512,7 @@ components: Our TypeScript SDK will default to using the value of the `TRIGGER_ACCESS_TOKEN` environment variable if it is set. If you are using the SDK in a different environment, you can set the key using the `configure` function. ```typescript - import { configure } from "@trigger.dev/sdk/v3"; + import { configure } from "@trigger.dev/sdk"; configure({ accessToken: "tr_pat_1234" }); ```