diff --git a/.changeset/ai-prompt-management.md b/.changeset/ai-prompt-management.md new file mode 100644 index 00000000000..d3250bebda7 --- /dev/null +++ b/.changeset/ai-prompt-management.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Define and manage AI prompts with `prompts.define()`. Create typesafe prompt templates with variables, resolve them at runtime, and manage versions and overrides from the dashboard without redeploying. 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/fix-dev-build-dir-leak.md b/.changeset/fix-dev-build-dir-leak.md new file mode 100644 index 00000000000..a1e6219c8bb --- /dev/null +++ b/.changeset/fix-dev-build-dir-leak.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Fix dev CLI leaking build directories on rebuild, causing disk space accumulation. Deprecated workers are now pruned (capped at 2 retained) when no active runs reference them. The watchdog process also cleans up `.trigger/tmp/` when the dev CLI is killed ungracefully (e.g. SIGKILL from pnpm). diff --git a/.changeset/fix-list-deploys-nullable.md b/.changeset/fix-list-deploys-nullable.md new file mode 100644 index 00000000000..d9d5e82116a --- /dev/null +++ b/.changeset/fix-list-deploys-nullable.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Fix `list_deploys` MCP tool failing when deployments have null `runtime` or `runtimeVersion` fields. 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/llm-metadata-run-tags.md b/.changeset/llm-metadata-run-tags.md new file mode 100644 index 00000000000..85f04c363b8 --- /dev/null +++ b/.changeset/llm-metadata-run-tags.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Propagate run tags to span attributes so they can be extracted server-side for LLM cost attribution metadata. diff --git a/.changeset/mcp-get-span-details.md b/.changeset/mcp-get-span-details.md new file mode 100644 index 00000000000..e69b7979b07 --- /dev/null +++ b/.changeset/mcp-get-span-details.md @@ -0,0 +1,11 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Add `get_span_details` MCP tool for inspecting individual spans within a run trace. + +- New `get_span_details` tool returns full span attributes, timing, events, and AI enrichment (model, tokens, cost, speed) +- Span IDs now shown in `get_run_details` trace output for easy discovery +- New API endpoint `GET /api/v1/runs/:runId/spans/:spanId` +- New `retrieveSpan()` method on the API client diff --git a/.changeset/mcp-query-tools.md b/.changeset/mcp-query-tools.md new file mode 100644 index 00000000000..23e09c1afec --- /dev/null +++ b/.changeset/mcp-query-tools.md @@ -0,0 +1,42 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +MCP server improvements: new tools, bug fixes, and new flags. + +**New tools:** +- `get_query_schema` — discover available TRQL tables and columns +- `query` — execute TRQL queries against your data +- `list_dashboards` — list built-in dashboards and their widgets +- `run_dashboard_query` — execute a single dashboard widget query +- `whoami` — show current profile, user, and API URL +- `list_profiles` — list all configured CLI profiles +- `switch_profile` — switch active profile for the MCP session +- `start_dev_server` — start `trigger dev` in the background and stream output +- `stop_dev_server` — stop the running dev server +- `dev_server_status` — check dev server status and view recent logs + +**New API endpoints:** +- `GET /api/v1/query/schema` — query table schema discovery +- `GET /api/v1/query/dashboards` — list built-in dashboards + +**New features:** +- `--readonly` flag hides write tools (`deploy`, `trigger_task`, `cancel_run`) so the AI cannot make changes +- `read:query` JWT scope for query endpoint authorization +- `get_run_details` trace output is now paginated with cursor support +- MCP tool annotations (`readOnlyHint`, `destructiveHint`) for all tools + +**Bug fixes:** +- Fixed `search_docs` tool failing due to renamed upstream Mintlify tool (`SearchTriggerDev` → `search_trigger_dev`) +- Fixed `list_deploys` failing when deployments have null `runtime`/`runtimeVersion` fields (#3139) +- Fixed `list_preview_branches` crashing due to incorrect response shape access +- Fixed `metrics` table column documented as `value` instead of `metric_value` in query docs +- Fixed dev CLI leaking build directories on rebuild — deprecated workers now clean up their build dirs when their last run completes + +**Context optimizations:** +- `get_query_schema` now requires a table name and returns only one table's schema (was returning all tables) +- `get_current_worker` no longer inlines payload schemas; use new `get_task_schema` tool instead +- Query results formatted as text tables instead of JSON (~50% fewer tokens) +- `cancel_run`, `list_deploys`, `list_preview_branches` formatted as text instead of raw JSON +- Schema and dashboard API responses cached to avoid redundant fetches 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/tame-oranges-change.md b/.changeset/tame-oranges-change.md new file mode 100644 index 00000000000..9755a41a26a --- /dev/null +++ b/.changeset/tame-oranges-change.md @@ -0,0 +1,8 @@ +--- +"@trigger.dev/redis-worker": patch +"@trigger.dev/sdk": patch +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Adapted the CLI API client to propagate the trigger source via http headers. 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/rules/database-safety.md b/.claude/rules/database-safety.md new file mode 100644 index 00000000000..14a6523595b --- /dev/null +++ b/.claude/rules/database-safety.md @@ -0,0 +1,13 @@ +--- +paths: + - "internal-packages/database/**" +--- + +# Database Migration Safety + +- When adding indexes to **existing tables**, use `CREATE INDEX CONCURRENTLY IF NOT EXISTS` to avoid table locks. These must be in their own separate migration file (one index per file). +- Indexes on **newly created tables** (same migration as `CREATE TABLE`) do not need CONCURRENTLY. +- When indexing a **new column on an existing table**, split into two migrations: first `ADD COLUMN IF NOT EXISTS`, then `CREATE INDEX CONCURRENTLY IF NOT EXISTS` in a separate file. +- After generating a migration with Prisma, remove extraneous lines for: `_BackgroundWorkerToBackgroundWorkerFile`, `_BackgroundWorkerToTaskQueue`, `_TaskRunToTaskRunTag`, `_WaitpointRunConnections`, `_completedWaitpoints`, `SecretStore_key_idx`, and unrelated TaskRun indexes. +- Never drop columns or tables without explicit approval. +- New code should target `RunEngineVersion.V2` only. diff --git a/.claude/rules/docs-writing.md b/.claude/rules/docs-writing.md new file mode 100644 index 00000000000..bbfb471368e --- /dev/null +++ b/.claude/rules/docs-writing.md @@ -0,0 +1,14 @@ +--- +paths: + - "docs/**" +--- + +# Documentation Writing Rules + +- Use Mintlify MDX format. Frontmatter: `title`, `description`, `sidebarTitle` (optional). +- After creating a new page, add it to `docs.json` navigation under the correct group. +- Use Mintlify components: ``, ``, ``, ``, ``, ``, ``/``. +- Code examples should be complete and runnable where possible. +- Always import from `@trigger.dev/sdk`, never `@trigger.dev/sdk/v3`. +- Keep paragraphs short. Use headers to break up content. +- Link to related pages using relative paths (e.g., `[Tasks](/tasks/overview)`). diff --git a/.claude/rules/legacy-v3-code.md b/.claude/rules/legacy-v3-code.md new file mode 100644 index 00000000000..6fd8d9402c2 --- /dev/null +++ b/.claude/rules/legacy-v3-code.md @@ -0,0 +1,33 @@ +--- +paths: + - "apps/webapp/app/v3/**" +--- + +# Legacy V1 Engine Code in `app/v3/` + +The `v3/` directory name is misleading - most code here is actively used by the current V2 engine. Only the specific files below are legacy V1-only code. + +## V1-Only Files - Never Modify + +- `marqs/` directory (entire MarQS queue system: sharedQueueConsumer, devQueueConsumer, fairDequeuingStrategy, devPubSub) +- `legacyRunEngineWorker.server.ts` (V1 background job worker) +- `services/triggerTaskV1.server.ts` (deprecated V1 task triggering) +- `services/cancelTaskRunV1.server.ts` (deprecated V1 cancellation) +- `authenticatedSocketConnection.server.ts` (V1 dev WebSocket using DevQueueConsumer) +- `sharedSocketConnection.ts` (V1 shared queue socket using SharedQueueConsumer) + +## V1/V2 Branching Pattern + +Some services act as routers that branch on `RunEngineVersion`: +- `services/cancelTaskRun.server.ts` - calls V1 service or `engine.cancelRun()` for V2 +- `services/batchTriggerV3.server.ts` - uses marqs for V1 path, run-engine for V2 + +When editing these shared services, only modify V2 code paths. + +## V2 Modern Stack + +- **Run lifecycle**: `@internal/run-engine` (internal-packages/run-engine) +- **Background jobs**: `@trigger.dev/redis-worker` (not graphile-worker/zodworker) +- **Queue operations**: RunQueue inside run-engine (not MarQS) +- **V2 engine singleton**: `runEngine.server.ts`, `runEngineHandlers.server.ts` +- **V2 workers**: `commonWorker.server.ts`, `alertsWorker.server.ts`, `batchTriggerWorker.server.ts` diff --git a/.claude/rules/sdk-packages.md b/.claude/rules/sdk-packages.md new file mode 100644 index 00000000000..549eb341809 --- /dev/null +++ b/.claude/rules/sdk-packages.md @@ -0,0 +1,12 @@ +--- +paths: + - "packages/**" +--- + +# Public Package Rules + +- Changes to `packages/` are **customer-facing**. Always add a changeset: `pnpm run changeset:add` +- Default to **patch**. Get maintainer approval for minor. Never select major without explicit approval. +- `@trigger.dev/core`: **Never import the root**. Always use subpath imports (e.g., `@trigger.dev/core/v3`). +- Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked. These are maintained in separate dedicated passes. +- Test changes using `references/hello-world` reference project. diff --git a/.claude/rules/server-apps.md b/.claude/rules/server-apps.md new file mode 100644 index 00000000000..4d46789701c --- /dev/null +++ b/.claude/rules/server-apps.md @@ -0,0 +1,23 @@ +--- +paths: + - "apps/**" +--- + +# Server App Changes + +When modifying server apps (webapp, supervisor, coordinator, etc.) with **no package changes**, add a `.server-changes/` file instead of a changeset: + +```bash +cat > .server-changes/descriptive-name.md << 'EOF' +--- +area: webapp +type: fix +--- + +Brief description of what changed and why. +EOF +``` + +- **area**: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider` +- **type**: `feature` | `fix` | `improvement` | `breaking` +- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed). 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/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..359ed5d4733 100644 --- a/.cursor/rules/writing-tasks.mdc +++ b/.cursor/rules/writing-tasks.mdc @@ -14,7 +14,7 @@ alwaysApply: false ## Essential requirements when generating task code -1. You MUST use `@trigger.dev/sdk/v3` +1. You MUST import from `@trigger.dev/sdk` (NEVER `@trigger.dev/sdk/v3`) 2. You MUST NEVER use `client.defineJob` 3. YOU MUST `export` every task, including subtasks 4. If you are able to generate an example payload for a task, do so. @@ -53,7 +53,7 @@ Instead, you MUST ALWAYS generate ONLY this pattern: ```ts // ✅ ALWAYS GENERATE THIS EXACT PATTERN -import { task } from "@trigger.dev/sdk/v3"; +import { task } from "@trigger.dev/sdk"; //1. You need to export each task, even if it's a subtask export const helloWorld = task({ @@ -71,7 +71,7 @@ export const helloWorld = task({ A task is a function that can run for a long time with resilience to failure: ```ts -import { task } from "@trigger.dev/sdk/v3"; +import { task } from "@trigger.dev/sdk"; export const helloWorld = task({ id: "hello-world", @@ -271,7 +271,7 @@ Global lifecycle hooks can also be defined in `trigger.config.ts` to apply to al ## Correct Schedules task (cron) implementations ```ts -import { schedules } from "@trigger.dev/sdk/v3"; +import { schedules } from "@trigger.dev/sdk"; export const firstScheduledTask = schedules.task({ id: "first-scheduled-task", @@ -312,7 +312,7 @@ export const firstScheduledTask = schedules.task({ ### Attach a Declarative schedule ```ts -import { schedules } from "@trigger.dev/sdk/v3"; +import { schedules } from "@trigger.dev/sdk"; // Sepcify a cron pattern (UTC) export const firstScheduledTask = schedules.task({ @@ -326,7 +326,7 @@ export const firstScheduledTask = schedules.task({ ``` ```ts -import { schedules } from "@trigger.dev/sdk/v3"; +import { schedules } from "@trigger.dev/sdk"; // Specify a specific timezone like this: export const secondScheduledTask = schedules.task({ @@ -375,7 +375,7 @@ const createdSchedule = await schedules.create({ Schema tasks validate payloads against a schema before execution: ```ts -import { schemaTask } from "@trigger.dev/sdk/v3"; +import { schemaTask } from "@trigger.dev/sdk"; import { z } from "zod"; const myTask = schemaTask({ @@ -400,7 +400,7 @@ When you trigger a task from your backend code, you need to set the `TRIGGER_SEC Triggers a single run of a task with specified payload and options without importing the task. Use type-only imports for full type checking. ```ts -import { tasks } from "@trigger.dev/sdk/v3"; +import { tasks } from "@trigger.dev/sdk"; import type { emailSequence } from "~/trigger/emails"; export async function POST(request: Request) { @@ -418,7 +418,7 @@ export async function POST(request: Request) { Triggers multiple runs of a single task with different payloads without importing the task. ```ts -import { tasks } from "@trigger.dev/sdk/v3"; +import { tasks } from "@trigger.dev/sdk"; import type { emailSequence } from "~/trigger/emails"; export async function POST(request: Request) { @@ -431,34 +431,12 @@ 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. ```ts -import { batch } from "@trigger.dev/sdk/v3"; +import { batch } from "@trigger.dev/sdk"; import type { myTask1, myTask2 } from "~/trigger/myTasks"; export async function POST(request: Request) { @@ -643,7 +621,7 @@ const handle = await myTask.trigger( Access metadata inside a run: ```ts -import { task, metadata } from "@trigger.dev/sdk/v3"; +import { task, metadata } from "@trigger.dev/sdk"; export const myTask = task({ id: "my-task", @@ -735,7 +713,7 @@ Trigger.dev Realtime enables subscribing to runs for real-time updates on run st Subscribe to a run after triggering a task: ```ts -import { runs, tasks } from "@trigger.dev/sdk/v3"; +import { runs, tasks } from "@trigger.dev/sdk"; async function myBackend() { const handle = await tasks.trigger("my-task", { some: "data" }); @@ -757,7 +735,7 @@ async function myBackend() { You can infer types of run's payload and output by passing the task type: ```ts -import { runs } from "@trigger.dev/sdk/v3"; +import { runs } from "@trigger.dev/sdk"; import type { myTask } from "./trigger/my-task"; for await (const run of runs.subscribeToRun(handle.id)) { @@ -774,7 +752,7 @@ for await (const run of runs.subscribeToRun(handle.id)) { Stream data in realtime from inside your tasks using the metadata system: ```ts -import { task, metadata } from "@trigger.dev/sdk/v3"; +import { task, metadata } from "@trigger.dev/sdk"; import OpenAI from "openai"; export type STREAMS = { @@ -969,7 +947,7 @@ For most use cases, Realtime hooks are preferred over SWR hooks with polling due For client-side usage, generate a public access token with appropriate scopes: ```ts -import { auth } from "@trigger.dev/sdk/v3"; +import { auth } from "@trigger.dev/sdk"; const publicToken = await auth.createPublicToken({ scopes: { @@ -989,7 +967,7 @@ Idempotency ensures that an operation produces the same result when called multi Provide an `idempotencyKey` when triggering a task to ensure it runs only once with that key: ```ts -import { idempotencyKeys, task } from "@trigger.dev/sdk/v3"; +import { idempotencyKeys, task } from "@trigger.dev/sdk"; export const myTask = task({ id: "my-task", @@ -1080,7 +1058,7 @@ function hash(payload: any): string { ```ts // onFailure executes after all retries are exhausted; use for notifications, logging, or side effects on final failure: -import { task, logger } from "@trigger.dev/sdk/v3"; +import { task, logger } from "@trigger.dev/sdk"; export const loggingExample = task({ id: "logging-example", @@ -1100,7 +1078,7 @@ export const loggingExample = task({ The `trigger.config.ts` file configures your Trigger.dev project, specifying task locations, retry settings, telemetry, and build options. ```ts -import { defineConfig } from "@trigger.dev/sdk/v3"; +import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ project: "", @@ -1248,7 +1226,7 @@ await myTask.trigger({ name: "Alice", age: 30 }); Before generating any code, you MUST verify: -1. Are you importing from `@trigger.dev/sdk/v3`? If not, STOP and FIX. +1. Are you importing from `@trigger.dev/sdk` (NOT `@trigger.dev/sdk/v3`)? If not, STOP and FIX. 2. Have you exported every task? If not, STOP and FIX. 3. Have you generated any DEPRECATED code patterns? If yes, STOP and FIX. 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 34bbb272e74..35c8c976ff6 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ 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" @@ -29,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= @@ -80,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 \ No newline at end of file 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..ce96548aa6f --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,20 @@ +# 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 +chengzp +bharathkumar39293 +bhekanik +jrossi \ 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..c7fc4e07136 --- /dev/null +++ b/.github/workflows/changesets-pr.yml @@ -0,0 +1,123 @@ +name: 🦋 Changesets PR + +on: + push: + branches: + - main + paths: + - "packages/**" + - ".changeset/**" + - ".server-changes/**" + - "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 and enhance body + 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" + + # Enhance the PR body with a clean, deduplicated summary + RAW_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body') + ENHANCED_BODY=$(CHANGESET_PR_BODY="$RAW_BODY" node scripts/enhance-release-pr.mjs "$VERSION") + if [ -n "$ENHANCED_BODY" ]; then + gh api repos/triggerdotdev/trigger.dev/pulls/"$PR_NUMBER" \ + -X PATCH \ + -f body="$ENHANCED_BODY" + fi + 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: Clean up consumed .server-changes/ files + run: | + set -e + shopt -s nullglob + files=(.server-changes/*.md) + for f in "${files[@]}"; do + if [ "$(basename "$f")" != "README.md" ]; then + git rm --ignore-unmatch "$f" + fi + done + + - name: Commit and push lockfile + server-changes cleanup + run: | + set -e + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add pnpm-lock.yaml + if ! git diff --cached --quiet; then + git commit -m "chore: update lockfile and clean up .server-changes/ for release" + git push origin changeset-release/main + else + echo "No changes to commit" + fi diff --git a/.github/workflows/claude-md-audit.yml b/.github/workflows/claude-md-audit.yml new file mode 100644 index 00000000000..a80bbca0f52 --- /dev/null +++ b/.github/workflows/claude-md-audit.yml @@ -0,0 +1,69 @@ +name: 📝 CLAUDE.md Audit + +on: + pull_request: + types: [opened, ready_for_review, synchronize] + paths-ignore: + - "docs/**" + - ".changeset/**" + - ".server-changes/**" + - "**/*.md" + - "references/**" + +concurrency: + group: claude-md-audit-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + audit: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + use_sticky_comment: true + + claude_args: | + --max-turns 15 + --allowedTools "Read,Glob,Grep,Bash(git diff:*)" + + prompt: | + You are reviewing a PR to check whether any CLAUDE.md files or .claude/rules/ files need updating. + + ## Your task + + 1. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR. + 2. For each changed directory, check if there's a CLAUDE.md in that directory or a parent directory. + 3. Determine if any CLAUDE.md or .claude/rules/ file should be updated based on the changes. Consider: + - New files/directories that aren't covered by existing documentation + - Changed architecture or patterns that contradict current CLAUDE.md guidance + - New dependencies, services, or infrastructure that Claude should know about + - Renamed or moved files that are referenced in CLAUDE.md + - Changes to build commands, test patterns, or development workflows + + ## Response format + + If NO updates are needed, respond with exactly: + ✅ CLAUDE.md files look current for this PR. + + If updates ARE needed, respond with a short list: + 📝 **CLAUDE.md updates suggested:** + - `path/to/CLAUDE.md`: [what should be added/changed] + - `.claude/rules/file.md`: [what should be added/changed] + + Keep suggestions specific and brief. Only flag things that would actually mislead Claude in future sessions. + Do NOT suggest updates for trivial changes (bug fixes, small refactors within existing patterns). + Do NOT suggest creating new CLAUDE.md files - only updates to existing ones. 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..79b113b0f2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,98 +1,264 @@ 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: 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 + - 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 + createGithubReleases: false 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 unified GitHub release + if: steps.changesets.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PR_BODY: ${{ github.event.pull_request.body }} + run: | + VERSION="${{ steps.get_version.outputs.package_version }}" + node scripts/generate-github-release.mjs "$VERSION" > /tmp/release-body.md + gh release create "v${VERSION}" \ + --title "trigger.dev v${VERSION}" \ + --notes-file /tmp/release-body.md \ + --target main + + - 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 }} + + # After Docker images are published, update the GitHub release with the exact GHCR tag URL. + # The GHCR package version ID is only known after the image is pushed, so we query for it here. + update-release: + name: 🔗 Update release Docker link + needs: [release, publish-docker] + if: needs.release.outputs.published == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + steps: + - name: Update GitHub release with Docker image link + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + VERSION="${{ needs.release.outputs.published_package_version }}" + TAG="v${VERSION}" + + # Query GHCR for the version ID matching this tag + VERSION_ID=$(gh api --paginate -H "Accept: application/vnd.github+json" \ + /orgs/triggerdotdev/packages/container/trigger.dev/versions \ + --jq ".[] | select(.metadata.container.tags[] == \"${TAG}\") | .id" \ + | head -1) + + if [ -z "$VERSION_ID" ]; then + echo "Warning: Could not find GHCR version ID for tag ${TAG}, skipping update" + exit 0 + fi + + DOCKER_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev/${VERSION_ID}?tag=${TAG}" + GENERIC_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev" + + # Get current release body and replace the generic link with the tag-specific one. + # Use word boundary after GENERIC_URL (closing paren) to avoid matching URLs that + # already have a version ID appended (idempotent on re-runs). + gh release view "${TAG}" --repo triggerdotdev/trigger.dev --json body --jq '.body' > /tmp/release-body.md + sed -i "s|${GENERIC_URL})|${DOCKER_URL})|g" /tmp/release-body.md + + gh release edit "${TAG}" --repo triggerdotdev/trigger.dev --notes-file /tmp/release-body.md + + # Dispatch changelog entry creation to the marketing site repo. + # Runs after update-release so the GitHub release body already has the exact Docker image URL. + dispatch-changelog: + name: 📝 Dispatch changelog PR + needs: [release, update-release] + if: needs.release.outputs.published == 'true' + runs-on: ubuntu-latest + steps: + - uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: triggerdotdev/trigger.dev-site-v3 + event-type: new-release + client-payload: '{"version": "${{ 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..21597cf467a --- /dev/null +++ b/.github/workflows/vouch-check-pr.yml @@ -0,0 +1,44 @@ +name: Vouch - Check PR + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + check-vouch: + runs-on: ubuntu-latest + steps: + - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + with: + pr-number: ${{ github.event.pull_request.number }} + auto-close: true + require-vouch: true + env: + GH_TOKEN: ${{ github.token }} + + require-draft: + needs: check-vouch + if: > + github.event.pull_request.draft == false && + github.event.pull_request.author_association != 'MEMBER' && + github.event.pull_request.author_association != 'OWNER' && + github.event.pull_request.author_association != 'COLLABORATOR' + runs-on: ubuntu-latest + steps: + - name: Close non-draft PR + env: + GH_TOKEN: ${{ github.token }} + run: | + STATE=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json state -q '.state') + if [ "$STATE" != "OPEN" ]; then + echo "PR is already closed, skipping." + exit 0 + fi + gh pr close ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --comment "Thanks for your contribution! We require all external PRs to be opened in **draft** status first so you can address CodeRabbit review comments and ensure CI passes before requesting a review. Please re-open this PR as a draft. See [CONTRIBUTING.md](https://github.com/${{ github.repository }}/blob/main/CONTRIBUTING.md#pr-workflow) for details." diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml new file mode 100644 index 00000000000..51bce367b3e --- /dev/null +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -0,0 +1,24 @@ +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: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + 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..5f6adddba0a 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,8 @@ 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 +ailogger-output.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/.server-changes/.gitkeep b/.server-changes/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.server-changes/README.md b/.server-changes/README.md new file mode 100644 index 00000000000..82716de981c --- /dev/null +++ b/.server-changes/README.md @@ -0,0 +1,81 @@ +# Server Changes + +This directory tracks changes to server-only components (webapp, supervisor, coordinator, etc.) that are not captured by changesets. Changesets only track published npm packages — server changes would otherwise go undocumented. + +## When to add a file + +**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, or other server components (and does NOT change anything in `packages/`), add a `.server-changes/` file. + +**Mixed PRs** (both packages and server): Just add a changeset as usual. No `.server-changes/` file needed — the changeset covers it. + +**Package-only PRs**: Just add a changeset as usual. + +## File format + +Create a markdown file with a descriptive name: + +``` +.server-changes/fix-batch-queue-stalls.md +``` + +With this format: + +```markdown +--- +area: webapp +type: fix +--- + +Speed up batch queue processing by removing stalls and fixing retry race +``` + +### Fields + +- **area** (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider` +- **type** (required): `feature` | `fix` | `improvement` | `breaking` + +### Description + +The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes. + +## Lifecycle + +1. Engineer adds a `.server-changes/` file in their PR +2. Files accumulate on `main` as PRs merge +3. The changeset release PR includes these in its summary +4. After the release merges, CI cleans up the consumed files + +## Examples + +**New feature:** + +```markdown +--- +area: webapp +type: feature +--- + +TRQL query language and the Query page +``` + +**Bug fix:** + +```markdown +--- +area: webapp +type: fix +--- + +Fix schedule limit counting for orgs with custom limits +``` + +**Improvement:** + +```markdown +--- +area: webapp +type: improvement +--- + +Use the replica for API auth queries to reduce primary load +``` diff --git a/.server-changes/ai-prompt-management.md b/.server-changes/ai-prompt-management.md new file mode 100644 index 00000000000..624ec391047 --- /dev/null +++ b/.server-changes/ai-prompt-management.md @@ -0,0 +1,30 @@ +--- +area: webapp +type: feature +--- + +AI prompt management dashboard and enhanced span inspectors. + +**Prompt management:** +- Prompts list page with version status, model, override indicators, and 24h usage sparklines +- Prompt detail page with template viewer, variable preview, version history timeline, and override editor +- Create, edit, and remove overrides to change prompt content or model without redeploying +- Promote any code-deployed version to current +- Generations tab with infinite scroll, live polling, and inline span inspector +- Per-prompt metrics: total generations, avg tokens, avg cost, latency, with version-level breakdowns + +**AI span inspectors:** +- Custom inspectors for `ai.generateText`, `ai.streamText`, `ai.generateObject`, `ai.streamObject` parent spans +- `ai.toolCall` inspector showing tool name, call ID, and input arguments +- `ai.embed` inspector showing model, provider, and input text +- Prompt tab on AI spans linking to prompt version with template and input variables +- Compact timestamp and duration header on all AI span inspectors + +**AI metrics dashboard:** +- Operations, Providers, and Prompts filters on the AI Metrics dashboard +- Cost by prompt widget +- "AI" section in the sidebar with Prompts and AI Metrics links + +**Other improvements:** +- Resizable panel sizes now persist across page refreshes +- Fixed `
` inside `

` DOM nesting warnings in span titles and chat messages diff --git a/.server-changes/allow-rollbacks-promote-api.md b/.server-changes/allow-rollbacks-promote-api.md new file mode 100644 index 00000000000..fc03fa114ff --- /dev/null +++ b/.server-changes/allow-rollbacks-promote-api.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add allowRollbacks query param to the promote deployment API to enable version downgrades diff --git a/.server-changes/ck-index-master-queue-dedup.md b/.server-changes/ck-index-master-queue-dedup.md new file mode 100644 index 00000000000..a2ff6495e61 --- /dev/null +++ b/.server-changes/ck-index-master-queue-dedup.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Concurrency-keyed queues now use a single master queue entry per base queue instead of one entry per key. Prevents high-CK-count tenants from consuming the entire parentQueueLimit window and starving other tenants on the same shard. diff --git a/.server-changes/dev-cli-disconnect-md b/.server-changes/dev-cli-disconnect-md new file mode 100644 index 00000000000..a0790d70765 --- /dev/null +++ b/.server-changes/dev-cli-disconnect-md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Added `/engine/v1/dev/disconnect` endpoint to auto-cancel runs when the CLI disconnects. Maximum of 500 runs can be cancelled. Uses the bulk action system when there are more than 25 runs to cancel. \ No newline at end of file diff --git a/.server-changes/fix-batch-waitpoint-lock-contention.md b/.server-changes/fix-batch-waitpoint-lock-contention.md new file mode 100644 index 00000000000..6b545eb794b --- /dev/null +++ b/.server-changes/fix-batch-waitpoint-lock-contention.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Reduce lock contention when processing large `batchTriggerAndWait` batches. Previously, each batch item acquired a Redis lock on the parent run to insert a `TaskRunWaitpoint` row, causing `LockAcquisitionTimeoutError` with high concurrency (880 errors/24h in prod). Since `blockRunWithCreatedBatch` already transitions the parent to `EXECUTING_WITH_WAITPOINTS` before items are processed, the per-item lock is unnecessary. The new `blockRunWithWaitpointLockless` method performs only the idempotent CTE insert without acquiring the lock. diff --git a/.server-changes/fix-clickhouse-query-client-secure-param.md b/.server-changes/fix-clickhouse-query-client-secure-param.md new file mode 100644 index 00000000000..4daa021fe40 --- /dev/null +++ b/.server-changes/fix-clickhouse-query-client-secure-param.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Strip `secure` query parameter from QUERY_CLICKHOUSE_URL before passing to ClickHouse client. This was already done for the main and logs ClickHouse clients but was missing for the query client, causing a startup crash with `Error: Unknown URL parameters: secure`. diff --git a/.server-changes/llm-cost-tracking.md b/.server-changes/llm-cost-tracking.md new file mode 100644 index 00000000000..7567aae7d1b --- /dev/null +++ b/.server-changes/llm-cost-tracking.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add automatic LLM cost calculation for spans with GenAI semantic conventions. When a span arrives with `gen_ai.response.model` and token usage data, costs are calculated from an in-memory pricing registry backed by Postgres and dual-written to both span attributes (`trigger.llm.*`) and a new `llm_metrics_v1` ClickHouse table that captures usage, cost, performance (TTFC, tokens/sec), and behavioral (finish reason, operation type) metrics. diff --git a/.server-changes/mcp-get-span-details.md b/.server-changes/mcp-get-span-details.md new file mode 100644 index 00000000000..336595d2203 --- /dev/null +++ b/.server-changes/mcp-get-span-details.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add API endpoint `GET /api/v1/runs/:runId/spans/:spanId` that returns detailed span information including properties, events, AI enrichment (model, tokens, cost), and triggered child runs. diff --git a/.vouch.yml b/.vouch.yml new file mode 100644 index 00000000000..8a9668392d3 --- /dev/null +++ b/.vouch.yml @@ -0,0 +1,2 @@ +vouch: + - github: edosrecki 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..2e225b9ad34 100644 --- a/CHANGESETS.md +++ b/CHANGESETS.md @@ -1,24 +1,49 @@ -# Changesets +# Changesets and Server Changes -Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage updated our packages and releasing them to npm. +Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage package versions and releasing them to npm. For server-only changes, we use a lightweight `.server-changes/` convention. -## Adding a changeset +## Adding a changeset (package changes) To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset. -## Release instructions (local only) +## Adding a server change (server-only changes) -Based on the instructions [here](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md) +If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) and does NOT change any published packages, add a `.server-changes/` file instead of a changeset: -1. Run `pnpm run changeset:version` -2. Run `pnpm run changeset:release` +```sh +cat > .server-changes/fix-batch-queue-stalls.md << 'EOF' +--- +area: webapp +type: fix +--- + +Speed up batch queue processing by removing stalls and fixing retry race +EOF +``` + +- `area`: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider` +- `type`: `feature` | `fix` | `improvement` | `breaking` + +For **mixed PRs** (both packages and server): just add a changeset. No `.server-changes/` file needed. + +See `.server-changes/README.md` for full documentation. + +## When to add which + +| PR changes | What to add | +|---|---| +| Only packages (`packages/`) | Changeset (`pnpm run changeset:add`) | +| Only server (`apps/`) | `.server-changes/` file | +| Both packages and server | Just the changeset | ## Release instructions (CI) Please follow the best-practice of adding changesets in the same commit as the code making the change with `pnpm run changeset:add`, as it will allow our release.yml CI workflow to function properly: -- Anytime new changesets are added in a commit in the `main` branch, the [release.yml](./.github/workflows/release.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`. -- When the version PR is merged into `main`, the release.yml workflow will automatically run `pnpm run changeset:release` to build and release packages to npm. +- Anytime new changesets are added in a commit in the `main` branch, the [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`. +- The release PR body is automatically enhanced with a clean, deduplicated summary that includes both package changes and `.server-changes/` entries. +- Consumed `.server-changes/` files are removed on the `changeset-release/main` branch — the same way changesets deletes `.changeset/*.md` files. When the release PR merges, they're gone from main. +- When the version PR is merged into `main`, the [release.yml](./.github/workflows/release.yml) workflow will automatically build, release packages to npm, and create a single unified GitHub release. ## Pre-release instructions @@ -30,14 +55,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..9e53955b092 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,243 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with this repository. Subdirectory CLAUDE.md files provide deeper context when you navigate into specific areas. + +## Build and Development Commands + +This is a pnpm 10.23.0 monorepo using Turborepo. Run commands from root with `pnpm run`. + +```bash +pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric) +pnpm run db:migrate # Run database migrations +pnpm run db:seed # Seed the database (required for reference projects) + +# Build packages (required before running) +pnpm run build --filter webapp && pnpm run build --filter trigger.dev && pnpm run build --filter @trigger.dev/sdk + +pnpm run dev --filter webapp # Run webapp (http://localhost:3030) +pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" # Watch CLI and packages +``` + +### Verifying Changes + +The verification command depends on where the change lives: + +- **Apps and internal packages** (`apps/*`, `internal-packages/*`): Use `typecheck`. **Never use `build`** for these — building proves almost nothing about correctness. +- **Public packages** (`packages/*`): Use `build`. + +```bash +# Apps and internal packages — use typecheck +pnpm run typecheck --filter webapp # ~1-2 minutes +pnpm run typecheck --filter @internal/run-engine + +# Public packages — use build +pnpm run build --filter @trigger.dev/sdk +pnpm run build --filter @trigger.dev/core +``` + +Only run typecheck/build after major changes (new files, significant refactors, schema changes). For small edits, trust the types and let CI catch issues. + +## Testing + +We use vitest exclusively. **Never mock anything** - use testcontainers instead. + +```bash +pnpm run test --filter webapp # All tests for a package +cd internal-packages/run-engine +pnpm run test ./src/engine/tests/ttl.test.ts --run # Single test file +pnpm run build --filter @internal/run-engine # May need to build deps first +``` + +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"; + +redisTest("should use redis", async ({ redisOptions }) => { + /* ... */ +}); +postgresTest("should use postgres", async ({ prisma }) => { + /* ... */ +}); +containerTest("should use both", async ({ prisma, redisOptions }) => { + /* ... */ +}); +``` + +## Changesets and Server Changes + +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 without explicit approval + +When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation. + +## Architecture Overview + +### Request Flow + +User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Supervisor -> Container execution -> Results back through RunEngine -> ClickHouse (analytics) + PostgreSQL (state) + +### Apps + +- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, orchestration. Uses Express server. +- **apps/supervisor**: Manages task execution containers (Docker/Kubernetes). + +### Public Packages + +- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK for writing tasks +- **packages/cli-v3** (`trigger.dev`): CLI - also bundles code that goes into customer task images +- **packages/core** (`@trigger.dev/core`): Shared types. **Import subpaths only** (never root). +- **packages/build** (`@trigger.dev/build`): Build extensions and types +- **packages/react-hooks**: React hooks for realtime and triggering +- **packages/redis-worker** (`@trigger.dev/redis-worker`): Redis-based background job system + +### Internal Packages + +- **internal-packages/database**: Prisma 6.14.0 client and schema (PostgreSQL) +- **internal-packages/clickhouse**: ClickHouse client, schema migrations, analytics queries +- **internal-packages/run-engine**: "Run Engine 2.0" - core run lifecycle management +- **internal-packages/redis**: Redis client creation utilities (ioredis) +- **internal-packages/testcontainers**: Test helpers for Redis/PostgreSQL containers +- **internal-packages/schedule-engine**: Durable cron scheduling +- **internal-packages/zodworker**: Graphile-worker wrapper (DEPRECATED - use redis-worker) + +### Legacy V1 Engine Code + +The `apps/webapp/app/v3/` directory name is misleading - most code there is actively used by V2. Only specific files are V1-only legacy (MarQS queue, triggerTaskV1, cancelTaskRunV1, etc.). See `apps/webapp/CLAUDE.md` for the exact list. When you encounter V1/V2 branching in services, only modify V2 code paths. All new work uses Run Engine 2.0 (`@internal/run-engine`) and redis-worker. + +### Documentation + +Docs live in `docs/` as a Mintlify site (MDX format). See `docs/CLAUDE.md` for conventions. + +### Reference Projects + +The `references/` directory contains test workspaces for testing SDK and platform features. Use `references/hello-world` to manually test changes before submitting PRs. + +## Docker Image Guidelines + +When updating Docker image references: + +- **Always use multiplatform/index digests**, not architecture-specific digests +- Architecture-specific digests cause CI failures on different build environments +- Use the digest from the main Docker Hub page, not from a specific OS/ARCH variant + +## Writing Trigger.dev Tasks + +Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`. + +```typescript +import { task } from "@trigger.dev/sdk"; + +export const myTask = task({ + id: "my-task", + run: async (payload: { message: string }) => { + // Task logic + }, +}); +``` + +### SDK Documentation Rules + +The `rules/` directory contains versioned SDK documentation distributed via the SDK installer. Current version: `rules/manifest.json`. Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked - these are maintained in separate dedicated passes. + +## Testing with hello-world Reference Project + +First-time setup: + +1. `pnpm run db:seed` to seed the database +2. Build CLI: `pnpm run build --filter trigger.dev && pnpm i` +3. Authorize: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030` + +Running: `cd references/hello-world && pnpm exec trigger dev` + +## Local Task Testing Workflow + +### Step 1: Start Webapp in Background + +```bash +# Run from repo root with run_in_background: true +pnpm run dev --filter webapp +curl -s http://localhost:3030/healthcheck # Verify running +``` + +### Step 2: Start Trigger Dev in Background + +```bash +cd references/hello-world && pnpm exec trigger dev +# Wait for "Local worker ready [node]" +``` + +### Step 3: Trigger and Monitor Tasks via MCP + +``` +mcp__trigger__get_current_worker(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev") +mcp__trigger__trigger_task(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskId: "hello-world", payload: {"message": "Hello"}) +mcp__trigger__list_runs(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskIdentifier: "hello-world", limit: 5) +``` + +Dashboard: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs + + + +# Skill mappings — when working in these areas, load the linked skill file into context. + +skills: + +- task: "Using agentcrumbs for debug tracing, adding crumbs, trails, markers, querying traces, or stripping debug code before merge" + load: "node_modules/agentcrumbs/skills/agentcrumbs/SKILL.md" +- task: "Setting up agentcrumbs in the project, initializing namespace catalog, running crumbs init" +load: "node_modules/agentcrumbs/skills/agentcrumbs/init/SKILL.md" + + +## agentcrumbs + +Add crumbs as you write code — not just when debugging. Mark lines with +`// @crumbs` or wrap blocks in `// #region @crumbs`. They stay on the +branch throughout development and are stripped by `agentcrumbs strip` +before merge. + +### Namespaces + +| Namespace | Description | Path | +| ----------------- | ---------------------------------------------- | ----------------------------------- | +| `webapp` | Main Remix app — API, dashboard, orchestration | `apps/webapp` | +| `supervisor` | Task execution container manager | `apps/supervisor` | +| `sdk` | Main SDK for writing tasks | `packages/trigger-sdk` | +| `cli` | CLI and task image bundling | `packages/cli-v3` | +| `core` | Shared types and subpath exports | `packages/core` | +| `build` | Build extensions and types | `packages/build` | +| `react-hooks` | React hooks for realtime/triggering | `packages/react-hooks` | +| `redis-worker` | Redis-based background job system | `packages/redis-worker` | +| `run-engine` | Run Engine 2.0 — core run lifecycle | `internal-packages/run-engine` | +| `database` | Prisma client and schema | `internal-packages/database` | +| `clickhouse` | ClickHouse client, migrations, analytics | `internal-packages/clickhouse` | +| `schedule-engine` | Durable cron scheduling | `internal-packages/schedule-engine` | +| `run-queue` | Run queue management | `internal-packages/run-queue` | + +Do not invent new namespaces — pick from this table or ask first. + +### For PR reviewers + +Do not flag `// @crumbs` markers, `#region @crumbs` blocks, or agentcrumbs +imports in reviews. These are temporary debug instrumentation stripped +before merge. Data logged in crumbs (IDs, names, values) never reaches +production. + +### CLI + +```bash +pnpm exec agentcrumbs collect # start collector (multi-service) +pnpm exec agentcrumbs tail --app trigger # live tail +pnpm exec agentcrumbs clear --app trigger # remove crumbs before merge +``` + +The preferred way to query for crumbs is to use `pnpm exec agentcrumbs query --app trigger` with the `--limit` option and cursor pagination, and clear existing crumbs before reproducing a bug via `pnpm exec agentcrumbs clear --app trigger`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 709dcca47cc..88e24cba4f0 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. +3. Open another terminal window, and change into the `/references/hello-world` directory. -4. You'll need to run the following commands to setup prisma and migrate the database: +4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution: ```sh -pnpm exec prisma migrate deploy -pnpm run generate:prisma -``` - -5. 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) - -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 -``` - -2. Set up dependencies - -```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 +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. -```sh -pnpm run docker -pnpm run db:migrate -pnpm run db:seed -``` +7. Navigate to the `hello-world` project in your local dashboard at localhost:3030 and you should see the list of tasks. -4. Run the end-to-end tests +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 -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 @@ -282,9 +242,23 @@ See the [Job Catalog](./references/job-catalog/README.md) file for more. **If you get errors, be sure to fix them before committing.** -- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR. -- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue - ](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). +> **Note:** We may close PRs if we decide that the cost of integrating the change outweighs the benefits. To improve the chances of your PR getting accepted, follow the guidelines below. + +### PR workflow + +1. **Always open your PR in draft status first.** Do not mark it as "Ready for Review" until the steps below are complete. +2. **Address all CodeRabbit code review comments.** Our CI runs an automated code review via CodeRabbit. Go through each comment and either fix the issue or resolve it with a comment explaining why no change is needed. +3. **Wait for all CI checks to pass.** Do not mark the PR as "Ready for Review" until every check is green. +4. **Then mark the PR as "Ready for Review"** so a maintainer can take a look. + +### Cost/benefit analysis for risky changes + +If your change touches core infrastructure, modifies widely-used code paths, or could introduce regressions, consider doing a brief cost/benefit analysis and including it in the PR description. Explain what the benefit is to users and why the risk is worth it. This goes a long way toward helping maintainers evaluate your contribution. + +### General guidelines + +- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR. +- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). - Be sure to fill the PR Template accordingly. ## Adding changesets @@ -307,6 +281,39 @@ You will be prompted to select which packages to include in the changeset. Only Most of the time the changes you'll make are likely to be categorized as patch releases. If you feel like there is the need for a minor or major release of the package based on the changes being made, add the changeset as such and it will be discussed during PR review. +## Adding server changes + +Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, etc.) with no package changes, add a `.server-changes/` file so the change appears in release notes. + +Create a markdown file with a descriptive name: + +```sh +cat > .server-changes/fix-batch-queue-stalls.md << 'EOF' +--- +area: webapp +type: fix +--- + +Speed up batch queue processing by removing stalls and fixing retry race +EOF +``` + +**Fields:** +- `area` (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider` +- `type` (required): `feature` | `fix` | `improvement` | `breaking` + +The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes. + +**When to add which:** + +| PR changes | What to add | +|---|---| +| Only packages (`packages/`) | Changeset | +| Only server (`apps/`) | `.server-changes/` file | +| Both packages and server | Just the changeset | + +See `.server-changes/README.md` for more details. + ## Troubleshooting ### EADDRINUSE: address already in use :::3030 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/RELEASE.md b/RELEASE.md index 1b9273cb142..8ba3ecb5007 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,28 @@ ## Guide on releasing a new version +### Automated release (v4+) + +Releases are fully automated via CI: + +1. PRs merge to `main` with changesets (for package changes) and/or `.server-changes/` files (for server-only changes). +2. The [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow automatically creates/updates the `changeset-release/main` PR with version bumps and an enhanced summary of all changes. Consumed `.server-changes/` files are removed on the release branch (same approach changesets uses for `.changeset/` files — they're deleted on the branch, so merging the PR cleans them up). +3. When ready to release, merge the changeset release PR into `main`. +4. The [release.yml](./.github/workflows/release.yml) workflow automatically: + - Publishes all packages to npm + - Creates a single unified GitHub release (e.g., "trigger.dev v4.3.4") + - Tags and triggers Docker image builds + - After Docker images are pushed, updates the GitHub release with the exact GHCR tag link + +### What engineers need to do + +- **Package changes**: Add a changeset with `pnpm run changeset:add` +- **Server-only changes**: Add a `.server-changes/` file (see `.server-changes/README.md`) +- **Mixed PRs**: Just the changeset is enough + +See `CHANGESETS.md` for full details on changesets and server changes. + +### Legacy release (v3) + 1. Merge in the changeset PR into main, making sure to cancel both the release and publish github actions from that merge. 2. Pull the changes locally into main 3. Run `pnpm i` which will update the pnpm lock file with the new versions 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/ailogger-output.log b/ailogger-output.log new file mode 100644 index 00000000000..e69de29bb2d 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/CLAUDE.md b/apps/supervisor/CLAUDE.md new file mode 100644 index 00000000000..ded836c6069 --- /dev/null +++ b/apps/supervisor/CLAUDE.md @@ -0,0 +1,20 @@ +# Supervisor + +Node.js app that manages task execution containers. Receives work from the platform, starts Docker/Kubernetes containers, monitors execution, and reports results. + +## Key Directories + +- `src/services/` - Core service logic +- `src/workloadManager/` - Container orchestration abstraction (Docker or Kubernetes) +- `src/workloadServer/` - HTTP server for workload communication (heartbeats, snapshots) +- `src/clients/` - Platform communication (webapp/coordinator) +- `src/env.ts` - Environment configuration + +## Architecture + +- **WorkloadManager**: Abstracts Docker vs Kubernetes execution +- **SupervisorSession**: Manages the dequeue loop with EWMA-based dynamic scaling +- **ResourceMonitor**: Tracks CPU/memory during execution +- **PodCleaner/FailedPodHandler**: Kubernetes-specific cleanup + +Communicates with the platform via Socket.io and HTTP. Receives task assignments through the dequeue protocol from the webapp. diff --git a/apps/supervisor/Containerfile b/apps/supervisor/Containerfile index b0b1ba92714..d5bb5862e96 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 @@ -37,7 +37,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 fd6bd610509..9eb4aead840 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(), @@ -76,6 +84,53 @@ 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 + // Large machine affinity settings - large-* presets prefer a dedicated pool + KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED: BoolEnv.default(false), + KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY: z.string().trim().min(1).default("node.cluster.x-k8s.io/machinepool"), + KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE: z.string().trim().min(1).default("large-machines"), + KUBERNETES_LARGE_MACHINE_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(100), + + // 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"), + + // Schedule affinity settings - runs from schedule trees prefer a dedicated pool + KUBERNETES_SCHEDULE_AFFINITY_ENABLED: BoolEnv.default(false), + KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_KEY: z.string().trim().min(1).default("node.cluster.x-k8s.io/machinepool"), + KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_VALUE: z.string().trim().min(1).default("scheduled-runs"), + KUBERNETES_SCHEDULE_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(80), + KUBERNETES_SCHEDULE_ANTI_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(20), + + // 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..bcd68318246 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,16 @@ 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, + annotations: message.run.annotations, }); // 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 81618e8eb5b..0aa5b170126 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 }); @@ -48,12 +119,13 @@ export class KubernetesWorkloadManager implements WorkloadManager { }, }, spec: { - ...this.#defaultPodSpec, + ...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags), + affinity: this.#getAffinity(opts), terminationGracePeriodSeconds: 60 * 60, containers: [ { name: "run-controller", - image: opts.image, + image: this.stripImageDigest(opts.image), ports: [ { containerPort: 8000, @@ -77,6 +149,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, @@ -228,6 +308,11 @@ export class KubernetesWorkloadManager implements WorkloadManager { restartPolicy: "Never", automountServiceAccountToken: false, imagePullSecrets: this.getImagePullSecrets(), + ...(env.KUBERNETES_SCHEDULER_NAME + ? { + schedulerName: env.KUBERNETES_SCHEDULER_NAME, + } + : {}), ...(env.KUBERNETES_WORKER_NODETYPE_LABEL ? { nodeSelector: { @@ -250,26 +335,50 @@ export class KubernetesWorkloadManager implements WorkloadManager { }; } + #isScheduledRun(opts: WorkloadManagerCreateOptions): boolean { + return opts.annotations?.rootTriggerSource === "schedule"; + } + #getSharedLabels(opts: WorkloadManagerCreateOptions): Record { return { env: opts.envId, envtype: this.#envTypeToLabelValue(opts.envType), org: opts.orgId, project: opts.projectId, + machine: opts.machine.name, + // We intentionally use a boolean label rather than exposing the full trigger source + // (e.g. sdk, api, cli, mcp, schedule) to keep label cardinality low in metrics. + // The schedule vs non-schedule distinction is all we need for the current metrics + // and pool-level scheduling decisions; finer-grained source breakdowns live in run annotations. + scheduled: String(this.#isScheduledRun(opts)), }; } #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`, }; } @@ -285,4 +394,156 @@ export class KubernetesWorkloadManager implements WorkloadManager { }, }; } + + #isLargeMachine(preset: MachinePreset): boolean { + return preset.name.startsWith("large-"); + } + + #getAffinity(opts: WorkloadManagerCreateOptions): k8s.V1Affinity | undefined { + const largeNodeAffinity = this.#getNodeAffinityRules(opts.machine); + const scheduleNodeAffinity = this.#getScheduleNodeAffinityRules(this.#isScheduledRun(opts)); + const podAffinity = this.#getProjectPodAffinity(opts.projectId); + + // Merge node affinity rules from multiple sources + const preferred = [ + ...(largeNodeAffinity?.preferredDuringSchedulingIgnoredDuringExecution ?? []), + ...(scheduleNodeAffinity?.preferredDuringSchedulingIgnoredDuringExecution ?? []), + ]; + // Only large machine affinity produces hard requirements (non-large runs must stay off the large pool). + // Schedule affinity is soft both ways. + const required = [ + ...(largeNodeAffinity?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms ?? []), + ]; + + const hasNodeAffinity = preferred.length > 0 || required.length > 0; + + if (!hasNodeAffinity && !podAffinity) { + return undefined; + } + + return { + ...(hasNodeAffinity && { + nodeAffinity: { + ...(preferred.length > 0 && { preferredDuringSchedulingIgnoredDuringExecution: preferred }), + ...(required.length > 0 && { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: required }, + }), + }, + }), + ...(podAffinity && { podAffinity }), + }; + } + + #getNodeAffinityRules(preset: MachinePreset): k8s.V1NodeAffinity | undefined { + if (!env.KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED) { + return undefined; + } + + if (this.#isLargeMachine(preset)) { + // soft preference for the large-machine pool, falls back to standard if unavailable + return { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: env.KUBERNETES_LARGE_MACHINE_AFFINITY_WEIGHT, + preference: { + matchExpressions: [ + { + key: env.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY, + operator: "In", + values: [env.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE], + }, + ], + }, + }, + ], + }; + } + + // not schedulable in the large-machine pool + return { + requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [ + { + matchExpressions: [ + { + key: env.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY, + operator: "NotIn", + values: [env.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE], + }, + ], + }, + ], + }, + }; + } + + #getScheduleNodeAffinityRules(isScheduledRun: boolean): k8s.V1NodeAffinity | undefined { + if (!env.KUBERNETES_SCHEDULE_AFFINITY_ENABLED || !env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_VALUE) { + return undefined; + } + + if (isScheduledRun) { + // soft preference for the schedule pool + return { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: env.KUBERNETES_SCHEDULE_AFFINITY_WEIGHT, + preference: { + matchExpressions: [ + { + key: env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_KEY, + operator: "In", + values: [env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_VALUE], + }, + ], + }, + }, + ], + }; + } + + // soft anti-affinity: non-schedule runs prefer to avoid the schedule pool + return { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: env.KUBERNETES_SCHEDULE_ANTI_AFFINITY_WEIGHT, + preference: { + matchExpressions: [ + { + key: env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_KEY, + operator: "NotIn", + values: [env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_VALUE], + }, + ], + }, + }, + ], + }; + } + + #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..fca27b249a2 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, RunAnnotations } from "@trigger.dev/core/v3"; export interface WorkloadManagerOptions { workloadApiProtocol: "http" | "https"; @@ -23,13 +23,17 @@ 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; snapshotFriendlyId: string; + annotations?: RunAnnotations; } 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/CLAUDE.md b/apps/webapp/CLAUDE.md new file mode 100644 index 00000000000..2acd5c02d77 --- /dev/null +++ b/apps/webapp/CLAUDE.md @@ -0,0 +1,100 @@ +# Webapp + +Remix 2.1.0 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`). + +## Verifying Changes + +**Never run `pnpm run build --filter webapp` to verify changes.** Building proves almost nothing about correctness. The webapp is an app, not a public package — use typecheck from the repo root: + +```bash +pnpm run typecheck --filter webapp # ~1-2 minutes +``` + +Only run typecheck after major changes (new files, significant refactors, schema changes). For small edits, trust the types and let CI catch issues. + +Note: Public packages (`packages/*`) use `build` instead. See the root CLAUDE.md for details. + +## Testing Dashboard Changes with Chrome DevTools MCP + +Use the `chrome-devtools` MCP server to visually verify local dashboard changes. The webapp must be running (`pnpm run dev --filter webapp` from repo root). + +### Login + +``` +1. mcp__chrome-devtools__new_page(url: "http://localhost:3030") + → Redirects to /login +2. mcp__chrome-devtools__click the "Continue with Email" link +3. mcp__chrome-devtools__fill the email field with "local@trigger.dev" +4. mcp__chrome-devtools__click "Send a magic link" + → Auto-logs in and redirects to the dashboard (no email verification needed locally) +``` + +### Navigating and Verifying + +- **take_snapshot**: Get an a11y tree of the page (text content, element UIDs for interaction). Prefer this over screenshots for understanding page structure. +- **take_screenshot**: Capture what the page looks like visually. Use to verify styling, layout, and visual changes. +- **navigate_page**: Go to specific URLs, e.g. `http://localhost:3030/orgs/references-bc08/projects/hello-world-SiWs/env/dev/runs` +- **click / fill**: Interact with elements using UIDs from `take_snapshot`. +- **evaluate_script**: Run JS in the browser console for debugging. +- **list_console_messages**: Check for console errors after navigating. + +### Tips + +- Snapshots can be very large on complex pages (200K+ chars). Use `take_screenshot` first to orient, then `take_snapshot` only when you need element UIDs to interact. +- The local seeded user email is `local@trigger.dev`. +- Dashboard URL pattern: `http://localhost:3030/orgs/{orgSlug}/projects/{projectSlug}/env/{envSlug}/{section}` + +## Key File Locations + +- **Trigger API**: `app/routes/api.v1.tasks.$taskId.trigger.ts` +- **Batch trigger**: `app/routes/api.v1.tasks.batch.ts` +- **OTEL endpoints**: `app/routes/otel.v1.logs.ts`, `app/routes/otel.v1.traces.ts` +- **Prisma setup**: `app/db.server.ts` +- **Run engine config**: `app/v3/runEngine.server.ts` +- **Services**: `app/v3/services/**/*.server.ts` +- **Presenters**: `app/v3/presenters/**/*.server.ts` + +## Route Convention + +Routes use Remix flat-file convention with dot-separated segments: +`api.v1.tasks.$taskId.trigger.ts` -> `/api/v1/tasks/:taskId/trigger` + +## Environment Variables + +Access via `env` export from `app/env.server.ts`. **Never use `process.env` directly.** + +For testable code, **never import env.server.ts** in test files. Pass configuration as options instead: +- `realtimeClient.server.ts` (testable service, takes config as constructor arg) +- `realtimeClientGlobal.server.ts` (creates singleton with env config) + +## Run Engine 2.0 + +The webapp integrates `@internal/run-engine` via `app/v3/runEngine.server.ts`. This is the singleton engine instance. Services in `app/v3/services/` call engine methods for all run lifecycle operations (triggering, completing, cancelling, etc.). + +The `engineVersion.server.ts` file determines V1 vs V2 for a given environment. New code should always target V2. + +## Background Workers + +Background job workers use `@trigger.dev/redis-worker`: +- `app/v3/commonWorker.server.ts` +- `app/v3/alertsWorker.server.ts` +- `app/v3/batchTriggerWorker.server.ts` + +Do NOT add new jobs using zodworker/graphile-worker (legacy). + +## Real-time + +- Socket.io: `app/v3/handleSocketIo.server.ts`, `app/v3/handleWebsockets.server.ts` +- Electric SQL: Powers real-time data sync for the dashboard + +## Legacy V1 Code + +The `app/v3/` directory name is misleading - most code is actively used by V2. Only these specific files are V1-only legacy: +- `app/v3/marqs/` (old MarQS queue system) +- `app/v3/legacyRunEngineWorker.server.ts` +- `app/v3/services/triggerTaskV1.server.ts` +- `app/v3/services/cancelTaskRunV1.server.ts` +- `app/v3/authenticatedSocketConnection.server.ts` +- `app/v3/sharedSocketConnection.ts` + +Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths. diff --git a/apps/webapp/app/assets/icons/AIMetricsIcon.tsx b/apps/webapp/app/assets/icons/AIMetricsIcon.tsx new file mode 100644 index 00000000000..038eea70b49 --- /dev/null +++ b/apps/webapp/app/assets/icons/AIMetricsIcon.tsx @@ -0,0 +1,16 @@ +export function AIMetricsIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/AIPromptsIcon.tsx b/apps/webapp/app/assets/icons/AIPromptsIcon.tsx new file mode 100644 index 00000000000..dd434df9931 --- /dev/null +++ b/apps/webapp/app/assets/icons/AIPromptsIcon.tsx @@ -0,0 +1,10 @@ +export function AIPromptsIcon({ className }: { className?: string }) { + return ( + + + + ); +} 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/AiProviderIcons.tsx b/apps/webapp/app/assets/icons/AiProviderIcons.tsx new file mode 100644 index 00000000000..85a01b98d63 --- /dev/null +++ b/apps/webapp/app/assets/icons/AiProviderIcons.tsx @@ -0,0 +1,177 @@ +type IconProps = { className?: string }; + +export function OpenAIIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function AnthropicIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function GeminiIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function LlamaIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function DeepseekIcon({ className }: IconProps) { + return ( + + + + + + + + + + + ); +} + +export function XAIIcon({ className }: IconProps) { + return ( + + + + + + + ); +} + +export function PerplexityIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function CerebrasIcon({ className }: IconProps) { + return ( + + + + + + + + ); +} + +export function MistralIcon({ className }: IconProps) { + return ( + + + + + + + + + + + + + ); +} + +export function AzureIcon({ className }: IconProps) { + return ( + + + + ); +} + diff --git a/apps/webapp/app/assets/icons/AnthropicLogoIcon.tsx b/apps/webapp/app/assets/icons/AnthropicLogoIcon.tsx new file mode 100644 index 00000000000..3e647284cce --- /dev/null +++ b/apps/webapp/app/assets/icons/AnthropicLogoIcon.tsx @@ -0,0 +1,12 @@ +export function AnthropicLogoIcon({ 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..fe39f6785c5 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -5,14 +5,14 @@ import { ChatBubbleLeftRightIcon, ClockIcon, PlusIcon, + QuestionMarkCircleIcon, RectangleGroupIcon, RectangleStackIcon, - ServerStackIcon, Squares2X2Icon, } from "@heroicons/react/20/solid"; import { useLocation } from "react-use"; +import { AIPromptsIcon } from "~/assets/icons/AIPromptsIcon"; 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"; @@ -23,26 +23,40 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { NewBranchPanel } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; +import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; import { docsPath, v3BillingPath, v3CreateBulkActionPath, v3EnvironmentPath, - v3EnvironmentVariablesPath, v3NewProjectAlertPath, v3NewSchedulePath, } from "~/utils/pathBuilder"; +import { AskAI } from "./AskAI"; +import { CodeBlock } from "./code/CodeBlock"; 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"; +import { + ClientTabs, + ClientTabsContent, + ClientTabsList, + ClientTabsTrigger, +} from "./primitives/ClientTabs"; import { Header1 } from "./primitives/Headers"; 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"; @@ -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,148 @@ 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. + +
+ ); +} + +export function PromptsNone() { + return ( + + Prompts docs + + } + > + + Managed prompts let you define AI prompts in code with typesafe variables, then edit and + version them from the dashboard without redeploying. + + + Add a prompt to your project using prompts.define() + : + + + + Deploy your project and your prompts will appear here with version history and a live + editor. + + + ); +} diff --git a/apps/webapp/app/components/BulkActionFilterSummary.tsx b/apps/webapp/app/components/BulkActionFilterSummary.tsx index b00d77d438c..a230e70b346 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; @@ -227,6 +228,18 @@ export function BulkActionFilterSummary({ /> ); } + case "errorId": { + return ( + + ); + } default: { assertNever(typedKey); } @@ -244,7 +257,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 */} +
+
+
+