diff --git a/.changeset/beige-horses-juggle.md b/.changeset/beige-horses-juggle.md deleted file mode 100644 index 9c2f5c40e28..00000000000 --- a/.changeset/beige-horses-juggle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Added experimental_devProcessCwdInBuildDir config option to opt-in to new process.cwd behavior when executing tasks in the dev CLI. Currently process.cwd maps to the "root" of your trigger.dev project (the directory that contains your trigger.config.ts file). Setting experimental_devProcessCwdInBuildDir to true changes process.cwd to instead be the temporary build directory inside of the .trigger directory. diff --git a/.changeset/big-carrots-fail.md b/.changeset/big-carrots-fail.md deleted file mode 100644 index 0dc095499b6..00000000000 --- a/.changeset/big-carrots-fail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix dev runs diff --git a/.changeset/blue-eyes-tickle.md b/.changeset/blue-eyes-tickle.md deleted file mode 100644 index ab4ca8b92c6..00000000000 --- a/.changeset/blue-eyes-tickle.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -The dev command will now use the platform-provided engine URL diff --git a/.changeset/breezy-turtles-talk.md b/.changeset/breezy-turtles-talk.md deleted file mode 100644 index da208cb2c8f..00000000000 --- a/.changeset/breezy-turtles-talk.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@trigger.dev/react-hooks": patch -"@trigger.dev/sdk": patch -"trigger.dev": patch -"@trigger.dev/build": patch -"@trigger.dev/core": patch -"@trigger.dev/rsc": patch ---- - -Run Engine 2.0 (alpha) diff --git a/.changeset/chatty-snakes-hope.md b/.changeset/chatty-snakes-hope.md deleted file mode 100644 index e0d2083a5d3..00000000000 --- a/.changeset/chatty-snakes-hope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -fix: Logging large objects is now much more performant and uses less memory diff --git a/.changeset/chilled-weeks-switch.md b/.changeset/chilled-weeks-switch.md deleted file mode 100644 index b49f9c57f4c..00000000000 --- a/.changeset/chilled-weeks-switch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix update command version mismatch detection diff --git a/.changeset/clean-beans-compete.md b/.changeset/clean-beans-compete.md deleted file mode 100644 index 520ca16c81e..00000000000 --- a/.changeset/clean-beans-compete.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -New internal idempotency implementation for trigger and batch trigger to prevent request retries from duplicating work diff --git a/.changeset/cuddly-boats-press.md b/.changeset/cuddly-boats-press.md deleted file mode 100644 index 7d442639363..00000000000 --- a/.changeset/cuddly-boats-press.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Add external log exporters and fix missing external trace exporters in deployed tasks diff --git a/.changeset/curvy-dogs-share.md b/.changeset/curvy-dogs-share.md deleted file mode 100644 index a0071042aab..00000000000 --- a/.changeset/curvy-dogs-share.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -When you create a Waitpoint token using `wait.createToken()` you get a URL back that can be used to complete it by making an HTTP POST request. diff --git a/.changeset/eight-ligers-help.md b/.changeset/eight-ligers-help.md deleted file mode 100644 index 57c832b0bec..00000000000 --- a/.changeset/eight-ligers-help.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Enhance deploy command output to better distinguish between local and remote builds diff --git a/.changeset/eighty-rings-divide.md b/.changeset/eighty-rings-divide.md deleted file mode 100644 index 193b46ca7bf..00000000000 --- a/.changeset/eighty-rings-divide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Configurable queue consumer count in supervisor session diff --git a/.changeset/fifty-beers-bake.md b/.changeset/fifty-beers-bake.md deleted file mode 100644 index 63c431c480a..00000000000 --- a/.changeset/fifty-beers-bake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fixes a bug that would allow processes that had OOM errors to be incorrectly reused when experimental_processKeepAlive was enabled diff --git a/.changeset/flat-pianos-live.md b/.changeset/flat-pianos-live.md deleted file mode 100644 index c1f915195cf..00000000000 --- a/.changeset/flat-pianos-live.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Runtime agnostic SDK config via env vars diff --git a/.changeset/four-needles-add.md b/.changeset/four-needles-add.md deleted file mode 100644 index 7dd18092dfb..00000000000 --- a/.changeset/four-needles-add.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@trigger.dev/redis-worker": major -"@trigger.dev/react-hooks": major -"@trigger.dev/sdk": major -"trigger.dev": major -"@trigger.dev/python": major -"@trigger.dev/build": major -"@trigger.dev/core": major -"@trigger.dev/rsc": major ---- - -Trigger.dev v4 release. Please see our upgrade to v4 docs to view the full changelog: https://trigger.dev/docs/upgrade-to-v4 diff --git a/.changeset/fuzzy-snakes-beg.md b/.changeset/fuzzy-snakes-beg.md deleted file mode 100644 index f5bd55cdeb0..00000000000 --- a/.changeset/fuzzy-snakes-beg.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Add supervisor http client option to disable debug logs diff --git a/.changeset/gentle-waves-suffer.md b/.changeset/gentle-waves-suffer.md deleted file mode 100644 index d96452b7757..00000000000 --- a/.changeset/gentle-waves-suffer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Fixed an issue with realtime streams that timeout and resume streaming dropping chunks diff --git a/.changeset/gold-insects-invite.md b/.changeset/gold-insects-invite.md deleted file mode 100644 index 7b260ed6076..00000000000 --- a/.changeset/gold-insects-invite.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Expose esbuild `keepNames` option (experimental) diff --git a/.changeset/green-lions-relate.md b/.changeset/green-lions-relate.md deleted file mode 100644 index da2a96b29d6..00000000000 --- a/.changeset/green-lions-relate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -The envvars.list() and retrieve() functions receive isSecret for each value. Secret values are always redacted. diff --git a/.changeset/grumpy-wasps-fold.md b/.changeset/grumpy-wasps-fold.md deleted file mode 100644 index 78b74f73b74..00000000000 --- a/.changeset/grumpy-wasps-fold.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Add `experimental_autoDetectExternal` trigger config option diff --git a/.changeset/healthy-apricots-drop.md b/.changeset/healthy-apricots-drop.md deleted file mode 100644 index aae2b175e84..00000000000 --- a/.changeset/healthy-apricots-drop.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Add project details to the whoami command diff --git a/.changeset/hip-cups-wave.md b/.changeset/hip-cups-wave.md deleted file mode 100644 index c21b94e37bc..00000000000 --- a/.changeset/hip-cups-wave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Fix issue where realtime streams would cut off after 5 minutes diff --git a/.changeset/honest-files-decide.md b/.changeset/honest-files-decide.md deleted file mode 100644 index 6bc65f34ccf..00000000000 --- a/.changeset/honest-files-decide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Deprecate toolTask and replace with `ai.tool(mySchemaTask)` diff --git a/.changeset/itchy-frogs-care.md b/.changeset/itchy-frogs-care.md deleted file mode 100644 index 72cb57c867d..00000000000 --- a/.changeset/itchy-frogs-care.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Log images sizes for self-hosted deploys diff --git a/.changeset/itchy-games-sort.md b/.changeset/itchy-games-sort.md deleted file mode 100644 index 3f04f68228e..00000000000 --- a/.changeset/itchy-games-sort.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@trigger.dev/sdk": patch -"trigger.dev": patch ---- - -Display clickable links in Cursor terminal diff --git a/.changeset/late-chairs-ring.md b/.changeset/late-chairs-ring.md deleted file mode 100644 index cd7c9f36202..00000000000 --- a/.changeset/late-chairs-ring.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix init.ts in custom trigger dirs diff --git a/.changeset/late-dancers-smile.md b/.changeset/late-dancers-smile.md deleted file mode 100644 index 58026740d8e..00000000000 --- a/.changeset/late-dancers-smile.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Add import timings and bundle size analysis, the dev command will now warn about slow imports diff --git a/.changeset/lazy-panthers-shop.md b/.changeset/lazy-panthers-shop.md deleted file mode 100644 index fa622e087ec..00000000000 --- a/.changeset/lazy-panthers-shop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Improve structured logs diff --git a/.changeset/lazy-plums-fetch.md b/.changeset/lazy-plums-fetch.md deleted file mode 100644 index 515d6f85d03..00000000000 --- a/.changeset/lazy-plums-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/build": patch ---- - -syncVercelEnvVars() fix for syncing the wrong preview branch env vars diff --git a/.changeset/light-peas-melt.md b/.changeset/light-peas-melt.md deleted file mode 100644 index 52d184b6cce..00000000000 --- a/.changeset/light-peas-melt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix update command version range handling diff --git a/.changeset/little-birds-appear.md b/.changeset/little-birds-appear.md deleted file mode 100644 index 870f253b852..00000000000 --- a/.changeset/little-birds-appear.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Removes the `releaseConcurrencyOnWaitpoint` option on queues and the `releaseConcurrency` option on various wait functions. Replaced with the following default behavior: - -- Concurrency is never released when a run is first blocked via a waitpoint, at either the env or queue level. -- Concurrency is always released when a run is checkpointed and shutdown, at both the env and queue level. - -Additionally, environment concurrency limits now have a new "Burst Factor", defaulting to 2.0x. The "Burst Factor" allows the environment-wide concurrency limit to be higher than any individual queue's concurrency limit. For example, if you have an environment concurrency limit of 100, and a Burst Factor of 2.0x, then you can execute up to 200 runs concurrently, but any one task/queue can still only execute 100 runs concurrently. - -We've done some work cleaning up the run statuses. The new statuses are: - -- `PENDING_VERSION`: Task is waiting for a version update because it cannot execute without additional information (task, queue, etc.) -- `QUEUED`: Task is waiting to be executed by a worker -- `DEQUEUED`: Task has been dequeued and is being sent to a worker to start executing. -- `EXECUTING`: Task is currently being executed by a worker -- `WAITING`: Task has been paused by the system, and will be resumed by the system -- `COMPLETED`: Task has been completed successfully -- `CANCELED`: Task has been canceled by the user -- `FAILED`: Task has failed to complete, due to an error in the system -- `CRASHED`: Task has crashed and won't be retried, most likely the worker ran out of resources, e.g. memory or storage -- `SYSTEM_FAILURE`: Task has failed to complete, due to an error in the system -- `DELAYED`: Task has been scheduled to run at a specific time -- `EXPIRED`: Task has expired and won't be executed -- `TIMED_OUT`: Task has reached it's maxDuration and has been stopped - -We've removed the following statuses: - -- `WAITING_FOR_DEPLOY`: This is no longer used, and is replaced by `PENDING_VERSION` -- `FROZEN`: This is no longer used, and is replaced by `WAITING` -- `INTERRUPTED`: This is no longer used -- `REATTEMPTING`: This is no longer used, and is replaced by `EXECUTING` - -We've also added "boolean" helpers to runs returned via the API and from Realtime: - -- `isQueued`: Returns true when the status is `QUEUED`, `PENDING_VERSION`, or `DELAYED` -- `isExecuting`: Returns true when the status is `EXECUTING`, `DEQUEUED`. These count against your concurrency limits. -- `isWaiting`: Returns true when the status is `WAITING`. These do not count against your concurrency limits. -- `isCompleted`: Returns true when the status is any of the completed statuses. -- `isCanceled`: Returns true when the status is `CANCELED` -- `isFailed`: Returns true when the status is any of the failed statuses. -- `isSuccess`: Returns true when the status is `COMPLETED` - -This change adds the ability to easily detect which runs are being counted against your concurrency limit by filtering for both `EXECUTING` or `DEQUEUED`. diff --git a/.changeset/moody-squids-count.md b/.changeset/moody-squids-count.md deleted file mode 100644 index e475088102f..00000000000 --- a/.changeset/moody-squids-count.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Init command will now correctly install v4-beta packages diff --git a/.changeset/nasty-cobras-wonder.md b/.changeset/nasty-cobras-wonder.md deleted file mode 100644 index 0cd7c417d18..00000000000 --- a/.changeset/nasty-cobras-wonder.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix metadata collapsing correctness diff --git a/.changeset/nice-colts-boil.md b/.changeset/nice-colts-boil.md deleted file mode 100644 index bd395ae9dd9..00000000000 --- a/.changeset/nice-colts-boil.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Improve warm start times by eagerly creating the child TaskRunProcess when a previous run as completed diff --git a/.changeset/ninety-games-grow.md b/.changeset/ninety-games-grow.md deleted file mode 100644 index df22eff4ee8..00000000000 --- a/.changeset/ninety-games-grow.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -- Resolve issue where CLI could get stuck during deploy finalization -- Unify local and remote build logic, with multi-platform build support -- Improve switch command; now accepts profile name as an argument -- Registry configuration is now fully managed by the webapp -- The deploy `--self-hosted` flag is no longer required -- Enhance deployment error reporting and image digest retrieval diff --git a/.changeset/orange-pens-smile.md b/.changeset/orange-pens-smile.md deleted file mode 100644 index 9a4948cda55..00000000000 --- a/.changeset/orange-pens-smile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/redis-worker": patch ---- - -Now each worker gets it's own pLimit concurrency limiter, and we will only ever dequeue items where there is concurrency capacity, preventing incorrectly retried jobs due to visibility timeout expiry diff --git a/.changeset/orange-rocks-grow.md b/.changeset/orange-rocks-grow.md deleted file mode 100644 index c8a6bdaa232..00000000000 --- a/.changeset/orange-rocks-grow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix init.ts detection when using the sentry esbuild plugin diff --git a/.changeset/plenty-dolphins-act.md b/.changeset/plenty-dolphins-act.md deleted file mode 100644 index 59d2c7fc44a..00000000000 --- a/.changeset/plenty-dolphins-act.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -- Correctly resolve waitpoints that come in early -- Ensure correct state before requesting suspension -- Fix race conditions in snapshot processing diff --git a/.changeset/polite-badgers-suffer.md b/.changeset/polite-badgers-suffer.md deleted file mode 100644 index bba234e21d9..00000000000 --- a/.changeset/polite-badgers-suffer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -experimental processKeepAlive diff --git a/.changeset/polite-impalas-care.md b/.changeset/polite-impalas-care.md deleted file mode 100644 index 134ff3dd4a1..00000000000 --- a/.changeset/polite-impalas-care.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fixes runLimiter check on #dequeueRuns diff --git a/.changeset/polite-lies-fix.md b/.changeset/polite-lies-fix.md deleted file mode 100644 index 6e60a776041..00000000000 --- a/.changeset/polite-lies-fix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Update nypm package to support test-based bun.lock files diff --git a/.changeset/pre.json b/.changeset/pre.json deleted file mode 100644 index 01f1e73c963..00000000000 --- a/.changeset/pre.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "mode": "pre", - "tag": "v4-beta", - "initialVersions": { - "coordinator": "0.0.1", - "docker-provider": "0.0.1", - "kubernetes-provider": "0.0.1", - "supervisor": "0.0.1", - "webapp": "1.0.0", - "@trigger.dev/build": "3.3.17", - "trigger.dev": "3.3.17", - "@trigger.dev/core": "3.3.17", - "@trigger.dev/python": "3.3.17", - "@trigger.dev/react-hooks": "3.3.17", - "@trigger.dev/redis-worker": "3.3.17", - "@trigger.dev/rsc": "3.3.17", - "@trigger.dev/sdk": "3.3.17" - }, - "changesets": [ - "beige-horses-juggle", - "big-carrots-fail", - "blue-eyes-tickle", - "breezy-turtles-talk", - "chatty-snakes-hope", - "chilled-weeks-switch", - "clean-beans-compete", - "cuddly-boats-press", - "curvy-dogs-share", - "eight-ligers-help", - "eighty-rings-divide", - "fifty-beers-bake", - "flat-pianos-live", - "four-needles-add", - "fuzzy-snakes-beg", - "gentle-waves-suffer", - "gold-insects-invite", - "green-lions-relate", - "grumpy-wasps-fold", - "healthy-apricots-drop", - "hip-cups-wave", - "honest-files-decide", - "itchy-frogs-care", - "itchy-games-sort", - "late-chairs-ring", - "late-dancers-smile", - "lazy-panthers-shop", - "lazy-plums-fetch", - "light-peas-melt", - "little-birds-appear", - "moody-squids-count", - "nasty-cobras-wonder", - "nice-colts-boil", - "ninety-games-grow", - "orange-pens-smile", - "orange-rocks-grow", - "plenty-dolphins-act", - "polite-badgers-suffer", - "polite-impalas-care", - "polite-lies-fix", - "rare-beds-accept", - "real-rats-drop", - "red-chairs-begin", - "red-rings-marry", - "red-wasps-cover", - "shiny-kiwis-beam", - "silly-cows-serve", - "silly-timers-repair", - "sixty-beers-share", - "small-birds-arrive", - "small-dancers-smell", - "smart-coins-hammer", - "smooth-planets-flow", - "soft-candles-do", - "sour-mirrors-accept", - "spotty-ducks-punch", - "spotty-pants-wink", - "strong-pianos-provide", - "sweet-dolphins-invent", - "tender-jobs-collect", - "thick-bikes-laugh", - "tidy-books-smell", - "tiny-buckets-teach", - "tricky-houses-invite", - "twelve-actors-hide", - "two-eagles-report", - "two-tigers-dream", - "weak-jobs-hide", - "weak-parents-sip", - "wet-deers-think", - "wet-dragons-boil", - "wet-steaks-reflect", - "wicked-ads-walk", - "wild-mirrors-return", - "witty-cherries-tan", - "witty-donkeys-unite", - "yellow-cars-tell" - ] -} diff --git a/.changeset/rare-beds-accept.md b/.changeset/rare-beds-accept.md deleted file mode 100644 index dccd97a96a9..00000000000 --- a/.changeset/rare-beds-accept.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Add verbose structured log level diff --git a/.changeset/real-rats-drop.md b/.changeset/real-rats-drop.md deleted file mode 100644 index 953794afd42..00000000000 --- a/.changeset/real-rats-drop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Add onCancel lifecycle hook diff --git a/.changeset/red-chairs-begin.md b/.changeset/red-chairs-begin.md deleted file mode 100644 index e54857e5529..00000000000 --- a/.changeset/red-chairs-begin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Added AI assistance link when you have build errors diff --git a/.changeset/red-rings-marry.md b/.changeset/red-rings-marry.md deleted file mode 100644 index 87fb25647ad..00000000000 --- a/.changeset/red-rings-marry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Provide realtime skipColumns option via untamperable public access tokens diff --git a/.changeset/red-wasps-cover.md b/.changeset/red-wasps-cover.md deleted file mode 100644 index 035e7549fa7..00000000000 --- a/.changeset/red-wasps-cover.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Suppress external instrumentation for fetch calls from ApiClient diff --git a/.changeset/shiny-kiwis-beam.md b/.changeset/shiny-kiwis-beam.md deleted file mode 100644 index c01b131162d..00000000000 --- a/.changeset/shiny-kiwis-beam.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Handle flush errors gracefully in dev diff --git a/.changeset/silly-cows-serve.md b/.changeset/silly-cows-serve.md deleted file mode 100644 index d655dbed805..00000000000 --- a/.changeset/silly-cows-serve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Added support for Preview branches in v4 projects diff --git a/.changeset/silly-timers-repair.md b/.changeset/silly-timers-repair.md deleted file mode 100644 index 711fbc7f2b7..00000000000 --- a/.changeset/silly-timers-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Can now set project ref using the TRIGGER_PROJECT_REF env var diff --git a/.changeset/sixty-beers-share.md b/.changeset/sixty-beers-share.md deleted file mode 100644 index 862628e2c13..00000000000 --- a/.changeset/sixty-beers-share.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Add runtime version detection for display in the dashboard diff --git a/.changeset/small-birds-arrive.md b/.changeset/small-birds-arrive.md deleted file mode 100644 index cf1039b83e7..00000000000 --- a/.changeset/small-birds-arrive.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@trigger.dev/react-hooks": patch ---- - -Added the ability to specify a "createdAt" filter when subscribing to tags in our useRealtime hooks: - -```tsx -// Only subscribe to runs created in the last 10 hours -useRealtimeRunWithTags("my-tag", { createdAt: "10h" }) -``` - -You can also now choose to skip subscribing to specific columns by specifying the `skipColumns` option: - -```tsx -useRealtimeRun(run.id, { skipColumns: ["usageDurationMs"] }); -``` diff --git a/.changeset/small-dancers-smell.md b/.changeset/small-dancers-smell.md deleted file mode 100644 index ff9b68c00fd..00000000000 --- a/.changeset/small-dancers-smell.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@trigger.dev/sdk": patch -"@trigger.dev/core": patch ---- - -Improve metadata flushing efficiency by collapsing operations diff --git a/.changeset/smart-coins-hammer.md b/.changeset/smart-coins-hammer.md deleted file mode 100644 index bea810c6cad..00000000000 --- a/.changeset/smart-coins-hammer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -fix: Realtime streams: prevent enqueuing into closed ReadableStream diff --git a/.changeset/smooth-planets-flow.md b/.changeset/smooth-planets-flow.md deleted file mode 100644 index 708932fccad..00000000000 --- a/.changeset/smooth-planets-flow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Update profile switcher diff --git a/.changeset/soft-candles-do.md b/.changeset/soft-candles-do.md deleted file mode 100644 index e423053d453..00000000000 --- a/.changeset/soft-candles-do.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Update base images to latest compatible versions. The `node-22` runtime now uses v22.16.0 and `bun` uses the latest v1.2.18 release. The default `node` runtime is unchanged and points at v21.7.3. diff --git a/.changeset/sour-mirrors-accept.md b/.changeset/sour-mirrors-accept.md deleted file mode 100644 index 34084228cae..00000000000 --- a/.changeset/sour-mirrors-accept.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Improve usage flushing diff --git a/.changeset/spotty-ducks-punch.md b/.changeset/spotty-ducks-punch.md deleted file mode 100644 index f6c02980119..00000000000 --- a/.changeset/spotty-ducks-punch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -fix: default machine config indexing now works diff --git a/.changeset/spotty-pants-wink.md b/.changeset/spotty-pants-wink.md deleted file mode 100644 index 7021ecc8fab..00000000000 --- a/.changeset/spotty-pants-wink.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Prevent large outputs from overwriting each other diff --git a/.changeset/strong-pianos-provide.md b/.changeset/strong-pianos-provide.md deleted file mode 100644 index 728179393bc..00000000000 --- a/.changeset/strong-pianos-provide.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Fail fast in CI when running deploy with missing `TRIGGER_ACCESS_TOKEN` and add useful error message with link to docs diff --git a/.changeset/sweet-dolphins-invent.md b/.changeset/sweet-dolphins-invent.md deleted file mode 100644 index df758a89e9c..00000000000 --- a/.changeset/sweet-dolphins-invent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Always print full deploy logs in CI diff --git a/.changeset/tender-jobs-collect.md b/.changeset/tender-jobs-collect.md deleted file mode 100644 index 829c628b6db..00000000000 --- a/.changeset/tender-jobs-collect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -TriggerApiError 4xx errors will no longer cause tasks to be retried diff --git a/.changeset/thick-bikes-laugh.md b/.changeset/thick-bikes-laugh.md deleted file mode 100644 index 7166bfca645..00000000000 --- a/.changeset/thick-bikes-laugh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/build": patch ---- - -Add ffmpeg v7 support to existing extension: `ffmpeg({ version: "7" })` diff --git a/.changeset/tidy-books-smell.md b/.changeset/tidy-books-smell.md deleted file mode 100644 index b8ecf87f55a..00000000000 --- a/.changeset/tidy-books-smell.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -- Fix polling interval reset bug that could create duplicate intervals -- Protect against unexpected attempt number changes -- Prevent run execution zombies after warm starts \ No newline at end of file diff --git a/.changeset/tiny-buckets-teach.md b/.changeset/tiny-buckets-teach.md deleted file mode 100644 index fdf3ae3a946..00000000000 --- a/.changeset/tiny-buckets-teach.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix stalled run detection diff --git a/.changeset/tricky-houses-invite.md b/.changeset/tricky-houses-invite.md deleted file mode 100644 index e21e7b58185..00000000000 --- a/.changeset/tricky-houses-invite.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Managed run controller performance and reliability improvements diff --git a/.changeset/twelve-actors-hide.md b/.changeset/twelve-actors-hide.md deleted file mode 100644 index 7187b92ab0c..00000000000 --- a/.changeset/twelve-actors-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix init.ts auto-import for deployed workers diff --git a/.changeset/two-eagles-report.md b/.changeset/two-eagles-report.md deleted file mode 100644 index 11f034ed3fa..00000000000 --- a/.changeset/two-eagles-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Added runs.list filtering for queue and machine diff --git a/.changeset/two-tigers-dream.md b/.changeset/two-tigers-dream.md deleted file mode 100644 index b4fee01cbea..00000000000 --- a/.changeset/two-tigers-dream.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -maintain proper context in metadata.root and parent getters diff --git a/.changeset/weak-jobs-hide.md b/.changeset/weak-jobs-hide.md deleted file mode 100644 index 0be1f49588c..00000000000 --- a/.changeset/weak-jobs-hide.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@trigger.dev/sdk": patch -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -v4: New lifecycle hooks diff --git a/.changeset/weak-parents-sip.md b/.changeset/weak-parents-sip.md deleted file mode 100644 index fb8589baeae..00000000000 --- a/.changeset/weak-parents-sip.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Output esbuild metafile, can be inspected after `deploy --dry run` diff --git a/.changeset/wet-deers-think.md b/.changeset/wet-deers-think.md deleted file mode 100644 index 9002d7b94f2..00000000000 --- a/.changeset/wet-deers-think.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Fix QUEUED status snapshot handler diff --git a/.changeset/wet-dragons-boil.md b/.changeset/wet-dragons-boil.md deleted file mode 100644 index becd48dd8c5..00000000000 --- a/.changeset/wet-dragons-boil.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Serialize metadata to prevent invalid metadata from breaking run completions diff --git a/.changeset/wet-steaks-reflect.md b/.changeset/wet-steaks-reflect.md deleted file mode 100644 index 3a777416896..00000000000 --- a/.changeset/wet-steaks-reflect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -If you pass a directory when calling deploy we validate it exists and give helpful hints diff --git a/.changeset/wicked-ads-walk.md b/.changeset/wicked-ads-walk.md deleted file mode 100644 index c9190c709f1..00000000000 --- a/.changeset/wicked-ads-walk.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@trigger.dev/react-hooks": patch -"@trigger.dev/core": patch ---- - -Fixes an issue with realtime when re-subscribing to a run, that would temporarily display stale data and the changes. Now when re-subscribing to a run only the latest changes will be vended diff --git a/.changeset/wild-mirrors-return.md b/.changeset/wild-mirrors-return.md deleted file mode 100644 index baee7565a3b..00000000000 --- a/.changeset/wild-mirrors-return.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Expose esbuild `minify` option (experimental) diff --git a/.changeset/witty-cherries-tan.md b/.changeset/witty-cherries-tan.md deleted file mode 100644 index 062f1c68de9..00000000000 --- a/.changeset/witty-cherries-tan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix `syncEnvVars` for non-preview deployments diff --git a/.changeset/witty-donkeys-unite.md b/.changeset/witty-donkeys-unite.md deleted file mode 100644 index f1a17eb7ff5..00000000000 --- a/.changeset/witty-donkeys-unite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/build": patch ---- - -Add playwright extension diff --git a/.changeset/yellow-cars-tell.md b/.changeset/yellow-cars-tell.md deleted file mode 100644 index a27ce329c28..00000000000 --- a/.changeset/yellow-cars-tell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Fix Bun runtime path resolution fallback diff --git a/.claude/skills/trigger-dev-tasks/SKILL.md b/.claude/skills/trigger-dev-tasks/SKILL.md new file mode 100644 index 00000000000..791c22c27ed --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/SKILL.md @@ -0,0 +1,200 @@ +--- +name: trigger-dev-tasks +description: Use this skill when writing, designing, or optimizing Trigger.dev background tasks and workflows. This includes creating reliable async tasks, implementing AI workflows, setting up scheduled jobs, structuring complex task hierarchies with subtasks, configuring build extensions for tools like ffmpeg or Puppeteer/Playwright, and handling task schemas with Zod validation. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +--- + +# Trigger.dev Task Expert + +You are an expert Trigger.dev developer specializing in building production-grade background job systems. Tasks deployed to Trigger.dev run in Node.js 21+ and use the `@trigger.dev/sdk` package. + +## Critical Rules + +1. **Always use `@trigger.dev/sdk`** - Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern +2. **Never use `node-fetch`** - Use the built-in `fetch` function +3. **Export all tasks** - Every task must be exported, including subtasks +4. **Never wrap wait/trigger calls in Promise.all** - `triggerAndWait`, `batchTriggerAndWait`, and `wait.*` calls cannot be wrapped in `Promise.all` or `Promise.allSettled` + +## Basic Task Pattern + +```ts +import { task } from "@trigger.dev/sdk"; + +export const processData = task({ + id: "process-data", + retry: { + maxAttempts: 10, + factor: 1.8, + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + }, + run: async (payload: { userId: string; data: any[] }) => { + console.log(`Processing ${payload.data.length} items`); + return { processed: payload.data.length }; + }, +}); +``` + +## Schema Task (with validation) + +```ts +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +export const validatedTask = schemaTask({ + id: "validated-task", + schema: z.object({ + name: z.string(), + email: z.string().email(), + }), + run: async (payload) => { + // Payload is automatically validated and typed + return { message: `Hello ${payload.name}` }; + }, +}); +``` + +## Triggering Tasks + +### From Backend Code (type-only import to prevent dependency leakage) + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { processData } from "./trigger/tasks"; + +const handle = await tasks.trigger("process-data", { + userId: "123", + data: [{ id: 1 }], +}); +``` + +### From Inside Tasks + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload) => { + // Trigger and wait - returns Result object, NOT direct output + const result = await childTask.triggerAndWait({ data: "value" }); + if (result.ok) { + console.log("Output:", result.output); + } else { + console.error("Failed:", result.error); + } + + // Or unwrap directly (throws on error) + const output = await childTask.triggerAndWait({ data: "value" }).unwrap(); + }, +}); +``` + +## Idempotency (Critical for Retries) + +Always use idempotency keys when triggering tasks from inside other tasks: + +```ts +import { idempotencyKeys } from "@trigger.dev/sdk"; + +export const paymentTask = task({ + id: "process-payment", + run: async (payload: { orderId: string }) => { + // Scoped to current run - survives retries + const key = await idempotencyKeys.create(`payment-${payload.orderId}`); + + await chargeCustomer.trigger(payload, { + idempotencyKey: key, + idempotencyKeyTTL: "24h", + }); + }, +}); +``` + +## Trigger Options + +```ts +await myTask.trigger(payload, { + delay: "1h", // Delay execution + ttl: "10m", // Cancel if not started within TTL + idempotencyKey: key, + queue: "my-queue", + machine: "large-1x", // micro, small-1x, small-2x, medium-1x, medium-2x, large-1x, large-2x + maxAttempts: 3, + tags: ["user_123"], // Max 10 tags + debounce: { // Consolidate rapid triggers + key: "unique-key", + delay: "5s", + mode: "trailing", // "leading" (default) or "trailing" + }, +}); +``` + +## Debouncing + +Consolidate multiple triggers into a single execution: + +```ts +// Rapid triggers with same key = single execution +await myTask.trigger({ userId: "123" }, { + debounce: { + key: "user-123-update", + delay: "5s", + }, +}); + +// Trailing mode: use payload from LAST trigger +await myTask.trigger({ data: "latest" }, { + debounce: { + key: "my-key", + delay: "10s", + mode: "trailing", + }, +}); +``` + +Use cases: user activity updates, webhook deduplication, search indexing, notification batching. + +## Batch Triggering + +Up to 1,000 items per batch, 3MB per payload: + +```ts +const results = await myTask.batchTriggerAndWait([ + { payload: { userId: "1" } }, + { payload: { userId: "2" } }, +]); + +for (const result of results) { + if (result.ok) console.log(result.output); +} +``` + +## Machine Presets + +| Preset | vCPU | Memory | +|-------------|------|--------| +| micro | 0.25 | 0.25GB | +| small-1x | 0.5 | 0.5GB | +| small-2x | 1 | 1GB | +| medium-1x | 1 | 2GB | +| medium-2x | 2 | 4GB | +| large-1x | 4 | 8GB | +| large-2x | 8 | 16GB | + +## Design Principles + +1. **Break complex workflows into subtasks** that can be independently retried and made idempotent +2. **Don't over-complicate** - Sometimes `Promise.allSettled` inside a single task is better than many subtasks (each task has dedicated process and is charged by millisecond) +3. **Always configure retries** - Set appropriate `maxAttempts` based on the operation +4. **Use idempotency keys** - Especially for payment/critical operations +5. **Group related subtasks** - Keep subtasks only used by one parent in the same file, don't export them +6. **Use logger** - Log at key execution points with `logger.info()`, `logger.error()`, etc. + +## Reference Documentation + +For detailed documentation on specific topics, read these files: + +- `basic-tasks.md` - Task basics, triggering, waits +- `advanced-tasks.md` - Tags, queues, concurrency, metadata, error handling +- `scheduled-tasks.md` - Cron schedules, declarative and imperative +- `realtime.md` - Real-time subscriptions, streams, React hooks +- `config.md` - trigger.config.ts, build extensions (Prisma, Playwright, FFmpeg, etc.) diff --git a/.claude/skills/trigger-dev-tasks/advanced-tasks.md b/.claude/skills/trigger-dev-tasks/advanced-tasks.md new file mode 100644 index 00000000000..32a00337f89 --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/advanced-tasks.md @@ -0,0 +1,485 @@ +# Trigger.dev Advanced Tasks (v4) + +**Advanced patterns and features for writing tasks** + +## Tags & Organization + +```ts +import { task, tags } from "@trigger.dev/sdk"; + +export const processUser = task({ + id: "process-user", + run: async (payload: { userId: string; orgId: string }, { ctx }) => { + // Add tags during execution + await tags.add(`user_${payload.userId}`); + await tags.add(`org_${payload.orgId}`); + + return { processed: true }; + }, +}); + +// Trigger with tags +await processUser.trigger( + { userId: "123", orgId: "abc" }, + { tags: ["priority", "user_123", "org_abc"] } // Max 10 tags per run +); + +// Subscribe to tagged runs +for await (const run of runs.subscribeToRunsWithTag("user_123")) { + console.log(`User task ${run.id}: ${run.status}`); +} +``` + +**Tag Best Practices:** + +- Use prefixes: `user_123`, `org_abc`, `video:456` +- Max 10 tags per run, 1-64 characters each +- Tags don't propagate to child tasks automatically + +## Batch Triggering v2 + +Enhanced batch triggering with larger payloads and streaming ingestion. + +### Limits + +- **Maximum batch size**: 1,000 items (increased from 500) +- **Payload per item**: 3MB each (increased from 1MB combined) +- Payloads > 512KB automatically offload to object storage + +### Rate Limiting (per environment) + +| Tier | Bucket Size | Refill Rate | +|------|-------------|-------------| +| Free | 1,200 runs | 100 runs/10 sec | +| Hobby | 5,000 runs | 500 runs/5 sec | +| Pro | 5,000 runs | 500 runs/5 sec | + +### Concurrent Batch Processing + +| Tier | Concurrent Batches | +|------|-------------------| +| Free | 1 | +| Hobby | 10 | +| Pro | 10 | + +### Usage + +```ts +import { myTask } from "./trigger/myTask"; + +// Basic batch trigger (up to 1,000 items) +const runs = await myTask.batchTrigger([ + { payload: { userId: "user-1" } }, + { payload: { userId: "user-2" } }, + { payload: { userId: "user-3" } }, +]); + +// Batch trigger with wait +const results = await myTask.batchTriggerAndWait([ + { payload: { userId: "user-1" } }, + { payload: { userId: "user-2" } }, +]); + +for (const result of results) { + if (result.ok) { + console.log("Result:", result.output); + } +} + +// With per-item options +const batchHandle = await myTask.batchTrigger([ + { + payload: { userId: "123" }, + options: { + idempotencyKey: "user-123-batch", + tags: ["priority"], + }, + }, + { + payload: { userId: "456" }, + options: { + idempotencyKey: "user-456-batch", + }, + }, +]); +``` + +## Debouncing + +Consolidate multiple triggers into a single execution by debouncing task runs with a unique key and delay window. + +### Use Cases + +- **User activity updates**: Batch rapid user actions into a single run +- **Webhook deduplication**: Handle webhook bursts without redundant processing +- **Search indexing**: Combine document updates instead of processing individually +- **Notification batching**: Group notifications to prevent user spam + +### Basic Usage + +```ts +await myTask.trigger( + { userId: "123" }, + { + debounce: { + key: "user-123-update", // Unique identifier for debounce group + delay: "5s", // Wait duration ("5s", "1m", or milliseconds) + }, + } +); +``` + +### Execution Modes + +**Leading Mode** (default): Uses payload/options from the first trigger; subsequent triggers only reschedule execution time. + +```ts +// First trigger sets the payload +await myTask.trigger({ action: "first" }, { + debounce: { key: "my-key", delay: "10s" } +}); + +// Second trigger only reschedules - payload remains "first" +await myTask.trigger({ action: "second" }, { + debounce: { key: "my-key", delay: "10s" } +}); +// Task executes with { action: "first" } +``` + +**Trailing Mode**: Uses payload/options from the most recent trigger. + +```ts +await myTask.trigger( + { data: "latest-value" }, + { + debounce: { + key: "trailing-example", + delay: "10s", + mode: "trailing", + }, + } +); +``` + +In trailing mode, these options update with each trigger: +- `payload` — task input data +- `metadata` — run metadata +- `tags` — run tags (replaces existing) +- `maxAttempts` — retry attempts +- `maxDuration` — maximum compute time +- `machine` — machine preset + +### Important Notes + +- Idempotency keys take precedence over debounce settings +- Compatible with `triggerAndWait()` — parent runs block correctly on debounced execution +- Debounce key is scoped to the task + +## Concurrency & Queues + +```ts +import { task, queue } from "@trigger.dev/sdk"; + +// Shared queue for related tasks +const emailQueue = queue({ + name: "email-processing", + concurrencyLimit: 5, // Max 5 emails processing simultaneously +}); + +// Task-level concurrency +export const oneAtATime = task({ + id: "sequential-task", + queue: { concurrencyLimit: 1 }, // Process one at a time + run: async (payload) => { + // Critical section - only one instance runs + }, +}); + +// Per-user concurrency +export const processUserData = task({ + id: "process-user-data", + run: async (payload: { userId: string }) => { + // Override queue with user-specific concurrency + await childTask.trigger(payload, { + queue: { + name: `user-${payload.userId}`, + concurrencyLimit: 2, + }, + }); + }, +}); + +export const emailTask = task({ + id: "send-email", + queue: emailQueue, // Use shared queue + run: async (payload: { to: string }) => { + // Send email logic + }, +}); +``` + +## Error Handling & Retries + +```ts +import { task, retry, AbortTaskRunError } from "@trigger.dev/sdk"; + +export const resilientTask = task({ + id: "resilient-task", + retry: { + maxAttempts: 10, + factor: 1.8, // Exponential backoff multiplier + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + randomize: false, + }, + catchError: async ({ error, ctx }) => { + // Custom error handling + if (error.code === "FATAL_ERROR") { + throw new AbortTaskRunError("Cannot retry this error"); + } + + // Log error details + console.error(`Task ${ctx.task.id} failed:`, error); + + // Allow retry by returning nothing + return { retryAt: new Date(Date.now() + 60000) }; // Retry in 1 minute + }, + run: async (payload) => { + // Retry specific operations + const result = await retry.onThrow( + async () => { + return await unstableApiCall(payload); + }, + { maxAttempts: 3 } + ); + + // Conditional HTTP retries + const response = await retry.fetch("https://api.example.com", { + retry: { + maxAttempts: 5, + condition: (response, error) => { + return response?.status === 429 || response?.status >= 500; + }, + }, + }); + + return result; + }, +}); +``` + +## Machines & Performance + +```ts +export const heavyTask = task({ + id: "heavy-computation", + machine: { preset: "large-2x" }, // 8 vCPU, 16 GB RAM + maxDuration: 1800, // 30 minutes timeout + run: async (payload, { ctx }) => { + // Resource-intensive computation + if (ctx.machine.preset === "large-2x") { + // Use all available cores + return await parallelProcessing(payload); + } + + return await standardProcessing(payload); + }, +}); + +// Override machine when triggering +await heavyTask.trigger(payload, { + machine: { preset: "medium-1x" }, // Override for this run +}); +``` + +**Machine Presets:** + +- `micro`: 0.25 vCPU, 0.25 GB RAM +- `small-1x`: 0.5 vCPU, 0.5 GB RAM (default) +- `small-2x`: 1 vCPU, 1 GB RAM +- `medium-1x`: 1 vCPU, 2 GB RAM +- `medium-2x`: 2 vCPU, 4 GB RAM +- `large-1x`: 4 vCPU, 8 GB RAM +- `large-2x`: 8 vCPU, 16 GB RAM + +## Idempotency + +```ts +import { task, idempotencyKeys } from "@trigger.dev/sdk"; + +export const paymentTask = task({ + id: "process-payment", + retry: { + maxAttempts: 3, + }, + run: async (payload: { orderId: string; amount: number }) => { + // Automatically scoped to this task run, so if the task is retried, the idempotency key will be the same + const idempotencyKey = await idempotencyKeys.create(`payment-${payload.orderId}`); + + // Ensure payment is processed only once + await chargeCustomer.trigger(payload, { + idempotencyKey, + idempotencyKeyTTL: "24h", // Key expires in 24 hours + }); + }, +}); + +// Payload-based idempotency +import { createHash } from "node:crypto"; + +function createPayloadHash(payload: any): string { + const hash = createHash("sha256"); + hash.update(JSON.stringify(payload)); + return hash.digest("hex"); +} + +export const deduplicatedTask = task({ + id: "deduplicated-task", + run: async (payload) => { + const payloadHash = createPayloadHash(payload); + const idempotencyKey = await idempotencyKeys.create(payloadHash); + + await processData.trigger(payload, { idempotencyKey }); + }, +}); +``` + +## Metadata & Progress Tracking + +```ts +import { task, metadata } from "@trigger.dev/sdk"; + +export const batchProcessor = task({ + id: "batch-processor", + run: async (payload: { items: any[] }, { ctx }) => { + const totalItems = payload.items.length; + + // Initialize progress metadata + metadata + .set("progress", 0) + .set("totalItems", totalItems) + .set("processedItems", 0) + .set("status", "starting"); + + const results = []; + + for (let i = 0; i < payload.items.length; i++) { + const item = payload.items[i]; + + // Process item + const result = await processItem(item); + results.push(result); + + // Update progress + const progress = ((i + 1) / totalItems) * 100; + metadata + .set("progress", progress) + .increment("processedItems", 1) + .append("logs", `Processed item ${i + 1}/${totalItems}`) + .set("currentItem", item.id); + } + + // Final status + metadata.set("status", "completed"); + + return { results, totalProcessed: results.length }; + }, +}); + +// Update parent metadata from child task +export const childTask = task({ + id: "child-task", + run: async (payload, { ctx }) => { + // Update parent task metadata + metadata.parent.set("childStatus", "processing"); + metadata.root.increment("childrenCompleted", 1); + + return { processed: true }; + }, +}); +``` + +## Logging & Tracing + +```ts +import { task, logger } from "@trigger.dev/sdk"; + +export const tracedTask = task({ + id: "traced-task", + run: async (payload, { ctx }) => { + logger.info("Task started", { userId: payload.userId }); + + // Custom trace with attributes + const user = await logger.trace( + "fetch-user", + async (span) => { + span.setAttribute("user.id", payload.userId); + span.setAttribute("operation", "database-fetch"); + + const userData = await database.findUser(payload.userId); + span.setAttribute("user.found", !!userData); + + return userData; + }, + { userId: payload.userId } + ); + + logger.debug("User fetched", { user: user.id }); + + try { + const result = await processUser(user); + logger.info("Processing completed", { result }); + return result; + } catch (error) { + logger.error("Processing failed", { + error: error.message, + userId: payload.userId, + }); + throw error; + } + }, +}); +``` + +## Hidden Tasks + +```ts +// Hidden task - not exported, only used internally +const internalProcessor = task({ + id: "internal-processor", + run: async (payload: { data: string }) => { + return { processed: payload.data.toUpperCase() }; + }, +}); + +// Public task that uses hidden task +export const publicWorkflow = task({ + id: "public-workflow", + run: async (payload: { input: string }) => { + // Use hidden task internally + const result = await internalProcessor.triggerAndWait({ + data: payload.input, + }); + + if (result.ok) { + return { output: result.output.processed }; + } + + throw new Error("Internal processing failed"); + }, +}); +``` + +## Best Practices + +- **Concurrency**: Use queues to prevent overwhelming external services +- **Retries**: Configure exponential backoff for transient failures +- **Idempotency**: Always use for payment/critical operations +- **Metadata**: Track progress for long-running tasks +- **Machines**: Match machine size to computational requirements +- **Tags**: Use consistent naming patterns for filtering +- **Debouncing**: Use for user activity, webhooks, and notification batching +- **Batch triggering**: Use for bulk operations up to 1,000 items +- **Error Handling**: Distinguish between retryable and fatal errors + +Design tasks to be stateless, idempotent, and resilient to failures. Use metadata for state tracking and queues for resource management. diff --git a/.claude/skills/trigger-dev-tasks/basic-tasks.md b/.claude/skills/trigger-dev-tasks/basic-tasks.md new file mode 100644 index 00000000000..56bff340761 --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/basic-tasks.md @@ -0,0 +1,199 @@ +# Trigger.dev Basic Tasks (v4) + +**MUST use `@trigger.dev/sdk`, NEVER `client.defineJob`** + +## Basic Task + +```ts +import { task } from "@trigger.dev/sdk"; + +export const processData = task({ + id: "process-data", + retry: { + maxAttempts: 10, + factor: 1.8, + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + randomize: false, + }, + run: async (payload: { userId: string; data: any[] }) => { + // Task logic - runs for long time, no timeouts + console.log(`Processing ${payload.data.length} items for user ${payload.userId}`); + return { processed: payload.data.length }; + }, +}); +``` + +## Schema Task (with validation) + +```ts +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +export const validatedTask = schemaTask({ + id: "validated-task", + schema: z.object({ + name: z.string(), + age: z.number(), + email: z.string().email(), + }), + run: async (payload) => { + // Payload is automatically validated and typed + return { message: `Hello ${payload.name}, age ${payload.age}` }; + }, +}); +``` + +## Triggering Tasks + +### From Backend Code + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { processData } from "./trigger/tasks"; + +// Single trigger +const handle = await tasks.trigger("process-data", { + userId: "123", + data: [{ id: 1 }, { id: 2 }], +}); + +// Batch trigger (up to 1,000 items, 3MB per payload) +const batchHandle = await tasks.batchTrigger("process-data", [ + { payload: { userId: "123", data: [{ id: 1 }] } }, + { payload: { userId: "456", data: [{ id: 2 }] } }, +]); +``` + +### Debounced Triggering + +Consolidate multiple triggers into a single execution: + +```ts +// Multiple rapid triggers with same key = single execution +await myTask.trigger( + { userId: "123" }, + { + debounce: { + key: "user-123-update", // Unique key for debounce group + delay: "5s", // Wait before executing + }, + } +); + +// Trailing mode: use payload from LAST trigger +await myTask.trigger( + { data: "latest-value" }, + { + debounce: { + key: "trailing-example", + delay: "10s", + mode: "trailing", // Default is "leading" (first payload) + }, + } +); +``` + +**Debounce modes:** +- `leading` (default): Uses payload from first trigger, subsequent triggers only reschedule +- `trailing`: Uses payload from most recent trigger + +### From Inside Tasks (with Result handling) + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload) => { + // Trigger and continue + const handle = await childTask.trigger({ data: "value" }); + + // Trigger and wait - returns Result object, NOT task output + const result = await childTask.triggerAndWait({ data: "value" }); + if (result.ok) { + console.log("Task output:", result.output); // Actual task return value + } else { + console.error("Task failed:", result.error); + } + + // Quick unwrap (throws on error) + const output = await childTask.triggerAndWait({ data: "value" }).unwrap(); + + // Batch trigger and wait + const results = await childTask.batchTriggerAndWait([ + { payload: { data: "item1" } }, + { payload: { data: "item2" } }, + ]); + + for (const run of results) { + if (run.ok) { + console.log("Success:", run.output); + } else { + console.log("Failed:", run.error); + } + } + }, +}); + +export const childTask = task({ + id: "child-task", + run: async (payload: { data: string }) => { + return { processed: payload.data }; + }, +}); +``` + +> Never wrap triggerAndWait or batchTriggerAndWait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Waits + +```ts +import { task, wait } from "@trigger.dev/sdk"; + +export const taskWithWaits = task({ + id: "task-with-waits", + run: async (payload) => { + console.log("Starting task"); + + // Wait for specific duration + await wait.for({ seconds: 30 }); + await wait.for({ minutes: 5 }); + await wait.for({ hours: 1 }); + await wait.for({ days: 1 }); + + // Wait until specific date + await wait.until({ date: new Date("2024-12-25") }); + + // Wait for token (from external system) + await wait.forToken({ + token: "user-approval-token", + timeoutInSeconds: 3600, // 1 hour timeout + }); + + console.log("All waits completed"); + return { status: "completed" }; + }, +}); +``` + +> Never wrap wait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Key Points + +- **Result vs Output**: `triggerAndWait()` returns a `Result` object with `ok`, `output`, `error` properties - NOT the direct task output +- **Type safety**: Use `import type` for task references when triggering from backend +- **Waits > 5 seconds**: Automatically checkpointed, don't count toward compute usage +- **Debounce + idempotency**: Idempotency keys take precedence over debounce settings + +## NEVER Use (v2 deprecated) + +```ts +// BREAKS APPLICATION +client.defineJob({ + id: "job-id", + run: async (payload, io) => { + /* ... */ + }, +}); +``` + +Use SDK (`@trigger.dev/sdk`), check `result.ok` before accessing `result.output` diff --git a/.claude/skills/trigger-dev-tasks/config.md b/.claude/skills/trigger-dev-tasks/config.md new file mode 100644 index 00000000000..f6a4db1c4b8 --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/config.md @@ -0,0 +1,346 @@ +# Trigger.dev Configuration + +**Complete guide to configuring `trigger.config.ts` with build extensions** + +## Basic Configuration + +```ts +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: "", // Required: Your project reference + dirs: ["./trigger"], // Task directories + runtime: "node", // "node", "node-22", or "bun" + logLevel: "info", // "debug", "info", "warn", "error" + + // Default retry settings + retries: { + enabledInDev: false, + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: true, + }, + }, + + // Build configuration + build: { + autoDetectExternal: true, + keepNames: true, + minify: false, + extensions: [], // Build extensions go here + }, + + // Global lifecycle hooks + onStartAttempt: async ({ payload, ctx }) => { + console.log("Global task start"); + }, + onSuccess: async ({ payload, output, ctx }) => { + console.log("Global task success"); + }, + onFailure: async ({ payload, error, ctx }) => { + console.log("Global task failure"); + }, +}); +``` + +## Build Extensions + +### Database & ORM + +#### Prisma + +```ts +import { prismaExtension } from "@trigger.dev/build/extensions/prisma"; + +extensions: [ + prismaExtension({ + schema: "prisma/schema.prisma", + version: "5.19.0", // Optional: specify version + migrate: true, // Run migrations during build + directUrlEnvVarName: "DIRECT_DATABASE_URL", + typedSql: true, // Enable TypedSQL support + }), +]; +``` + +#### TypeScript Decorators (for TypeORM) + +```ts +import { emitDecoratorMetadata } from "@trigger.dev/build/extensions/typescript"; + +extensions: [ + emitDecoratorMetadata(), // Enables decorator metadata +]; +``` + +### Scripting Languages + +#### Python + +```ts +import { pythonExtension } from "@trigger.dev/build/extensions/python"; + +extensions: [ + pythonExtension({ + scripts: ["./python/**/*.py"], // Copy Python files + requirementsFile: "./requirements.txt", // Install packages + devPythonBinaryPath: ".venv/bin/python", // Dev mode binary + }), +]; + +// Usage in tasks +const result = await python.runInline(`print("Hello, world!")`); +const output = await python.runScript("./python/script.py", ["arg1"]); +``` + +### Browser Automation + +#### Playwright + +```ts +import { playwright } from "@trigger.dev/build/extensions/playwright"; + +extensions: [ + playwright({ + browsers: ["chromium", "firefox", "webkit"], // Default: ["chromium"] + headless: true, // Default: true + }), +]; +``` + +#### Puppeteer + +```ts +import { puppeteer } from "@trigger.dev/build/extensions/puppeteer"; + +extensions: [puppeteer()]; + +// Environment variable needed: +// PUPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable" +``` + +#### Lightpanda + +```ts +import { lightpanda } from "@trigger.dev/build/extensions/lightpanda"; + +extensions: [ + lightpanda({ + version: "latest", // or "nightly" + disableTelemetry: false, + }), +]; +``` + +### Media Processing + +#### FFmpeg + +```ts +import { ffmpeg } from "@trigger.dev/build/extensions/core"; + +extensions: [ + ffmpeg({ version: "7" }), // Static build, or omit for Debian version +]; + +// Automatically sets FFMPEG_PATH and FFPROBE_PATH +// Add fluent-ffmpeg to external packages if using +``` + +#### Audio Waveform + +```ts +import { audioWaveform } from "@trigger.dev/build/extensions/audioWaveform"; + +extensions: [ + audioWaveform(), // Installs Audio Waveform 1.1.0 +]; +``` + +### System & Package Management + +#### System Packages (apt-get) + +```ts +import { aptGet } from "@trigger.dev/build/extensions/core"; + +extensions: [ + aptGet({ + packages: ["ffmpeg", "imagemagick", "curl=7.68.0-1"], // Can specify versions + }), +]; +``` + +#### Additional NPM Packages + +Only use this for installing CLI tools, NOT packages you import in your code. + +```ts +import { additionalPackages } from "@trigger.dev/build/extensions/core"; + +extensions: [ + additionalPackages({ + packages: ["wrangler"], // CLI tools and specific versions + }), +]; +``` + +#### Additional Files + +```ts +import { additionalFiles } from "@trigger.dev/build/extensions/core"; + +extensions: [ + additionalFiles({ + files: ["wrangler.toml", "./assets/**", "./fonts/**"], // Glob patterns supported + }), +]; +``` + +### Environment & Build Tools + +#### Environment Variable Sync + +```ts +import { syncEnvVars } from "@trigger.dev/build/extensions/core"; + +extensions: [ + syncEnvVars(async (ctx) => { + // ctx contains: environment, projectRef, env + return [ + { name: "SECRET_KEY", value: await getSecret(ctx.environment) }, + { name: "API_URL", value: ctx.environment === "prod" ? "api.prod.com" : "api.dev.com" }, + ]; + }), +]; +``` + +#### ESBuild Plugins + +```ts +import { esbuildPlugin } from "@trigger.dev/build/extensions"; +import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin"; + +extensions: [ + esbuildPlugin( + sentryEsbuildPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + { placement: "last", target: "deploy" } // Optional config + ), +]; +``` + +## Custom Build Extensions + +```ts +import { defineConfig } from "@trigger.dev/sdk"; + +const customExtension = { + name: "my-custom-extension", + + externalsForTarget: (target) => { + return ["some-native-module"]; // Add external dependencies + }, + + onBuildStart: async (context) => { + console.log(`Build starting for ${context.target}`); + // Register esbuild plugins, modify build context + }, + + onBuildComplete: async (context, manifest) => { + console.log("Build complete, adding layers"); + // Add build layers, modify deployment + context.addLayer({ + id: "my-layer", + files: [{ source: "./custom-file", destination: "/app/custom" }], + commands: ["chmod +x /app/custom"], + }); + }, +}; + +export default defineConfig({ + project: "my-project", + build: { + extensions: [customExtension], + }, +}); +``` + +## Advanced Configuration + +### Telemetry + +```ts +import { PrismaInstrumentation } from "@prisma/instrumentation"; +import { OpenAIInstrumentation } from "@langfuse/openai"; + +export default defineConfig({ + // ... other config + telemetry: { + instrumentations: [new PrismaInstrumentation(), new OpenAIInstrumentation()], + exporters: [customExporter], // Optional custom exporters + }, +}); +``` + +### Machine & Performance + +```ts +export default defineConfig({ + // ... other config + defaultMachine: "large-1x", // Default machine for all tasks + maxDuration: 300, // Default max duration (seconds) + enableConsoleLogging: true, // Console logging in development +}); +``` + +## Common Extension Combinations + +### Full-Stack Web App + +```ts +extensions: [ + prismaExtension({ schema: "prisma/schema.prisma", migrate: true }), + additionalFiles({ files: ["./public/**", "./assets/**"] }), + syncEnvVars(async (ctx) => [...envVars]), +]; +``` + +### AI/ML Processing + +```ts +extensions: [ + pythonExtension({ + scripts: ["./ai/**/*.py"], + requirementsFile: "./requirements.txt", + }), + ffmpeg({ version: "7" }), + additionalPackages({ packages: ["wrangler"] }), +]; +``` + +### Web Scraping + +```ts +extensions: [ + playwright({ browsers: ["chromium"] }), + puppeteer(), + additionalFiles({ files: ["./selectors.json", "./proxies.txt"] }), +]; +``` + +## Best Practices + +- **Use specific versions**: Pin extension versions for reproducible builds +- **External packages**: Add modules with native addons to the `build.external` array +- **Environment sync**: Use `syncEnvVars` for dynamic secrets +- **File paths**: Use glob patterns for flexible file inclusion +- **Debug builds**: Use `--log-level debug --dry-run` for troubleshooting + +Extensions only affect deployment, not local development. Use `external` array for packages that shouldn't be bundled. diff --git a/.claude/skills/trigger-dev-tasks/realtime.md b/.claude/skills/trigger-dev-tasks/realtime.md new file mode 100644 index 00000000000..c1c4c5821a9 --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/realtime.md @@ -0,0 +1,244 @@ +# Trigger.dev Realtime + +**Real-time monitoring and updates for runs** + +## Core Concepts + +Realtime allows you to: + +- Subscribe to run status changes, metadata updates, and streams +- Build real-time dashboards and UI updates +- Monitor task progress from frontend and backend + +## Authentication + +### Public Access Tokens + +```ts +import { auth } from "@trigger.dev/sdk"; + +// Read-only token for specific runs +const publicToken = await auth.createPublicToken({ + scopes: { + read: { + runs: ["run_123", "run_456"], + tasks: ["my-task-1", "my-task-2"], + }, + }, + expirationTime: "1h", // Default: 15 minutes +}); +``` + +### Trigger Tokens (Frontend only) + +```ts +// Single-use token for triggering tasks +const triggerToken = await auth.createTriggerPublicToken("my-task", { + expirationTime: "30m", +}); +``` + +## Backend Usage + +### Subscribe to Runs + +```ts +import { runs, tasks } from "@trigger.dev/sdk"; + +// Trigger and subscribe +const handle = await tasks.trigger("my-task", { data: "value" }); + +// Subscribe to specific run +for await (const run of runs.subscribeToRun(handle.id)) { + console.log(`Status: ${run.status}, Progress: ${run.metadata?.progress}`); + if (run.status === "COMPLETED") break; +} + +// Subscribe to runs with tag +for await (const run of runs.subscribeToRunsWithTag("user-123")) { + console.log(`Tagged run ${run.id}: ${run.status}`); +} + +// Subscribe to batch +for await (const run of runs.subscribeToBatch(batchId)) { + console.log(`Batch run ${run.id}: ${run.status}`); +} +``` + +### Realtime Streams v2 + +```ts +import { streams, InferStreamType } from "@trigger.dev/sdk"; + +// 1. Define streams (shared location) +export const aiStream = streams.define({ + id: "ai-output", +}); + +export type AIStreamPart = InferStreamType; + +// 2. Pipe from task +export const streamingTask = task({ + id: "streaming-task", + run: async (payload) => { + const completion = await openai.chat.completions.create({ + model: "gpt-4", + messages: [{ role: "user", content: payload.prompt }], + stream: true, + }); + + const { waitUntilComplete } = aiStream.pipe(completion); + await waitUntilComplete(); + }, +}); + +// 3. Read from backend +const stream = await aiStream.read(runId, { + timeoutInSeconds: 300, + startIndex: 0, // Resume from specific chunk +}); + +for await (const chunk of stream) { + console.log("Chunk:", chunk); // Fully typed +} +``` + +## React Frontend Usage + +### Installation + +```bash +npm add @trigger.dev/react-hooks +``` + +### Triggering Tasks + +```tsx +"use client"; +import { useTaskTrigger, useRealtimeTaskTrigger } from "@trigger.dev/react-hooks"; +import type { myTask } from "../trigger/tasks"; + +function TriggerComponent({ accessToken }: { accessToken: string }) { + // Basic trigger + const { submit, handle, isLoading } = useTaskTrigger("my-task", { + accessToken, + }); + + // Trigger with realtime updates + const { + submit: realtimeSubmit, + run, + isLoading: isRealtimeLoading, + } = useRealtimeTaskTrigger("my-task", { accessToken }); + + return ( +
+ + + + + {run &&
Status: {run.status}
} +
+ ); +} +``` + +### Subscribing to Runs + +```tsx +"use client"; +import { useRealtimeRun, useRealtimeRunsWithTag } from "@trigger.dev/react-hooks"; +import type { myTask } from "../trigger/tasks"; + +function SubscribeComponent({ runId, accessToken }: { runId: string; accessToken: string }) { + // Subscribe to specific run + const { run, error } = useRealtimeRun(runId, { + accessToken, + onComplete: (run) => { + console.log("Task completed:", run.output); + }, + }); + + // Subscribe to tagged runs + const { runs } = useRealtimeRunsWithTag("user-123", { accessToken }); + + if (error) return
Error: {error.message}
; + if (!run) return
Loading...
; + + return ( +
+
Status: {run.status}
+
Progress: {run.metadata?.progress || 0}%
+ {run.output &&
Result: {JSON.stringify(run.output)}
} + +

Tagged Runs:

+ {runs.map((r) => ( +
+ {r.id}: {r.status} +
+ ))} +
+ ); +} +``` + +### Realtime Streams with React + +```tsx +"use client"; +import { useRealtimeStream } from "@trigger.dev/react-hooks"; +import { aiStream } from "../trigger/streams"; + +function StreamComponent({ runId, accessToken }: { runId: string; accessToken: string }) { + // Pass defined stream directly for type safety + const { parts, error } = useRealtimeStream(aiStream, runId, { + accessToken, + timeoutInSeconds: 300, + throttleInMs: 50, // Control re-render frequency + }); + + if (error) return
Error: {error.message}
; + if (!parts) return
Loading...
; + + const text = parts.join(""); // parts is typed as AIStreamPart[] + + return
Streamed Text: {text}
; +} +``` + +### Wait Tokens + +```tsx +"use client"; +import { useWaitToken } from "@trigger.dev/react-hooks"; + +function WaitTokenComponent({ tokenId, accessToken }: { tokenId: string; accessToken: string }) { + const { complete } = useWaitToken(tokenId, { accessToken }); + + return ; +} +``` + +## Run Object Properties + +Key properties available in run subscriptions: + +- `id`: Unique run identifier +- `status`: `QUEUED`, `EXECUTING`, `COMPLETED`, `FAILED`, `CANCELED`, etc. +- `payload`: Task input data (typed) +- `output`: Task result (typed, when completed) +- `metadata`: Real-time updatable data +- `createdAt`, `updatedAt`: Timestamps +- `costInCents`: Execution cost + +## Best Practices + +- **Use Realtime over SWR**: Recommended for most use cases due to rate limits +- **Scope tokens properly**: Only grant necessary read/trigger permissions +- **Handle errors**: Always check for errors in hooks and subscriptions +- **Type safety**: Use task types for proper payload/output typing +- **Cleanup subscriptions**: Backend subscriptions auto-complete, frontend hooks auto-cleanup diff --git a/.claude/skills/trigger-dev-tasks/scheduled-tasks.md b/.claude/skills/trigger-dev-tasks/scheduled-tasks.md new file mode 100644 index 00000000000..b314753497f --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/scheduled-tasks.md @@ -0,0 +1,113 @@ +# Scheduled Tasks (Cron) + +Recurring tasks using cron. For one-off future runs, use the **delay** option. + +## Define a Scheduled Task + +```ts +import { schedules } from "@trigger.dev/sdk"; + +export const task = schedules.task({ + id: "first-scheduled-task", + run: async (payload) => { + payload.timestamp; // Date (scheduled time, UTC) + payload.lastTimestamp; // Date | undefined + payload.timezone; // IANA, e.g. "America/New_York" (default "UTC") + payload.scheduleId; // string + payload.externalId; // string | undefined + payload.upcoming; // Date[] + + payload.timestamp.toLocaleString("en-US", { timeZone: payload.timezone }); + }, +}); +``` + +> Scheduled tasks need at least one schedule attached to run. + +## Attach Schedules + +**Declarative (sync on dev/deploy):** + +```ts +schedules.task({ + id: "every-2h", + cron: "0 */2 * * *", // UTC + run: async () => {}, +}); + +schedules.task({ + id: "tokyo-5am", + cron: { pattern: "0 5 * * *", timezone: "Asia/Tokyo", environments: ["PRODUCTION", "STAGING"] }, + run: async () => {}, +}); +``` + +**Imperative (SDK or dashboard):** + +```ts +await schedules.create({ + task: task.id, + cron: "0 0 * * *", + timezone: "America/New_York", // DST-aware + externalId: "user_123", + deduplicationKey: "user_123-daily", // updates if reused +}); +``` + +### Dynamic / Multi-tenant Example + +```ts +// /trigger/reminder.ts +export const reminderTask = schedules.task({ + id: "todo-reminder", + run: async (p) => { + if (!p.externalId) throw new Error("externalId is required"); + const user = await db.getUser(p.externalId); + await sendReminderEmail(user); + }, +}); +``` + +```ts +// app/reminders/route.ts +export async function POST(req: Request) { + const data = await req.json(); + return Response.json( + await schedules.create({ + task: reminderTask.id, + cron: "0 8 * * *", + timezone: data.timezone, + externalId: data.userId, + deduplicationKey: `${data.userId}-reminder`, + }) + ); +} +``` + +## Cron Syntax (no seconds) + +``` +* * * * * +| | | | └ day of week (0–7 or 1L–7L; 0/7=Sun; L=last) +| | | └── month (1–12) +| | └──── day of month (1–31 or L) +| └────── hour (0–23) +└──────── minute (0–59) +``` + +## When Schedules Won't Trigger + +- **Dev:** only when the dev CLI is running. +- **Staging/Production:** only for tasks in the **latest deployment**. + +## SDK Management + +```ts +await schedules.retrieve(id); +await schedules.list(); +await schedules.update(id, { cron: "0 0 1 * *", externalId: "ext", deduplicationKey: "key" }); +await schedules.deactivate(id); +await schedules.activate(id); +await schedules.del(id); +await schedules.timezones(); // list of IANA timezones +``` diff --git a/.cursor/commands/deslop.md b/.cursor/commands/deslop.md new file mode 100644 index 00000000000..d82835663f7 --- /dev/null +++ b/.cursor/commands/deslop.md @@ -0,0 +1,11 @@ +# Remove AI code slop + +Check the diff against main, and remove all AI generated slop introduced in this branch. + +This includes: +- Extra comments that a human wouldn't add or is inconsistent with the rest of the file +- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths) +- Casts to any to get around type issues +- Any other style that is inconsistent with the file + +Report at the end with only a 1-3 sentence summary of what you changed \ No newline at end of file diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 9b3221784d4..da39e4ffafe 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,7 +1,3 @@ { - "mcpServers": { - "trigger.dev": { - "url": "http://localhost:3333/sse" - } - } -} \ No newline at end of file + "mcpServers": {} +} diff --git a/.cursor/plans/resolve_upstream_merge_conflicts_86e4a884.plan.md b/.cursor/plans/resolve_upstream_merge_conflicts_86e4a884.plan.md new file mode 100644 index 00000000000..628b20ad126 --- /dev/null +++ b/.cursor/plans/resolve_upstream_merge_conflicts_86e4a884.plan.md @@ -0,0 +1,161 @@ +--- +name: Resolve upstream merge conflicts +overview: Resolve 68 merge conflicts between the GovSignals fork (with two-phase deploy, custom images, and Kubernetes enhancements) and upstream/main, preserving all fork customizations on top of upstream's refactored code. +todos: + - id: phase1-deletions + content: "Bulk-resolve: git rm deleted references, git add fork-only files, accept upstream lock file" + status: completed + - id: phase2-packagejson + content: "Resolve package.json: accept upstream pnpm@10.23.0 and overrides structure, re-add fork-specific overrides" + status: completed + - id: phase3-env + content: "Resolve env.server.ts: accept upstream refactored schema, add DEPLOY_VERSION_SUFFIX and DEPLOY_IMAGE_OVERRIDE" + status: completed + - id: phase4-kubernetes + content: "Resolve kubernetes.ts: combine fork's serviceAccountName/securityContext with upstream's schedulerName" + status: completed + - id: phase5-initdeploy + content: "Resolve initializeDeployment.server.ts: keep upstream's new API, integrate fork's DEPLOY_IMAGE_OVERRIDE with rewritten else branch" + status: completed + - id: phase6-buildworker + content: "Resolve buildWorker.ts: combine fork's baseImageNode/containerfileModule with upstream's plain option" + status: completed + - id: phase7-buildimage + content: "Resolve buildImage.ts: combine imports, keep both indexEnvVars and compression options, keep fork's --load and image tag" + status: completed + - id: phase8-deploy + content: "Resolve deploy.ts: combine imports/options from both sides, keep all new functions from both fork and upstream" + status: completed + - id: phase9-finalize + content: Run pnpm install, verify build compiles, complete merge commit + status: completed +isProject: false +--- + +# Resolve Upstream Merge Conflicts + +The merge from `upstream/main` into the `update-trigger` branch has 68 conflicted files. The fork's custom features (two-phase deploy, custom images/containerfiles, Kubernetes service account) need to be preserved on top of upstream's refactored code. + +## Phase 1: Bulk-resolve trivial conflicts + +### 1a. Delete files removed by upstream + +55 files in `references/seed/src copy/` and 2 files in `references/v3-catalog/` were deleted upstream. Accept the deletions. + +```bash +git rm -r "references/seed/src copy/" +git rm references/v3-catalog/src/trigger/returnTypes.ts +git rm references/v3-catalog/trigger.config.ts +``` + +### 1b. Keep fork-only files + +2 files in `references/seed/` exist only in the fork. Keep them. + +```bash +git add references/seed/.triggerdeploy.json +git add references/seed/src/trigger/testTasks.ts +``` + +### 1c. Accept upstream's lock file + +```bash +git checkout --theirs pnpm-lock.yaml +git add pnpm-lock.yaml +``` + +## Phase 2: package.json (root) + +Two conflicts in [package.json](package.json): + +- **packageManager** (line 69): Accept upstream's `pnpm@10.23.0` +- **pnpm.overrides** (line 99): Accept upstream's version (includes granular `form-data` overrides, security fixes, and `onlyBuiltDependencies`). Re-add the fork's overrides that are still needed: `ws`, `semver`, `cross-spawn`, `@babel/runtime` -- insert them into the upstream overrides block + +## Phase 3: env.server.ts (webapp) + +One massive conflict spanning the entire file in [apps/webapp/app/env.server.ts](apps/webapp/app/env.server.ts). + +- **Accept upstream's refactored schema structure** (multi-schema composition with `GithubAppEnvSchema`, `S2EnvSchema`, `.and()` chain) +- **Add the fork's 2 custom env vars** into the upstream `EnvironmentSchema` z.object, near the deployment registry section: + - `DEPLOY_VERSION_SUFFIX: z.string().optional()` + - `DEPLOY_IMAGE_OVERRIDE: z.string().optional()` + +## Phase 4: kubernetes.ts (supervisor) + +One conflict in [apps/supervisor/src/workloadManager/kubernetes.ts](apps/supervisor/src/workloadManager/kubernetes.ts) at line 323. + +Both sides add non-overlapping features to the pod spec. **Keep both**: + +- Fork's `serviceAccountName` spread + `securityContext` block +- Upstream's `schedulerName` spread + +## Phase 5: initializeDeployment.server.ts (webapp) + +One conflict in [apps/webapp/app/v3/services/initializeDeployment.server.ts](apps/webapp/app/v3/services/initializeDeployment.server.ts) at line 122. + +This is the most nuanced conflict. The resolution must: + +1. **Keep upstream's new variables**: `isV4Deployment`, `registryConfig`, `deploymentShortCode` +2. **Declare fork's variables**: `let imageRef: string; let isEcr = false; let repoCreated = false;` +3. **Keep the fork's `DEPLOY_IMAGE_OVERRIDE` check** (lines 144-151) as-is +4. **Rewrite the else branch** (lines 152-183): replace the old `getDeploymentImageRef` call with upstream's new API signature, then extract `imageRef`, `isEcr`, `repoCreated` from the result + +The else branch should become: + +```typescript +} else { + const [imageRefError, imageRefResult] = await tryCatch( + getDeploymentImageRef({ + registry: registryConfig, + projectRef: environment.project.externalRef, + nextVersion, + environmentType: environment.type, + deploymentShortCode, + }) + ); + + if (imageRefError) { + logger.error("Failed to get deployment image ref", { ... }); + throw new ServiceValidationError("Failed to get deployment image ref"); + } + + imageRef = imageRefResult.imageRef; + isEcr = imageRefResult.isEcr; + repoCreated = imageRefResult.repoCreated; +} +``` + +## Phase 6: buildWorker.ts (cli-v3) + +Two conflicts in [packages/cli-v3/src/build/buildWorker.ts](packages/cli-v3/src/build/buildWorker.ts). + +- **Line 38**: Keep all three options in `BuildWorkerOptions`: fork's `baseImageNode` + `containerfileModule` AND upstream's `plain` +- **Line 223**: Keep fork's `metafile.json` write and `writeContainerfile` with extra args; also accept upstream's simplified default call path + +## Phase 7: buildImage.ts (cli-v3) + +Five conflicts in [packages/cli-v3/src/deploy/buildImage.ts](packages/cli-v3/src/deploy/buildImage.ts). + +- **Line 8 (imports)**: Combine both -- fork's `cpSync, mkdirSync`, `pathToFileURL`, `ContainerfileTemplate` AND upstream's `tryCatch`, `CliApiClient` +- **Line 161 (function params)**: Keep both `indexEnvVars` and `compression`/`compressionLevel`/`forceCompression` +- **Line 192 (interface)**: Same -- keep both sets of fields +- **Line 593 (`--load` flag)**: Keep fork's conditional `--load` +- **Line 623 (image tag)**: Keep fork's `-t options.imageTag "."` instead of upstream's bare `"."` + +## Phase 8: deploy.ts (cli-v3) -- largest and most complex + +Six conflicts in [packages/cli-v3/src/commands/deploy.ts](packages/cli-v3/src/commands/deploy.ts). + +- **Line 3 (imports)**: Combine both -- fork's `CreateBackgroundWorkerRequestBody` AND upstream's `InitializeDeploymentRequestBody`, `GitMeta`, `DeploymentFinalizedEvent`, `DeploymentEventFromString`, `DeploymentTriggeredVia`, plus upstream's `relative` import +- **Line 93 (DeployCommandOptions)**: Keep ALL options from both sides -- fork's `registry`, `repository`, `buildOnly`, `registerOnly`, `baseImageNode`, `containerfileModule`, `skipDigest` AND upstream's `nativeBuildServer`, `detach`, `plain`, `compression`, `cacheCompression`, `compressionLevel`, `forceCompression` +- **Line 453 (buildWorker call)**: Keep all three: `baseImageNode`, `containerfileModule`, `plain` +- **Line 464 (spinner text)**: Trivial -- use upstream's `"Successfully built code"` (no trailing period) +- **Line 618 (build options)**: Keep both `indexEnvVars` and compression options +- **Line 1080 (large block)**: Both sides add entirely new functions. **Keep both**: fork's `buildOnlyDeploy` + `registerOnlyDeploy` AND upstream's `initializeOrAttachDeployment` + `getTriggeredVia` + `handleNativeBuildServerDeploy` + +## Phase 9: Final steps + +1. Run `pnpm install` to regenerate `pnpm-lock.yaml` +2. Verify the build compiles: `pnpm run build --filter webapp --filter trigger.dev --filter @trigger.dev/sdk` +3. Complete the merge commit + diff --git a/.cursor/rules/migrations.mdc b/.cursor/rules/migrations.mdc new file mode 100644 index 00000000000..370c87c051d --- /dev/null +++ b/.cursor/rules/migrations.mdc @@ -0,0 +1,6 @@ +--- +description: how to create and apply database migrations +alwaysApply: false +--- + +Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations. diff --git a/.cursor/rules/otel-metrics.mdc b/.cursor/rules/otel-metrics.mdc new file mode 100644 index 00000000000..218f07c41e2 --- /dev/null +++ b/.cursor/rules/otel-metrics.mdc @@ -0,0 +1,66 @@ +--- +description: Guidelines for creating OpenTelemetry metrics to avoid cardinality issues +globs: + - "**/*.ts" +--- + +# OpenTelemetry Metrics Guidelines + +When creating or editing OTEL metrics (counters, histograms, gauges), always ensure metric attributes have **low cardinality**. + +## What is Cardinality? + +Cardinality refers to the number of unique values an attribute can have. Each unique combination of attribute values creates a new time series, which consumes memory and storage in your metrics backend. + +## Rules + +### DO use low-cardinality attributes: +- **Enums**: `environment_type` (PRODUCTION, STAGING, DEVELOPMENT, PREVIEW) +- **Booleans**: `hasFailures`, `streaming`, `success` +- **Bounded error codes**: A finite, controlled set of error types +- **Shard IDs**: When sharding is bounded (e.g., 0-15) + +### DO NOT use high-cardinality attributes: +- **UUIDs/IDs**: `envId`, `userId`, `runId`, `projectId`, `organizationId` +- **Unbounded integers**: `itemCount`, `batchSize`, `retryCount` +- **Timestamps**: `createdAt`, `startTime` +- **Free-form strings**: `errorMessage`, `taskName`, `queueName` + +## Example + +```typescript +// BAD - High cardinality +this.counter.add(1, { + envId: options.environmentId, // UUID - unbounded + itemCount: options.runCount, // Integer - unbounded +}); + +// GOOD - Low cardinality +this.counter.add(1, { + environment_type: options.environmentType, // Enum - 4 values + streaming: true, // Boolean - 2 values +}); +``` + +## Prometheus Metric Naming + +When metrics are exported via OTLP to Prometheus, the exporter automatically adds unit suffixes to metric names: + +| OTel Metric Name | Unit | Prometheus Name | +|------------------|------|-----------------| +| `my_duration_ms` | `ms` | `my_duration_ms_milliseconds` | +| `my_counter` | counter | `my_counter_total` | +| `items_inserted` | counter | `items_inserted_inserts_total` | +| `batch_size` | histogram | `batch_size_items_bucket` | + +Keep this in mind when writing Grafana dashboards or Prometheus queries—the metric names in Prometheus will differ from the names defined in code. + +## Reference + +See the schedule engine (`internal-packages/schedule-engine/src/engine/index.ts`) for a good example of low-cardinality metric attributes. + +High cardinality metrics can cause: +- Memory bloat in metrics backends (Axiom, Prometheus, etc.) +- Slow queries and dashboard timeouts +- Increased costs (many backends charge per time series) +- Potential data loss or crashes at scale diff --git a/.cursor/rules/webapp.mdc b/.cursor/rules/webapp.mdc index 6cda9739514..a362f14fe12 100644 --- a/.cursor/rules/webapp.mdc +++ b/.cursor/rules/webapp.mdc @@ -6,7 +6,7 @@ alwaysApply: false The main trigger.dev webapp, which powers it's API and dashboard and makes up the docker image that is produced as an OSS image, is a Remix 2.1.0 app that uses an express server, written in TypeScript. The following subsystems are either included in the webapp or are used by the webapp in another part of the monorepo: -- `@trigger.dev/database` exports a Prisma 5.4.1 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma) +- `@trigger.dev/database` exports a Prisma 6.14.0 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma) - `@trigger.dev/core` is a published package and is used to share code between the `@trigger.dev/sdk` and the webapp. It includes functionality but also a load of Zod schemas for data validation. When importing from `@trigger.dev/core` in the webapp, we never import the root `@trigger.dev/core` path, instead we favor one of the subpath exports that you can find in [package.json](mdc:packages/core/package.json) - `@internal/run-engine` has all the code needed to trigger a run and take it through it's lifecycle to completion. - `@trigger.dev/redis-worker` is a custom redis based background job/worker system that's used in the webapp and also used inside the run engine. @@ -31,7 +31,10 @@ We originally the Trigger.dev "Run Engine" not as a single system, but just spre - The batch trigger API endpoint is [api.v1.tasks.batch.ts](mdc:apps/webapp/app/routes/api.v1.tasks.batch.ts) - Setup code for the prisma client is in [db.server.ts](mdc:apps/webapp/app/db.server.ts) - The run engine is configured in [runEngine.server.ts](mdc:apps/webapp/app/v3/runEngine.server.ts) -- All the "services" that are found in app/v3/services/**/*.server.ts +- All the "services" that are found in app/v3/services/\*_/_.server.ts - The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts) -- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/**/*.server.ts +- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts +- All the "services" that are found in app/v3/services/\*_/_.server.ts +- The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts) +- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts diff --git a/.cursor/rules/writing-tasks.mdc b/.cursor/rules/writing-tasks.mdc index 6090b85f090..5116d083e23 100644 --- a/.cursor/rules/writing-tasks.mdc +++ b/.cursor/rules/writing-tasks.mdc @@ -431,28 +431,6 @@ export async function POST(request: Request) { } ``` -### tasks.triggerAndPoll() - -Triggers a task and polls until completion. Not recommended for web requests as it blocks until the run completes. Consider using Realtime docs for better alternatives. - -```ts -import { tasks } from "@trigger.dev/sdk/v3"; -import type { emailSequence } from "~/trigger/emails"; - -export async function POST(request: Request) { - const data = await request.json(); - const result = await tasks.triggerAndPoll( - "email-sequence", - { - to: data.email, - name: data.name, - }, - { pollIntervalMs: 5000 } - ); - return Response.json(result); -} -``` - ### batch.trigger() Triggers multiple runs of different tasks at once, useful when you need to execute multiple tasks simultaneously. diff --git a/.dockerignore b/.dockerignore index d3f8720a9ef..a3ea4db8eec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,8 @@ **/dist **/node_modules +**/generated/prisma + apps/webapp/build apps/webapp/public/build diff --git a/.env.example b/.env.example index ae4a3672a5b..84228647d60 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,10 @@ APP_ORIGIN=http://localhost:3030 ELECTRIC_ORIGIN=http://localhost:3060 NODE_ENV=development +# Clickhouse CLICKHOUSE_URL=http://default:password@localhost:8123 +RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123 +RUN_REPLICATION_ENABLED=1 # Set this to UTC because Node.js uses the system timezone TZ="UTC" @@ -31,9 +34,9 @@ DEPLOY_REGISTRY_HOST=localhost:5000 # OPTIONAL VARIABLES # This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected. -# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com" +# WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$" # Accounts with these emails will get global admin rights. This grants access to the admin UI. -# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com" +# ADMIN_EMAILS="^(admin@example\.com|another-admin@example\.com)$" # This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication. # AUTH_GITHUB_CLIENT_ID= # AUTH_GITHUB_CLIENT_SECRET= @@ -82,4 +85,10 @@ POSTHOG_PROJECT_KEY= # These control the server-side internal telemetry # INTERNAL_OTEL_TRACE_EXPORTER_URL= # INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1 -# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0, \ No newline at end of file +# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0 + +# Enable local observability stack (requires `pnpm run docker` to start otel-collector) +# Uncomment these to send metrics to the local Prometheus via OTEL Collector: +# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1 +# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics +# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000 diff --git a/.github/ISSUE_TEMPLATE/vouch-request.yml b/.github/ISSUE_TEMPLATE/vouch-request.yml new file mode 100644 index 00000000000..9ffe04a8984 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/vouch-request.yml @@ -0,0 +1,28 @@ +name: Vouch Request +description: Request to be vouched as a contributor +labels: ["vouch-request"] +body: + - type: markdown + attributes: + value: | + ## Vouch Request + + We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. PRs from unvouched users are automatically closed. + + To get vouched, fill out this form. A maintainer will review your request and vouch for you by commenting on this issue. + - type: textarea + id: context + attributes: + label: Why do you want to contribute? + description: Tell us a bit about yourself and what you'd like to work on. + placeholder: "I'd like to fix a bug I found in..." + validations: + required: true + - type: textarea + id: prior-work + attributes: + label: Prior contributions or relevant experience + description: Links to previous open source work, relevant projects, or anything that helps us understand your background. + placeholder: "https://github.com/..." + validations: + required: false diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 00000000000..8e06c770ab8 --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,16 @@ +# Vouched contributors for Trigger.dev +# See: https://github.com/mitchellh/vouch +# +# Org members +0ski +D-K-P +ericallam +matt-aitken +mpcgrid +myftija +nicktrn +samejr +isshaddad +# Outside contributors +gautamsi +capaj \ No newline at end of file diff --git a/.github/workflows/changesets-pr.yml b/.github/workflows/changesets-pr.yml new file mode 100644 index 00000000000..e2fdc187614 --- /dev/null +++ b/.github/workflows/changesets-pr.yml @@ -0,0 +1,102 @@ +name: 🦋 Changesets PR + +on: + push: + branches: + - main + paths: + - "packages/**" + - ".changeset/**" + - "package.json" + - "pnpm-lock.yaml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + release-pr: + name: Create Release PR + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + if: github.repository == 'triggerdotdev/trigger.dev' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Create release PR + id: changesets + uses: changesets/action@v1 + with: + version: pnpm run changeset:version + commit: "chore: release" + title: "chore: release" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update PR title with version + if: steps.changesets.outputs.published != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number') + if [ -n "$PR_NUMBER" ]; then + git fetch origin changeset-release/main + # we arbitrarily reference the version of the cli package here; it is the same for all package releases + VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version') + gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION" + fi + + update-lockfile: + name: Update lockfile on release PR + runs-on: ubuntu-latest + needs: release-pr + permissions: + contents: write + steps: + - name: Checkout release branch + uses: actions/checkout@v4 + with: + ref: changeset-release/main + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + + - name: Install and update lockfile + run: pnpm install --no-frozen-lockfile + + - name: Commit and push lockfile + run: | + set -e + if git diff --quiet pnpm-lock.yaml; then + echo "No lockfile changes" + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add pnpm-lock.yaml + git commit -m "chore: update lockfile for release" + git push origin changeset-release/main + fi diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000000..cadbe31773f --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,70 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: ⎔ Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: 📥 Download deps + run: pnpm install --frozen-lockfile + + - name: 📀 Generate Prisma Client + run: pnpm run generate + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + claude_args: | + --model claude-opus-4-5-20251101 + --allowedTools "Bash(pnpm:*),Bash(turbo:*),Bash(git:*),Bash(gh:*),Bash(npx:*),Bash(docker:*),Edit,MultiEdit,Read,Write,Glob,Grep,LS,Task" + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index db78308a43f..9518ca6157c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -31,12 +31,12 @@ jobs: - name: ⎔ Setup pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.5 + version: 10.23.0 - name: ⎔ Setup node uses: buildjet/setup-node@v4 with: - node-version: 20.11.1 + node-version: 20.20.0 - name: 📥 Download deps run: pnpm install --frozen-lockfile --filter trigger.dev... diff --git a/.github/workflows/helm-pr-prerelease.yml b/.github/workflows/helm-pr-prerelease.yml new file mode 100644 index 00000000000..8df045945e6 --- /dev/null +++ b/.github/workflows/helm-pr-prerelease.yml @@ -0,0 +1,138 @@ +name: 🧭 Helm Chart PR Prerelease + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "hosting/k8s/helm/**" + +concurrency: + group: helm-prerelease-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + CHART_NAME: trigger + +jobs: + lint-and-test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: "3.18.3" + + - name: Build dependencies + run: helm dependency build ./hosting/k8s/helm/ + + - name: Extract dependency charts + run: | + cd ./hosting/k8s/helm/ + for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done + + - name: Lint Helm Chart + run: | + helm lint ./hosting/k8s/helm/ + + - name: Render templates + run: | + helm template test-release ./hosting/k8s/helm/ \ + --values ./hosting/k8s/helm/values.yaml \ + --output-dir ./helm-output + + - name: Validate manifests + uses: docker://ghcr.io/yannh/kubeconform:v0.7.0 + with: + entrypoint: "/kubeconform" + args: "-summary -output json ./helm-output" + + prerelease: + needs: lint-and-test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: "3.18.3" + + - name: Build dependencies + run: helm dependency build ./hosting/k8s/helm/ + + - name: Extract dependency charts + run: | + cd ./hosting/k8s/helm/ + for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate prerelease version + id: version + run: | + BASE_VERSION=$(grep '^version:' ./hosting/k8s/helm/Chart.yaml | awk '{print $2}') + PR_NUMBER=${{ github.event.pull_request.number }} + SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) + PRERELEASE_VERSION="${BASE_VERSION}-pr${PR_NUMBER}.${SHORT_SHA}" + echo "version=$PRERELEASE_VERSION" >> $GITHUB_OUTPUT + echo "Prerelease version: $PRERELEASE_VERSION" + + - name: Update Chart.yaml with prerelease version + run: | + sed -i "s/^version:.*/version: ${{ steps.version.outputs.version }}/" ./hosting/k8s/helm/Chart.yaml + + - name: Package Helm Chart + run: | + helm package ./hosting/k8s/helm/ --destination /tmp/ + + - name: Push Helm Chart to GHCR + run: | + VERSION="${{ steps.version.outputs.version }}" + CHART_PACKAGE="/tmp/${{ env.CHART_NAME }}-${VERSION}.tgz" + + # Push to GHCR OCI registry + helm push "$CHART_PACKAGE" "oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts" + + - name: Find existing comment + uses: peter-evans/find-comment@v3 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "Helm Chart Prerelease Published" + + - name: Create or update PR comment + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: | + ### 🧭 Helm Chart Prerelease Published + + **Version:** `${{ steps.version.outputs.version }}` + + **Install:** + ```bash + helm upgrade --install trigger \ + oci://ghcr.io/${{ github.repository_owner }}/charts/trigger \ + --version "${{ steps.version.outputs.version }}" + ``` + + > ⚠️ This is a prerelease for testing. Do not use in production. + edit-mode: replace diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index b6be1eddfa1..dab18223e35 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -29,3 +29,7 @@ jobs: with: package: cli-v3 secrets: inherit + + sdk-compat: + uses: ./.github/workflows/sdk-compat.yml + secrets: inherit diff --git a/.github/workflows/publish-webapp.yml b/.github/workflows/publish-webapp.yml index ed5a259a853..6fcc30209ab 100644 --- a/.github/workflows/publish-webapp.yml +++ b/.github/workflows/publish-webapp.yml @@ -86,3 +86,8 @@ jobs: BUILD_GIT_SHA=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }} BUILD_GIT_REF_NAME=${{ steps.set_build_info.outputs.BUILD_GIT_REF_NAME }} BUILD_TIMESTAMP_SECONDS=${{ steps.set_build_info.outputs.BUILD_TIMESTAMP_SECONDS }} + SENTRY_RELEASE=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }} + SENTRY_ORG=triggerdev + SENTRY_PROJECT=trigger-cloud + secrets: | + sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3acda8a7f03..6213499c5ad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,7 @@ name: 🚀 Publish Trigger.dev Docker on: + workflow_dispatch: workflow_call: inputs: image_tag: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 686240aaef6..3b4135ec099 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,98 +1,195 @@ name: 🦋 Changesets Release -permissions: - contents: write - on: - push: + pull_request: + types: [closed] branches: - main - paths-ignore: - - "docs/**" - - "**.md" - - ".github/CODEOWNERS" - - ".github/ISSUE_TEMPLATE/**" + workflow_dispatch: + inputs: + type: + description: "Select release type" + required: true + type: choice + options: + - release + - prerelease + default: "prerelease" + ref: + description: "The ref (branch, tag, or SHA) to checkout and release from" + required: true + type: string + prerelease_tag: + description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')" + required: false + type: string + default: "prerelease" concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }} + cancel-in-progress: false jobs: + show-release-summary: + name: 📋 Release Summary + runs-on: ubuntu-latest + if: | + github.repository == 'triggerdotdev/trigger.dev' && + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'changeset-release/main' + steps: + - name: Show release summary + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY + release: - name: 🦋 Changesets Release + name: 🚀 Release npm packages runs-on: ubuntu-latest + environment: npm-publish permissions: contents: write packages: write - pull-requests: write - if: github.repository == 'triggerdotdev/trigger.dev' + id-token: write + if: | + github.repository == 'triggerdotdev/trigger.dev' && + ( + (github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') || + (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main') + ) outputs: published: ${{ steps.changesets.outputs.published }} published_packages: ${{ steps.changesets.outputs.publishedPackages }} published_package_version: ${{ steps.get_version.outputs.package_version }} steps: - - name: ⬇️ Checkout repo + - name: Checkout repo uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }} - - name: ⎔ Setup pnpm + - name: Verify ref is on main + if: github.event_name == 'workflow_dispatch' + run: | + if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then + echo "Error: ref must be an ancestor of main (i.e., already merged)" + exit 1 + fi + + - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.5 + version: 10.23.0 - - name: ⎔ Setup node + - name: Setup node uses: buildjet/setup-node@v4 with: - node-version: 20.11.1 + node-version: 20.20.0 cache: "pnpm" - - name: 📥 Download deps + # npm v11.5.1 or newer is required for OIDC support + # https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new + - name: Setup npm 11.x for OIDC + run: npm install -g npm@11.6.4 + + - name: Install dependencies run: pnpm install --frozen-lockfile - - name: 📀 Generate Prisma Client + - name: Generate Prisma client run: pnpm run generate - - name: 🏗️ Build + - name: Build run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev" - - name: 🔎 Type check + - name: Type check run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev" - - name: 🔐 Setup npm auth - run: | - echo "registry=https://registry.npmjs.org" >> ~/.npmrc - echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc - - # This action has two responsibilities. The first time the workflow runs - # (initial push to the `main` branch) it will create a new branch and - # then open a PR with the related changes for the new version. After the - # PR is merged, the workflow will run again and this action will build + - # publish to npm. - - name: 🚀 PR / Publish - if: ${{ !env.ACT }} + - name: Publish id: changesets uses: changesets/action@v1 with: - version: pnpm run changeset:version - commit: "chore: Update version for release" - title: "chore: Update version for release" publish: pnpm run changeset:release createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - # - name: 🚀 PR / Publish (mock) - # if: ${{ env.ACT }} - # id: changesets - # run: | - # echo "published=true" >> "$GITHUB_OUTPUT" - # echo "publishedPackages=[{\"name\": \"@xx/xx\", \"version\": \"1.2.0\"}, {\"name\": \"@xx/xy\", \"version\": \"0.8.9\"}]" >> "$GITHUB_OUTPUT" - - name: 📦 Get package version + - name: Show package version if: steps.changesets.outputs.published == 'true' id: get_version run: | package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version') echo "package_version=${package_version}" >> "$GITHUB_OUTPUT" + + - name: Create and push Docker tag + if: steps.changesets.outputs.published == 'true' + run: | + set -e + git tag "v.docker.${{ steps.get_version.outputs.package_version }}" + git push origin "v.docker.${{ steps.get_version.outputs.package_version }}" + + # Trigger Docker builds directly via workflow_call since tags pushed with + # GITHUB_TOKEN don't trigger other workflows (GitHub Actions limitation). + publish-docker: + name: 🐳 Publish Docker images + needs: release + if: needs.release.outputs.published == 'true' + uses: ./.github/workflows/publish.yml + secrets: inherit + with: + image_tag: v${{ needs.release.outputs.published_package_version }} + + # The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims. + prerelease: + name: 🧪 Prerelease + runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: read + id-token: write + if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease' + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + # npm v11.5.1 or newer is required for OIDC support + # https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new + - name: Setup npm 11.x for OIDC + run: npm install -g npm@11.6.4 + + - name: Download deps + run: pnpm install --frozen-lockfile + + - name: Generate Prisma Client + run: pnpm run generate + + - name: Snapshot version + run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Clean + run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev" + + - name: Build + run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev" + + - name: Publish prerelease + run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sdk-compat.yml b/.github/workflows/sdk-compat.yml new file mode 100644 index 00000000000..eb347c0f771 --- /dev/null +++ b/.github/workflows/sdk-compat.yml @@ -0,0 +1,178 @@ +name: "🔌 SDK Compatibility Tests" + +permissions: + contents: read + +on: + workflow_call: + +jobs: + node-compat: + name: "Node.js ${{ matrix.node }} (${{ matrix.os }})" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: ["20.20", "22.12"] + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: ⎔ Setup node + uses: buildjet/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: "pnpm" + + - name: 📥 Download deps + run: pnpm install --frozen-lockfile + + - name: 📀 Generate Prisma Client + run: pnpm run generate + + - name: 🔨 Build SDK dependencies + shell: bash + run: pnpm run build --filter '@trigger.dev/sdk^...' + + - name: 🔨 Build SDK + shell: bash + run: pnpm run build --filter '@trigger.dev/sdk' + + - name: 🧪 Run SDK Compatibility Tests + shell: bash + run: pnpm --filter @internal/sdk-compat-tests test + + bun-compat: + name: "Bun Runtime" + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: ⎔ Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: 🥟 Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: 📥 Download deps + run: pnpm install --frozen-lockfile + + - name: 📀 Generate Prisma Client + run: pnpm run generate + + - name: 🔨 Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: 🔨 Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: 🧪 Run Bun Compatibility Test + working-directory: internal-packages/sdk-compat-tests/src/fixtures/bun + run: bun run test.ts + + deno-compat: + name: "Deno Runtime" + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: ⎔ Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: 🦕 Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: 📥 Download deps + run: pnpm install --frozen-lockfile + + - name: 📀 Generate Prisma Client + run: pnpm run generate + + - name: 🔨 Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: 🔨 Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: 🔗 Link node_modules for Deno fixture + working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno + run: ln -s ../../../../../node_modules node_modules + + - name: 🧪 Run Deno Compatibility Test + working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno + run: deno run --allow-read --allow-env --allow-sys test.ts + + cloudflare-compat: + name: "Cloudflare Workers" + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: ⎔ Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: 📥 Download deps + run: pnpm install --frozen-lockfile + + - name: 📀 Generate Prisma Client + run: pnpm run generate + + - name: 🔨 Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: 🔨 Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: 📥 Install Cloudflare fixture deps + working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker + run: pnpm install + + - name: 🧪 Run Cloudflare Workers Compatibility Test (dry-run) + working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker + run: npx wrangler deploy --dry-run --outdir dist diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index acd1fb3d642..665d54b2563 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -19,12 +19,12 @@ jobs: - name: ⎔ Setup pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.5 + version: 10.23.0 - name: ⎔ Setup node uses: buildjet/setup-node@v4 with: - node-version: 20.11.1 + node-version: 20.20.0 cache: "pnpm" - name: 📥 Download deps @@ -35,6 +35,8 @@ jobs: - name: 🔎 Type check run: pnpm run typecheck + env: + NODE_OPTIONS: --max-old-space-size=8192 - name: 🔎 Check exports run: pnpm run check-exports diff --git a/.github/workflows/unit-tests-internal.yml b/.github/workflows/unit-tests-internal.yml index 5acac054a66..92b951e8aa0 100644 --- a/.github/workflows/unit-tests-internal.yml +++ b/.github/workflows/unit-tests-internal.yml @@ -53,12 +53,12 @@ jobs: - name: ⎔ Setup pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.5 + version: 10.23.0 - name: ⎔ Setup node uses: buildjet/setup-node@v4 with: - node-version: 20.11.1 + node-version: 20.20.0 cache: "pnpm" # ..to avoid rate limits when pulling images @@ -72,6 +72,17 @@ jobs: if: ${{ !env.DOCKERHUB_USERNAME }} run: echo "DockerHub login skipped because secrets are not available." + - name: 🐳 Pre-pull testcontainer images + if: ${{ env.DOCKERHUB_USERNAME }} + run: | + echo "Pre-pulling Docker images with authenticated session..." + docker pull postgres:14 + docker pull clickhouse/clickhouse-server:25.4-alpine + docker pull redis:7-alpine + docker pull testcontainers/ryuk:0.11.0 + docker pull electricsql/electric:1.2.4 + echo "Image pre-pull complete" + - name: 📥 Download deps run: pnpm install --frozen-lockfile @@ -111,12 +122,12 @@ jobs: - name: ⎔ Setup pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.5 + version: 10.23.0 - name: ⎔ Setup node uses: buildjet/setup-node@v4 with: - node-version: 20.11.1 + node-version: 20.20.0 # no cache enabled, we're not installing deps - name: Download blob reports from GitHub Actions Artifacts diff --git a/.github/workflows/unit-tests-packages.yml b/.github/workflows/unit-tests-packages.yml index cfa5e88baa2..78474e03f27 100644 --- a/.github/workflows/unit-tests-packages.yml +++ b/.github/workflows/unit-tests-packages.yml @@ -53,12 +53,12 @@ jobs: - name: ⎔ Setup pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.5 + version: 10.23.0 - name: ⎔ Setup node uses: buildjet/setup-node@v4 with: - node-version: 20.11.1 + node-version: 20.20.0 cache: "pnpm" # ..to avoid rate limits when pulling images @@ -72,6 +72,17 @@ jobs: if: ${{ !env.DOCKERHUB_USERNAME }} run: echo "DockerHub login skipped because secrets are not available." + - name: 🐳 Pre-pull testcontainer images + if: ${{ env.DOCKERHUB_USERNAME }} + run: | + echo "Pre-pulling Docker images with authenticated session..." + docker pull postgres:14 + docker pull clickhouse/clickhouse-server:25.4-alpine + docker pull redis:7-alpine + docker pull testcontainers/ryuk:0.11.0 + docker pull electricsql/electric:1.2.4 + echo "Image pre-pull complete" + - name: 📥 Download deps run: pnpm install --frozen-lockfile @@ -111,12 +122,12 @@ jobs: - name: ⎔ Setup pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.5 + version: 10.23.0 - name: ⎔ Setup node uses: buildjet/setup-node@v4 with: - node-version: 20.11.1 + node-version: 20.20.0 # no cache enabled, we're not installing deps - name: Download blob reports from GitHub Actions Artifacts diff --git a/.github/workflows/unit-tests-webapp.yml b/.github/workflows/unit-tests-webapp.yml index 26599f43312..523a1887db8 100644 --- a/.github/workflows/unit-tests-webapp.yml +++ b/.github/workflows/unit-tests-webapp.yml @@ -12,8 +12,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - shardTotal: [10] + shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] + shardTotal: [8] env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} SHARD_INDEX: ${{ matrix.shardIndex }} @@ -53,12 +53,12 @@ jobs: - name: ⎔ Setup pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.5 + version: 10.23.0 - name: ⎔ Setup node uses: buildjet/setup-node@v4 with: - node-version: 20.11.1 + node-version: 20.20.0 cache: "pnpm" # ..to avoid rate limits when pulling images @@ -72,6 +72,17 @@ jobs: if: ${{ !env.DOCKERHUB_USERNAME }} run: echo "DockerHub login skipped because secrets are not available." + - name: 🐳 Pre-pull testcontainer images + if: ${{ env.DOCKERHUB_USERNAME }} + run: | + echo "Pre-pulling Docker images with authenticated session..." + docker pull postgres:14 + docker pull clickhouse/clickhouse-server:25.4-alpine + docker pull redis:7-alpine + docker pull testcontainers/ryuk:0.11.0 + docker pull electricsql/electric:1.2.4 + echo "Image pre-pull complete" + - name: 📥 Download deps run: pnpm install --frozen-lockfile @@ -85,7 +96,7 @@ jobs: DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres SESSION_SECRET: "secret" MAGIC_LINK_SECRET: "secret" - ENCRYPTION_KEY: "secret" + ENCRYPTION_KEY: "dummy-encryption-keeeey-32-bytes" DEPLOY_REGISTRY_HOST: "docker.io" CLICKHOUSE_URL: "http://default:password@localhost:8123" @@ -119,12 +130,12 @@ jobs: - name: ⎔ Setup pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.5 + version: 10.23.0 - name: ⎔ Setup node uses: buildjet/setup-node@v4 with: - node-version: 20.11.1 + node-version: 20.20.0 # no cache enabled, we're not installing deps - name: Download blob reports from GitHub Actions Artifacts diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml new file mode 100644 index 00000000000..a2f4c6d1b6b --- /dev/null +++ b/.github/workflows/vouch-check-pr.yml @@ -0,0 +1,23 @@ +name: Vouch - Check PR + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + check-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: mitchellh/vouch/action/check-pr@main + with: + pr-number: ${{ github.event.pull_request.number }} + auto-close: true + require-vouch: true + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml new file mode 100644 index 00000000000..36de055752f --- /dev/null +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -0,0 +1,25 @@ +name: Vouch - Manage by Issue + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + +jobs: + manage: + runs-on: ubuntu-latest + if: >- + contains(github.event.comment.body, 'vouch') || + contains(github.event.comment.body, 'denounce') || + contains(github.event.comment.body, 'unvouch') + steps: + - uses: actions/checkout@v4 + - uses: mitchellh/vouch/action/manage-by-issue@main + with: + comment-id: ${{ github.event.comment.id }} + issue-id: ${{ github.event.issue.number }} + env: + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index 9bee46fc278..071b9b59035 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ out/ dist packages/**/dist +# vendored bundles (generated during build) +packages/**/src/**/vendor + # Tailwind apps/**/styles/tailwind.css packages/**/styles/tailwind.css @@ -29,12 +32,10 @@ yarn-debug.log* yarn-error.log* # local env files -.env.docker +.env +.env.* .docker/*.env -.env.local -.env.development.local -.env.test.local -.env.production.local +!.env.example # turbo .turbo @@ -63,4 +64,7 @@ apps/**/public/build /packages/core/src/package.json /packages/trigger-sdk/src/package.json /packages/python/src/package.json -.claude \ No newline at end of file +**/.claude/settings.local.json +.mcp.log +.mcp.json +.cursor/debug.log \ No newline at end of file diff --git a/.npmrc b/.npmrc deleted file mode 100644 index c83a08985cd..00000000000 --- a/.npmrc +++ /dev/null @@ -1,5 +0,0 @@ -link-workspace-packages=false -public-hoist-pattern[]=*prisma* -prefer-workspace-packages=true -update-notifier=false -side-effects-cache=false \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 2efc7e111f7..7c663e0a0bd 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.11.1 \ No newline at end of file +v20.20.0 \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 6d08392086c..71a76904a2b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -31,6 +31,15 @@ "cwd": "${workspaceFolder}/apps/webapp", "sourceMaps": true }, + { + "type": "node-terminal", + "request": "launch", + "name": "Debug opened test file", + "command": "pnpm run test -- ./${relativeFile}", + "envFile": "${workspaceFolder}/.env", + "cwd": "${workspaceFolder}", + "sourceMaps": true + }, { "type": "chrome", "request": "launch", @@ -59,7 +68,7 @@ "request": "launch", "name": "Debug V3 Dev CLI", "command": "pnpm exec trigger dev", - "cwd": "${workspaceFolder}/references/v3-catalog", + "cwd": "${workspaceFolder}/references/hello-world", "sourceMaps": true }, { @@ -83,7 +92,7 @@ "request": "launch", "name": "Debug V3 Deploy CLI", "command": "pnpm exec trigger deploy --self-hosted --load-image", - "cwd": "${workspaceFolder}/references/v3-catalog", + "cwd": "${workspaceFolder}/references/hello-world", "sourceMaps": true }, { @@ -91,7 +100,7 @@ "request": "launch", "name": "Debug V3 list-profiles CLI", "command": "pnpm exec trigger list-profiles --log-level debug", - "cwd": "${workspaceFolder}/references/v3-catalog", + "cwd": "${workspaceFolder}/references/hello-world", "sourceMaps": true }, { @@ -99,7 +108,7 @@ "request": "launch", "name": "Debug V3 update CLI", "command": "pnpm exec trigger update", - "cwd": "${workspaceFolder}/references/v3-catalog", + "cwd": "${workspaceFolder}/references/hello-world", "sourceMaps": true }, { @@ -107,7 +116,7 @@ "request": "launch", "name": "Debug V3 Management", "command": "pnpm run management", - "cwd": "${workspaceFolder}/references/v3-catalog", + "cwd": "${workspaceFolder}/references/hello-world", "sourceMaps": true }, { diff --git a/.vscode/settings.json b/.vscode/settings.json index f8a7bd06977..fd9f3dcde0c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,6 @@ "**/node_modules/**": true, "packages/cli-v3/e2e": true }, - "vitest.disableWorkspaceWarning": true + "vitest.disableWorkspaceWarning": true, + "chat.agent.maxRequests": 10000 } diff --git a/AGENTS.md b/AGENTS.md index 11e926fe948..99496f91bde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ This repository is a pnpm monorepo managed with Turbo. It contains multiple apps See `ai/references/repo.md` for a more complete explanation of the workspaces. ## Development setup -1. Install dependencies with `pnpm i` (pnpm `8.15.5` and Node.js `20.11.1` are required). +1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.20.0` are required). 2. Copy `.env.example` to `.env` and generate a random 16 byte hex string for `ENCRYPTION_KEY` (`openssl rand -hex 16`). Update other secrets if needed. 3. Start the local services with Docker: ```bash diff --git a/CHANGESETS.md b/CHANGESETS.md index cf660076617..722fe64eb4c 100644 --- a/CHANGESETS.md +++ b/CHANGESETS.md @@ -30,14 +30,16 @@ Please follow the best-practice of adding changesets in the same commit as the c ## Snapshot instructions -1. Delete the `.changeset/pre.json` file (if it exists) +1. Update the `.changeset/config.json` file to set the `"changelog"` field to this: -2. Do a temporary commit (do NOT push this, you should undo it after) +```json +"changelog": "@changesets/cli/changelog", +``` -3. Copy the `GITHUB_TOKEN` line from the .env file +2. Do a temporary commit (do NOT push this, you should undo it after) -4. Run `GITHUB_TOKEN=github_pat_12345 ./scripts/publish-prerelease.sh re2` +3. Run `./scripts/publish-prerelease.sh prerelease` -Make sure to replace the token with yours. `re2` is the tag that will be used for the pre-release. +You can choose a different tag if you want, but usually `prerelease` is fine. -5. Undo the commit where you deleted the pre.json file. +5. Undo the commit where you updated the config.json file. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..acc59359707 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,303 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and Development Commands + +This is a pnpm 10.23.0 monorepo using Turborepo. Run commands from root with `pnpm run`. + +### Essential Commands + +```bash +# Start Docker services (PostgreSQL, Redis, Electric) +pnpm run docker + +# Run database migrations +pnpm run db:migrate + +# Seed the database (required for reference projects) +pnpm run db:seed + +# Build packages (required before running) +pnpm run build --filter webapp && pnpm run build --filter trigger.dev && pnpm run build --filter @trigger.dev/sdk + +# Run webapp in development mode (http://localhost:3030) +pnpm run dev --filter webapp + +# Build and watch for changes (CLI and packages) +pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" +``` + +### Testing + +We use vitest exclusively. **Never mock anything** - use testcontainers instead. + +```bash +# Run all tests for a package +pnpm run test --filter webapp + +# Run a single test file (preferred - cd into directory first) +cd internal-packages/run-engine +pnpm run test ./src/engine/tests/ttl.test.ts --run + +# May need to build dependencies first +pnpm run build --filter @internal/run-engine +``` + +Test files go next to source files (e.g., `MyService.ts` → `MyService.test.ts`). + +#### Testcontainers for Redis/PostgreSQL + +```typescript +import { redisTest, postgresTest, containerTest } from "@internal/testcontainers"; + +// Redis only +redisTest("should use redis", async ({ redisOptions }) => { + /* ... */ +}); + +// PostgreSQL only +postgresTest("should use postgres", async ({ prisma }) => { + /* ... */ +}); + +// Both Redis and PostgreSQL +containerTest("should use both", async ({ prisma, redisOptions }) => { + /* ... */ +}); +``` + +### Changesets + +When modifying any public package (`packages/*` or `integrations/*`), add a changeset: + +```bash +pnpm run changeset:add +``` + +- Default to **patch** for bug fixes and minor changes +- Confirm with maintainers before selecting **minor** (new features) +- **Never** select major (breaking changes) without explicit approval + +## Architecture Overview + +### Apps + +- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, and Docker image. Uses Express server. +- **apps/supervisor**: Node.js app handling task execution, interfacing with Docker/Kubernetes. + +### Public Packages + +- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK +- **packages/cli-v3** (`trigger.dev`): CLI package +- **packages/core** (`@trigger.dev/core`): Shared code between SDK and webapp. Import subpaths only (never root). +- **packages/build**: Build extensions and types +- **packages/react-hooks**: React hooks for realtime and triggering +- **packages/redis-worker** (`@trigger.dev/redis-worker`): Custom Redis-based background job system + +### Internal Packages + +- **internal-packages/database** (`@trigger.dev/database`): Prisma 6.14.0 client and schema +- **internal-packages/clickhouse** (`@internal/clickhouse`): ClickHouse client and schema migrations +- **internal-packages/run-engine** (`@internal/run-engine`): "Run Engine 2.0" - run lifecycle management +- **internal-packages/redis** (`@internal/redis`): Redis client creation utilities +- **internal-packages/testcontainers** (`@internal/testcontainers`): Test helpers for Redis/PostgreSQL containers +- **internal-packages/zodworker** (`@internal/zodworker`): Graphile-worker wrapper (being replaced by redis-worker) + +### Reference Projects + +The `references/` directory contains test workspaces for developing and testing new SDK and platform features. Use these projects (e.g., `references/hello-world`) to manually test changes to the CLI, SDK, core packages, and webapp before submitting PRs. + +## Webapp Development + +### Key Locations + +- Trigger API: `apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts` +- Batch trigger: `apps/webapp/app/routes/api.v1.tasks.batch.ts` +- Prisma setup: `apps/webapp/app/db.server.ts` +- Run engine config: `apps/webapp/app/v3/runEngine.server.ts` +- Services: `apps/webapp/app/v3/services/**/*.server.ts` +- Presenters: `apps/webapp/app/v3/presenters/**/*.server.ts` +- OTEL endpoints: `apps/webapp/app/routes/otel.v1.logs.ts`, `otel.v1.traces.ts` + +### Environment Variables + +Access via `env` export from `apps/webapp/app/env.server.ts`, never `process.env` directly. + +For testable code, **never import env.server.ts** in test files. Pass configuration as options instead. Example pattern: + +- `realtimeClient.server.ts` (testable service) +- `realtimeClientGlobal.server.ts` (configuration) + +### Legacy vs Run Engine 2.0 + +The codebase is transitioning from the "legacy run engine" (spread across codebase) to "Run Engine 2.0" (`@internal/run-engine`). Focus on Run Engine 2.0 for new work. + +## Docker Image Guidelines + +When updating Docker image references in `docker/Dockerfile` or other container files: + +- **Always use multiplatform/index digests**, not architecture-specific digests +- Architecture-specific digests (e.g., for `linux/amd64` only) will cause CI failures on different build environments +- On Docker Hub, the multiplatform digest is shown on the main image page, while architecture-specific digests are listed under "OS/ARCH" +- Example: Use `node:20.20-bullseye-slim@sha256:abc123...` where the digest is from the multiplatform index, not from a specific OS/ARCH variant + +## Database Migrations (PostgreSQL) + +1. Edit `internal-packages/database/prisma/schema.prisma` +2. Create migration: + ```bash + cd internal-packages/database + pnpm run db:migrate:dev:create --name "add_new_column" + ``` +3. **Important**: Generated migration includes extraneous changes. Remove lines related to: + - `_BackgroundWorkerToBackgroundWorkerFile` + - `_BackgroundWorkerToTaskQueue` + - `_TaskRunToTaskRunTag` + - `_WaitpointRunConnections` + - `_completedWaitpoints` + - `SecretStore_key_idx` + - Various `TaskRun` indexes unless you added them +4. Apply migration: + ```bash + pnpm run db:migrate:deploy && pnpm run generate + ``` + +### Index Migration Rules + +- Indexes **must use CONCURRENTLY** to avoid table locks +- **CONCURRENTLY indexes must be in their own separate migration file** - they cannot be combined with other schema changes + +## ClickHouse Migrations + +ClickHouse migrations use Goose format and live in `internal-packages/clickhouse/schema/`. + +1. Create a new numbered SQL file (e.g., `010_add_new_column.sql`) +2. Use Goose markers: + + ```sql + -- +goose Up + ALTER TABLE trigger_dev.your_table + ADD COLUMN new_column String DEFAULT ''; + + -- +goose Down + ALTER TABLE trigger_dev.your_table + DROP COLUMN new_column; + ``` + +Follow naming conventions in `internal-packages/clickhouse/README.md`: + +- `raw_` prefix for input tables +- `_v1`, `_v2` suffixes for versioning +- `_mv_v1` suffix for materialized views + +## Writing Trigger.dev Tasks + +Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern. + +```typescript +import { task } from "@trigger.dev/sdk"; + +// Every task must be exported +export const myTask = task({ + id: "my-task", // Unique ID + run: async (payload: { message: string }) => { + // Task logic - no timeouts + }, +}); +``` + +### SDK Documentation Rules + +The `rules/` directory contains versioned documentation for writing Trigger.dev tasks, distributed to users via the SDK installer. Current version is defined in `rules/manifest.json`. + +- `rules/4.3.0/` - Latest: batch trigger v2 (1,000 items, 3MB payloads), debouncing +- `rules/4.1.0/` - Realtime streams v2, updated config +- `rules/4.0.0/` - Base v4 SDK documentation + +When adding new SDK features, create a new version directory with only the files that changed from the previous version. Update `manifest.json` to point unchanged files to previous versions. + +### Claude Code Skill + +The `.claude/skills/trigger-dev-tasks/` skill provides Claude Code with Trigger.dev task expertise. It includes: + +- `SKILL.md` - Core instructions and patterns +- Reference files for basic tasks, advanced tasks, scheduled tasks, realtime, and config + +Keep the skill in sync with the latest rules version when SDK features change. + +## Testing with hello-world Reference Project + +First-time setup: + +1. Run `pnpm run db:seed` to seed the database (creates the hello-world project) +2. Build CLI: `pnpm run build --filter trigger.dev && pnpm i` +3. Authorize CLI: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030` + +Running: + +```bash +cd references/hello-world +pnpm exec trigger dev # or with --log-level debug +``` + +## Local Task Testing Workflow + +This workflow enables Claude Code to run the webapp and trigger dev simultaneously, trigger tasks, and inspect results for testing code changes. + +### Step 1: Start Webapp in Background + +```bash +# Run from repo root with run_in_background: true +pnpm run dev --filter webapp +``` + +Verify webapp is running: + +```bash +curl -s http://localhost:3030/healthcheck # Should return 200 +``` + +### Step 2: Start Trigger Dev in Background + +```bash +# Run from hello-world directory with run_in_background: true +cd references/hello-world && pnpm exec trigger dev +``` + +The worker will build and register tasks. Check output for "Local worker ready [node]" message. + +### Step 3: Trigger and Monitor Tasks via MCP + +Use the Trigger.dev MCP tools to interact with tasks: + +``` +# Get current worker and registered tasks +mcp__trigger__get_current_worker(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev") + +# Trigger a task +mcp__trigger__trigger_task( + projectRef: "proj_rrkpdguyagvsoktglnod", + environment: "dev", + taskId: "hello-world", + payload: {"message": "Hello from Claude"} +) + +# List runs to see status +mcp__trigger__list_runs( + projectRef: "proj_rrkpdguyagvsoktglnod", + environment: "dev", + taskIdentifier: "hello-world", + limit: 5 +) +``` + +### Step 4: Monitor Execution + +- Check trigger dev output file for real-time execution logs +- Successful runs show: `Task | Run ID | Success (Xms)` +- Dashboard available at: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs + +### Key Project Refs + +- hello-world: `proj_rrkpdguyagvsoktglnod` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 709dcca47cc..b4b280bda06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,10 +2,25 @@ Thank you for taking the time to contribute to Trigger.dev. Your involvement is not just welcomed, but we encourage it! 🚀 -Please take some time to read this guide to understand contributing best practices for Trigger.dev. +Please take some time to read this guide to understand contributing best practices for Trigger.dev. Note that we use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust, so you'll need to be vouched before opening a PR. Thank you for helping us make Trigger.dev even better! 🤩 +> **Important:** We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features. If you have multiple contributions, open a separate PR for each one. + +## Getting vouched (required before opening a PR) + +We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. **PRs from unvouched users are automatically closed.** + +Before you open your first pull request, you need to be vouched by a maintainer. Here's how: + +1. Open a [Vouch Request](https://github.com/triggerdotdev/trigger.dev/issues/new?template=vouch-request.yml) issue. +2. Tell us what you'd like to work on and share any relevant background. +3. A maintainer will review your request and vouch for you by commenting on the issue. +4. Once vouched, your PRs will be accepted normally. + +If you're unsure whether you're already vouched, go ahead and open a PR — the check will tell you. + ## Developing The development branch is `main`. This is the branch that all pull @@ -14,8 +29,8 @@ branch are tagged into a release periodically. ### Prerequisites -- [Node.js](https://nodejs.org/en) version 20.11.1 -- [pnpm package manager](https://pnpm.io/installation) version 8.15.5 +- [Node.js](https://nodejs.org/en) version 20.20.0 +- [pnpm package manager](https://pnpm.io/installation) version 10.23.0 - [Docker](https://www.docker.com/get-started/) - [protobuf](https://github.com/protocolbuffers/protobuf) @@ -34,9 +49,9 @@ branch are tagged into a release periodically. ``` cd trigger.dev ``` -3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository. +3. Ensure you are on the correct version of Node.js (20.20.0). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository. -4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file. +4. Run `corepack enable` to use the correct version of pnpm (`10.23.0`) as specified in the root `package.json` file. 5. Install the required packages using pnpm. ``` @@ -84,17 +99,17 @@ branch are tagged into a release periodically. 2. Once the app is running click the magic link button and enter your email. You will automatically be logged in, since you are running locally. Create an Org and your first project in the dashboard. -## Manual testing using v3-catalog +## Manual testing using hello-world -We use the `/references/v3-catalog` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `packages/core`), the CLI (`trigger.dev` at `/packages/cli-v3`) and the platform (The remix app at `/apps/webapp`). The instructions below will get you started on using the `v3-catalog` for local development of Trigger.dev (v3). +We use the `/references/hello-world` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `packages/core`), the CLI (`trigger.dev` at `/packages/cli-v3`) and the platform (The remix app at `/apps/webapp`). The instructions below will get you started on using the `hello-world` for local development of Trigger.dev. ### First-time setup First, make sure you are running the webapp according to the instructions above. Then: -1. Visit http://localhost:3030 in your browser and create a new V3 project called "v3-catalog". +1. Visit http://localhost:3030 in your browser and create a new project called "hello-world". -2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `yubjwjsfkxnylobaqvqz`. +2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `proj_rrkpdguyagvsoktglnod`. 3. Build the CLI @@ -105,10 +120,10 @@ pnpm run build --filter trigger.dev pnpm i ``` -4. Change into the `/references/v3-catalog` directory and authorize the CLI to the local server: +4. Change into the `/references/hello-world` directory and authorize the CLI to the local server: ```sh -cd references/v3-catalog +cd references/hello-world cp .env.example .env pnpm exec trigger login -a http://localhost:3030 ``` @@ -118,7 +133,7 @@ This will open a new browser window and authorize the CLI against your local use You can optionally pass a `--profile` flag to the `login` command, which will allow you to use the CLI with separate accounts/servers. We suggest using a profile called `local` for your local development: ```sh -cd references/v3-catalog +cd references/hello-world pnpm exec trigger login -a http://localhost:3030 --profile local # later when you run the dev or deploy command: pnpm exec trigger dev --profile local @@ -127,7 +142,7 @@ pnpm exec trigger deploy --profile local ### Running -The following steps should be followed any time you start working on a new feature you want to test in v3: +The following steps should be followed any time you start working on a new feature you want to test: 1. Make sure the webapp is running on localhost:3030 @@ -137,84 +152,29 @@ The following steps should be followed any time you start working on a new featu pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" ``` -3. Open another terminal window, and change into the `/references/v3-catalog` directory. - -4. You'll need to run the following commands to setup prisma and migrate the database: - -```sh -pnpm exec prisma migrate deploy -pnpm run generate:prisma -``` +3. Open another terminal window, and change into the `/references/hello-world` directory. -5. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution: +4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution: ```sh -# in /references/v3-catalog +# in /references/hello-world pnpm exec trigger dev ``` If you want additional debug logging, you can use the `--log-level debug` flag: ```sh -# in /references/v3-catalog +# in /references/hello-world pnpm exec trigger dev --log-level debug ``` -6. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `v3-catalog/src/trigger` dir will automatically be rebuilt by the `dev` command. - -7. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks. - -8. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/v3-catalog/src/trigger` folder. Many of them accept an empty payload. - -9. Feel free to add additional files in `v3-catalog/src/trigger` to test out specific aspects of the system, or add in edge cases. - -## Running end-to-end webapp tests (deprecated) +6. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `hello-world/src/trigger` dir will automatically be rebuilt by the `dev` command. -To run the end-to-end tests, follow the steps below: - -1. Set up environment variables (copy example envs into the correct place) - -```sh -cp ./.env.example ./.env -cp ./references/nextjs-test/.env.example ./references/nextjs-test/.env.local -``` +7. Navigate to the `hello-world` project in your local dashboard at localhost:3030 and you should see the list of tasks. -2. Set up dependencies +8. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/hello-world/src/trigger` folder. Many of them accept an empty payload. -```sh -# Build packages -pnpm run build --filter @references/nextjs-test^... -pnpm --filter @trigger.dev/database generate - -# Move trigger-cli bin to correct place -pnpm install --frozen-lockfile - -# Install playwrite browsers (ONE TIME ONLY) -npx playwright install -``` - -3. Set up the database - -```sh -pnpm run docker -pnpm run db:migrate -pnpm run db:seed -``` - -4. Run the end-to-end tests - -```sh -pnpm run test:e2e -``` - -### Cleanup - -The end-to-end tests use a `setup` and `teardown` script to seed the database with test data. If the test runner doesn't exit cleanly, then the database can be left in a state where the tests can't run because the `setup` script will try to create data that already exists. If this happens, you can manually delete the `users` and `organizations` from the database using prisma studio: - -```sh -# With the database running (i.e. pnpm run docker) -pnpm run db:studio -``` +9. Feel free to add additional files in `hello-world/src/trigger` to test out specific aspects of the system, or add in edge cases. ## Adding and running migrations diff --git a/README.md b/README.md index dab0551dc00..0d7f1ca2930 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,76 @@
- - - - Trigger.dev logo - - -### Open source background jobs and AI infrastructure -[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs) +![Trigger.dev logo](https://content.trigger.dev/github-header-banner.jpg) -[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/triggerdotdev.svg?style=social&label=Follow%20%40trigger.dev)](https://twitter.com/triggerdotdev) +### Build and deploy fully‑managed AI agents and workflows + +[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Example projects](https://github.com/triggerdotdev/examples) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview) + +[![Open Source](https://img.shields.io/badge/Open%20Source-%E2%9D%A4-red.svg)](https://github.com/triggerdotdev/trigger.dev) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE) +[![npm](https://img.shields.io/npm/v/@trigger.dev/sdk.svg?label=npm)](https://www.npmjs.com/package/@trigger.dev/sdk) +[![SDK downloads](https://img.shields.io/npm/dm/@trigger.dev/sdk.svg?label=SDK%20downloads)](https://www.npmjs.com/package/@trigger.dev/sdk) + +[![Twitter Follow](https://img.shields.io/twitter/follow/triggerdotdev?style=social)](https://twitter.com/triggerdotdev) +[![Discord](https://img.shields.io/discord/1066956501299777596?logo=discord&logoColor=white&color=7289da)](https://discord.gg/nkqV9xBYWy) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/triggerdotdev/trigger.dev) +[![GitHub stars](https://img.shields.io/github/stars/triggerdotdev/trigger.dev?style=social)](https://github.com/triggerdotdev/trigger.dev)
## About Trigger.dev -Trigger.dev is an open source platform and SDK which allows you to create long-running background jobs. Write normal async code, deploy, and never hit a timeout. +Trigger.dev is the open-source platform for building AI workflows in TypeScript. Long-running tasks with retries, queues, observability, and elastic scaling. + +## The platform designed for building AI agents + +Build [AI agents](https://trigger.dev/product/ai-agents) using all the frameworks, services and LLMs you're used to, deploy them to Trigger.dev and get durable, long-running tasks with retries, queues, observability, and elastic scaling out of the box. + +- **Long-running without timeouts**: Execute your tasks with absolutely no timeouts, unlike AWS Lambda, Vercel, and other serverless platforms. + +- **Durability, retries & queues**: Build rock solid agents and AI applications using our durable tasks, retries, queues and idempotency. -### Key features: +- **True runtime freedom**: Customize your deployed tasks with system packages – run browsers, Python scripts, FFmpeg and more. -- JavaScript and TypeScript SDK -- No timeouts -- Retries (with exponential backoff) -- Queues and concurrency controls -- Schedules and crons -- Full Observability; logs, live trace views, advanced filtering -- React hooks to interact with the Trigger API from your React app -- Pipe LLM streams straight to your users through the Realtime API -- Trigger tasks and display the run status and metadata anywhere in your app -- Custom alerts, get notified by email, Slack or webhooks -- No infrastructure to manage -- Elastic (scaling) -- Works with your existing tech stack +- **Human-in-the-loop**: Programmatically pause your tasks until a human can approve, reject or give feedback. -## In your codebase +- **Realtime apps & streaming**: Move your background jobs to the foreground by subscribing to runs or streaming AI responses to your app. + +- **Observability & monitoring**: Each run has full tracing and logs. Configure error alerts to catch bugs fast. + +## Key features: + +- **[JavaScript and TypeScript SDK](https://trigger.dev/docs/tasks/overview)** - Build background tasks using familiar programming models +- **[Long-running tasks](https://trigger.dev/docs/runs/max-duration)** - Handle resource-heavy tasks without timeouts +- **[Durable cron schedules](https://trigger.dev/docs/tasks/scheduled#scheduled-tasks-cron)** - Create and attach recurring schedules of up to a year +- **[Trigger.dev Realtime](https://trigger.dev/docs/realtime/overview)** - Trigger, subscribe to, and get real-time updates for runs, with LLM streaming support +- **[Build extensions](https://trigger.dev/docs/config/extensions/overview#build-extensions)** - Hook directly into the build system and customize the build process. Run Python scripts, FFmpeg, browsers, and more. +- **[React hooks](https://trigger.dev/docs/frontend/react-hooks#react-hooks)** - Interact with the Trigger.dev API on your frontend using our React hooks package +- **[Batch triggering](https://trigger.dev/docs/triggering#tasks-batchtrigger)** - Use batchTrigger() to initiate multiple runs of a task with custom payloads and options +- **[Structured inputs / outputs](https://trigger.dev/docs/tasks/schemaTask#schematask)** - Define precise data schemas for your tasks with runtime payload validation +- **[Waits](https://trigger.dev/docs/wait)** - Add waits to your tasks to pause execution for a specified duration +- **[Preview branches](https://trigger.dev/docs/deployment/preview-branches)** - Create isolated environments for testing and development. Integrates with Vercel and git workflows +- **[Waitpoints](https://trigger.dev/docs/wait-for-token#wait-for-token)** - Add human-in-the-loop judgment at critical decision points without disrupting workflow +- **[Concurrency & queues](https://trigger.dev/docs/queue-concurrency#concurrency-and-queues)** - Set concurrency rules to manage how multiple tasks execute +- **[Multiple environments](https://trigger.dev/docs/how-it-works#dev-mode)** - Support for DEV, PREVIEW, STAGING, and PROD environments +- **[No infrastructure to manage](https://trigger.dev/docs/how-it-works#trigger-dev-architecture)** - Auto-scaling infrastructure that eliminates timeouts and server management +- **[Automatic retries](https://trigger.dev/docs/errors-retrying)** - If your task encounters an uncaught error, we automatically attempt to run it again +- **[Checkpointing](https://trigger.dev/docs/how-it-works#the-checkpoint-resume-system)** - Tasks are inherently durable, thanks to our checkpointing feature +- **[Versioning](https://trigger.dev/docs/versioning)** - Atomic versioning allows you to deploy new versions without affecting running tasks +- **[Machines](https://trigger.dev/docs/machines)** - Configure the number of vCPUs and GBs of RAM you want the task to use +- **[Observability & monitoring](https://trigger.dev/product/observability-and-monitoring)** - Monitor every aspect of your tasks' performance with comprehensive logging and visualization tools +- **[Logging & tracing](https://trigger.dev/docs/logging)** - Comprehensive logging and tracing for all your tasks +- **[Tags](https://trigger.dev/docs/tags#tags)** - Attach up to ten tags to each run, allowing you to filter via the dashboard, realtime, and the SDK +- **[Run metadata](https://trigger.dev/docs/runs/metadata#run-metadata)** - Attach metadata to runs which updates as the run progresses and is available to use in your frontend for live updates +- **[Bulk actions](https://trigger.dev/docs/bulk-actions)** - Perform actions on multiple runs simultaneously, including replaying and cancelling +- **[Real-time alerts](https://trigger.dev/docs/troubleshooting-alerts#alerts)** - Choose your preferred notification method for run failures and deployments + +## Write tasks in your codebase Create tasks where they belong: in your codebase. Version control, localhost, test and review like you're already used to. ```ts -import { task } from "@trigger.dev/sdk/v3"; +import { task } from "@trigger.dev/sdk"; //1. You need to export each task export const helloWorld = task({ @@ -58,13 +90,13 @@ Use our SDK to write tasks in your codebase. There's no infrastructure to manage ## Environments -We support `Development`, `Staging`, and `Production` environments, allowing you to test your tasks before deploying them to production. +We support `Development`, `Staging`, `Preview`, and `Production` environments, allowing you to test your tasks before deploying them to production. ## Full visibility of every job run View every task in every run so you can tell exactly what happened. We provide a full trace view of every task run so you can see what happened at every step. -![Trace view image](https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/7c1b347f-004c-4482-38a7-3f6fa9c00d00/public) +![Trace view image](https://content.trigger.dev/trace-view.png) # Getting started @@ -73,14 +105,19 @@ The quickest way to get started is to create an account and project in our [web ### Useful links: - [Quick start](https://trigger.dev/docs/quick-start) - get up and running in minutes -- [How it works](https://trigger.dev/docs/v3/how-it-works) - understand how Trigger.dev works under the hood +- [How it works](https://trigger.dev/docs/how-it-works) - understand how Trigger.dev works under the hood - [Guides and examples](https://trigger.dev/docs/guides/introduction) - walk-through guides and code examples for popular frameworks and use cases ## Self-hosting -If you prefer to self-host Trigger.dev, you can follow our [self-hosting guide](https://trigger.dev/docs/v3/open-source-self-hosting#overview). +If you prefer to self-host Trigger.dev, you can follow our [self-hosting guides](https://trigger.dev/docs/self-hosting/overview): + +- [Docker self-hosting guide](https://trigger.dev/docs/self-hosting/docker) - use Docker Compose to spin up a Trigger.dev instance +- [Kubernetes self-hosting guide](https://trigger.dev/docs/self-hosting/kubernetes) - use our official Helm chart to deploy Trigger.dev to your Kubernetes cluster + +## Support and community -We also have a dedicated self-hosting channel in our [Discord server](https://trigger.dev/discord) for support. +We have a large active community in our official [Discord server](https://trigger.dev/discord) for support, including a dedicated channel for self-hosting. ## Development diff --git a/ai/references/migrations.md b/ai/references/migrations.md new file mode 100644 index 00000000000..c6fbf79e9d7 --- /dev/null +++ b/ai/references/migrations.md @@ -0,0 +1,121 @@ +## Creating and applying migrations + +We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file: + +Edit the `schema.prisma` file to add or modify the schema. + +Create a new migration file but don't apply it yet: + +```bash +cd internal-packages/database +pnpm run db:migrate:dev:create --name "add_new_column_to_table" +``` + +The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like: + +```sql +-- AlterEnum +ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED'; + +-- AlterTable +ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_WaitpointRunConnections_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_completedWaitpoints_AB_unique"; + +-- CreateIndex +CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC); +``` + +All the following lines should be removed: + +```sql +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_WaitpointRunConnections_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_completedWaitpoints_AB_unique"; + +-- CreateIndex +CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC); +``` + +Leaving only this: + +```sql +-- AlterEnum +ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED'; + +-- AlterTable +ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB; +``` + +After editing the migration file, apply the migration: + +```bash +cd internal-packages/database +pnpm run db:migrate:deploy && pnpm run generate +``` diff --git a/ai/references/repo.md b/ai/references/repo.md index 0e9b49b4602..4f67bde2b4b 100644 --- a/ai/references/repo.md +++ b/ai/references/repo.md @@ -1,6 +1,6 @@ ## Repo Overview -This is a pnpm 8.15.5 monorepo that uses turborepo @turbo.json. The following workspaces are relevant +This is a pnpm 10.23.0 monorepo that uses turborepo @turbo.json. The following workspaces are relevant ## Apps diff --git a/apps/coordinator/Containerfile b/apps/coordinator/Containerfile index 4e7b89e0af1..9e973675ab9 100644 --- a/apps/coordinator/Containerfile +++ b/apps/coordinator/Containerfile @@ -35,7 +35,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ . COPY --from=dev-deps --chown=node:node /app/ . COPY --chown=node:node turbo.json turbo.json -RUN pnpm run -r --filter coordinator build:bundle +RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter coordinator build:bundle FROM alpine AS cri-tools diff --git a/apps/docker-provider/Containerfile b/apps/docker-provider/Containerfile index bea730bda80..42a7ac23092 100644 --- a/apps/docker-provider/Containerfile +++ b/apps/docker-provider/Containerfile @@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ . COPY --from=dev-deps --chown=node:node /app/ . COPY --chown=node:node turbo.json turbo.json -RUN pnpm run -r --filter docker-provider build:bundle +RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter docker-provider build:bundle FROM base AS runner diff --git a/apps/kubernetes-provider/Containerfile b/apps/kubernetes-provider/Containerfile index fb96304c26b..b46b9943275 100644 --- a/apps/kubernetes-provider/Containerfile +++ b/apps/kubernetes-provider/Containerfile @@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ . COPY --from=dev-deps --chown=node:node /app/ . COPY --chown=node:node turbo.json turbo.json -RUN pnpm run -r --filter kubernetes-provider build:bundle +RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter kubernetes-provider build:bundle FROM base AS runner diff --git a/apps/supervisor/Containerfile b/apps/supervisor/Containerfile index 45a7bbcae5a..fc620a1d437 100644 --- a/apps/supervisor/Containerfile +++ b/apps/supervisor/Containerfile @@ -5,7 +5,7 @@ WORKDIR /app FROM node-22-alpine AS pruner COPY --chown=node:node . . -RUN npx -q turbo@1.10.9 prune --scope=supervisor --docker +RUN npx -q turbo@2.5.4 prune --scope=supervisor --docker FROM node-22-alpine AS base @@ -16,7 +16,7 @@ COPY --from=pruner --chown=node:node /app/out/json/ . COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml -RUN corepack enable && corepack prepare --activate +RUN corepack enable && corepack prepare pnpm@10.23.0 --activate FROM base AS deps-fetcher RUN apk add --no-cache python3-dev py3-setuptools make g++ gcc linux-headers @@ -38,7 +38,7 @@ COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts RUN pnpm run generate && \ pnpm run --filter supervisor... build&& \ - pnpm deploy --filter=supervisor --prod /prod/supervisor + pnpm deploy --legacy --filter=supervisor --prod /prod/supervisor FROM base AS runner diff --git a/apps/supervisor/package.json b/apps/supervisor/package.json index ae365492721..e9609bf1541 100644 --- a/apps/supervisor/package.json +++ b/apps/supervisor/package.json @@ -13,13 +13,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@aws-sdk/client-ecr": "^3.839.0", "@kubernetes/client-node": "^1.0.0", "@trigger.dev/core": "workspace:*", "dockerode": "^4.0.6", "prom-client": "^15.1.0", "socket.io": "4.7.4", "std-env": "^3.8.0", - "zod": "3.23.8" + "zod": "3.25.76" }, "devDependencies": { "@types/dockerode": "^3.3.33" diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts index 8dd4fb58505..9ccc3f48236 100644 --- a/apps/supervisor/src/env.ts +++ b/apps/supervisor/src/env.ts @@ -15,7 +15,7 @@ const Env = z.object({ OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(), // set on the runners // Workload API settings (coordinator mode) - the workload API is what the run controller connects to - TRIGGER_WORKLOAD_API_ENABLED: BoolEnv.default("true"), + TRIGGER_WORKLOAD_API_ENABLED: BoolEnv.default(true), TRIGGER_WORKLOAD_API_PROTOCOL: z .string() .transform((s) => z.enum(["http", "https"]).parse(s.toLowerCase())) @@ -32,11 +32,19 @@ const Env = z.object({ RUNNER_PRETTY_LOGS: BoolEnv.default(false), // Dequeue settings (provider mode) - TRIGGER_DEQUEUE_ENABLED: BoolEnv.default("true"), + TRIGGER_DEQUEUE_ENABLED: BoolEnv.default(true), TRIGGER_DEQUEUE_INTERVAL_MS: z.coerce.number().int().default(250), TRIGGER_DEQUEUE_IDLE_INTERVAL_MS: z.coerce.number().int().default(1000), - TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(10), - TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(1), + TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(1), + TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT: z.coerce.number().int().default(1), + TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(10), + TRIGGER_DEQUEUE_SCALING_STRATEGY: z.enum(["none", "smooth", "aggressive"]).default("none"), + TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS: z.coerce.number().int().default(5000), // 5 seconds + TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS: z.coerce.number().int().default(30000), // 30 seconds + TRIGGER_DEQUEUE_SCALING_TARGET_RATIO: z.coerce.number().default(1.0), // Target ratio of queue items to consumers (1.0 = 1 item per consumer) + TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA: z.coerce.number().min(0).max(1).default(0.3), // Smooths queue length measurements (0=historical, 1=current) + TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS: z.coerce.number().int().positive().default(1000), // Batch window for metrics processing (ms) + TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR: z.coerce.number().min(0).max(1).default(0.7), // Smooths consumer count changes after EWMA (0=no scaling, 1=immediate) // Optional services TRIGGER_WARM_START_URL: z.string().optional(), @@ -49,7 +57,7 @@ const Env = z.object({ RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(), // Docker settings - DOCKER_API_VERSION: z.string().default("v1.41"), + DOCKER_API_VERSION: z.string().optional(), DOCKER_PLATFORM: z.string().optional(), // e.g. linux/amd64, linux/arm64 DOCKER_STRIP_IMAGE_DIGEST: BoolEnv.default(true), DOCKER_REGISTRY_USERNAME: z.string().optional(), @@ -78,6 +86,42 @@ const Env = z.object({ KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv 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 + + // Per-preset overrides of the global KUBERNETES_CPU_REQUEST_RATIO + KUBERNETES_CPU_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(), + + // Per-preset overrides of the global KUBERNETES_MEMORY_REQUEST_RATIO + KUBERNETES_MEMORY_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(), + + KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB + KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods + KUBERNETES_LARGE_MACHINE_POOL_LABEL: z.string().optional(), // if set, large-* presets affinity for machinepool= + + // Project affinity settings - pods from the same project prefer the same node + KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false), + KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50), + KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z.string().trim().min(1).default("kubernetes.io/hostname"), + + // Placement tags settings + PLACEMENT_TAGS_ENABLED: BoolEnv.default(false), + PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"), // Metrics METRICS_ENABLED: BoolEnv.default(true), diff --git a/apps/supervisor/src/envUtil.ts b/apps/supervisor/src/envUtil.ts index 95d44d6c450..917f984cc37 100644 --- a/apps/supervisor/src/envUtil.ts +++ b/apps/supervisor/src/envUtil.ts @@ -3,7 +3,7 @@ import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLog const logger = new SimpleStructuredLogger("env-util"); -export const BoolEnv = z.preprocess((val) => { +const baseBoolEnv = z.preprocess((val) => { if (typeof val !== "string") { return val; } @@ -11,6 +11,11 @@ export const BoolEnv = z.preprocess((val) => { return ["true", "1"].includes(val.toLowerCase().trim()); }, z.boolean()); +// Create a type-safe version that only accepts boolean defaults +export const BoolEnv = baseBoolEnv as Omit & { + default: (value: boolean) => z.ZodDefault; +}; + export const AdditionalEnvVars = z.preprocess((val) => { if (typeof val !== "string") { return val; diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index 83fe89c1ed0..0e274b30390 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -128,7 +128,18 @@ class ManagedSupervisor { dequeueIdleIntervalMs: env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS, queueConsumerEnabled: env.TRIGGER_DEQUEUE_ENABLED, maxRunCount: env.TRIGGER_DEQUEUE_MAX_RUN_COUNT, - maxConsumerCount: env.TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT, + metricsRegistry: register, + scaling: { + strategy: env.TRIGGER_DEQUEUE_SCALING_STRATEGY, + minConsumerCount: env.TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT, + maxConsumerCount: env.TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT, + scaleUpCooldownMs: env.TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS, + scaleDownCooldownMs: env.TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS, + targetRatio: env.TRIGGER_DEQUEUE_SCALING_TARGET_RATIO, + ewmaAlpha: env.TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA, + batchWindowMs: env.TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS, + dampingFactor: env.TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR, + }, runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED, heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS, sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS, @@ -233,6 +244,12 @@ class ManagedSupervisor { } try { + if (!message.deployment.friendlyId) { + // mostly a type guard, deployments always exists for deployed environments + // a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments. + throw new Error("Deployment is missing"); + } + await this.workloadManager.create({ dequeuedAt: message.dequeuedAt, envId: message.environment.id, @@ -241,12 +258,15 @@ class ManagedSupervisor { machine: message.run.machine, orgId: message.organization.id, projectId: message.project.id, + deploymentFriendlyId: message.deployment.friendlyId, + deploymentVersion: message.backgroundWorker.version, runId: message.run.id, runFriendlyId: message.run.friendlyId, version: message.version, nextAttemptNumber: message.run.attemptNumber, snapshotId: message.snapshot.id, snapshotFriendlyId: message.snapshot.friendlyId, + placementTags: message.placementTags, }); // Disabled for now diff --git a/apps/supervisor/src/services/failedPodHandler.ts b/apps/supervisor/src/services/failedPodHandler.ts index 26a589e6776..07217243769 100644 --- a/apps/supervisor/src/services/failedPodHandler.ts +++ b/apps/supervisor/src/services/failedPodHandler.ts @@ -25,6 +25,7 @@ export class FailedPodHandler { private readonly informer: Informer; private readonly reconnectIntervalMs: number; + private reconnecting = false; // Metrics private readonly register: Registry; @@ -250,21 +251,48 @@ export class FailedPodHandler { } private makeOnError(informerName: string) { - return () => this.onError(informerName); + return (err?: unknown) => this.onError(informerName, err); } - private async onError(informerName: string) { + private async onError(informerName: string, err?: unknown) { if (!this.isRunning) { this.logger.warn("onError: informer not running"); return; } - this.logger.error("error event fired", { informerName }); - this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" }); + // Guard against multiple simultaneous reconnections + if (this.reconnecting) { + this.logger.debug("onError: reconnection already in progress, skipping", { + informerName, + }); + return; + } - // Reconnect on errors - await setTimeout(this.reconnectIntervalMs); - await this.informer.start(); + this.reconnecting = true; + + try { + const error = err instanceof Error ? err : undefined; + this.logger.error("error event fired", { + informerName, + error: error?.message, + errorType: error?.name, + }); + this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" }); + + // Reconnect on errors + await setTimeout(this.reconnectIntervalMs); + await this.informer.start(); + } catch (handlerError) { + const error = handlerError instanceof Error ? handlerError : undefined; + this.logger.error("onError: reconnection attempt failed", { + informerName, + error: error?.message, + errorType: error?.name, + errorStack: error?.stack, + }); + } finally { + this.reconnecting = false; + } } private makeOnConnect(informerName: string) { diff --git a/apps/supervisor/src/workloadManager/docker.ts b/apps/supervisor/src/workloadManager/docker.ts index 6aa74a7eccb..d6651d325a2 100644 --- a/apps/supervisor/src/workloadManager/docker.ts +++ b/apps/supervisor/src/workloadManager/docker.ts @@ -8,14 +8,16 @@ import { env } from "../env.js"; import { getDockerHostDomain, getRunnerId, normalizeDockerHostUrl } from "../util.js"; import Docker from "dockerode"; import { tryCatch } from "@trigger.dev/core"; +import { ECRAuthService } from "./ecrAuth.js"; export class DockerWorkloadManager implements WorkloadManager { private readonly logger = new SimpleStructuredLogger("docker-workload-manager"); private readonly docker: Docker; private readonly runnerNetworks: string[]; - private readonly auth?: Docker.AuthConfig; + private readonly staticAuth?: Docker.AuthConfig; private readonly platformOverride?: string; + private readonly ecrAuthService?: ECRAuthService; constructor(private opts: WorkloadManagerOptions) { this.docker = new Docker({ @@ -44,13 +46,18 @@ export class DockerWorkloadManager implements WorkloadManager { url: env.DOCKER_REGISTRY_URL, }); - this.auth = { + this.staticAuth = { username: env.DOCKER_REGISTRY_USERNAME, password: env.DOCKER_REGISTRY_PASSWORD, serveraddress: env.DOCKER_REGISTRY_URL, }; + } else if (ECRAuthService.hasAWSCredentials()) { + this.logger.info("🐋 AWS credentials found, initializing ECR auth service"); + this.ecrAuthService = new ECRAuthService(); } else { - this.logger.warn("🐋 No Docker registry credentials provided, skipping auth"); + this.logger.warn( + "🐋 No Docker registry credentials or AWS credentials provided, skipping auth" + ); } } @@ -65,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager { `TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`, `TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`, `TRIGGER_ENV_ID=${opts.envId}`, + `TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`, + `TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`, `TRIGGER_RUN_ID=${opts.runFriendlyId}`, `TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`, `TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`, @@ -160,9 +169,12 @@ export class DockerWorkloadManager implements WorkloadManager { imageArchitecture: inspectResult?.Architecture, }); + // Get auth config (static or ECR) + const authConfig = await this.getAuthConfig(); + // Ensure the image is present const [createImageError, imageResponseReader] = await tryCatch( - this.docker.createImage(this.auth, { + this.docker.createImage(authConfig, { fromImage: imageRef, ...(this.platformOverride ? { platform: this.platformOverride } : {}), }) @@ -216,6 +228,26 @@ export class DockerWorkloadManager implements WorkloadManager { logger.debug("create succeeded", { startResult, containerId: container.id }); } + /** + * Get authentication config for Docker operations + * Uses static credentials if available, otherwise attempts ECR auth + */ + private async getAuthConfig(): Promise { + // Use static credentials if available + if (this.staticAuth) { + return this.staticAuth; + } + + // Use ECR auth if service is available + if (this.ecrAuthService) { + const ecrAuth = await this.ecrAuthService.getAuthConfig(); + return ecrAuth || undefined; + } + + // No auth available + return undefined; + } + private async attachContainerToNetworks({ containerId, networkNames, diff --git a/apps/supervisor/src/workloadManager/ecrAuth.ts b/apps/supervisor/src/workloadManager/ecrAuth.ts new file mode 100644 index 00000000000..33e98f63195 --- /dev/null +++ b/apps/supervisor/src/workloadManager/ecrAuth.ts @@ -0,0 +1,144 @@ +import { ECRClient, GetAuthorizationTokenCommand } from "@aws-sdk/client-ecr"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { tryCatch } from "@trigger.dev/core"; +import Docker from "dockerode"; + +interface ECRTokenCache { + token: string; + username: string; + serverAddress: string; + expiresAt: Date; +} + +export class ECRAuthService { + private readonly logger = new SimpleStructuredLogger("ecr-auth-service"); + private readonly ecrClient: ECRClient; + private tokenCache: ECRTokenCache | null = null; + + constructor() { + this.ecrClient = new ECRClient(); + + this.logger.info("🔐 ECR Auth Service initialized", { + region: this.ecrClient.config.region, + }); + } + + /** + * Check if we have AWS credentials configured + */ + static hasAWSCredentials(): boolean { + if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { + return true; + } + + if ( + process.env.AWS_PROFILE || + process.env.AWS_ROLE_ARN || + process.env.AWS_WEB_IDENTITY_TOKEN_FILE + ) { + return true; + } + + return false; + } + + /** + * Check if the current token is still valid with a 10-minute buffer + */ + private isTokenValid(): boolean { + if (!this.tokenCache) { + return false; + } + + const now = new Date(); + const bufferMs = 10 * 60 * 1000; // 10 minute buffer before expiration + return now < new Date(this.tokenCache.expiresAt.getTime() - bufferMs); + } + + /** + * Get a fresh ECR authorization token from AWS + */ + private async fetchNewToken(): Promise { + const [error, response] = await tryCatch( + this.ecrClient.send(new GetAuthorizationTokenCommand({})) + ); + + if (error) { + this.logger.error("Failed to get ECR authorization token", { error }); + return null; + } + + const authData = response.authorizationData?.[0]; + if (!authData?.authorizationToken || !authData.proxyEndpoint) { + this.logger.error("Invalid ECR authorization response", { authData }); + return null; + } + + // Decode the base64 token to get username:password + const decoded = Buffer.from(authData.authorizationToken, "base64").toString("utf-8"); + const [username, password] = decoded.split(":", 2); + + if (!username || !password) { + this.logger.error("Failed to parse ECR authorization token"); + return null; + } + + const expiresAt = authData.expiresAt || new Date(Date.now() + 12 * 60 * 60 * 1000); // Default 12 hours + + const tokenCache: ECRTokenCache = { + token: password, + username, + serverAddress: authData.proxyEndpoint, + expiresAt, + }; + + this.logger.info("🔐 Successfully fetched ECR token", { + username, + serverAddress: authData.proxyEndpoint, + expiresAt: expiresAt.toISOString(), + }); + + return tokenCache; + } + + /** + * Get ECR auth config for Docker operations + * Returns cached token if valid, otherwise fetches a new one + */ + async getAuthConfig(): Promise { + // Check if cached token is still valid + if (this.isTokenValid()) { + this.logger.debug("Using cached ECR token"); + return { + username: this.tokenCache!.username, + password: this.tokenCache!.token, + serveraddress: this.tokenCache!.serverAddress, + }; + } + + // Fetch new token + this.logger.info("Fetching new ECR authorization token"); + const newToken = await this.fetchNewToken(); + + if (!newToken) { + return null; + } + + // Cache the new token + this.tokenCache = newToken; + + return { + username: newToken.username, + password: newToken.token, + serveraddress: newToken.serverAddress, + }; + } + + /** + * Clear the cached token (useful for testing or forcing refresh) + */ + clearCache(): void { + this.tokenCache = null; + this.logger.debug("ECR token cache cleared"); + } +} diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index bdbffb65b80..147a8bfc5a7 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -4,7 +4,13 @@ import { type WorkloadManagerCreateOptions, type WorkloadManagerOptions, } from "./types.js"; -import type { EnvironmentType, MachinePreset } from "@trigger.dev/core/v3"; +import type { + EnvironmentType, + MachinePreset, + MachinePresetName, + PlacementTag, +} from "@trigger.dev/core/v3"; +import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly"; import { env } from "../env.js"; import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js"; import { getRunnerId } from "../util.js"; @@ -13,13 +19,45 @@ type ResourceQuantities = { [K in "cpu" | "memory" | "ephemeral-storage"]?: string; }; +const cpuRequestRatioByMachinePreset: Record = { + micro: env.KUBERNETES_CPU_REQUEST_RATIO_MICRO, + "small-1x": env.KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X, + "small-2x": env.KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X, + "medium-1x": env.KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X, + "medium-2x": env.KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X, + "large-1x": env.KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X, + "large-2x": env.KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X, +}; + +const memoryRequestRatioByMachinePreset: Record = { + micro: env.KUBERNETES_MEMORY_REQUEST_RATIO_MICRO, + "small-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X, + "small-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X, + "medium-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X, + "medium-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X, + "large-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X, + "large-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X, +}; + export class KubernetesWorkloadManager implements WorkloadManager { private readonly logger = new SimpleStructuredLogger("kubernetes-workload-provider"); private k8s: K8sApi; 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; + private readonly memoryOverheadGb = env.KUBERNETES_MEMORY_OVERHEAD_GB; constructor(private opts: WorkloadManagerOptions) { this.k8s = createK8sApi(); + this.placementTagProcessor = new PlacementTagProcessor({ + enabled: env.PLACEMENT_TAGS_ENABLED, + prefix: env.PLACEMENT_TAGS_PREFIX, + }); if (opts.workloadApiDomain) { this.logger.warn("[KubernetesWorkloadManager] ⚠️ Custom workload API domain", { @@ -28,6 +66,39 @@ export class KubernetesWorkloadManager implements WorkloadManager { } } + private addPlacementTags( + podSpec: Omit, + placementTags?: PlacementTag[] + ): Omit { + const nodeSelector = this.placementTagProcessor.convertToNodeSelector( + placementTags, + podSpec.nodeSelector + ); + + return { + ...podSpec, + nodeSelector, + }; + } + + private stripImageDigest(imageRef: string): string { + if (!env.KUBERNETES_STRIP_IMAGE_DIGEST) { + return imageRef; + } + + const atIndex = imageRef.lastIndexOf("@"); + + if (atIndex === -1) { + return imageRef; + } + + 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 }); @@ -51,12 +122,13 @@ export class KubernetesWorkloadManager implements WorkloadManager { }, }, spec: { - ...this.#defaultPodSpec, + ...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags), + affinity: this.#getAffinity(opts.machine, opts.projectId), terminationGracePeriodSeconds: 60 * 60, containers: [ { name: "run-controller", - image: opts.image, + image: this.stripImageDigest(opts.image), ports: [ { containerPort: 8000, @@ -88,6 +160,14 @@ export class KubernetesWorkloadManager implements WorkloadManager { name: "TRIGGER_ENV_ID", value: opts.envId, }, + { + name: "TRIGGER_DEPLOYMENT_ID", + value: opts.deploymentFriendlyId, + }, + { + name: "TRIGGER_DEPLOYMENT_VERSION", + value: opts.deploymentVersion, + }, { name: "TRIGGER_SNAPSHOT_ID", value: opts.snapshotFriendlyId, @@ -240,10 +320,14 @@ export class KubernetesWorkloadManager implements WorkloadManager { // Explicit control over service account token mounting (defaults to false for security) automountServiceAccountToken: env.KUBERNETES_WORKER_AUTOMOUNT_SERVICE_ACCOUNT_TOKEN, imagePullSecrets: this.getImagePullSecrets(), - // Optionally specify a service account for the worker pods ...(env.KUBERNETES_WORKER_SERVICE_ACCOUNT ? { serviceAccountName: env.KUBERNETES_WORKER_SERVICE_ACCOUNT } : {}), + ...(env.KUBERNETES_SCHEDULER_NAME + ? { + schedulerName: env.KUBERNETES_SCHEDULER_NAME, + } + : {}), securityContext: { runAsNonRoot: true, runAsUser: 1000, @@ -277,20 +361,35 @@ export class KubernetesWorkloadManager implements WorkloadManager { envtype: this.#envTypeToLabelValue(opts.envType), org: opts.orgId, project: opts.projectId, + machine: opts.machine.name, }; } #getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities { + const cpuRatio = cpuRequestRatioByMachinePreset[preset.name] ?? this.cpuRequestRatio; + const memoryRatio = memoryRequestRatioByMachinePreset[preset.name] ?? this.memoryRequestRatio; + + const cpuRequest = preset.cpu * cpuRatio; + const memoryRequest = preset.memory * memoryRatio; + + // 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`, }; } #getResourceLimitsForMachine(preset: MachinePreset): ResourceQuantities { + const memoryLimit = this.memoryOverheadGb + ? preset.memory + this.memoryOverheadGb + : preset.memory; + return { cpu: `${preset.cpu}`, - memory: `${preset.memory}G`, + memory: `${memoryLimit}G`, }; } @@ -306,4 +405,91 @@ export class KubernetesWorkloadManager implements WorkloadManager { }, }; } + + #isLargeMachine(preset: MachinePreset): boolean { + return preset.name.startsWith("large-"); + } + + #getAffinity(preset: MachinePreset, projectId: string): k8s.V1Affinity | undefined { + const nodeAffinity = this.#getNodeAffinityRules(preset); + const podAffinity = this.#getProjectPodAffinity(projectId); + + if (!nodeAffinity && !podAffinity) { + return undefined; + } + + return { + ...(nodeAffinity && { nodeAffinity }), + ...(podAffinity && { podAffinity }), + }; + } + + #getNodeAffinityRules(preset: MachinePreset): k8s.V1NodeAffinity | undefined { + if (!env.KUBERNETES_LARGE_MACHINE_POOL_LABEL) { + return undefined; + } + + if (this.#isLargeMachine(preset)) { + // soft preference for the large-machine pool, falls back to standard if unavailable + return { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: 100, + preference: { + matchExpressions: [ + { + key: "node.cluster.x-k8s.io/machinepool", + operator: "In", + values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL], + }, + ], + }, + }, + ], + }; + } + + // not schedulable in the large-machine pool + return { + requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [ + { + matchExpressions: [ + { + key: "node.cluster.x-k8s.io/machinepool", + operator: "NotIn", + values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL], + }, + ], + }, + ], + }, + }; + } + + #getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined { + if (!env.KUBERNETES_PROJECT_AFFINITY_ENABLED) { + return undefined; + } + + return { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: env.KUBERNETES_PROJECT_AFFINITY_WEIGHT, + podAffinityTerm: { + labelSelector: { + matchExpressions: [ + { + key: "project", + operator: "In", + values: [projectId], + }, + ], + }, + topologyKey: env.KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY, + }, + }, + ], + }; + } } diff --git a/apps/supervisor/src/workloadManager/types.ts b/apps/supervisor/src/workloadManager/types.ts index b3cd418f1e1..90b61957795 100644 --- a/apps/supervisor/src/workloadManager/types.ts +++ b/apps/supervisor/src/workloadManager/types.ts @@ -1,4 +1,4 @@ -import { type EnvironmentType, type MachinePreset } from "@trigger.dev/core/v3"; +import type { EnvironmentType, MachinePreset, PlacementTag } from "@trigger.dev/core/v3"; export interface WorkloadManagerOptions { workloadApiProtocol: "http" | "https"; @@ -23,11 +23,14 @@ export interface WorkloadManagerCreateOptions { version: string; nextAttemptNumber?: number; dequeuedAt: Date; + placementTags?: PlacementTag[]; // identifiers envId: string; envType: EnvironmentType; orgId: string; projectId: string; + deploymentFriendlyId: string; + deploymentVersion: string; runId: string; runFriendlyId: string; snapshotId: string; diff --git a/apps/supervisor/src/workloadServer/index.ts b/apps/supervisor/src/workloadServer/index.ts index e7e391bce38..35d53d36099 100644 --- a/apps/supervisor/src/workloadServer/index.ts +++ b/apps/supervisor/src/workloadServer/index.ts @@ -16,7 +16,6 @@ import { type WorkloadRunAttemptCompleteResponseBody, WorkloadRunAttemptStartRequestBody, type WorkloadRunAttemptStartResponseBody, - type WorkloadRunLatestSnapshotResponseBody, WorkloadRunSnapshotsSinceResponseBody, type WorkloadServerToClientEvents, type WorkloadSuspendRunResponseBody, @@ -126,7 +125,7 @@ export class WorkloadServer extends EventEmitter { } private createHttpServer({ host, port }: { host: string; port: number }) { - return new HttpServer({ + const httpServer = new HttpServer({ port, host, metrics: { @@ -322,28 +321,6 @@ export class WorkloadServer extends EventEmitter { }, } ) - .route("/api/v1/workload-actions/runs/:runFriendlyId/snapshots/latest", "GET", { - paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }), - handler: async ({ req, reply, params }) => { - const latestSnapshotResponse = await this.workerClient.getLatestSnapshot( - params.runFriendlyId, - this.runnerIdFromRequest(req) - ); - - if (!latestSnapshotResponse.success) { - this.logger.error("Failed to get latest snapshot", { - runId: params.runFriendlyId, - error: latestSnapshotResponse.error, - }); - reply.empty(500); - return; - } - - reply.json({ - execution: latestSnapshotResponse.data.execution, - } satisfies WorkloadRunLatestSnapshotResponseBody); - }, - }) .route( "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/since/:snapshotFriendlyId", "GET", @@ -369,23 +346,6 @@ export class WorkloadServer extends EventEmitter { }, } ) - .route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", { - paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }), - bodySchema: WorkloadDebugLogRequestBody, - handler: async ({ req, reply, params, body }) => { - reply.empty(204); - - if (!env.SEND_RUN_DEBUG_LOGS) { - return; - } - - await this.workerClient.sendDebugLog( - params.runFriendlyId, - body, - this.runnerIdFromRequest(req) - ); - }, - }) .route("/api/v1/workload-actions/deployments/:deploymentId/dequeue", "GET", { paramsSchema: z.object({ deploymentId: z.string(), @@ -410,6 +370,31 @@ export class WorkloadServer extends EventEmitter { reply.json(dequeueResponse.data satisfies WorkloadDequeueFromVersionResponseBody); }, }); + + if (env.SEND_RUN_DEBUG_LOGS) { + httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", { + paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }), + bodySchema: WorkloadDebugLogRequestBody, + handler: async ({ req, reply, params, body }) => { + reply.empty(204); + + await this.workerClient.sendDebugLog( + params.runFriendlyId, + body, + this.runnerIdFromRequest(req) + ); + }, + }); + } else { + // Lightweight mock route without schemas + httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", { + handler: async ({ reply }) => { + reply.empty(204); + }, + }); + } + + return httpServer; } private createWebsocketServer() { diff --git a/apps/webapp/.env b/apps/webapp/.env new file mode 120000 index 00000000000..c7360fb82d2 --- /dev/null +++ b/apps/webapp/.env @@ -0,0 +1 @@ +../../.env \ No newline at end of file diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore index 8b81451eadb..595ab180e15 100644 --- a/apps/webapp/.gitignore +++ b/apps/webapp/.gitignore @@ -9,7 +9,8 @@ node_modules /app/styles/tailwind.css - +# Ensure the .env symlink is not removed by accident +!.env # Storybook build outputs build-storybook.log @@ -18,4 +19,5 @@ build-storybook.log storybook-static /prisma/seed.js -/prisma/populate.js \ No newline at end of file +/prisma/populate.js +.memory-snapshots \ No newline at end of file diff --git a/apps/webapp/app/assets/icons/AbacusIcon.tsx b/apps/webapp/app/assets/icons/AbacusIcon.tsx new file mode 100644 index 00000000000..f0b7bfdf7be --- /dev/null +++ b/apps/webapp/app/assets/icons/AbacusIcon.tsx @@ -0,0 +1,71 @@ +export function AbacusIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ArrowTopRightBottomLeftIcon.tsx b/apps/webapp/app/assets/icons/ArrowTopRightBottomLeftIcon.tsx new file mode 100644 index 00000000000..c49aa8cb0c2 --- /dev/null +++ b/apps/webapp/app/assets/icons/ArrowTopRightBottomLeftIcon.tsx @@ -0,0 +1,22 @@ +export function ArrowTopRightBottomLeftIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ChevronExtraSmallDown.tsx b/apps/webapp/app/assets/icons/ChevronExtraSmallDown.tsx new file mode 100644 index 00000000000..134cbe4dfda --- /dev/null +++ b/apps/webapp/app/assets/icons/ChevronExtraSmallDown.tsx @@ -0,0 +1,13 @@ +export function ChevronExtraSmallDown({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ChevronExtraSmallUp.tsx b/apps/webapp/app/assets/icons/ChevronExtraSmallUp.tsx new file mode 100644 index 00000000000..710eeccdf20 --- /dev/null +++ b/apps/webapp/app/assets/icons/ChevronExtraSmallUp.tsx @@ -0,0 +1,13 @@ +export function ChevronExtraSmallUp({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CloudProviderIcon.tsx b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx new file mode 100644 index 00000000000..6c162528247 --- /dev/null +++ b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx @@ -0,0 +1,76 @@ +export function CloudProviderIcon({ + provider, + className, +}: { + provider: "aws" | "digitalocean" | (string & {}); + className?: string; +}) { + switch (provider) { + case "aws": + return ; + case "digitalocean": + return ; + default: + return null; + } +} + +export function AWS({ className }: { className?: string }) { + return ( + + + + + + ); +} + +export function DigitalOcean({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ConcurrencyIcon.tsx b/apps/webapp/app/assets/icons/ConcurrencyIcon.tsx new file mode 100644 index 00000000000..710ba4e6fa9 --- /dev/null +++ b/apps/webapp/app/assets/icons/ConcurrencyIcon.tsx @@ -0,0 +1,13 @@ +export function ConcurrencyIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ListBulletIcon.tsx b/apps/webapp/app/assets/icons/ListBulletIcon.tsx new file mode 100644 index 00000000000..3ca7636a900 --- /dev/null +++ b/apps/webapp/app/assets/icons/ListBulletIcon.tsx @@ -0,0 +1,30 @@ +export function ListBulletIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/LogsIcon.tsx b/apps/webapp/app/assets/icons/LogsIcon.tsx new file mode 100644 index 00000000000..3178da237e7 --- /dev/null +++ b/apps/webapp/app/assets/icons/LogsIcon.tsx @@ -0,0 +1,66 @@ +export function LogsIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoveToBottomIcon.tsx b/apps/webapp/app/assets/icons/MoveToBottomIcon.tsx new file mode 100644 index 00000000000..997550e9265 --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveToBottomIcon.tsx @@ -0,0 +1,27 @@ +export function MoveToBottomIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoveToTopIcon.tsx b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx new file mode 100644 index 00000000000..46938fd391a --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx @@ -0,0 +1,34 @@ +export function MoveToTopIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoveUpIcon.tsx b/apps/webapp/app/assets/icons/MoveUpIcon.tsx new file mode 100644 index 00000000000..6e5d8a84ba9 --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveUpIcon.tsx @@ -0,0 +1,41 @@ +export function MoveUpIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RegionIcons.tsx b/apps/webapp/app/assets/icons/RegionIcons.tsx new file mode 100644 index 00000000000..098d5bc98ce --- /dev/null +++ b/apps/webapp/app/assets/icons/RegionIcons.tsx @@ -0,0 +1,106 @@ +export function FlagIcon({ + region, + className, +}: { + region: "usa" | "europe" | (string & {}); + className?: string; +}) { + switch (region) { + case "usa": + return ; + case "europe": + return ; + default: + return null; + } +} + +export function FlagUSA({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +export function FlagEurope({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SnakedArrowIcon.tsx b/apps/webapp/app/assets/icons/SnakedArrowIcon.tsx new file mode 100644 index 00000000000..0766cce1b46 --- /dev/null +++ b/apps/webapp/app/assets/icons/SnakedArrowIcon.tsx @@ -0,0 +1,20 @@ +export function SnakedArrowIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/StreamsIcon.tsx b/apps/webapp/app/assets/icons/StreamsIcon.tsx new file mode 100644 index 00000000000..73cc480f4d4 --- /dev/null +++ b/apps/webapp/app/assets/icons/StreamsIcon.tsx @@ -0,0 +1,10 @@ +export function StreamsIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + diff --git a/apps/webapp/app/assets/images/blurred-dashboard-background-menu-bottom.jpg b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-bottom.jpg new file mode 100644 index 00000000000..2a993f82127 Binary files /dev/null and b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-bottom.jpg differ diff --git a/apps/webapp/app/assets/images/blurred-dashboard-background-menu-top.jpg b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-top.jpg new file mode 100644 index 00000000000..8aca8563cdc Binary files /dev/null and b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-top.jpg differ diff --git a/apps/webapp/app/assets/images/blurred-dashboard-background-table.jpg b/apps/webapp/app/assets/images/blurred-dashboard-background-table.jpg new file mode 100644 index 00000000000..a2ae4029fe2 Binary files /dev/null and b/apps/webapp/app/assets/images/blurred-dashboard-background-table.jpg differ diff --git a/apps/webapp/app/assets/logos/GoogleLogo.tsx b/apps/webapp/app/assets/logos/GoogleLogo.tsx new file mode 100644 index 00000000000..e0ff9597f07 --- /dev/null +++ b/apps/webapp/app/assets/logos/GoogleLogo.tsx @@ -0,0 +1,22 @@ +export function GoogleLogo({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/components/AlphaBadge.tsx b/apps/webapp/app/components/AlphaBadge.tsx new file mode 100644 index 00000000000..0a1c4a7fc9a --- /dev/null +++ b/apps/webapp/app/components/AlphaBadge.tsx @@ -0,0 +1,61 @@ +import { cn } from "~/utils/cn"; +import { Badge } from "./primitives/Badge"; +import { SimpleTooltip } from "./primitives/Tooltip"; + +export function AlphaBadge({ + inline = false, + className, +}: { + inline?: boolean; + className?: string; +}) { + return ( + + Alpha + + } + content="This feature is in Alpha." + disableHoverableContent + /> + ); +} + +export function AlphaTitle({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} + +export function BetaBadge({ + inline = false, + className, +}: { + inline?: boolean; + className?: string; +}) { + return ( + + Beta + + } + content="This feature is in Beta." + disableHoverableContent + /> + ); +} + +export function BetaTitle({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index 39cc4cdaaf3..814d4649c8f 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -5,6 +5,7 @@ import { HandThumbUpIcon, StopIcon, } from "@heroicons/react/20/solid"; +import { cn } from "~/utils/cn"; import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk"; import { useSearchParams } from "@remix-run/react"; import DOMPurify from "dompurify"; @@ -37,7 +38,7 @@ function useKapaWebsiteId() { return routeMatch?.kapa.websiteId; } -export function AskAI() { +export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { const { isManagedCloud } = useFeatures(); const websiteId = useKapaWebsiteId(); @@ -54,21 +55,23 @@ export function AskAI() { hideShortcutKey data-modal-override-open-class-ask-ai="true" disabled + className={isCollapsed ? "w-full justify-center" : ""} > } > - {() => } + {() => } ); } type AskAIProviderProps = { websiteId: string; + isCollapsed?: boolean; }; -function AskAIProvider({ websiteId }: AskAIProviderProps) { +function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) { const [isOpen, setIsOpen] = useState(false); const [initialQuery, setInitialQuery] = useState(); const [searchParams, setSearchParams] = useSearchParams(); @@ -112,28 +115,39 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) { }} botProtectionMechanism="hcaptcha" > - - - -
- -
-
- - Ask AI - - -
-
+ + + + + + + + + + Ask AI + + + + + + + + + {/* Left menu top background - fixed width 260px, maintains aspect ratio */} +
+ + {/* Left menu bottom background - fixed width 260px, maintains aspect ratio */} +
+ + {/* Right table background - fixed width 2000px, positioned next to menu */} +
+ + {/* Content layer */} +
{children}
+
+ ); +} diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 03ebae66ee1..7423bd61ac8 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -5,6 +5,7 @@ import { ChatBubbleLeftRightIcon, ClockIcon, PlusIcon, + QuestionMarkCircleIcon, RectangleGroupIcon, RectangleStackIcon, ServerStackIcon, @@ -12,7 +13,6 @@ import { } from "@heroicons/react/20/solid"; import { useLocation } from "react-use"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; -import { TaskIcon } from "~/assets/icons/TaskIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import openBulkActionsPanel from "~/assets/images/open-bulk-actions-panel.png"; import selectRunsIndividually from "~/assets/images/select-runs-individually.png"; @@ -32,8 +32,9 @@ import { v3NewProjectAlertPath, v3NewSchedulePath, } from "~/utils/pathBuilder"; +import { AskAI } from "./AskAI"; import { InlineCode } from "./code/InlineCode"; -import { environmentFullTitle } from "./environments/EnvironmentLabel"; +import { environmentFullTitle, EnvironmentIcon } from "./environments/EnvironmentLabel"; import { Feedback } from "./Feedback"; import { EnvironmentSelector } from "./navigation/EnvironmentSelector"; import { Button, LinkButton } from "./primitives/Buttons"; @@ -42,9 +43,22 @@ import { InfoPanel } from "./primitives/InfoPanel"; import { Paragraph } from "./primitives/Paragraph"; import { StepNumber } from "./primitives/StepNumber"; import { TextLink } from "./primitives/TextLink"; -import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands"; +import { SimpleTooltip } from "./primitives/Tooltip"; +import { + InitCommandV3, + PackageManagerProvider, + TriggerDeployStep, + TriggerDevStepV3, +} from "./SetupCommands"; import { StepContentContainer } from "./StepContentContainer"; import { V4Badge } from "./V4Badge"; +import { + ClientTabs, + ClientTabsContent, + ClientTabsList, + ClientTabsTrigger, +} from "./primitives/ClientTabs"; +import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; export function HasNoTasksDev() { return ( @@ -86,28 +100,7 @@ export function HasNoTasksDev() { } export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) { - return ( - - How to deploy tasks - - } - > - - Run the CLI deploy command to - deploy your tasks to the {environmentFullTitle(environment)} environment. - - - ); + return ; } export function SchedulesNoPossibleTaskPanel() { @@ -225,45 +218,7 @@ export function TestHasNoTasks() { } export function DeploymentsNone() { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - - return ( - - - There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration - service like GitHub Actions. Make sure you{" "} - - set your environment variables - {" "} - first. - -
- - Deploy with the CLI - - - Deploy with GitHub actions - -
-
- ); + return ; } export function DeploymentsNoneDev() { @@ -272,46 +227,52 @@ export function DeploymentsNoneDev() { const environment = useEnvironment(); return ( -
- - + <> +
+
+ + Deploy your tasks +
+
+ + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + +
+
+ + + This is the Development environment. When you're ready to deploy your tasks, switch to a different environment. - - There are several ways to deploy your tasks. You can use the CLI or a Continuous - Integration service like GitHub Actions. Make sure you{" "} - - set your environment variables - {" "} - first. - -
- - Deploy with the CLI - - - Deploy with GitHub actions - -
-
- -
+ + + ); } @@ -477,6 +438,10 @@ export function BranchesNoBranchableEnvironment() { Preview branches in Trigger.dev create isolated environments for testing new features before production. + + You must be on to access preview branches. Read our{" "} + upgrade to v4 guide to learn more. + ); } @@ -625,3 +590,99 @@ export function BulkActionsNone() {
); } + +function DeploymentOnboardingSteps() { + const environment = useEnvironment(); + const organization = useOrganization(); + const project = useProject(); + + return ( + +
+
+ + Deploy your tasks to {environmentFullTitle(environment)} +
+
+ + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + +
+
+ + + + GitHub + + + Manual + + + GitHub Actions + + + + + + + Deploy automatically with every push. Read the{" "} + full guide. + +
+ +
+
+
+ + + + + This will deploy your tasks to the {environmentFullTitle(environment)} environment. + Read the full guide. + + + + + + + + + Read the GitHub Actions guide to + get started. + + + +
+ + + + This page will automatically refresh when your tasks are deployed. + +
+ ); +} diff --git a/apps/webapp/app/components/BulkActionFilterSummary.tsx b/apps/webapp/app/components/BulkActionFilterSummary.tsx index b00d77d438c..073940d7d0a 100644 --- a/apps/webapp/app/components/BulkActionFilterSummary.tsx +++ b/apps/webapp/app/components/BulkActionFilterSummary.tsx @@ -11,10 +11,11 @@ import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v import { formatNumber } from "~/utils/numberFormatter"; import { SpinnerWhite } from "./primitives/Spinner"; import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid"; +import { XCircleIcon as XCircleIconOutline } from "@heroicons/react/24/outline"; import assertNever from "assert-never"; import { AppliedFilter } from "./primitives/AppliedFilter"; import { runStatusTitle } from "./runs/v3/TaskRunStatus"; -import { type TaskRunStatus } from "@trigger.dev/database"; +import type { TaskRunStatus } from "@trigger.dev/database"; export const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]); export type BulkActionMode = z.infer; @@ -244,7 +245,7 @@ function Action({ action }: { action: BulkActionAction }) { case "cancel": return ( - + Canceled ); diff --git a/apps/webapp/app/components/CloudProvider.tsx b/apps/webapp/app/components/CloudProvider.tsx new file mode 100644 index 00000000000..acf8cff5506 --- /dev/null +++ b/apps/webapp/app/components/CloudProvider.tsx @@ -0,0 +1,10 @@ +export function cloudProviderTitle(provider: "aws" | "digitalocean" | (string & {})) { + switch (provider) { + case "aws": + return "Amazon Web Services"; + case "digitalocean": + return "Digital Ocean"; + default: + return provider; + } +} diff --git a/apps/webapp/app/components/DefinitionTooltip.tsx b/apps/webapp/app/components/DefinitionTooltip.tsx index 0e2d4d43637..5bb3a713997 100644 --- a/apps/webapp/app/components/DefinitionTooltip.tsx +++ b/apps/webapp/app/components/DefinitionTooltip.tsx @@ -14,7 +14,7 @@ export function DefinitionTip({ return ( - + {children} diff --git a/apps/webapp/app/components/ErrorDisplay.tsx b/apps/webapp/app/components/ErrorDisplay.tsx index 1a8f4b2ad94..5787a2edbac 100644 --- a/apps/webapp/app/components/ErrorDisplay.tsx +++ b/apps/webapp/app/components/ErrorDisplay.tsx @@ -1,11 +1,10 @@ import { HomeIcon } from "@heroicons/react/20/solid"; import { isRouteErrorResponse, useRouteError } from "@remix-run/react"; -import { motion } from "framer-motion"; import { friendlyErrorDisplay } from "~/utils/httpErrors"; import { LinkButton } from "./primitives/Buttons"; import { Header1 } from "./primitives/Headers"; import { Paragraph } from "./primitives/Paragraph"; -import Spline from "@splinetool/react-spline"; +import { TriggerRotatingLogo } from "./TriggerRotatingLogo"; import { type ReactNode } from "react"; type ErrorDisplayOptions = { @@ -57,14 +56,7 @@ export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) { {button ? button.title : "Go to homepage"}
- - - + ); } diff --git a/apps/webapp/app/components/Feedback.tsx b/apps/webapp/app/components/Feedback.tsx index cba709aba42..ecfd4e88c9a 100644 --- a/apps/webapp/app/components/Feedback.tsx +++ b/apps/webapp/app/components/Feedback.tsx @@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid"; import { EnvelopeIcon } from "@heroicons/react/24/solid"; -import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react"; +import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react"; import { type ReactNode, useEffect, useState } from "react"; import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback"; import { Button } from "./primitives/Buttons"; @@ -23,10 +23,12 @@ import { DialogClose } from "@radix-ui/react-dialog"; type FeedbackProps = { button: ReactNode; defaultValue?: FeedbackType; + onOpenChange?: (open: boolean) => void; }; -export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) { +export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) { const [open, setOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); const lastSubmission = useActionData(); const navigation = useNavigation(); @@ -52,8 +54,26 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) { } }, [navigation, form]); + // Handle URL param functionality + useEffect(() => { + const open = searchParams.get("feedbackPanel"); + if (open) { + setType(open as FeedbackType); + setOpen(true); + // Clone instead of mutating in place + const next = new URLSearchParams(searchParams); + next.delete("feedbackPanel"); + setSearchParams(next); + } + }, [searchParams]); + + const handleOpenChange = (value: boolean) => { + setOpen(value); + onOpenChange?.(value); + }; + return ( - + {button} Contact us diff --git a/apps/webapp/app/components/GitHubLoginButton.tsx b/apps/webapp/app/components/GitHubLoginButton.tsx index 87238db087e..76a494927cd 100644 --- a/apps/webapp/app/components/GitHubLoginButton.tsx +++ b/apps/webapp/app/components/GitHubLoginButton.tsx @@ -32,8 +32,6 @@ export function OctoKitty({ className }: { className?: string }) { baseProfile="tiny" id="Layer_1" xmlns="http://www.w3.org/2000/svg" - x="0px" - y="0px" viewBox="0 0 2350 2314.8" xmlSpace="preserve" fill="currentColor" diff --git a/apps/webapp/app/components/LogLevelTooltipInfo.tsx b/apps/webapp/app/components/LogLevelTooltipInfo.tsx new file mode 100644 index 00000000000..2a8093af066 --- /dev/null +++ b/apps/webapp/app/components/LogLevelTooltipInfo.tsx @@ -0,0 +1,56 @@ +import { Header3 } from "./primitives/Headers"; +import { Paragraph } from "./primitives/Paragraph"; +import { LogLevel } from "./logs/LogLevel"; + +export function LogLevelTooltipInfo() { + return ( +
+
+ Log Levels + + Structured logging helps you debug and monitor your tasks. + +
+
+
+ +
+ + Traces and spans representing the execution flow of your tasks. + +
+
+
+ +
+ + General informational messages about task execution. + +
+
+
+ +
+ + Warning messages indicating potential issues that don't prevent execution. + +
+
+
+ +
+ + Error messages for failures and exceptions during task execution. + +
+
+
+ +
+ + Detailed diagnostic information for development and debugging. + +
+
+ ); +} diff --git a/apps/webapp/app/components/SetupCommands.tsx b/apps/webapp/app/components/SetupCommands.tsx index e68273a0dbf..accb2f65a8f 100644 --- a/apps/webapp/app/components/SetupCommands.tsx +++ b/apps/webapp/app/components/SetupCommands.tsx @@ -208,3 +208,64 @@ export function TriggerLoginStepV3({ title }: TabsProps) { ); } + +export function TriggerDeployStep({ title, environment }: TabsProps & { environment: { type: string } }) { + const triggerCliTag = useTriggerCliTag(); + const { activePackageManager, setActivePackageManager } = usePackageManager(); + + // Generate the environment flag based on environment type + const getEnvironmentFlag = () => { + switch (environment.type) { + case "STAGING": + return " --env staging"; + case "PREVIEW": + return " --env preview"; + case "PRODUCTION": + default: + return ""; + } + }; + + const environmentFlag = getEnvironmentFlag(); + + return ( + +
+ {title && {title}} + + npm + pnpm + yarn + +
+ + + + + + + + + +
+ ); +} diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 8349ed970fa..2decc82c914 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -1,18 +1,17 @@ import { Keyboard } from "lucide-react"; +import { useState } from "react"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { Button } from "./primitives/Buttons"; import { Header3 } from "./primitives/Headers"; import { Paragraph } from "./primitives/Paragraph"; import { Sheet, SheetContent, - SheetDescription, SheetHeader, SheetTitle, - SheetTrigger, + SheetTrigger } from "./primitives/SheetV3"; import { ShortcutKey } from "./primitives/ShortcutKey"; -import { Button } from "./primitives/Buttons"; -import { useState } from "react"; -import { useShortcutKeys } from "~/hooks/useShortcutKeys"; export function Shortcuts() { return ( @@ -26,8 +25,8 @@ export function Shortcuts() { fullWidth textAlignLeft shortcut={{ modifiers: ["shift"], key: "?", enabled: false }} - className="gap-x-0 pl-0.5" - iconSpacing="gap-x-0.5" + className="gap-x-0 pl-1.5" + iconSpacing="gap-x-1.5" > Shortcuts @@ -77,11 +76,16 @@ function ShortcutContent() { - + + + + + + @@ -123,7 +127,7 @@ function ShortcutContent() { - + @@ -134,6 +138,10 @@ function ShortcutContent() { + + + + @@ -147,6 +155,49 @@ function ShortcutContent() { + + + + + + + +
+ Logs page + + + + + + + + + + + + + to + + + + + + + + + + + + + + + +
+
+ Metrics page + + +
Schedules page diff --git a/apps/webapp/app/components/TimezoneSetter.tsx b/apps/webapp/app/components/TimezoneSetter.tsx new file mode 100644 index 00000000000..3481af6571d --- /dev/null +++ b/apps/webapp/app/components/TimezoneSetter.tsx @@ -0,0 +1,30 @@ +import { useFetcher } from "@remix-run/react"; +import { useEffect, useRef } from "react"; +import { useTypedLoaderData } from "remix-typedjson"; +import type { loader } from "~/root"; + +export function TimezoneSetter() { + const { timezone: storedTimezone } = useTypedLoaderData(); + const fetcher = useFetcher(); + const hasSetTimezone = useRef(false); + + useEffect(() => { + if (hasSetTimezone.current) return; + + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + if (browserTimezone && browserTimezone !== storedTimezone) { + hasSetTimezone.current = true; + fetcher.submit( + { timezone: browserTimezone }, + { + method: "POST", + action: "/resources/timezone", + encType: "application/json", + } + ); + } + }, [storedTimezone, fetcher]); + + return null; +} diff --git a/apps/webapp/app/components/TriggerRotatingLogo.tsx b/apps/webapp/app/components/TriggerRotatingLogo.tsx new file mode 100644 index 00000000000..878c203a3ca --- /dev/null +++ b/apps/webapp/app/components/TriggerRotatingLogo.tsx @@ -0,0 +1,75 @@ +import { motion } from "framer-motion"; +import { useEffect, useState } from "react"; + +declare global { + namespace JSX { + interface IntrinsicElements { + "spline-viewer": React.DetailedHTMLProps< + React.HTMLAttributes & { + url?: string; + "loading-anim-type"?: string; + }, + HTMLElement + >; + } + } + + interface Window { + __splineLoader?: Promise; + } +} + +export function TriggerRotatingLogo() { + const [isSplineReady, setIsSplineReady] = useState(false); + + useEffect(() => { + // Already registered from a previous render + if (customElements.get("spline-viewer")) { + setIsSplineReady(true); + return; + } + + // Another mount already started loading - share the same promise + if (window.__splineLoader) { + window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false)); + return; + } + + // First mount: create script and shared loader promise + const script = document.createElement("script"); + script.type = "module"; + // Version pinned; SRI hash omitted as unpkg doesn't guarantee hash stability across deploys + script.src = "https://unpkg.com/@splinetool/viewer@1.12.29/build/spline-viewer.js"; + + window.__splineLoader = new Promise((resolve, reject) => { + script.onload = () => resolve(); + script.onerror = () => reject(); + }); + + window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false)); + + document.head.appendChild(script); + + // Intentionally no cleanup: once the custom element is registered globally, + // removing the script would break re-mounts while providing no benefit + }, []); + + if (!isSplineReady) { + return null; + } + + return ( + + + + ); +} diff --git a/apps/webapp/app/components/UserProfilePhoto.tsx b/apps/webapp/app/components/UserProfilePhoto.tsx index 5903353d17a..99febd1c240 100644 --- a/apps/webapp/app/components/UserProfilePhoto.tsx +++ b/apps/webapp/app/components/UserProfilePhoto.tsx @@ -22,6 +22,7 @@ export function UserAvatar({ className={cn("aspect-square rounded-full p-[7%]")} src={avatarUrl} alt={name ?? "User"} + referrerPolicy="no-referrer" />
) : ( diff --git a/apps/webapp/app/components/billing/UpgradePrompt.tsx b/apps/webapp/app/components/billing/UpgradePrompt.tsx index e9b0fc1c97d..8a3e098ba42 100644 --- a/apps/webapp/app/components/billing/UpgradePrompt.tsx +++ b/apps/webapp/app/components/billing/UpgradePrompt.tsx @@ -30,8 +30,8 @@ export function UpgradePrompt() { You have exceeded the monthly $ - {(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. No runs - will execute in Prod until{" "} + {(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. Existing + runs will be queued and new runs won't be created until{" "} , or you upgrade. diff --git a/apps/webapp/app/components/code/AIQueryInput.tsx b/apps/webapp/app/components/code/AIQueryInput.tsx new file mode 100644 index 00000000000..0775ec2c2a0 --- /dev/null +++ b/apps/webapp/app/components/code/AIQueryInput.tsx @@ -0,0 +1,415 @@ +import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { AnimatePresence, motion } from "framer-motion"; +import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { Spinner } from "~/components/primitives/Spinner"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types"; +import { cn } from "~/utils/cn"; + +// Lazy load streamdown components to avoid SSR issues +const StreamdownRenderer = lazy(() => + import("streamdown").then((mod) => ({ + default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => ( + + {children} + + ), + })) +); + +type StreamEventType = + | { type: "thinking"; content: string } + | { type: "tool_call"; tool: string; args: unknown } + | { type: "time_filter"; filter: AITimeFilter } + | { type: "result"; success: true; query: string; timeFilter?: AITimeFilter } + | { type: "result"; success: false; error: string }; + +export type AIQueryMode = "new" | "edit"; + +interface AIQueryInputProps { + onQueryGenerated: (query: string) => void; + /** Called when the AI sets a time filter - updates URL search params */ + onTimeFilterChange?: (filter: AITimeFilter) => void; + /** Set this to a prompt to auto-populate and immediately submit */ + autoSubmitPrompt?: string; + /** Change this to force re-submission even if prompt is the same */ + autoSubmitKey?: number; + /** Get the current query in the editor (used for edit mode) */ + getCurrentQuery?: () => string; +} + +export function AIQueryInput({ + onQueryGenerated, + onTimeFilterChange, + autoSubmitPrompt, + autoSubmitKey, + getCurrentQuery, +}: AIQueryInputProps) { + const [prompt, setPrompt] = useState(""); + const [mode, setMode] = useState("new"); + const [isLoading, setIsLoading] = useState(false); + const [thinking, setThinking] = useState(""); + const [error, setError] = useState(null); + const [showThinking, setShowThinking] = useState(false); + const [lastResult, setLastResult] = useState<"success" | "error" | null>(null); + const textareaRef = useRef(null); + const abortControllerRef = useRef(null); + const lastAutoSubmitRef = useRef<{ prompt: string; key?: number } | null>(null); + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-generate`; + + // Can only use edit mode if there's a current query + const canEdit = Boolean(getCurrentQuery?.()?.trim()); + + // If mode is edit but there's no current query, switch to new + useEffect(() => { + if (mode === "edit" && !canEdit) { + setMode("new"); + } + }, [mode, canEdit]); + + const submitQuery = useCallback( + async (queryPrompt: string, submitMode: AIQueryMode = mode) => { + if (!queryPrompt.trim() || isLoading) return; + const currentQuery = getCurrentQuery?.(); + if (submitMode === "edit" && !currentQuery?.trim()) return; + + setIsLoading(true); + setThinking(""); + setError(null); + setShowThinking(true); + setLastResult(null); + + // Abort any existing request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + try { + const formData = new FormData(); + formData.append("prompt", queryPrompt); + formData.append("mode", submitMode); + if (submitMode === "edit" && currentQuery) { + formData.append("currentQuery", currentQuery); + } + + const response = await fetch(resourcePath, { + method: "POST", + body: formData, + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { error?: string }; + setError(errorData.error || "Failed to generate query"); + setIsLoading(false); + setLastResult("error"); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + setError("No response stream"); + setIsLoading(false); + setLastResult("error"); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete events from buffer + const lines = buffer.split("\n\n"); + buffer = lines.pop() || ""; // Keep incomplete line in buffer + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const event = JSON.parse(line.slice(6)) as StreamEventType; + processStreamEvent(event); + } catch { + // Ignore parse errors + } + } + } + } + + // Process any remaining data + if (buffer.startsWith("data: ")) { + try { + const event = JSON.parse(buffer.slice(6)) as StreamEventType; + processStreamEvent(event); + } catch { + // Ignore parse errors + } + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + // Request was aborted, ignore + return; + } + setError(err instanceof Error ? err.message : "An error occurred"); + setLastResult("error"); + } finally { + setIsLoading(false); + } + }, + [isLoading, resourcePath, mode, getCurrentQuery] + ); + + const processStreamEvent = useCallback( + (event: StreamEventType) => { + switch (event.type) { + case "thinking": + setThinking((prev) => prev + event.content); + break; + case "tool_call": + // Tool calls are handled silently — no UI text needed + break; + case "time_filter": + // Apply time filter immediately when the AI sets it + onTimeFilterChange?.(event.filter); + break; + case "result": + if (event.success) { + // Apply time filter if included in result (backup in case time_filter event was missed) + if (event.timeFilter) { + onTimeFilterChange?.(event.timeFilter); + } + onQueryGenerated(event.query); + setPrompt(""); + setLastResult("success"); + // Keep thinking visible to show what happened + } else { + setError(event.error); + setLastResult("error"); + } + break; + } + }, + [onQueryGenerated, onTimeFilterChange] + ); + + const handleSubmit = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + submitQuery(prompt); + }, + [prompt, submitQuery] + ); + + // Auto-submit when autoSubmitPrompt or autoSubmitKey changes + useEffect(() => { + if (!autoSubmitPrompt || !autoSubmitPrompt.trim() || isLoading) { + return; + } + + const last = lastAutoSubmitRef.current; + const isDifferent = + last === null || autoSubmitPrompt !== last.prompt || autoSubmitKey !== last.key; + + if (isDifferent) { + lastAutoSubmitRef.current = { prompt: autoSubmitPrompt, key: autoSubmitKey }; + setPrompt(autoSubmitPrompt); + submitQuery(autoSubmitPrompt); + } + }, [autoSubmitPrompt, autoSubmitKey, isLoading, submitQuery]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + // Auto-hide error after delay + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(null), 15000); + return () => clearTimeout(timer); + } + }, [error]); + + return ( +
+ {/* Gradient border wrapper like the schedules AI input */} +
+
+
+