Compare commits

...

322 Commits

Author SHA1 Message Date
Matt Aitken 9dc666851f Delete the old SSE util and updated the test endpoint to use the new one 2025-04-09 12:24:00 +01:00
Matt Aitken 3ce6144339 Added the Attempt span error back (and properties) (#1900) 2025-04-08 22:45:11 +01:00
Matt Aitken 3cbbece473 Run completed at fix and engine API rate limit whitelist (#1899)
* Use completedAt instead of updatedAt

* Disable verbose SSE logs

* Engine rate limiter: whitelist engine worker-actions

* Show the compute column in the runs table

* The compute column should be invocation + compute cost
2025-04-08 20:55:35 +01:00
Matt Aitken 79d6044dd0 Added engine worker immediatePollIntervalMs and limit env vars (#1898) 2025-04-08 15:39:44 +01:00
nicktrn 72d98778eb Fix node options and OOM retries (#1897)
* don't hardcode max old space size in deployment images

* flags: treat underscores as hyphens

* append attempt number to runner name if >1

* improve retry spans for oom errors
2025-04-08 13:07:45 +01:00
Matt Aitken a51b9b9f98 Fix: custom queue releaseConcurrencyOnWaitpoint. (#1896)
* Make sure releaseConcurrencyOnWaitpoint is set on taskQueue

* Allow task queue to have releaseConcurrencyOnWaitpoint (required to fix custom queues)

* Test for releaseConcurrencyOnWaitpoint

* Added a warning if you use the same queue twice with different settings

* Improved the error some more
2025-04-08 12:32:22 +01:00
Logan Mimaroglu 9d261f4986 Update atomic-deployment.mdx (#1895) 2025-04-08 10:08:31 +01:00
nicktrn fa0e2bf17c Fix v4 build workflow (#1894) 2025-04-07 17:34:07 +01:00
Matt Aitken 56a880f1bd Fix dequeue snapshot batch ids (#1893)
* Pass the batchId through from previous snapshots when dequeuing

* Fix and test for resuming batch after checkpoint
2025-04-07 17:22:56 +01:00
James Ritchie b322cf1eda Fix for long file names wrapping instead of truncating on the RunTimeline (#1891) 2025-04-07 16:36:22 +01:00
James Ritchie 86e56144c0 Adds selected state for waitpoint table rows (#1892) 2025-04-07 16:35:58 +01:00
Matt Aitken fc0a331c42 We weren’t copying the batch info when suspending a run (#1890) 2025-04-07 16:00:07 +01:00
nicktrn a9d5646d75 Very short waits count towards compute usage (#1888)
* add duration wait threshold

* improve wording

* Updates the wait overview, wait.for and wait.until pages copy to better explain compute costs

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-04-07 14:52:33 +01:00
Matt Aitken 32d2992802 Security update of nanoid from 3.3.4 to 3.3.8 (#1887) 2025-04-07 13:56:37 +01:00
Himanshu Sharma e546028e9b upgrade nanoid to latest version (#1886) 2025-04-07 11:31:06 +01:00
nicktrn 58567bfe44 Add v4 timeline metrics to prod runs (#1884)
* set correct worker name on create

* dev: kill run process on failed cancel attempt

* add missing prod timeline events

* add more debug logs after api call failures

* import events are always visible

* set dequeued at on new runners

* skip fork event test

* fix timeline test for import event
2025-04-04 18:39:13 +01:00
Eric Allam e7bca6fa1b fix: release concurrency system only consumes tokens when releasings are executed successfully (#1883)
* fix: release concurrency system only consumes tokens when releasings are executed successfully

* Add release concurrency metrics sending to otel
2025-04-04 17:20:33 +01:00
Matt Aitken 195c5d758b Update the path for the subcription portal (#1885) 2025-04-04 17:15:07 +01:00
Matt Aitken 36159be544 Warm start UI (#1882)
* Better trace icon

* Better Waitpoint token icon

* Fix for bad jsx

* Warm and cold start icons

* Tooltips now use a <Portal> so they appear on top

* Warm start components

* Added warm start markers to the Run page and inspector

* Fix for getting the correct value from the metadata

* Better trace icon, with fallback to a passed in one

* Removed unused isWarmStart function
2025-04-04 14:20:28 +01:00
nicktrn 7644d92efa Fix restored runner ID and various small enhancements (#1880)
* make worker nodetype configurable

* make max dequeue count configurable from supervisor

* deterministic runner ids

* update runner id on restore

* disable resource monitor

* explicit run controller type imports

* enhance debug logs
2025-04-04 12:35:23 +01:00
Eric Allam e6efb3c61b realtime: allow realtime streams to have it's own redis env vars (#1876) 2025-04-04 12:30:29 +01:00
Eric Allam 7b9ab1781a re2: add ability to selectively disable release concurrency queue consumers via env var (#1881)
* run engine: add ability to selectively disable release concurrency queue consumers via env var

Also added some additional logging

* actually pass the disableConsumers option through to the release concurrency system

* Fixed CLI e2e failing tests
2025-04-04 12:28:39 +01:00
Eric Allam 25c1abac8e v4: eagerly fork child processes before warm start initiates (#1879)
* v4: eagerly fork child process before warm start

Also fixes an issue where the attempt span events weren't coming through in the partial spans

* Making flushing a safer operation

* Add changeset

* Improve attempt metrics when in a warm start, and pass the STYLE_VARIANT for a warm or cold start on attempt spans
2025-04-04 11:07:36 +01:00
Dan 179a9c95c7 Added satori and improved react email (#1877)
* Improved the react email docs and added a video

* Copy

* Added a link to the cursor rules

* Added satori example

* Copy updates

* Added react pdf link
2025-04-03 16:42:54 +01:00
Eric Allam 18ad897171 fix: silence erroneous prisma package.json warnings (#1875) 2025-04-02 22:39:29 +01:00
Matt Aitken 5bdbf47fb3 Run page improvements (#1874)
* When selecting spans, don’t add to the browser history

* Only non-debug events can extend the total duration of the run timeline
2025-04-02 19:51:09 +01:00
nicktrn 7591b6e619 Fix v4 restore race condition (#1870) 2025-04-02 18:05:43 +01:00
Eric Allam cb6d697e6d chore: improve blank state copy (#1872) 2025-04-02 17:44:18 +01:00
Eric Allam 8e5d5cf690 fix: only pass engine version header when inside a task (#1871)
This will allow migrating to the v4 SDK in your application backend before deploying v4 to trigger, and everything will just work
2025-04-02 17:40:07 +01:00
nicktrn dcacc3a9e2 Fixes for v4 waits and restores (#1868)
* remove dummy metrics from heartbeat

* fix heartbeat timeouts..

* reset clock when resolving waitpoints

* optionally set metadata url when scheduling run

* don't use global vitest as it's outdated

* don't run test files in parallel

* handle special graceful shutdown code

* exit codes for success and failure can now be set and overridden

* ensure immediate cleanup in tests

* update lockfile after conflict
2025-04-02 15:27:01 +01:00
Eric Allam 6d819c6726 chore: publish docker even if the unit tests fail for now (#1867) 2025-04-02 14:20:16 +01:00
Eric Allam 3e341701ae v4 engine version selection logic updated (#1866) 2025-04-02 14:04:01 +01:00
Eric Allam 51bb3ee464 v4 ai improvements (#1863)
* AI SDK sql generator and approval example WIP

* Create some nice spans and extract the tools out so we can get the type of the streams

* Enable AI SDK telemetry

* Adding ai.tool and removing toolTask, 3rd party telemetry spans now wil create partials, better ai SDK telemetry icons

* Created a separate d3-chat example, split out from the openai-agents example

* Fixed the tool options being passed to metadata in `ai.tool`

* Add a link to the run

* Slightly improved design

* Add a crawler task using crawl4ai

* Use a tool to get the userId

* Couple of tweaks

* Adding markdown rendering to assistant messages and added an e2b based chart rendering task

* Backup to anthropic

* Add changeset
2025-04-02 12:00:02 +01:00
Eric Allam e539c7c941 v4: allow disabling of the run engine worker via the RUN_ENGINE_WORKER_ENABLED env var (#1865) 2025-04-02 11:39:55 +01:00
Eric Allam 31bef8d7cd fix: update migration with the "Drop/Add" index of SecretStore key (#1864) 2025-04-02 10:43:02 +01:00
Matt Aitken 7ce8bd1acd Queues page: fix for upgrade state, and upgrade docs link (#1862) 2025-04-01 19:20:23 +01:00
James Ritchie 49c067d755 Fixes the avatar squishing if the project name is too long (#1861) 2025-04-01 17:39:10 +01:00
James Ritchie 551b7947f2 New waitpoint token docs page + updates the URL and description for the waitpoint blank state in the app (#1860)
* Delete old wait-for pages & create new wait-for-token page

* Link to new wait-for-token page and update description

* Adds an upgrade to v4 coming soon docs page

* Removes unncessary property from Note component
2025-04-01 17:15:59 +01:00
James Ritchie a5b9151e3a Adds a "dev connected" banner to the top of the Run and Runs list pages (#1855)
* Adds DevPresenceBanner to the Run page

* Adds DevPresenceBanner to the Run list page

* Makes the DevConnection component reusable and moves components to the DevPresence.tsx file

* SideMenu: only show dev presence when project engine === V2

* WIP on disconnected banner on v3 and v4

* v3 dev connection working. Made it slightly red

* Only show the disconnected banner on v3 if the run is not completed

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-04-01 14:34:01 +01:00
Himanshu Sharma 28121249bb fix: prevent enqueuing into closed ReadableStream (#1781)
* fix: prevent enqueuing into closed ReadableStream

* Create smart-coins-hammer.md

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2025-04-01 11:52:41 +01:00
James Ritchie 81941c8910 Fixes a docs link and tweaks some wording for the triggering task blank state (#1856) 2025-04-01 11:33:07 +01:00
nicktrn bed834b589 Misc v4 checkpoint fixes (#1859)
* logs for optional services

* print env vars on startup in debug mode

* routes need to explicitly ask to keep connection alive

* log indicators for now

* make workload api listen host configurable

* expose supervisor metrics and make more configurable

* configurable pull secrets, no defaults

* remove restore route

* run controller to handle queued executing

* fix v3 deploys in v4 project

* update admin worker route

* only start pod cleaner et al in k8s mode

* set new worker group as default if none yet

* make image ref optional

* checkpoint image ref is optional for output as well

* export feature flag const

* one last image ref type fix

* make runner intervals configurable

* ability to set arbitrary env vars on new runners

* set default runtime back to node 21

* move all runner env vars to the same section
2025-04-01 10:08:15 +01:00
Eric Allam 9daa3977fa fix: safe upserting of task queues during worker indexing (#1858) 2025-03-31 16:28:08 +01:00
Eric Allam f320e14f38 cli: add dev lock file to prevent 2 dev processes running at the same time in the same dir (#1854)
* cli: add dev lock file to prevent 2 dev processes running at the same time in the same dir

* Make sure the .trigger dir exists before creating the dev.lock file
2025-03-31 16:27:11 +01:00
James Ritchie 4d5080d097 Fixes missing truncation on the project name text (#1857) 2025-03-31 14:49:37 +01:00
Matt Aitken 2b34dbe4b0 Fix for TaskRunErrorCodes.MAX_DURATION_EXCEEDED error saying ms when it’s seconds (#1848) 2025-03-31 14:48:03 +01:00
nicktrn 394f1decd3 Misc v4 fixes and improved logging (#1831)
* logs for optional services

* print env vars on startup in debug mode

* routes need to explicitly ask to keep connection alive

* log indicators for now

* make workload api listen host configurable

* expose supervisor metrics and make more configurable

* configurable pull secrets, no defaults

* remove restore route

* run controller to handle queued executing

* fix v3 deploys in v4 project

* update admin worker route

* only start pod cleaner et al in k8s mode

* set new worker group as default if none yet
2025-03-31 14:06:17 +01:00
Eric Allam c963dcda57 fix: use default retry settings when no catchError handlers are defined (#1852) 2025-03-31 13:57:02 +01:00
Eric Allam 0e77747d45 fix: runs.retrieve now uses the output stored on the TaskRun table instead of deprecated attempts table (#1853)
* fix: runs.retrieve now uses the output stored on the TaskRun table instead of deprecated attempts table

* Fixed typecheck error
2025-03-31 13:54:46 +01:00
Eric Allam 8977d2546c fix: custom queues defined on tasks on v3 now get correctly created (#1846) 2025-03-28 17:56:26 +00:00
Eric Allam af45e8397a fix: ensure task list and test action use correct dev env (#1845)
* Ensure the task list uses the dev environment of the logged in user

* Ensure the test task action uses the correct dev environment

* Fixed issue with missing case statement scope, wrapping in block
2025-03-28 17:55:05 +00:00
Dan 15b426d5b6 Add realtime / react hooks examples snippet (#1770)
* Added realtime / react hooks examples snippet and added it to all the relevant pages

* Fix link
2025-03-28 15:57:41 +00:00
James Ritchie b29f3ffb2d Updates the onboarding copy now we have multiple example projects (#1842) 2025-03-28 15:56:26 +00:00
James Ritchie f3efdad797 "Dev connecting" icon update + text color fix for the purple login buttons (#1835)
* Updates the large login buttons to the new purple style

* Updates the dev checking connection icon to include 3 dots on the screen

* Fixes svg property names
2025-03-28 15:56:00 +00:00
James Ritchie 182f5bc706 Fixes the disabled state for the Switch component (#1841) 2025-03-28 15:55:03 +00:00
James Ritchie 94c574bfef Fixes svg icon property for React (#1837) 2025-03-28 15:54:39 +00:00
Matt Aitken 5640467ce5 Task queues weren’t setting concurrencyLimit or releaseConcurrencyOnWaitpoint (#1844) 2025-03-28 15:53:56 +00:00
James Ritchie 73b41c28bf Support solo buttons in table row (#1840)
* Removed old condition that handled old v2 tables

* Adds support for a single button to be shown on hover in a table cell

* Makes the “hiddenButtons” support a single button instead of adding a new property

* Format the docs link for Schedules like the other for consistency
2025-03-28 15:53:15 +00:00
Eric Allam f4ef008e2c feat: Python otel support, enriching spans, OpenAI Agents SDK example (#1839)
* D3 demo WIP

* Agent working and completing token successfully

* Sending OpenAI Agent SDK spans through to the platform now works

* A couple of perf tweaks for enriching events
2025-03-28 13:01:59 +00:00
Dan 2a6d8251b2 Added react email example (#1802)
* Added react email docs

* Added jsx note

* Removed troubleshooting
2025-03-28 11:56:02 +00:00
Dan fc7e196a09 Added supabase auth page and updated examples (#1838)
* Added supabase auth page

* Added auth info snippet

* Added snippet and updated examples
2025-03-28 11:55:47 +00:00
James Ritchie 49c43a128b Adds a python logo icon (#1834) 2025-03-28 11:04:16 +00:00
Matt Aitken 174484fb32 Added default time period to Runs, Waitpoint and Batch list pages. Improved dev presence (#1832)
* New time period filter (permanently displayed)

* Batches, and fix for blank state

* Waitpoint token filtering

* Tags query: remove things we’re not using

* Run tag and waitpoint tags use startsWith for faster search

* Fix for run page on wrong env. Added schedule last triggered column

* Removed the Redis pubsub, just use the presence key

* Improve the dev presence responsiveness

* The CLI presence connection recovers when the webapp is restarted

* Dev schedules are now working for v4

* Refactored to make the dev presence stuff

* Got rid of stupid extra /dev, added a connecting state with icon

* Remove unused Redis client
2025-03-28 09:32:42 +00:00
Eric Allam 589f7c3bcc v4: Fix HandleErrorFunction type (#1829)
* v4: Fix HandleErrorFunction type

* Only show the catchError span if there are any catchError hooks
2025-03-28 09:15:33 +00:00
James Ritchie ff6359caf2 Buttons: Updates the primary button style to fit the UI style better (#1828)
* Move to indigo primary button to better fit in with the app UI style

* Updates Pause queue button to secondary

* Updates some blank state CTA buttons to primary
2025-03-27 13:25:34 +00:00
Eric Allam 0e82698548 v4: Waitpoint PAT completion, fix dequeuing without retry config, fixed metadata (#1826)
* WIP

* Completing waitpoints using public access tokens

* fixed example

* adding agent-loops reference, adding useWaitToken react hook, adding a way to throw an error to output from the run, adding an icon option to logger.trace

* Fixed metadata system

* Check if the waitpoint is COMPLETED already in the complete endpoint and return true
2025-03-27 10:04:12 +00:00
James Ritchie be02439d1d Icons: updates and new icons for the side menu, run page and general improvements to the way they work (#1825)
* New format for the functions instead of “onWait() task”

* Make the icon sizes match the hero icons

* Updating icons for the new task hooks

* Adds new icons to the RunIcon.tsx component

* Improves the size of the menu icons

* Fixes the missaligned environment dropdown and dev connected button

* Changes the button colours so they are all unique

* More icon updates

* Adds new variables for main page icons

* Improves icons sizes

* Using new color variables in the side menu and blank states

* Adds preview environment color variable

* Align the env icon in the menu

* Updates the env icons to be the correct size and padding to match hero icons

* Label uses new colors, removes unused cases and new env icons

* Lower case env label

* Removes unused code

* Use full env title

* Organize imports

* More variants of the runs icon to work at smaller sizes

* small padding adjustment

* reformat init function span

* Fix last init formatting

* New colour for settings

* Organize switch statement for icons nicely

* Use new color variable

* Update icon svgs

* Renamed lifecycle hook icon to Function icon

* Renamed function icon file name
2025-03-26 19:12:12 +00:00
James Ritchie d745bb63ae Cursor rules: Copy improvements and reordering sections (#1813)
* Re-ordered some rules for the video

* Adds description back in
2025-03-26 18:38:01 +00:00
Matt Aitken 00586ffaaf Waitpoint tokens page, wait.listTokens() and wait.retrieveToken() (#1824)
* Added waitpoints/tokens to the sidebar

* Added indexes to the Waitpoint time for filtering

* Begun work on `WaitpointTokenListPresenter`, the pag is a copy of the Queues page for now

* MVP of waitpoint token page

* Added status

* Expiry of timeout/ttl

* Improvements to the waitpoint table

* Improved columns and icon

* Changes from the RunTag copy on hover branch

* Fix for nested button error

* Added waitpoint tags to the DB/table

* Applied Eric’s task run tag fix (it’s live on prod in the legacy run engine branch)

* Added tags to waitpoints

* Removed todos that have been done

* Added token support for releaseConcurrency. Also added a ton of JSDocs

* Added releaseConcurrency to the API token endpoint…

* WIP on waitpoint page filters

* Fix for tags filtering

* Waitpoint filters working

* Fix for badly named function

* WaitpointPresenter used from SpanPresenter

* Waitpoint detail panel WIP

* Fix for server client hydration issue with CodeBlock

* Selected waitpoint panel

* Added a blank state

* Added waitpoint docs link

* Fix for animated number going past the target

* Fix for the queue list pagination and upgrade status

* Engine version error for waitpoint token list

* RunTag component doesn’t get squished and hover behaviour is nicer

* Associating runs with waitpoints

* Added triggered icon

* Link directly to the waitpoint

* Fix for TS error on waitpoint retrieve

* Added CopyableText component, used for waitpoint id in the table

* Removed the confetti 🎊

* Deleted some old images

* Moved some schemas/types to core. Use `id` instead of `friendlyId`

* Added wait.listTokens() function. Made some changes to the types to make it nicer

* WIP wait.retrieveToken()

* wait.retrieveToken working

* Added data to retrieve token

* Separate ApiWaitpointPresenter completely

* Added completed time to the waitpoint detail panel

* Fix for the Avatar component having SSR issues. Specify the size in rems and removed the useLayoutEffect

* Fix for applied idempotency key filter dropdown showing the id field

* Use parentheses to make sure the token list query respects idempotency key correctly

* Use the proper logger, and have a decent message with info to track the bug down

* Pass the org title into the Avatar

* Better error when failing to creating a manual waitpoint after X attempts
2025-03-26 18:05:26 +00:00
nicktrn c08c3b488c Update lockfile (#1822) 2025-03-26 10:55:59 +00:00
nicktrn 8c3542cfd6 Small package release tweaks (#1821)
* set core version for redis-worker

* add empty changelog
2025-03-26 10:39:11 +00:00
Eric Allam d6fd7f559e Run engine: Using root queue timestamp to prioritize completing runs (#1818)
* Improve the new run engine Trigger/Batch trigger service class names

* centralize queue timestamp logic in EnqueueSystem, adding queueTimestamp support and propagation

* Fixing the create tags in the new run engine trigger task service
2025-03-26 10:25:49 +00:00
nicktrn 4fe1d49f94 Add v4 pod lifecycle handlers (#1819)
* patch k8s client to allow field selector on informers

* add pod cleaner and tests

* add failed pod handler and tests

* remove supervisor test script for now
2025-03-26 10:22:24 +00:00
nicktrn 5f48b9c7f9 Fix worker deployment engine version (#1820)
* enable re2 cli flag by default

* background worker engine determines deployment type
2025-03-26 10:22:13 +00:00
Eric Allam 82251e21f2 Merge pull request #1817 from triggerdotdev/revamped-lifecycle-hooks
v4: New lifecycle hooks
2025-03-25 16:46:34 +00:00
Eric Allam 6d5d10a45a Support other init file names 2025-03-25 16:33:12 +00:00
Eric Allam 1def8d06c2 Use allSettled so onWait/onResume errors don't break anything 2025-03-25 16:32:17 +00:00
Eric Allam e745fcb663 Remove console.log 2025-03-25 15:58:50 +00:00
Eric Allam 887c5cac5c Add changeset 2025-03-25 15:56:55 +00:00
Eric Allam 268c9f6898 implement onWait/onResume 2025-03-25 15:55:42 +00:00
Eric Allam 3ca5985277 Hooks now all use the new types, and adding some spans 2025-03-25 15:55:42 +00:00
Eric Allam df9cad6667 Get it all working 2025-03-25 15:55:42 +00:00
Eric Allam ba520fcdd8 Make sure and register all the config hooks 2025-03-25 15:55:42 +00:00
Eric Allam 23ae77afff handle max duration timeout errors better 2025-03-25 15:55:42 +00:00
Eric Allam 30705fd59b implement cleanup 2025-03-25 15:55:42 +00:00
Eric Allam d0a5c16c63 Add cleanup hook 2025-03-25 15:55:42 +00:00
Eric Allam 6ba2b0476b more tests 2025-03-25 15:55:42 +00:00
Eric Allam 5edbb77dfe use tryCatch in TaskExecutor 2025-03-25 15:55:42 +00:00
Eric Allam 7f804f381c middleware working and tests passing 2025-03-25 15:55:42 +00:00
Eric Allam 6ab74c47d6 implement and test handleError (now catchError) 2025-03-25 15:55:42 +00:00
Eric Allam f84ddcd6ea test oncomplete 2025-03-25 15:55:42 +00:00
Eric Allam 87824a8ce3 dry up some stuff 2025-03-25 15:55:42 +00:00
Eric Allam 91225da813 test on failure 2025-03-25 15:55:42 +00:00
Eric Allam 2262a42632 more tests and fixes 2025-03-25 15:55:42 +00:00
Eric Allam ef871b0434 starting test executor 2025-03-25 15:55:42 +00:00
Eric Allam 4a8d6932c8 a couple tweaks 2025-03-25 15:55:42 +00:00
Eric Allam 307309f4f7 implement onComplete 2025-03-25 15:55:42 +00:00
Eric Allam b974d8297d use new onFailure 2025-03-25 15:55:42 +00:00
Eric Allam 116788912b use new onStart 2025-03-25 15:55:42 +00:00
Eric Allam 2e6d0d8783 share the hook registration code 2025-03-25 15:55:42 +00:00
Eric Allam 2d16d664ef Add middleware hooks 2025-03-25 15:55:42 +00:00
Eric Allam 6001dfa7de Add middleware types 2025-03-25 15:55:42 +00:00
Eric Allam dc6e659e94 new locals API 2025-03-25 15:55:42 +00:00
Eric Allam e34e520057 more hooks 2025-03-25 15:55:42 +00:00
Eric Allam 597b7baaaa adding imports 2025-03-25 15:55:42 +00:00
Eric Allam e77e8d48d6 handleError 2025-03-25 15:55:42 +00:00
Eric Allam aaf2ed8a20 onWait and onResume 2025-03-25 15:55:42 +00:00
Eric Allam e816ba4382 onComplete 2025-03-25 15:55:42 +00:00
Eric Allam 06f6a04056 onStart 2025-03-25 15:55:42 +00:00
Eric Allam be676fdb33 onFailure 2025-03-25 15:55:42 +00:00
Eric Allam 1777ff4cfb move onStart to the new lifecycle hook system 2025-03-25 15:55:42 +00:00
Eric Allam 17e162310d Improve init lifecycle hook types and fix tabler icons on spans 2025-03-25 15:55:42 +00:00
Eric Allam 2c44e59d07 init.ts at the root of the trigger dir is now automatically loaded 2025-03-25 15:55:42 +00:00
Eric Allam 0215a8cfa8 vibes 2025-03-25 15:55:42 +00:00
Eric Allam f59bad9227 Revamping the lifecycle hooks, starting with init 2025-03-25 15:55:42 +00:00
James Ritchie 2ef60ce51f Side menu: style fixes, small improvements and better Info Panel component props (#1816)
* Adds “plan” to the plan name if it’s a paid plan so they all format correctly

* Fixes menu item having incorrect height

* Adds gap between switch org menu items

* Moves main action button to right side in blank state

* Simpler way to add “plan” to the plan name

* Styled the plan badge a bit nicer

* Project page now matches the other settings page layouts

* Fixes colour of queue icon

* Fixes table blank state not spanning enough cols

* Organize imports

* Unified the blank state panels and improved some copy in them

* Improved the copy in the no deployed tasks blank state

* Make the upgrade icon solid and indigo

* Transition the hover state on the side menu section

* Replaces many props with a single ‘accessory’ React node on the Info Panel component

* Organize imports
2025-03-24 14:44:08 +00:00
Eric Allam c4e1364cf9 Merge pull request #1808 from triggerdotdev/re2-queue-indexing
re2: Queue indexing, queue trigger changes
2025-03-21 16:09:15 +00:00
Eric Allam f1d5886c11 Another attempt at getting CI to pass tests 2025-03-21 15:50:51 +00:00
Eric Allam b72b7c2c52 Fixed typechecks 2025-03-21 15:22:53 +00:00
Eric Allam f46a409f0e Fixed typescript error 2025-03-21 15:06:55 +00:00
Eric Allam 5a725675d3 Fix the unit tests CI action 2025-03-21 15:06:55 +00:00
Eric Allam 671ff26d50 Removing more exportName stuff 2025-03-21 15:06:55 +00:00
Eric Allam 109bfaca00 Remove exportName from the trigger span names 2025-03-21 15:06:55 +00:00
Eric Allam b65f2256e0 Tasks are indexed even if you don't export them 🤩 2025-03-21 15:06:55 +00:00
Eric Allam c87ff2c634 Various fixes for no longer having the exportName 2025-03-21 15:06:55 +00:00
Eric Allam 233a22d59e Move runs sidebar menu item below tasks and alerts into the manage section 2025-03-21 15:06:55 +00:00
Eric Allam e53eed1753 Fixed the types on pauseQueue service 2025-03-21 15:06:55 +00:00
Eric Allam ad08756001 Run tests sequentially in CI, and pass "--run" to vitest to disable watch mode 2025-03-21 15:06:55 +00:00
Eric Allam 8d514b902d Use orderableName on the TaskQueue and add the releaseConcurrencyOnWaitpoint to the SDK and the dashboard 2025-03-21 15:06:55 +00:00
Eric Allam f58dee1aa1 Fix the task run status reason UI 2025-03-21 15:06:55 +00:00
Eric Allam de5e0c8e8b try to fix flaky tests 2025-03-21 15:06:55 +00:00
Eric Allam bee5e75931 Fixed cli e2e tests 2025-03-21 15:06:55 +00:00
Eric Allam f4a1eb1914 Fixed changesets and added one 2025-03-21 15:06:55 +00:00
Eric Allam 02eeefd647 Increase maxRetriesPerRequest in CI 2025-03-21 15:06:24 +00:00
Eric Allam cbe651e7e4 Fix useTaskTrigger queue option params and remove exportName from e2e tests 2025-03-21 15:06:24 +00:00
Eric Allam 941e03b3eb Using the new PENDING_VERSION status now in the UI 2025-03-21 15:06:24 +00:00
Eric Allam 1bec4e1432 Getting the queues indexing to work by passing them to the create background worker request bodies 2025-03-21 15:06:24 +00:00
Eric Allam 7e411ac162 New PENDING_VERSION system which now requires queues to exist at dequeue time 2025-03-21 15:06:24 +00:00
Eric Allam 7a58439728 WIP queue indexing 2025-03-21 15:06:24 +00:00
nicktrn 49a3f72e13 Publish redis-worker and add graceful shutdown manager (#1810)
* add shutdown manager

* update ai test instructions

* add shutdown timeout to redis-worker

* move redis worker to packages

* add unregister method

* prep for publishing package

* fix types

* update ai files

* fix cursor terminal links

* prevent overly friendly ids

* use structured logger

* use unique shutdown handler names

* rework suspend completion

* add trycatch util

* rework suspend restore

* add http server metrics

* add missing prom-client to core

* add prom metrics to redis worker

* bundle redis-worker

* fix esm/cjs interop

* remove proxy from changeset ignore and add supervisor

* add pause to prerelease script for any manual edits

* unregister the correct handler and add early detection

* small change to http handler return

* fix worker tests

* fix shutdown manager tests
2025-03-21 14:53:27 +00:00
Dan adca1997af Added turborepo and prisma docs (#1807)
* Added python links in guides intro

* Added example 1

* Updated intro

* Added example 2

* Typo

* Improved intro
2025-03-20 17:29:06 +00:00
Niels 1eb3fb34e1 Enhance RunTag parsing with robust key-value tag splitting (#1782)
* Enhance RunTag parsing with robust key-value tag splitting

* Clarify RunTag parsing documentation
2025-03-20 13:19:26 +00:00
Eric Allam a8b3c7003f Merge pull request #1804 from triggerdotdev/re2-reserve-concurrency
re2: New release concurrency system
2025-03-19 18:01:01 +00:00
Eric Allam 38e1887495 Fixed failing redis worker tests 2025-03-19 17:58:53 +00:00
Eric Allam e5ea9cb2d7 move the release concurrency queue into the release concurrency system, make it disabled by default, configure the run engine in the webapp with env vars 2025-03-19 17:55:29 +00:00
Eric Allam 28b3ed0496 Implement release concurrency system 2025-03-19 17:55:29 +00:00
Eric Allam 7eaf81abbe Use releaserId in case we don't end up using run IDs 2025-03-19 17:54:47 +00:00
Eric Allam f9c7e95981 Added updated execution states chart and updated readme 2025-03-19 17:54:47 +00:00
Eric Allam b170a62671 fixed the create checkpoint valid snapshot logic 2025-03-19 17:54:47 +00:00
Eric Allam e7c8f94447 implement the QUEUED_EXECUTING dequeuing, and creating a checkpoint while the run is in QUEUED_EXECUTING state by saving the EXECUTING_WITH_WAITPOINTS snapshotId as the previousSnapshotId on the QUEUED_EXECUTING snapshot 2025-03-19 17:54:47 +00:00
Eric Allam 29371e950b Implement checkpoint tests, handle dequeuing QUEUED_EXECUTING runs 2025-03-19 17:54:47 +00:00
Eric Allam 42fb5d05ff Fix more tests 2025-03-19 17:54:47 +00:00
Eric Allam 67d74f066c More tests passing, fixed the heartbeat issue 2025-03-19 17:54:47 +00:00
Eric Allam 7866e95636 waiting for worker system 2025-03-19 17:54:47 +00:00
Eric Allam a4581f1597 ttl system 2025-03-19 17:54:47 +00:00
Eric Allam 363f0668b5 Delayed run system 2025-03-19 17:54:47 +00:00
Eric Allam 63b43ff357 more system work 2025-03-19 17:54:47 +00:00
Eric Allam 717cec879b move startRunAttempt to RunAttemptSystem 2025-03-19 17:54:47 +00:00
Eric Allam 1b61b95c57 WIP run engine systems 2025-03-19 17:54:47 +00:00
Eric Allam 7d11e827d9 Remove reserve concurrency system from run engine 2025-03-19 17:54:47 +00:00
Eric Allam 8c66ec3189 Remove reserve concurrency system from run queue 2025-03-19 17:54:47 +00:00
Eric Allam 063651c7b8 Upgrade vitest in the run-engine package 2025-03-19 17:54:47 +00:00
Eric Allam 2048b72288 go to QUEUE_EXECUTING state if reacquiring concurrency doesn't work 2025-03-19 17:54:47 +00:00
Eric Allam 61c0834aae Implement reserve concurrency clearing when the child run is acked 2025-03-19 17:54:47 +00:00
Eric Allam fd9b0bf676 WIP new reacquire concurrency system 2025-03-19 17:54:47 +00:00
Eric Allam cf3b23862e remove project and task current concurrency tracking 2025-03-19 17:54:47 +00:00
Eric Allam de9e296551 Add configuration for the release concurrency queue 2025-03-19 17:54:47 +00:00
Eric Allam cce402dfe7 If max tokens is 0, then don't do releasings 2025-03-19 17:54:47 +00:00
Eric Allam b7ddf20d62 Correctly use the new release concurrency queue in the run engine 2025-03-19 17:54:47 +00:00
Eric Allam bf41703dfd improve the consumer of the concurrency queue 2025-03-19 17:54:47 +00:00
Eric Allam c8b99728b9 Get all the release concurrency queue tests passing 2025-03-19 17:54:47 +00:00
Eric Allam d11d491215 WIP release concurrency queue 2025-03-19 17:54:47 +00:00
Eric Allam 1362d8296d Add batchId to the delayed enqueueRun call 2025-03-19 17:54:47 +00:00
Eric Allam 2b6ce169be remove unused method 2025-03-19 17:54:47 +00:00
Eric Allam e9fa4ce3a2 Update delays to use a redis worker and work with the new reserve concurrency system 2025-03-19 17:54:47 +00:00
Eric Allam 1a233efd42 the run engine now works with the new reserve concurrency system 2025-03-19 17:54:47 +00:00
Eric Allam 555d9912ec fixed some key producer tests 2025-03-19 17:54:47 +00:00
Eric Allam 880538ff92 improve the dead letter queue stuff 2025-03-19 17:54:47 +00:00
Eric Allam a5b2b39201 ack 2025-03-19 17:54:47 +00:00
Eric Allam 16546f144f dequeue message 2025-03-19 17:54:47 +00:00
Eric Allam 8b3551c9e2 wip reserve concurrency system 2025-03-19 17:54:47 +00:00
Eric Allam 71c7b53891 Merge pull request #1805 from triggerdotdev/queues-page
Queues page and pausing the environment/queues
2025-03-19 17:52:24 +00:00
James Ritchie 4b6f51a110 Adds js to file paths and fixes typos (#1803) 2025-03-19 17:32:03 +00:00
Matt Aitken 4e3ef9f199 Blank state if you have no queues 2025-03-19 17:21:01 +00:00
Matt Aitken 16ff502605 Better scrolling on the Queues page 2025-03-19 17:11:58 +00:00
Matt Aitken 99e722c5c9 Do all the environment concurrency lookups in parallel 2025-03-19 17:01:26 +00:00
Matt Aitken 1d5dadbc24 Remove unused number-flow package 2025-03-19 16:44:34 +00:00
Matt Aitken 110548c354 Don’t use defer, it causes a horrible UI flash with revalidate 2025-03-19 16:42:10 +00:00
Matt Aitken 5c9a305e6b Auto-reload the queue page every 10 seconds 2025-03-19 16:34:06 +00:00
Matt Aitken 46eed03743 Added pause/resume functions to the SDK 2025-03-19 14:59:37 +00:00
Matt Aitken 03fb6e2890 Redirect to the correct page, to keep your place 2025-03-19 14:39:24 +00:00
Matt Aitken 42089a4691 Pausing individual queues working 2025-03-19 14:36:53 +00:00
Matt Aitken 34a178f169 Added upgrade panel 2025-03-19 13:22:31 +00:00
Matt Aitken 8ddaa626b6 WIP on upgrade panel 2025-03-19 13:13:52 +00:00
Matt Aitken 57a57b4b87 InfoBox storybook file 2025-03-19 13:07:19 +00:00
Matt Aitken aa787c2dbb Queues page promise fix 2025-03-19 11:49:20 +00:00
Matt Aitken d8b114465f use TypedAwait 2025-03-19 11:41:46 +00:00
Matt Aitken c74d57f9fd Fix for type issue 2025-03-19 11:41:34 +00:00
Matt Aitken c2038b51a8 Retrieving a queue is working well 2025-03-19 11:18:19 +00:00
Matt Aitken e2da1810b8 WIP on retrieving a queue using the SDK/API 2025-03-19 11:09:16 +00:00
Matt Aitken 4ee85cbe8c Git ignore the react hooks src/package.json 2025-03-19 11:08:56 +00:00
Matt Aitken 2b8c77d8bf Added some JSDocs 2025-03-18 19:53:56 +00:00
Matt Aitken b4a20909f7 Don’t return more than 100 queues per page 2025-03-18 19:53:49 +00:00
Matt Aitken 714e4ecb39 Added queues.list() SDK function 2025-03-18 19:50:15 +00:00
Matt Aitken 323a12499d Merge remote-tracking branch 'origin/main' into queues-page
# Conflicts:
#	apps/webapp/app/components/SetupCommands.tsx
#	apps/webapp/app/components/navigation/SideMenu.tsx
2025-03-18 19:08:25 +00:00
Matt Aitken 50593eb24f Transform the queue type and added tooltips 2025-03-18 19:07:33 +00:00
James Ritchie a6e85f00b2 Side menu and Org dropdown improvements (#1801)
* Organize imports

* Removes animation on the dropdown menu icon

* Improves Settings and Usage button states

* Side menu sections animate with easeInOut

* Incorporates docs button into the text and adds title to the clipboard field

* Icons look better as the small variant

* Improved the typography layout of the org name and small links

* Made the divide line show when the side menu scrolls

* Made the impersonating icon amber so it stands out more

* Adds an optional amber border to the org dropdown
2025-03-18 19:04:51 +00:00
Matt Aitken 629393334d Show 25 queues per page 2025-03-18 18:58:29 +00:00
Matt Aitken 3a4991dd45 Loading improvements 2025-03-18 18:55:56 +00:00
Dan f713b1e056 Added 2 more python examples (#1800)
* Added python image processing and updated docs.json

* Edits to crawl4ai

* Updated commands

* Added pdf form extractor
2025-03-18 16:50:43 +00:00
Matt Aitken d6beb0c745 Separate the presenters out, useful for the SDK 2025-03-18 16:38:59 +00:00
Matt Aitken df53715ee5 Added app version to the org setting menu 2025-03-18 13:31:20 +00:00
Matt Aitken 343e7ec90b Merge remote-tracking branch 'origin/main' into queues-page 2025-03-18 13:16:01 +00:00
Matt Aitken 0173406999 Pausing the environment is working 2025-03-18 13:14:29 +00:00
James Ritchie ec1cec5767 Centering account pages and making them consistent (#1799)
* Small updates to the Settings page

* Remove max-w-md from Members page

* Removing max width from Profile page
2025-03-18 12:57:23 +00:00
Matt Aitken a51f8c56a4 WIP pausing 2025-03-18 09:28:14 +00:00
Matt Aitken 43413c1b41 Reduce the env data that determineEngineVersion needs 2025-03-18 09:12:05 +00:00
Matt Aitken ec4511b629 Add paused status to RuntimeEnvironment and TaskQueue 2025-03-18 09:11:39 +00:00
Matt Aitken bbe30ea625 Move increase limit button to the new spot 2025-03-17 18:54:38 +00:00
Matt Aitken d5ee760b9d Get values for the queue and running 2025-03-17 18:49:19 +00:00
Matt Aitken c6fd1816ba Current badge changed 2025-03-17 17:33:13 +00:00
Matt Aitken 6539c4a267 Added pagination 2025-03-17 16:32:02 +00:00
Matt Aitken a871137e09 WIP on queues page 2025-03-17 15:52:31 +00:00
Matt Aitken fabfb5051b WIP on queues page 2025-03-17 15:07:47 +00:00
Matt Aitken 3fd4470e89 Fix for SSE error when trying to send when the controller has been aborted 2025-03-17 14:29:51 +00:00
Matt Aitken d5561b5400 Disable vscode warning about vitest 2025-03-17 14:29:25 +00:00
Matt Aitken 2ec76d6a35 Ignore .husky 2025-03-17 14:29:01 +00:00
Matt Aitken 9a777099ee Changed CLI clipboard fields to secondary 2025-03-17 13:36:05 +00:00
Matt Aitken 7b862b9438 Major dashboard improvements (environment centric) (#1796)
* Delete the proxy app (was v2)

* Delete RunPresenterElectric

* Select the best proj/org/env

* Storing current proj/env in DB. Initial selection logic working with tasks page

* 2sm needed to be in the Tailwind merge list

* Move the task stream route (although we don’t actually use the env for now)

* Alerts moved from /v3

* API keys page moved from /v3

* Concurrency page moved from /v3

* WIP on side menu sections

* Improved the accordion animation

* Moved schedules from /v3

* More pages moved

* Move pages working

* Run page working

* Schedules working

* Moved deployments

* Alert pages moved

* Delete electric hooks, not used

* Started setting up blank states

* Test page working

* Removed “Select task” from the test page

* Some work on deployment page

* Style tweaks

* Redirect from project root to approriate env

* Improved env selector styling

* Fix for jsx errors

* Better min width on env selector

* Improved the env switching logic

* Added deployments to env routing

* Redirect deployments to the correct env

* Redirect run from proj to env

* JSX icon fix

* Only allow single env schedules from now on

* Remove env var count from the API keys page

* Move improvements and redirects

* Project settings moved

* Fix for scroll area on test page

* Tweaked the test design

* Made recent payloads column narrower

* Improved the test layout some more

* Added org icon, new project selector menu

* WIP on org switching menu

* Org switching is working

* New menu working well, removed old side menu items

* Buttons can now have a component name or an actual component for their icons

* Removed the Projects page, instead redirect appropriately

* Fix for broken blank states

* Minor run table improvements

* Removed unused switcher log and logic

* Concurrency page fix for invalid html, improved layout

* Minor improvements

* Moved the side menu to the project level

* Improved account styling

* Moved org settings pages (with redirects)

* Add current plan to billing side menu link

* Upgrade to get staging from env dropdown

* New env badge on concurrency limits page

* Show Run Engine version in span presenter

* New promote icon

* Concurrency limits page is the sum of engine v1 + v2 queues

* Fix for missing batch import

* Added currentConcurrencyOfEnvQueue function

* Basic avatar setting working

* Avatar setting is working

* You can change the color of your icon

* Avatar improvements

* Bugfix for mising prop

* Removed some old env badges

* Fixed replaying

* Removed EnvironmentLabel

* Old env badge deleted, changed everywhere to the new one

* Fix for Slack integration paths

* Fix for waitpoint completion form moving

* Bulk replay/cancel env fix

* Fix for alert webhook path

* Redirect projects/v3/* to project/*

* Fixes for CLI redirect routes

* Remove welcome email (unused)

* Change how we count schedules towards your limits

* Use new schedules limits when checking a schedule

* Added projectId back in to task queries (indexes)

* WIP dev presence

* CLI modal

* Moved things around and use Context

* Fix for p inside p

* Dev connected status on run page

* Correct dev env (not a teammates)

* Show disconnected message at the end

* Minor tweak on project dropdown icon padding

* Fix for inconsistent date format for presence

* Added a message when pushing to the billing page

* Center the team page

* Project settings page centered

* Improvements to the dev presence
2025-03-17 13:32:31 +00:00
Erin Allison 2a51ae86a2 Suppress external instrumentations for fetch calls from ApiClient (#1788)
Signed-off-by: Erin Allison <erin@eallison.us>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-03-16 16:45:48 +00:00
nicktrn 4fda8a5ee1 Fix prerelease script (#1794)
* clean before building

* add main branch commit protection
2025-03-15 12:20:54 +00:00
nicktrn 7842e9dff0 Warm start and restore improvements (#1793)
* handle warm start service failure on supervisor side

* export zodfetch wrapper

* add warm start client

* rename to keepalive

* add missing warm start header

* make heartbeat and snapshot poll interval configurable

* create warm start client in constructor

* add warm start run debug log

* re-enable checkpoints and improve error messages

* reduce run pod container name cardinality

* move runner id generator into core

* fix runner id import

* log when no checkpoint client and we try to restore

* move run controller constructor to the top

* fix import

* support env overrides after restore
2025-03-14 18:37:53 +00:00
Eric Allam 58961658d8 Merge pull request #1791 from triggerdotdev/cursor-rules-writing-tasks 2025-03-12 18:46:03 +00:00
James Ritchie 9d06c114b6 Fixed some copy 2025-03-12 17:56:38 +00:00
nicktrn 7f2569721e Fix supervisor builds (#1790)
* fix and restructure dockerignore

* switch to using pnpm deploy

* pass webapp node image as build arg

* ensure pnpm is downloaded at build, not runtime
2025-03-12 16:10:37 +00:00
Eric Allam 3a0475930f Merge pull request #1785 from myftija/mcp-server
Add support for running a local MCP server via the `dev` CLI command
2025-03-12 10:53:47 +00:00
Eric Allam 80e981c9f7 Merge pull request #1787 from triggerdotdev/docs/crawl-docs-updates 2025-03-11 21:32:28 +00:00
D-K-P 60c1e2c16e Updated crawl4ai docs 2025-03-11 20:42:08 +00:00
saadi 7d65aa9299 Describe the MCP server feature in the readme 2025-03-11 19:17:46 +01:00
saadi 603c903fbe Add MCP tool to list all available tasks and enable fuzzy task matching
This enables LLMs to figure out which task you are referring to, without needing to specify the full task ID
2025-03-11 18:34:17 +01:00
saadi 2e2632d5cc Add new MCP tool to list the logs for a run
This enables some basic level of debugging capabilities in combinatio with the other MCP tools
2025-03-11 16:20:56 +01:00
saadi ed1f4cea12 Expose a new API endpoint to list events for a given task run 2025-03-11 16:20:12 +01:00
Dan 282cc0629f Added Python crawling example (#1786)
* Added Python crawling (slithering?) example

* Fixed link
2025-03-11 14:16:55 +00:00
saadi b6b25b05f5 Add MCP tool to cancel task runs 2025-03-11 15:11:54 +01:00
saadi 9cd6d40acc Shut down the MCP server when the dev session stops 2025-03-11 14:53:55 +01:00
saadi 2e7b1a7b1c Make the MCP server port configurable via a flag in the dev command 2025-03-11 14:47:11 +01:00
saadi af3af9ca17 Add MCP tool to get a single run
This can be used in combination with the trigger-task tool from LLMs to show details about the run after triggering the task.
2025-03-11 14:33:51 +01:00
saadi 1d7224d2ee Add MCP tool to list runs 2025-03-11 14:22:42 +01:00
saadi ce3e49b4e3 Enable passing custom payload in the trigger-task MCP tool 2025-03-11 14:05:07 +01:00
James Ritchie 25c2fdb30a Adds writing-tasks.mdc for cursor rules 2025-03-11 12:56:54 +00:00
saadi e00afc1332 Use the v2 run engine by default in the SDK API client
V2 is the way to go for the future.
2025-03-11 12:58:11 +01:00
saadi 5c0158e98b Expose a basic version of the MCP server as an option for the dev CLI command
This just adds the main components for the MCP server. It hooks on the
existing `dev` command and can be enabled by passing the `--mcp` flag.

Currently only the `trigger-task` tool is exposed, which enables users
trigger tasks via MCP and see the resulting run.

Still WIP :)
2025-03-11 12:11:33 +01:00
Eric Allam 67115ca880 Contributing guide updates (#1784) 2025-03-11 07:54:03 +00:00
Eric Allam de4ee19d15 Merge pull request #1783 from triggerdotdev/cursor-repo-rules-and-docs
Added initial repo cursor rules and other docs for AI
2025-03-11 06:20:10 +00:00
Eric Allam 5db995856e Added repo cursor rules and other docs for AI 2025-03-10 23:03:27 +00:00
Eric Allam 70ff5de7b9 WIP 2025-03-10 13:26:13 +00:00
Matt Aitken 6cf86d5916 Deleting v2 dashboard/api (#1628)
* Delete v2 Stripe routes

* Delete v2 billing/usage pages

* Delete v2 integration pages

* Delete v2 project pages

* Deleted a load of components and services

* Deleted a load more components, presenters and services

* Deleted another 100 files or so…

* Removed old v2 paths

* Removed named icons from form titles

* Removed more string icons

* Delete NamedIcon

* Fixed some type errors

* Delete endpointApi

* Removed v2 from core/sdk

* Post merge fixes

* added explicit return types

* using the new sdk export without v3

* Delete old v2 file

* Added explicit return types because TS was complaining…

* Don’t export RuntimeEnvironmentType from two core files. Was causing TS issue

* Fix for removal of NamedIcon in new route

* Removed strange eslintrc rule

* Use the new redis client

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-03-08 14:53:06 +00:00
Eric Allam d855d55ea0 re2: env based queue selection algo (#1775)
* re2: fix @trigger.dev/core exports

* re2: WIP env based queue selection algo

* more wip

* WIP

* Get run engine tests to pass

* Adding tests for the fair dequeueing strat in the run engine

* Configure the new queue selection strategy in the webapp and get it all building and typechecks passing

* webapp now uses built packages, building redis-worker, run-engine, database, using better tsconfig setups for tests, moving isomorphic code into core/v3/isomorphic

* Fixed webapp typechecks

* dev now depends on build, fixed supervisor typecheck

* Fixed run engine tests

* Fixed e2e tests
2025-03-07 14:30:19 +00:00
nicktrn 38ddd830d7 Add supervisor split service controls (#1774)
* dockerignore node_modules in subdirectories

* image tag action should handle re2 tags

* add supervisor containerfile

* add publish worker re2 workflow

* fix copypasta

* require branch check

* add more granular service control to supervisor session

* fix supervisor api domain for split setups

* remove default workload api domain

* option to disable workload api

* fix bool env var coercion
2025-03-07 12:40:11 +00:00
Matt Aitken 4dbf11aed1 Run Engine 2: More robust attempt failing/retrying (inc. OOM retrying) (#1773)
* Added describe to tests that were missing it

* Added a function to get the maxOldSpaceSize

* Make it easy to take `NODE_OPTIONS` and set the old space flag

* Added a zed task to rebuild the packages

* Moved isOOMRunError and added SIBABRT condition

* Deduplication flags function with tests

* Export flags file

* On TaskRunProcess, set max old space and deduplicate the flags with priority order

* Move retrying logic to a separate function, it was getting very messy

* Created new test file for attempt failures

* Allow setting retry settings for tests

* Some retrying tests, including OOM

* More failure condition tests

* Fix for OOM retrying

* Complete the attempt span if it was an OOM error

* Remove old broken import

* Fixed order of exports

* Fix for docker-provider checkpoints import
2025-03-07 09:07:49 +00:00
nicktrn e297c7f7f0 Add supervisor build pipeline (#1772)
* dockerignore node_modules in subdirectories

* image tag action should handle re2 tags

* add supervisor containerfile

* add publish worker re2 workflow

* fix copypasta

* require branch check
2025-03-06 18:22:36 +00:00
Luca D'Alessandro 3089caff0c docs: suggest updated vercel-wait action for Atomic deploys (#1771) 2025-03-06 17:22:22 +00:00
nicktrn 94fb532f5e Add RE2 supervisor (#1769)
* add new core server only export

* exit after run if warm starts disabled

* add supervisor
2025-03-06 13:20:45 +00:00
James Ritchie 78194e1a36 Corrects the number of schedules on Free tier (#1768) 2025-03-06 13:06:26 +00:00
Eric Allam cf4c4d7b65 Add attempt metrics in dev (prod WIP). Added max concurrent runs setting to dev using p-limit (#1766) 2025-03-06 11:57:01 +00:00
Matt Aitken 722fae566c Reschedule heartbeats and added a test to check it works (#1765) 2025-03-06 10:44:53 +00:00
Matt Aitken 983bb411cc Redis client factory (error handling and good defaults) (#1761)
* @internal/redis package for creating Redis clients with sensible defaults and error handling

* Added @internal/redis to webapp
2025-03-05 18:32:46 +00:00
James Ritchie 4358e2d2ef Clarifies only executing runs count towards concurrency (#1760) 2025-03-05 17:57:42 +00:00
James Ritchie 31c2a23ffc Docs improvements: clearing build cache, concurrently running next + trigger, trouble shooting pino (#1758)
* Adds “Explore by build extension” to the intro page

* Adds instructions for clearing the build cache

* Add a tip for running trigger dev + next dev concurrently

* left align table headers

* Adds trouble shooting for pino

* Adds corepack bug and workaround

* Removes reference to undici as it messes with spans

* Left align table headers

* Adds example for exporting logs to Axiom
2025-03-05 17:35:23 +00:00
Eric Allam 7b10d0b0ee re2: fixed cli e2e tests (#1757) 2025-03-05 16:58:28 +00:00
Eric Allam e89fb92532 re2: dev runs work without worker groups, fixed some type issues (#1756)
* In dev, the worker group is optional when triggering tasks (the master queue is defined by the environment). Also deprecated the TaskEvent.isDebug column and using TaskEventKind.LOG instead for debug events

* Fixed a couple of type issues

* More type fixes
2025-03-05 16:29:41 +00:00
nicktrn e97704d904 Run Engine 2.0 (WIP) (#1575)
* bump worker version

* Suggested glossary for the RunEngine, TBC

* Removed BatchTaskRun changes from this branch, they were done in main

* Set the BatchTaskRun status to completed when all runs are completed

* When dequeuing respect passed in maxResources

* Ported over the new run props: idempotencyKeyExpiresAt, versions, oneTimeUseToken, maxDurationInSeconds

* Didn’t hit save… the new props when triggering tasks passed through

* Idempotency expiration + waitpoint edge case

* WIP on creating checkpoint, parking for now

* fix worker routes

* upgrade webapp node types to support generic event emitter

* separate event bus handler singleton and run failure alerts

* duration waits

* fix execution snapshot debug spans

* task waits

* fix event bus types

* temporary fix for react hook run handle type

* disable run notifications for now

* convert any typecasts to expect errors to more easily fix later

* fix webapp types after node types upgrade

* updateEnvConcurrencyLimits across marqs and the runqueue

* Pass proper values into the run engine

* RunQueue settings and removed unused rebalancing workers

* Remove rebalancing prop

* Tidied more things up

* Update/remove queue limits for MARQS and RunQueue

* taskQueue/concurrencyLimit changes ported back into the RunEngine

* Reworked completing waitpoints to improve performance and reduce race conditions

* Improved test robustness

* Down to a single run lock only when a run is totally unblocked and ready to continue

* warm starts, worker notifications, wait fixes

* Fix for Run Engine poll interval env var

* Expect the waitpoint to be completed quickly

* If a run is locked then it’s too late to expire it

* Added VALKEY_ env vars and plugged them into the run engine

* Extracted and updated the guard queue function so it can be used when batching

* Added logging and universal concurrency changes to trigger task v1

* Added notes back in

* Bump @trigger.dev/worker to 3.3.7

* reportInvocationUsage for the runAttemptStarted event

* improve execution snapshot span debug span start times

* Unfriendly IDs

* update lockfile

* Created a shared determineEngineVersion function

* disable unfinished commands

* save new cli config to different location, misc fixes

* add basic engine version check via current deploy

* new run engine will default to node 22 runtime

* block some actions for projects on previous run engine

* fix worker group tests

* fix triggerAndWait test

* one typescript version to rule them all

* redlock type patch

* fix type issues caused by ts-reset

* improve cleanup scripts

* add missing socket.io dep

* fix run notification handler type

* fix worker group test again

* generate prisma client for e2e tests

* remove worker group tests for now

* prevent image pull rate limits during unit tests

* increase timeout for queue concurrency limit test

* generate prisma client for preview release

* same node types everywhere

* Updated engine readme, removed legacy system notes

* use default machine preset from platform package

* worker instances plural in schema

* disable pnpm update notifications

* return worker group details from connect call

* add workers admin route

* fix heartbeat route return type

* move deployment labels to core apps

* refactor run controller env schema

* Add firstAttemptStartedAt to TaskRun

* RunEngine 2.0 batch trigger support (#1581)

* Make it clear when BatchTriggerV2Service is used

* Copy of BatchTriggerV2Service

* WIP batch triggering

* Allow blocking a run with multiple waitpoints at once. Made it atomic

* Removed unused param

* New batch service

* Pass through the parentRunId and resumeParentOnCompletion

* Use the new batch service, and correct trigger task version

* Force V1 engine if using BatchTriggerV2Service, we’ve already done the check at this point

* Removed the $transaction and early exit if nothing changed

* Adedd a simple batch task to the hello world reference catalog

* Fix for batch waits not working

* Added parentRunId in a couple more places

* Removed waitForBatch log

* Added another parentRunId

* Expanded the example to include all the different triggers

* More changes to blocking to support continuing after idempotent completed runs

* Fix for the wrong type when blocking a run

* remove @map

* optimise worker auth query

* add engine version header to core api client requests

* remove unique constraint for default group id

* consolidate migrations

* the first managed worker becomes the global default

* Debug events off by default, added an admin toggle to show them

* worker group name can't be an empty string

* add exec helper to core

* move machine resources to core

* add pre-dequeue callback to determine max resources

* optionally skip dequeue

* bump worker package

* move worker to core

* fix ReadableStream type error

* fix another type issue

* update a few more tsconfigs

* add metadata changes introduced in #1563

* Run Engine 2.0 trigger idempotency (#1613)

* Return isCached from the trigger API endpoint

* Fix for the wrong type when blocking a run

* Render the idempotent run in the inspector

* Event repository for idempotency

* Debug events off by default, added an admin toggle to show them

* triggerAndWait idempotency span

* Some improvements to the reference idempotency task

* Removed the cached tracing from the SDK

* Server-side creating cached span

* Improved idempotency test task

* Create cached task spans in a better way

* Idempotency span support inc batch trigger

* Simplified how the spans are done, using more of the existing code

* Improved the idempotency test task

* Added Waitpoint Batch type, add to TaskRunWaitpoint with order

* Pass batch ids through to the run engine when triggering

* Added batchIndex

* Better batch support in the run engine

* Added settings to batch trigger service, before major overhaul

* Allow the longer run/batch ids in the filters

* Changed how batching works, includes breaking changes in CLI

* Removed batch idempotency because it gets put on the runs instead

* Added `runs` to the batch.retrieve call/API

* Set firstAttemptStartedAt when creating the first attempt

* Do nothing when receiving a BATCH waitpoint

* Some fixes in the new batch trigger service… mostly just passing missing optional params through

* Tweaked the idempotency test task for more situations

* Only block with a batch if it’s a batchTriggerAndWait… 🤦‍♂️

* Added another case to the idempotency test task: multiple of the same idempotencyKey in a single batch

* Support for the same run multiple times in the same batch

* Small tweaks

* Make sure to complete batches, even if they’re not andWait ones

* Export RunDuplicateIdempotencyKeyError from the run engine

* Latest lockfile

* Trigger with a machine (old run engine)

* RE2, allow setting machine when triggering

* Fix for new glob patterns

* add max run count to dequeue from version route

* add worker instance name env var and header

* queue consumer pre skip callback

* poll for more runs after final execution errors

* fix dequeue search param schema

* add shortcut to debug switch

* expose run engine timeouts as env vars

* make warm start durations configurable

* add optional status to json reply helper

* fix preSkip hook, add debug logs

* BLOCKED_BY_WAITPOINTS -> SUSPENDED

* exit controller when run suspended

* check if already replied before http reply

* run controller will wait for next run after the current one is suspended

* cancel run button shortcut

* minimal event repository environment type

* fix update metadata call

* run suspension and misc fixes wip

* change debug shortcut to shift + D

* Started work on the Dev supervisor

* Formatting

* Fix for bad imports

* Before rebuilding SSE

* Presence updating from the CLI working via SSE

* add worker notification debug logs

* send run:stop when exiting run phase

* skip current snapshot poll on worker notification

* add more logs and route to submit run debug logs

* add worker and runner ids to snapshots

* improve run notification debug logs

* add workload debug log route

* misc run controller fixes and refactor

* prevent parallel execution of critical functions

* update bun to 1.2.1

* WIP with dev dequeuing

* Method to convert friendlyIds to non-friendly, do nothing with actual ids

* Set the engine on BackgroundWorker, lazily upgrade projects to engine V2

* Runs with ttls were getting immediately expired… oops.

* Pass the Waiting for deploy reason through, so we have it on the execution snapshots

* Fixed the logic for getting the right background worker for a run

* Use the correct ID when dequeuing…

* determineEngineVersion is now fully functional

* Rate limiter ignores the dev endpoints

* Retrieving a batch gives you the runIds

* Set a unique version for the RE2 BatchTaskRun

* add provisional changeset

* The start of dev run execution is working

* First dev run working

* Moved the dev run controller closer to what Nick did with the managed one

* export exec output type

* Heartbeat fix: don’t heartbeat if _isHeartbeating == false

* Dev runs get notifications, some dev bug fixes

* Improved logging or dequeuing

* We need to dequeue runs from the latest version too, for triggerAndWait

* Ported Eric’s validateWorkerManifest with nicer errors

* When flattening an idempotency key if part is undefined, return undefined

* Dev logging fixes

* Remove sigterm listener

* Deprecating workers. Don’t specify a BackgroundWorker when dequeuing an environment

* Deleted some old files. Renamed “managed” to “deploy”

* When a build finishes, always copy the build dir (otherwise the first one gets trampled on by the 2nd)

* Dev master queues should work differently

* Deleting old workers

* Added debounce function to core

* Improvement to canceling

* WIP on debounce canceling on socket disconnection

* Added environment data to execution snapshots

* Dev runs that have stalled get “Canceled” with a reason explaining why

* Show CLI messaged when a connection to the platform is lost/restored

* Fix TriggerTask after merge

* Add trigger task v2 max attempts, replace some findUniques

* Port the new queue logic to the run engine

* More fixes post-merge

* We weren’t setting a `retryConfig` up for the tests… it’s now required

* Start the Redis worker inside the Run Engine… 🤦‍♂️

* Trying to make the testcontainers more reliable

* Added keyPrefix: "engine:”

* Badly placed bracket in trigger task

* Better Redis namespacing

* Fix for expired run not getting removed from the queue

* Don’t create a redis client in the testcontainers, return the redisOptions instead

* Cleanup redis client in the run lock tests

* Fix for the RunQueue not supporting keyPrefix

* Updated more of the RunQueue scripts rebalancing

* Trying to make Redis more robust in the tests…

* Improved test resiliciency more

* Fix for delays (checkpoint check)

* Increase the timeout slightly to fix ttl test

* Added priority support when triggering

* More wip trying to make test containers more reliable

* batchTriggerAndWait test is still failing… some wip to try fix it

* Fixed redis tests now we’re not providing a client

* Separate Redis clients for the run engine worker/queue/runlock

* Made the wait for duration test more resilient

* Added idempotencyKeyExpiresAt to Waitpoints

* Waitpoint timeouts and idempotency expiry

* Use finishWaitpoint, removed extra worker job

* Added waitpoint idempotency tests

* Creating resume tokens is working

* Some improvements to the resume tokens

* Moved resumeTokens to just be wait functions 🥳

* Delete old RuntimeManagers

* Wait for token is working

* Better test for the wait tokens

* Improved the test task some more

* Hide the accessories in the span inspector

* WIP on waitpoint inspector

* WIP on complete waitpoint form

* Span overview panel can be changed based on the entity type

* Improved the waitpoint display

* WIP on completing waitpoint form

* Use the existing CodeBlock for the tip

* Style improvements

* Complete waitpoint

* All waitpoint sidebar variants

* Waits now use a pause icon

* Durations waits use the API to create/block with a waitpoint, not the runtime

* Fix for engine.blockRunWithWaitpoint required org id

* Removed old wait code from the run controllers/task run process

* Form action for skipping a datetime waitpoint

* Move testDockerCheckpoint to a separate core package export (it can’t be bundled on the client)

* Fix for glitchy hourglass animation

* Completed waitpoints display better

* Increase Redis maxRetriesPerRequest to 20 (default)

* Completing and skipping waitpoints is working

* Remove the database prisma dev command, since we need to use create only now. Updated docs

* Added skip timeout, reworked the UI

* Tweaked spacing

* Added payload limit to waitpoint token completion from dashboard

* Test idempotency works on wait.for and wait.until

* Moved the worker-actions to /engine/ from /api/

* Moved dev engine endpoints to /engine/ from /api/

* Separate /engine/ rate limiter

* Added parallel wait prevention, it’s working for duration waits but not well for triggerAndWait yet

* WIP post-merge conflicts

* Set taskEventStore column in the new engine

* Remove duplicate keys

* Post-merge fixes

* Fix for span merge layout

* Use executedAt instead of firstAttemptStartedAt

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-03-05 14:40:14 +00:00
Matt Aitken c519a5afc7 Convert the number part of the version to a number, from a string (#1754)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
⚒️ Publish Worker / build (coordinator) (push) Has been cancelled
⚒️ Publish Worker / build (docker-provider) (push) Has been cancelled
⚒️ Publish Worker / build (kubernetes-provider) (push) Has been cancelled
2025-03-05 08:29:16 +00:00
Eric Allam 5730faea00 Release 3.3.17 (#1752)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 10s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2025-03-04 12:47:31 +00:00
github-actions[bot] 58f5955c66 chore: Update version for release (#1710)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-04 12:40:41 +00:00
James Ritchie 7162f07216 Updates sveltekit community plugin links (#1746) 2025-03-04 12:30:50 +00:00
Eric Allam 8f3a28effb pythonExtension and python runtime improvements (#1734)
* pythonExtension and python runtime improvements

* Adding streaming support

* Use writeFileSync

* Restructure extension docs and add python extension docs

* Fix broken link

* Update docs/config/extensions/overview.mdx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update docs/config/extensions/aptGet.mdx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update docs/config/extensions/custom.mdx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Add environment variable support

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-03-04 12:30:26 +00:00
Eric Allam bbd82adbda docs: improve deployment docs and add atomic deploy guide (#1748)
* docs: improve deployment docs and add atomic deploy guide

* Various docs fixes
2025-03-04 12:00:06 +00:00
Eric Allam edf0c1024f Publish docker image when internal-packages change (#1750) 2025-03-04 11:18:29 +00:00
Eric Allam 457273e4cf Fix email alert failures because of newlines in error messages (fixes #1745) (#1749) 2025-03-04 10:54:39 +00:00
Dan c315e38d76 Updated imports (#1747) 2025-03-03 20:21:28 +00:00
Eric Allam e0241819a4 fix: needsPromotion fixed in deploy github actions (#1741) 2025-03-01 10:29:44 +00:00
Dan 796e710439 Added claude thinking chatbot docs (#1743) 2025-03-01 09:57:22 +00:00
Eric Allam c8732becbd Updates @clack/prompts to 0.10.0 to improve CI output when using the spinner (#1740)
* Updates @clack/prompts to 0.10.0 to improve CI output when using the spinner

* Add changeset
2025-03-01 07:13:58 +00:00
Dan 5164935f9c Added X-Frame-Options and CSP (#1690) 2025-02-28 11:06:27 +00:00
James Ritchie e1db12b8ba Expandable code blocks (#1683)
* Removed unnecessary classes from Dialog

* Display large modal with code and copy button

* Fixes button position if chrome is shown

* Removes the duplication of the Highlight component
2025-02-28 09:17:56 +00:00
Eric Allam 187200a1bc Feat: Improved run start timeline visibility (#1732)
* Record cold start and execution metrics on attempt executions. Add cold start metrics as span events on attempt spans and display them in the run dashboard

* Add deployed tasks run timeline metrics

* Add Dequeued event to run timeline and cleanup the run timeline code

* Adds variants to storybook

* WIP adding new span styles

* Added offset progress bar animation

* More storybook states

* Adds support for the full vertical span to show the same state

* Adds error state to timelineLine

* Added additional state

* Added more line styling

* Added progress state to dequeued

* Added another state to storybook

* Fixed classname error

* Updated styles for the span timeline points

* Fixes alignment of timeline follow cursor indicator

* Adds help text tooltip to timeline span type titles

* Fixes type error

* Tweaked wording of tooltips

* Fixed type error (check this)

* Moved isAdmin to a higher level

* removed unused admin props

* Removed unused Admin filter

* Fixed border styling

* made the opacity of the timeline states 30% less

* Undo type cast

* Added a diminished style that’s used for spans (grey progress bar)

* Adds new storybook state

* Fixed timeline state

* Removed state if span isn’t the first

* Changed the timestamp span icon

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-02-27 20:47:48 +00:00
Eric Allam 26f9a1e478 Feat: two phase deployment, version pinning (#1739)
* WIP two-phase deployments

* Fix the help text

* Rename TRIGGER_WORKER_VERSION to TRIGGER_VERSION

* Add changeset

* A few naming fixes
2025-02-27 20:44:23 +00:00
Eric Allam 0e5ec8bfbc Only allow a single dev queue consumer to dequeue at once (#1737)
* Only allow a single dev queue consumer to dequeue at once

* Expire dev queue consumer connections keys

* Add missing return
2025-02-27 15:08:47 +00:00
Eric Allam d2ceb825a7 Fix issue where a typescript file with only types causes indexing to fail (#1735)
* Fix issue where a typescript file with only types causes indexing to fail

* Create ninety-cars-peel.md
2025-02-27 15:03:14 +00:00
Dan 376a0c5d04 Update README.md 2025-02-26 15:37:27 +00:00
Victor Duarte 06f60f2720 feat(build): Add support for Python scripts via pythonExtension (#1686)
* add PythonExtension

* remove potential shell injection risk

* Filter out blank lines or comment lines

* fix spelling

* add pythonExtension's `runInline`

* changes to requirements don’t invalidate the entire install layer

* copy script files on-demand

* improve PythonExtension types and logging

* add changeset

* fix broken imports

* Improve security of inline script execution

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Add file existence check for requirementsFile

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* update lock file

* Enhance error handling with detailed error information

* Add portable type annotation

* fix error TS18046: 'e' is of type 'unknown'

* export the python extension

* add `pythonExtension` to the catalog

* fix `Cannot find module '@trigger.dev/build/extensions/core' (TS2307)

* replace execa by tinyexec

* Update pnpm-lock.yaml

* add custom traces instead of logging

* The cleanup in the finally block does not fail silently anymore

* move python runtime/extension to independent package

* fix build package readme

* update lock file

* add documentation to python's package

* add missing dependency

* Update little-trains-begin.md

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
Co-authored-by: Eric Allam <eallam@icloud.com>
2025-02-26 13:39:28 +00:00
Gustavo Fior ca05a544d5 add missing imports to overview.mdx (#1731)
added missing imports:

import { auth } from "@trigger.dev/sdk/v3";
import { tasks } from "@trigger.dev/sdk/v3";
2025-02-25 21:00:38 +00:00
Dmitrii Malakhov 8044433320 Typo fix cli-commands-deploy.mdx (#1728) 2025-02-25 08:42:26 +00:00
James Ritchie 412eb81ee1 Explain TTL even better so the LLMs hopefully understand it (#1726) 2025-02-21 16:11:16 +00:00
Eric Allam 62e3238ecc Add configurable redis TTL on realtime streams (#1725) 2025-02-21 16:07:12 +00:00
James Ritchie 5a250cf92d Adds help and fix for “No loader is configured” (#1724) 2025-02-21 10:09:41 +00:00
nicktrn 80f2fc8bbe Add scheduled at timestamp to created run containers (#1722)
* add scheduled at timestamp to k8s provider

* add scheduled at timestamp to docker provider
2025-02-20 17:27:38 +00:00
Eric Allam bf43fd4f8e Use priority offsets in MarQS and schedule future messages using redis worker (#1720)
* Improve the MarQS priority system by moving future messages into the LRE worker and using a priority timestamp offset to define priority in messages

* Add a test to make sure priority offsets don't unfairly favor environments

* requeuing should clear concurrency sets

* Heartbeats should only reschedule existing heartbeat jobs

* Fix type error
2025-02-20 14:06:32 +00:00
nicktrn 7186b1e428 Add support for deferred checkpoints (#1721)
* add ability to delay checkpoints

* optional checkpoint delays for dependency waits

* prevent checkpoint creation for resumed batches

* unpause after checkpoint rejected by platform

* prevent checkpoint creation for resumed task waits

* fix checkpoint cancellation
2025-02-20 12:47:09 +00:00
James Ritchie 99f2caf5c2 Docs/new reference section (#1719)
* Moves API reference to a new dropdown section

* Move toubleshooting higher up

* Separates the reference Overview page into new pages

* Removed Projects api and redirects old page to overview

* typo
2025-02-20 11:30:32 +00:00
James Ritchie e19bf2abaf Docs/more docs improvements (#1718)
* Format the machine override example code

* Adds note to add `fluent-ffmpeg` to external

* Updates onFailure with more clarification

* Adds debugging in vs code to Troubleshooting

* Removed link
2025-02-19 16:06:42 +00:00
Eric Allam 23b4c2a214 Add MarQS requeueMessage, an atomic version of replaceMessage (#1717)
* New MarQS method requeueMessage, an atomic version of replace message

* Remove redundant call to remove the message from the env queue in requeueMessage
2025-02-19 15:11:43 +00:00
Eric Allam 7b1159eb45 MarQS reserve concurrency system & queue priority for resuming/retrying (#1715)
* run engine v1: orgs are no longer considered for concurrency

* Add reserve concurrency concept to allow waiting to resume parent tasks to release concurrency at the env level for child tasks to use (or else there is a deadlock). WIP recursive tasks

* child tasks inherit the queue timestamp from their parent tasks to prioritize completing child tasks based on when their parent started

* handle reserve concurrency with recursive deadlocks

* Finish docs update for concurrency

* Some fixes from badge conflict resolution

* WIP priority queues

* Implement MarQS priority queues

* Fix the migrations
2025-02-19 11:43:34 +00:00
James Ritchie c9f7ea87c3 Docs/various docs improvements (#1716)
* Adds more Prisma documentation

* Adds more info and a diagram to improve idempotencyKeyTTLs

* Adds more clarity to the run with TTL docs section

* Limits page copy improvements

* Adds schema properties to the alert webhooks
2025-02-19 11:13:29 +00:00
James Ritchie d6c869eb8f Docs/community packages (#1714)
* typo

* Adds feature list to the sveltekit plugin page

* Removed Migrations section in side menu

* Tidy up the table links

* Creates a community packages section

* Small tweak

* Adds info and links to community packages

* Updated mintlify broken links version in workflow file

* fixed broken link
2025-02-18 13:13:07 +00:00
James Ritchie 31a43273df Latest lock file 2025-02-18 11:01:14 +00:00
Erin Allison b4d1619732 Remove V2 countdown from sidebar (#1712)
Signed-off-by: Erin Allison <erin@eallison.us>
Signed-off-by: Erin Allison <eallison@andrettikarting.com>
Co-authored-by: James Ritchie <james@trigger.dev>
2025-02-18 10:59:12 +00:00
Vinny ferddy-ogo lagueule 1e194a53e5 Bump next from 14.2.15 to 14.2.21 in /references/nextjs-realtime (#1583)
Bumps [next](https://github.com/vercel/next.js) from 14.2.15 to 14.2.21.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.15...v14.2.21)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-17 18:40:09 +00:00
nicktrn 8f3f373002 Improve reliability of completion submission (#1711)
* add timeout support to sendWithAck

* coordinator will retry completion submission

* actually retry

* increase default retries

* something went wrong there, add this back in

* add changeset
2025-02-14 15:14:42 +00:00
nicktrn 36fea7d645 Add support for manual checkpoints (#1709)
* don't crash run on lazy attempt errors and count on retry

* remove abort controller after checkpoint in all cases

* duration checkpoints

* manual checkpoints

* add busybox to coordinator image

* add async toggle to process in background

* remove early returns

* add changeset for manual checkpoint schema
2025-02-14 11:35:10 +00:00
James Ritchie 37723344cd Adds SvelteKit to the frameworks (#1707) 2025-02-13 17:30:38 +00:00
Matt Aitken e61b29ae52 Release 3.3.16 2025-02-13 15:31:55 +00:00
github-actions[bot] 4dbf217edb chore: Update version for release (#1705)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-13 15:25:08 +00:00
James Ritchie a62cb3e60b Agent docs examples (#1706)
* Uses image cards for the frameworks

* Removes old snippets

* New AI agents side menu section

* WIP adding new ai agent pages

* Better overview page

* More copy added to the agent example pages

* Copy improvements

* Removes “Creating a project” page and side menu section

* Fixes broken links

* Updates to the latest Mintlify version, fixes issues, changes theme

* Adds descriptions to the main dropdown menu items

* Reformatted Introduction docs ‘landing page’

* Retry heartbeat timeouts by putting back in the queue (#1689)

* If there’s a heartbeat error and no attempts we put it back in the queue to try again

* When nacking, return whether it was put back in the queue or not

* Try and nack, if it fails then fail the run

* Consolidated switch statement

* Fail executing/retrying runs

* OOM retrying on larger machines (#1691)

* OOM retrying on larger machines

* Create forty-windows-shop.md

* Update forty-windows-shop.md

* Only retry again if the machine is different from the original

* Kubernetes OOMs appear as non-zero sigkills, adding support for treating these as OOMs

* Complete the original attempt span if retrying due to an OOM

* Revert "Complete the original attempt span if retrying due to an OOM"

This reverts commit 5f652c6212.

* chore: Update version for release (#1666)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Release 3.3.14

* Set machine when triggering docs

* Batch queue runs that are waiting for deploy (#1693)

* Detect ffmpeg OOM errors, added manual OutOfMemoryError (#1694)

* Detect ffmpeg OOM errors, added manual OutOfMemoryError

* Create eighty-spies-knock.md

* Improved the machines docs, including the new OutOfMemoryError

* chore: Update version for release (#1695)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Release 3.3.15

* Create new partitioned TaskEvent table, and switch to it gradually as new runs are created (#1696)

* Create new partitioned TaskEvent table, and switch to it gradually as new runs are created

* Add env var for partition window in seconds

* Make startCreatedAt required in task event store

* Don't create an attempt if the run is final, batchTriggerAndWait bad continue fix (#1698)

* WIP fix for ResumeAttemptService selecting the wrong attempt (which has no error or output)

* Don’t create an attempt if the run is already in a final status

* Don’t get all the columns for the query. Improved the logging.

* Added a log to the batch example

* Filter out the undefined values

* Fix missing logs on child runs by using the root task run createdAt if it exists (#1697)

* Provider changes to support image cache (#1700)

* add env var for additional pull secrets

* make static images configurable

* optional image prefixes

* optional labels with sample rates

* add missing core paths

* remove excessive logs

* Fix run container exits after OOM retries (#1701)

* remove unused imports

* tell run to exit before force requeue

* handle exit for case where we already retried after oom

* improve retry span and add machine props

* don't try to exit run in dev

* Upgrade local dev to use electric beta.15 (#1699)

* Text fixes

* Removed pnpm files

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <eric@trigger.dev>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-02-13 14:51:43 +00:00
Matt Aitken 440d413ce8 Alert Webhook improvements (#1703)
* WIP with webhook SDK function and types

* JSDocs added to the schema

* Webhooks are working

* Expanded the alert docs

* Remove duplicate export of waitUntil.js

* Use uncrypto

* Don’t rate limit webhooks

* Create slow-olives-fix.md
2025-02-13 13:44:36 +00:00
Eric Allam a2c70b450a Upgrade local dev to use electric beta.15 (#1699) 2025-02-13 11:02:01 +00:00
nicktrn a38f713e32 Fix run container exits after OOM retries (#1701)
* remove unused imports

* tell run to exit before force requeue

* handle exit for case where we already retried after oom

* improve retry span and add machine props

* don't try to exit run in dev
2025-02-12 18:32:58 +00:00
nicktrn 0924b37283 Provider changes to support image cache (#1700)
* add env var for additional pull secrets

* make static images configurable

* optional image prefixes

* optional labels with sample rates

* add missing core paths

* remove excessive logs
2025-02-12 16:57:55 +00:00
Eric Allam d622aba86d Fix missing logs on child runs by using the root task run createdAt if it exists (#1697) 2025-02-12 14:03:48 +00:00
Matt Aitken d214aa9093 Don't create an attempt if the run is final, batchTriggerAndWait bad continue fix (#1698)
* WIP fix for ResumeAttemptService selecting the wrong attempt (which has no error or output)

* Don’t create an attempt if the run is already in a final status

* Don’t get all the columns for the query. Improved the logging.

* Added a log to the batch example

* Filter out the undefined values
2025-02-12 14:02:29 +00:00
Eric Allam bd0cc541c5 Create new partitioned TaskEvent table, and switch to it gradually as new runs are created (#1696)
* Create new partitioned TaskEvent table, and switch to it gradually as new runs are created

* Add env var for partition window in seconds

* Make startCreatedAt required in task event store
2025-02-12 09:12:08 +00:00
1541 changed files with 98592 additions and 72769 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"@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)
+7 -1
View File
@@ -12,7 +12,13 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["webapp", "proxy", "coordinator", "docker-provider", "kubernetes-provider"],
"ignore": [
"webapp",
"coordinator",
"docker-provider",
"kubernetes-provider",
"supervisor"
],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Deprecate toolTask and replace with `ai.tool(mySchemaTask)`
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Improve warm start times by eagerly creating the child TaskRunProcess when a previous run as completed
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Suppress external instrumentation for fetch calls from ApiClient
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
fix: Realtime streams: prevent enqueuing into closed ReadableStream
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
v4: New lifecycle hooks
+1 -1
View File
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"moduleDetection": "force",
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"trigger.dev": {
"url": "http://localhost:3333/sse"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
---
description: how to run commands in the monorepo
globs:
alwaysApply: true
---
Almost all commands in the monorepo should be executed when `pnpm run ...` from the root of the monorepo. For example, running tests for the `@internal/run-engine` internal package:
```
pnpm run dev --filter webapp
```
But often, when running tests, it's better to `cd` into the directory and then run tests:
```
cd apps/webapp
pnpm run test --run
```
This way you can run for a single file easily:
```
cd internal-packages/run-engine
pnpm run test ./src/engine/tests/ttl.test.ts --run
```
+6
View File
@@ -0,0 +1,6 @@
---
description: understanding the structure of the monorepo
globs:
alwaysApply: true
---
We've documented the structure of our monorepo here: [repo.md](mdc:ai/references/repo.md)
+37
View File
@@ -0,0 +1,37 @@
---
description: Making updates to the main trigger.dev remix webapp
globs: apps/webapp/**/*.tsx,apps/webapp/**/*.ts
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/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.
## Environment variables and testing
In the webapp, all environment variables are accessed through the `env` export of [env.server.ts](mdc:apps/webapp/app/env.server.ts), instead of directly accessing `process.env`.
Ideally, the `env.server.ts` file would never be imported into a test file, either directly or indirectly. Tests should only imported classes and functions from a file matching `app/**/*.ts` of the webapp, and that file should not use environment variables, everything should be passed through as options instead. This "service/configuration" separation is important, and can be seen in a few places in the code for examples:
- [realtimeClient.server.ts](mdc:apps/webapp/app/services/realtimeClient.server.ts) is the testable service, and [realtimeClientGlobal.server.ts](mdc:apps/webapp/app/services/realtimeClientGlobal.server.ts) is the configuration
Also for writing tests in the webapp, checkout our [tests.md](mdc:ai/references/tests.md) guide
## Legacy run engine vs Run Engine 2.0
We originally the Trigger.dev "Run Engine" not as a single system, but just spread out all over the codebase, with no real separate or encapsulation. And we didn't even call it a "Run Engine". With Run Engine 2.0, we've completely rewritten big parts of the way the system works, and moved it over to an internal package called `@internal/run-engine`. So we've retroactively named the previous run engine "Legacy run engine". We're focused almost exclusively now on moving to Run Engine 2.0 and will be deprecating and removing the legacy run engine code eventually.
## Where to look for code
- The trigger API endpoint is [api.v1.tasks.$taskId.trigger.ts](mdc:apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts)
- 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
- 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
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
---
description: How to write tests in the monorepo
globs:
alwaysApply: true
---
Follow our [tests.md](mdc:ai/references/tests.md) guide for how to write tests in the monorepo.
+7
View File
@@ -0,0 +1,7 @@
apps/docker-provider/
apps/kubernetes-provider/
apps/proxy/
apps/coordinator/
packages/rsc/
.changeset
.zed
+31 -39
View File
@@ -1,44 +1,23 @@
\*.log
.git
.github
**/*.log
**/*.pem
**/*.tsbuildinfo
# editor
**/.cache
**/.env
**/.next
**/.output
**/.trigger
**/.tshy
**/.tshy-build
**/.turbo
**/.vercel
**/.wrangler
.idea
.vscode
**/dist
**/node_modules
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# next.js
.next/
build
# packages
build
dist
packages/\*\*/dist
# misc
.DS_Store
\*.pem
.turbo
.vercel
.cache
.output
.trigger
apps/\*\*/public/build
apps/webapp/build
apps/webapp/public/build
cypress/screenshots
cypress/videos
@@ -47,8 +26,21 @@ apps/**/styles/tailwind.css
packages/**/styles/tailwind.css
.changeset
references
.DS_Store
.git
.github
.idea
.pnp
.pnp.js
.vscode
coverage
build
docs
examples
out
references
CHANGESETS.md
CONTRIBUTING.md
README.md
-14
View File
@@ -1,14 +0,0 @@
module.exports = {
root: true,
// This tells ESLint to load the config from the package `eslint-config-custom`
extends: ["custom"],
settings: {
next: {
rootDir: ["apps/*/"],
},
},
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
},
};
+5
View File
@@ -31,6 +31,11 @@ runs:
sha=$(echo ${{ github.sha }} | head -c7)
ts=$(date +%s)
tag=${env}-${sha}-${ts}
elif [[ "${{ github.ref_name }}" == re2-*-* ]]; then
env=$(echo ${{ github.ref_name }} | cut -d- -f2)
sha=$(echo ${{ github.sha }} | head -c7)
ts=$(date +%s)
tag=${env}-${sha}-${ts}
elif [[ "${{ github.ref_name }}" == v.docker.* ]]; then
version="${GITHUB_REF_NAME#v.docker.}"
tag="v${version}"
+1 -1
View File
@@ -39,4 +39,4 @@ jobs:
${{ runner.os }}-mintlify
- name: 🔗 Check for broken links
run: npx mintlify@4.0.222 broken-links
run: npx mintlify@4.0.393 broken-links
+3
View File
@@ -38,6 +38,9 @@ jobs:
- name: 📥 Download deps
run: pnpm install --frozen-lockfile --filter trigger.dev...
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔧 Build v3 cli monorepo dependencies
run: pnpm run build --filter trigger.dev^...
+95
View File
@@ -0,0 +1,95 @@
name: "⚒️ Publish Worker RE2"
on:
workflow_call:
inputs:
image_tag:
description: The image tag to publish
type: string
required: false
default: ""
push:
tags:
- "re2-test-*"
- "re2-prod-*"
permissions:
packages: write
contents: read
jobs:
# check-branch:
# runs-on: ubuntu-latest
# steps:
# - name: Fail if re2-prod-* is pushed from a non-main branch
# if: startsWith(github.ref_name, 're2-prod-') && github.base_ref != 'main'
# run: |
# echo "🚫 re2-prod-* tags can only be pushed from the main branch."
# exit 1
build:
# needs: check-branch
strategy:
matrix:
package: [supervisor]
runs-on: ubuntu-latest
env:
DOCKER_BUILDKIT: "1"
steps:
- name: ⬇️ Checkout git repo
uses: actions/checkout@v4
- name: 📦 Get image repo
id: get_repository
run: |
if [[ "${{ matrix.package }}" == *-provider ]]; then
provider_type=$(echo "${{ matrix.package }}" | cut -d- -f1)
repo=provider/${provider_type}
else
repo="${{ matrix.package }}"
fi
echo "repo=${repo}" >> "$GITHUB_OUTPUT"
- id: get_tag
uses: ./.github/actions/get-image-tag
with:
tag: ${{ inputs.image_tag }}
- name: 🐋 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# ..to avoid rate limits when pulling images
- name: 🐳 Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 🚢 Build Container Image
run: |
docker build -t infra_image -f ./apps/${{ matrix.package }}/Containerfile .
# ..to push image
- name: 🐙 Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 🐙 Push to GitHub Container Registry
run: |
docker tag infra_image "$REGISTRY/$REPOSITORY:$IMAGE_TAG"
docker push "$REGISTRY/$REPOSITORY:$IMAGE_TAG"
env:
REGISTRY: ghcr.io/triggerdotdev
REPOSITORY: ${{ steps.get_repository.outputs.repo }}
IMAGE_TAG: ${{ steps.get_tag.outputs.tag }}
- name: 🐙 Push 'v3' tag to GitHub Container Registry
if: steps.get_tag.outputs.is_semver == 'true'
run: |
docker tag infra_image "$REGISTRY/$REPOSITORY:v3"
docker push "$REGISTRY/$REPOSITORY:v3"
env:
REGISTRY: ghcr.io/triggerdotdev
REPOSITORY: ${{ steps.get_repository.outputs.repo }}
+3 -5
View File
@@ -24,12 +24,10 @@ on:
- "packages/**"
- "!packages/**/*.md"
- "!packages/**/*.eslintrc"
- "internal-packages/**"
- "apps/**"
- "!apps/**/*.md"
- "!apps/**/*.eslintrc"
- "integrations/**"
- "!integrations/**/*.md"
- "!integrations/**/*.eslintrc"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
@@ -58,14 +56,14 @@ jobs:
secrets: inherit
publish-webapp:
needs: [typecheck, units]
needs: [typecheck]
uses: ./.github/workflows/publish-webapp.yml
secrets: inherit
with:
image_tag: ${{ inputs.image_tag }}
publish-worker:
needs: [typecheck, units]
needs: [typecheck]
uses: ./.github/workflows/publish-worker.yml
secrets: inherit
with:
+10 -3
View File
@@ -24,6 +24,13 @@ jobs:
node-version: 20.11.1
cache: "pnpm"
# ..to avoid rate limits when pulling images
- name: 🐳 Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
@@ -31,7 +38,7 @@ jobs:
run: pnpm run generate
- name: 🧪 Run Webapp Unit Tests
run: pnpm run test --filter webapp
run: pnpm run test:webapp
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
@@ -40,7 +47,7 @@ jobs:
ENCRYPTION_KEY: "secret"
- name: 🧪 Run Package Unit Tests
run: pnpm run test --filter "@trigger.dev/*"
run: pnpm run test:packages
- name: 🧪 Run Internal Unit Tests
run: pnpm run test --filter "@internal/*"
run: pnpm run test:internal
+5
View File
@@ -58,3 +58,8 @@ apps/**/public/build
.yarn
*.tsbuildinfo
/packages/cli-v3/src/package.json
.husky
/packages/react-hooks/src/package.json
/packages/core/src/package.json
/packages/trigger-sdk/src/package.json
/packages/python/src/package.json
+3 -1
View File
@@ -1,3 +1,5 @@
link-workspace-packages=false
public-hoist-pattern[]=*prisma*
prefer-workspace-packages=true
prefer-workspace-packages=true
update-notifier=false
side-effects-cache=false
+24
View File
@@ -133,6 +133,30 @@
"command": "pnpm exec trigger dev",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug RunEngine tests",
"command": "pnpm run test ./src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts -t 'Should retrieve metrics for all queues via getQueueMetrics'",
"cwd": "${workspaceFolder}/internal-packages/run-engine",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug RunQueue tests",
"command": "pnpm run test ./src/run-queue/index.test.ts",
"cwd": "${workspaceFolder}/internal-packages/run-engine",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug d3-demo",
"command": "pnpm exec trigger dev",
"cwd": "${workspaceFolder}/references/d3-demo",
"sourceMaps": true
}
]
}
+2 -1
View File
@@ -5,5 +5,6 @@
"search.exclude": {
"**/node_modules/**": true,
"packages/cli-v3/e2e": true
}
},
"vitest.disableWorkspaceWarning": true
}
+45
View File
@@ -0,0 +1,45 @@
[
{
"label": "Build packages",
"command": "pnpm run build --filter \"@trigger.dev/*\" --filter trigger.dev",
//"args": [],
// Env overrides for the command, will be appended to the terminal's environment from the settings.
"env": { "foo": "bar" },
// Current working directory to spawn the command into, defaults to current project root.
//"cwd": "/path/to/working/directory",
// Whether to use a new terminal tab or reuse the existing one to spawn the process, defaults to `false`.
"use_new_terminal": false,
// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish, defaults to `false`.
"allow_concurrent_runs": false,
// What to do with the terminal pane and tab, after the command was started:
// * `always` — always show the task's pane, and focus the corresponding tab in it (default)
// * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it
// * `never` — do not alter focus, but still add/reuse the task's tab in its pane
"reveal": "always",
// What to do with the terminal pane and tab, after the command has finished:
// * `never` — Do nothing when the command finishes (default)
// * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it
// * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`
"hide": "never",
// Which shell to use when running a task inside the terminal.
// May take 3 values:
// 1. (default) Use the system's default terminal configuration in /etc/passwd
// "shell": "system"
// 2. A program:
// "shell": {
// "program": "sh"
// }
// 3. A program with arguments:
// "shell": {
// "with_arguments": {
// "program": "/bin/bash",
// "args": ["--login"]
// }
// }
"shell": "system",
// Whether to show the task line in the output of the spawned task, defaults to `true`.
"show_summary": true,
// Whether to show the command line in the output of the spawned task, defaults to `true`.
"show_output": true
}
]
+26 -18
View File
@@ -135,23 +135,22 @@ The following steps should be followed any time you start working on a new featu
1. Make sure the webapp is running on localhost:3030
2. Open a terminal window and build the CLI and watch for changes
2. Open a terminal window and build the CLI and packages and watch for changes
```sh
pnpm run dev --filter trigger.dev
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*"
```
2. Open a new terminal window, and anytime changes are made to the `@trigger.dev/core` package, you'll need to manually rebuild the CLI:
```sh
pnpm run build --filter trigger.dev
```
Note: You do not need to do the same for `@trigger.dev/sdk`, just core.
3. Open another terminal window, and change into the `<root>/references/v3-catalog` directory.
4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution:
4. You'll need to run the following commands to setup prisma and migrate the database:
```sh
pnpm exec prisma migrate deploy
pnpm run generate:prisma
```
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 <root>/references/v3-catalog
@@ -165,13 +164,13 @@ If you want additional debug logging, you can use the `--log-level debug` flag:
pnpm exec trigger dev --log-level debug
```
5. 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.
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.
6. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks.
7. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks.
7. 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.
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.
8. Feel free to add additional files in `v3-catalog/src/trigger` to test out specific aspects of the system, or add in edge cases.
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)
@@ -230,13 +229,22 @@ pnpm run db:studio
cd packages/database
```
3. Create and apply the migrations
3. Create a migration
```
pnpm run db:migrate:dev
pnpm run db:migrate:dev:create
```
This creates a migration file and executes the migrations against your database and applies changes to the database schema(s)
This creates a migration file. Check the migration file does only what you want. If you're adding any database indexes they must use `CONCURRENTLY`, otherwise they'll lock the table when executed.
4. Run the migration.
```
pnpm run db:migrate:deploy
pnpm run generate
```
This executes the migrations against your database and applies changes to the database schema(s), and then regenerates the Prisma client.
4. Commit generated migrations as well as changes to the schema.prisma file
5. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`.
+4 -2
View File
@@ -5,7 +5,7 @@
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
</picture>
### Open source background jobs with no timeouts
### 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)
@@ -20,12 +20,14 @@ Trigger.dev is an open source platform and SDK which allows you to create long-r
### Key features:
- JavaScript and TypeScript SDK
- Write reliable code by default
- 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)
+37
View File
@@ -0,0 +1,37 @@
## Repo Overview
This is a pnpm 8.15.5 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
## Apps
- <root>/apps/webapp is a remix app that is the main API and dashboard for trigger.dev
- <root>/apps/supervisor is a node.js app that handles the execution of built tasks, interaction with the webapp through internal "engine" APIs, as well as interfacing with things like docker or kubernetes, to execute the code.
## Public Packages
- <root>/packages/trigger-sdk is the `@trigger.dev/sdk` main SDK package.
- <root>/packages/cli-v3 is the `trigger.dev` CLI package. See our [CLI dev command](https://trigger.dev/docs/cli-dev.md) and [Deployment](https://trigger.dev/docs/deployment/overview.md) docs for more information.
- <root>/packages/core is the `@trigger.dev/core` package that is shared across the SDK and other packages
- <root>/packages/build defines the types and prebuilt build extensions for trigger.dev. See our [build extensions docs](https://trigger.dev/docs/config/extensions/overview.md) for more information.
- <root>/packages/react-hooks defines some useful react hooks like our realtime hooks. See our [Realtime hooks](https://trigger.dev/docs/frontend/react-hooks/realtime.md) and our [Trigger hooks](https://trigger.dev/docs/frontend/react-hooks/triggering.md) for more information.
- <root>/packages/redis-worker is the `@trigger.dev/redis-worker` package that implements a custom background job/worker sytem powered by redis for offloading work to the background, used in the webapp and also in the Run Engine 2.0.
## Internal Packages
- <root>/internal-packages/\* are packages that are used internally only, not published, and usually they have a tsc build step and are used in the webapp
- <root>/internal-packages/database is the `@trigger.dev/database` package that exports a prisma client, has the schema file, and exports a few other helpers.
- <root>/internal-packages/run-engine is the `@internal/run-engine` package that is "Run Engine 2.0" and handles moving a run all the way through it's lifecycle
- <root>/internal-packages/redis is the `@internal/redis` package that exports Redis types and the `createRedisClient` function to unify how we create redis clients in the repo. It's not used everywhere yet, but it's the preferred way to create redis clients from now on.
- <root>/internal-packages/testcontainers is the `@internal/testcontainers` package that exports a few useful functions for spinning up local testcontainers when writing vitest tests. See our [tests.md](./tests.md) file for more information.
- <root>/internal-packages/zodworker is the `@internal/zodworker` package that implements a wrapper around graphile-worker that allows us to use zod to validate our background jobs. We are moving away from using graphile-worker as our background job system, replacing it with our own redis-worker package.
## References
- <root>/references/\* are test workspaces that we use to write and test the system. Not quite e2e tests or automated, but just a useful place to help develop new features
## Other
- <root>/docs is our trigger.dev/docs mintlify documentation site
- <root>/docker/Dockerfile is the one that creates the main trigger.dev published image
- <root>/docker/docker-compose.yml is the file we run locally to start postgresql, redis, and electric when we are doing local development. You can run it with `pnpm run docker`
- <root>/CONTRIBUTING.md defines the steps it takes for OSS contributors to start contributing.
+86
View File
@@ -0,0 +1,86 @@
## Running Tests
We use vitest exclusively for testing. To execute tests for a particular workspace, run the following command:
```bash
pnpm run test --filter webapp
```
Prefer running tests on a single file (and first cding into the directory):
```bash
cd apps/webapp
pnpm run test ./src/components/Button.test.ts
```
If you are cd'ing into a directory, you may have to build dependencies first:
```bash
pnpm run build --filter webapp
cd apps/webapp
pnpm run test ./src/components/Button.test.ts
```
## Writing Tests
We use vitest for testing. We almost NEVER mock anything. Start with a top-level "describe", and have multiple "it" statements inside of it.
New test files should be placed right next to the file being tested. For example:
- Source file: `./src/services/MyService.ts`
- Test file: `./src/services/MyService.test.ts`
When writing anything that needs redis or postgresql, we have some internal "testcontainers" that are used to spin up a local instance, redis, or both.
redisTest:
```typescript
import { redisTest } from "@internal/testcontainers";
import { createRedisClient } from "@internal/redis";
describe("redisTest", () => {
redisTest("should use redis", async ({ redisOptions }) => {
const redis = createRedisClient(redisOptions);
await redis.set("test", "test");
const result = await redis.get("test");
expect(result).toEqual("test");
});
});
```
postgresTest:
```typescript
import { postgresTest } from "@internal/testcontainers";
describe("postgresTest", () => {
postgresTest("should use postgres", async ({ prisma }) => {
// prisma is an instance of PrismaClient
});
});
```
containerTest:
```typescript
import { containerTest } from "@internal/testcontainers";
describe("containerTest", () => {
containerTest("should use container", async ({ prisma, redisOptions }) => {
// container has both prisma and redis
});
});
```
## Dos and Dont's
- Do not mock anything.
- Do not use mocks in tests.
- Do not use spies in tests.
- Do not use stubs in tests.
- Do not use fakes in tests.
- Do not use sinon in tests.
- Structure each test with a setup, action, and assertion style.
- Feel free to write long test names.
- If there is any randomness in the code under test, use `seedrandom` to make it deterministic by allowing the caller to provide a seed.
+1 -1
View File
@@ -13,7 +13,7 @@ RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
FROM node-20 AS base
RUN apt-get update \
&& apt-get install -y buildah ca-certificates dumb-init docker.io \
&& apt-get install -y buildah ca-certificates dumb-init docker.io busybox \
&& rm -rf /var/lib/apt/lists/*
COPY --chown=node:node .gitignore .gitignore
+1 -3
View File
@@ -23,10 +23,8 @@
"tinyexec": "^0.3.0"
},
"devDependencies": {
"@types/node": "^18",
"dotenv": "^16.4.2",
"esbuild": "^0.19.11",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
"tsx": "^4.7.0"
}
}
+146 -109
View File
@@ -1,5 +1,5 @@
import { ExponentialBackoff } from "@trigger.dev/core/v3/apps";
import { testDockerCheckpoint } from "@trigger.dev/core/v3/apps";
import { testDockerCheckpoint } from "@trigger.dev/core/v3/serverOnly";
import { nanoid } from "nanoid";
import fs from "node:fs/promises";
import { ChaosMonkey } from "./chaosMonkey";
@@ -27,7 +27,7 @@ type CheckpointAndPushResult =
| { success: true; checkpoint: CheckpointData }
| {
success: false;
reason?: "CANCELED" | "DISABLED" | "ERROR" | "IN_PROGRESS" | "NO_SUPPORT" | "SKIP_RETRYING";
reason?: "CANCELED" | "ERROR" | "SKIP_RETRYING";
};
type CheckpointData = {
@@ -87,9 +87,14 @@ export class Checkpointer {
#dockerMode: boolean;
#logger = new SimpleStructuredLogger("checkpointer");
#abortControllers = new Map<string, AbortController>();
#failedCheckpoints = new Map<string, unknown>();
#waitingForRetry = new Set<string>();
// Indexed by run ID
#runAbortControllers = new Map<
string,
{ signal: AbortSignal; abort: AbortController["abort"] }
>();
private registryHost: string;
private registryNamespace: string;
@@ -189,29 +194,80 @@ export class Checkpointer {
}
}
async checkpointAndPush(opts: CheckpointAndPushOptions): Promise<CheckpointData | undefined> {
async checkpointAndPush(
opts: CheckpointAndPushOptions,
delayMs?: number
): Promise<CheckpointData | undefined> {
const start = performance.now();
this.#logger.log(`checkpointAndPush() start`, { start, opts });
let interval: NodeJS.Timer | undefined;
const { runId } = opts;
let interval: NodeJS.Timer | undefined;
if (opts.shouldHeartbeat) {
interval = setInterval(() => {
this.#logger.log("Sending heartbeat", { runId: opts.runId });
this.opts.heartbeat(opts.runId);
this.#logger.log("Sending heartbeat", { runId });
this.opts.heartbeat(runId);
}, 20_000);
}
const controller = new AbortController();
const signal = controller.signal;
const abort = controller.abort.bind(controller);
const onAbort = () => {
this.#logger.error("Checkpoint aborted", { runId, options: opts });
};
signal.addEventListener("abort", onAbort, { once: true });
const removeCurrentAbortController = () => {
const controller = this.#runAbortControllers.get(runId);
// Ensure only the current controller is removed
if (controller && controller.signal === signal) {
this.#runAbortControllers.delete(runId);
}
// Remove the abort listener in case it hasn't fired
signal.removeEventListener("abort", onAbort);
};
if (!this.#dockerMode && !this.#canCheckpoint) {
this.#logger.error("No checkpoint support. Simulation requires docker.");
this.#failCheckpoint(runId, "NO_SUPPORT");
return;
}
if (this.#isRunCheckpointing(runId)) {
this.#logger.error("Checkpoint procedure already in progress", { options: opts });
this.#failCheckpoint(runId, "IN_PROGRESS");
return;
}
// This is a new checkpoint, clear any last failure for this run
this.#clearFailedCheckpoint(runId);
if (this.disableCheckpointSupport) {
this.#logger.error("Checkpoint support disabled", { options: opts });
this.#failCheckpoint(runId, "DISABLED");
return;
}
this.#runAbortControllers.set(runId, { signal, abort });
try {
const result = await this.#checkpointAndPushWithBackoff(opts);
const result = await this.#checkpointAndPushWithBackoff(opts, { delayMs, signal });
const end = performance.now();
this.#logger.log(`checkpointAndPush() end`, {
start,
end,
diff: end - start,
diffWithoutDelay: end - start - (delayMs ?? 0),
opts,
success: result.success,
delayMs,
});
if (!result.success) {
@@ -221,53 +277,69 @@ export class Checkpointer {
return result.checkpoint;
} finally {
if (opts.shouldHeartbeat) {
// @ts-ignore - Some kind of node incompatible type issue
clearInterval(interval);
}
removeCurrentAbortController();
}
}
isCheckpointing(runId: string) {
return this.#abortControllers.has(runId) || this.#waitingForRetry.has(runId);
#isRunCheckpointing(runId: string) {
return this.#runAbortControllers.has(runId);
}
cancelCheckpoint(runId: string): boolean {
cancelAllCheckpointsForRun(runId: string): boolean {
this.#logger.log("cancelAllCheckpointsForRun: call", { runId });
// If the last checkpoint failed, pretend we canceled it
// This ensures tasks don't wait for external resume messages to continue
if (this.#hasFailedCheckpoint(runId)) {
this.#logger.log("cancelAllCheckpointsForRun: hasFailedCheckpoint", { runId });
this.#clearFailedCheckpoint(runId);
return true;
}
if (this.#waitingForRetry.has(runId)) {
this.#waitingForRetry.delete(runId);
return true;
}
const controller = this.#abortControllers.get(runId);
const controller = this.#runAbortControllers.get(runId);
if (!controller) {
this.#logger.debug("Nothing to cancel", { runId });
this.#logger.debug("cancelAllCheckpointsForRun: no abort controller", { runId });
return false;
}
if (controller.signal.aborted) {
this.#logger.debug("Controller already aborted", { runId });
const { abort, signal } = controller;
if (signal.aborted) {
this.#logger.debug("cancelAllCheckpointsForRun: signal already aborted", { runId });
return false;
}
controller.abort("cancelCheckpoint()");
this.#abortControllers.delete(runId);
abort("cancelCheckpoint()");
this.#runAbortControllers.delete(runId);
return true;
}
async #checkpointAndPushWithBackoff({
runId,
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
projectRef,
deploymentVersion,
attemptNumber,
}: CheckpointAndPushOptions): Promise<CheckpointAndPushResult> {
async #checkpointAndPushWithBackoff(
{
runId,
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
projectRef,
deploymentVersion,
attemptNumber,
}: CheckpointAndPushOptions,
{ delayMs, signal }: { delayMs?: number; signal: AbortSignal }
): Promise<CheckpointAndPushResult> {
if (delayMs && delayMs > 0) {
this.#logger.log("Delaying checkpoint", { runId, delayMs });
try {
await setTimeout(delayMs, undefined, { signal });
} catch (error) {
this.#logger.log("Checkpoint canceled during initial delay", { runId });
return { success: false, reason: "CANCELED" };
}
}
this.#logger.log("Checkpointing with backoff", {
runId,
leaveRunning,
@@ -290,24 +362,24 @@ export class Checkpointer {
delay,
});
this.#waitingForRetry.add(runId);
await setTimeout(delay.milliseconds);
if (!this.#waitingForRetry.has(runId)) {
this.#logger.log("Checkpoint canceled while waiting for retry", { runId });
try {
await setTimeout(delay.milliseconds, undefined, { signal });
} catch (error) {
this.#logger.log("Checkpoint canceled during retry delay", { runId });
return { success: false, reason: "CANCELED" };
} else {
this.#waitingForRetry.delete(runId);
}
}
const result = await this.#checkpointAndPush({
runId,
leaveRunning,
projectRef,
deploymentVersion,
attemptNumber,
});
const result = await this.#checkpointAndPush(
{
runId,
leaveRunning,
projectRef,
deploymentVersion,
attemptNumber,
},
{ signal }
);
if (result.success) {
return result;
@@ -319,24 +391,6 @@ export class Checkpointer {
return result;
}
if (result.reason === "IN_PROGRESS") {
this.#logger.log("Checkpoint already in progress, won't retry", { runId });
this.#failCheckpoint(runId, result.reason);
return result;
}
if (result.reason === "NO_SUPPORT") {
this.#logger.log("No checkpoint support, won't retry", { runId });
this.#failCheckpoint(runId, result.reason);
return result;
}
if (result.reason === "DISABLED") {
this.#logger.log("Checkpoint support disabled, won't retry", { runId });
this.#failCheckpoint(runId, result.reason);
return result;
}
if (result.reason === "SKIP_RETRYING") {
this.#logger.log("Skipping retrying", { runId });
return result;
@@ -364,13 +418,16 @@ export class Checkpointer {
return { success: false, reason: "ERROR" };
}
async #checkpointAndPush({
runId,
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
projectRef,
deploymentVersion,
attemptNumber,
}: CheckpointAndPushOptions): Promise<CheckpointAndPushResult> {
async #checkpointAndPush(
{
runId,
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
projectRef,
deploymentVersion,
attemptNumber,
}: CheckpointAndPushOptions,
{ signal }: { signal: AbortSignal }
): Promise<CheckpointAndPushResult> {
await this.init();
const options = {
@@ -381,39 +438,12 @@ export class Checkpointer {
attemptNumber,
};
if (!this.#dockerMode && !this.#canCheckpoint) {
this.#logger.error("No checkpoint support. Simulation requires docker.");
return { success: false, reason: "NO_SUPPORT" };
}
if (this.isCheckpointing(runId)) {
this.#logger.error("Checkpoint procedure already in progress", { options });
return { success: false, reason: "IN_PROGRESS" };
}
// This is a new checkpoint, clear any last failure for this run
this.#clearFailedCheckpoint(runId);
if (this.disableCheckpointSupport) {
this.#logger.error("Checkpoint support disabled", { options });
return { success: false, reason: "DISABLED" };
}
const controller = new AbortController();
this.#abortControllers.set(runId, controller);
const onAbort = () => {
this.#logger.error("Checkpoint aborted", { options });
controller.signal.removeEventListener("abort", onAbort);
};
controller.signal.addEventListener("abort", onAbort);
const shortCode = nanoid(8);
const imageRef = this.#getImageRef(projectRef, deploymentVersion, shortCode);
const exportLocation = this.#getExportLocation(projectRef, deploymentVersion, shortCode);
const buildah = new Buildah({ id: `${runId}-${shortCode}`, abortSignal: controller.signal });
const crictl = new Crictl({ id: `${runId}-${shortCode}`, abortSignal: controller.signal });
const buildah = new Buildah({ id: `${runId}-${shortCode}`, abortSignal: signal });
const crictl = new Crictl({ id: `${runId}-${shortCode}`, abortSignal: signal });
const cleanup = async () => {
const metadata = {
@@ -435,32 +465,26 @@ export class Checkpointer {
} catch (error) {
this.#logger.error("Error during cleanup", { ...metadata, error });
}
// Ensure only the current controller is removed
if (this.#abortControllers.get(runId) === controller) {
this.#abortControllers.delete(runId);
}
controller.signal.removeEventListener("abort", onAbort);
};
try {
await this.chaosMonkey.call();
this.#logger.log("Checkpointing:", { options });
this.#logger.log("checkpointAndPush: checkpointing", { options });
const containterName = this.#getRunContainerName(runId);
// Create checkpoint (docker)
if (this.#dockerMode) {
await this.#createDockerCheckpoint(
controller.signal,
signal,
runId,
exportLocation,
leaveRunning,
attemptNumber
);
this.#logger.log("checkpoint created:", {
this.#logger.log("checkpointAndPush: checkpoint created", {
runId,
location: exportLocation,
});
@@ -561,13 +585,16 @@ export class Checkpointer {
}
}
this.#logger.error("Unhandled checkpoint error", { options, error });
this.#logger.error("Unhandled checkpoint error", {
options,
error: error instanceof Error ? error.message : error,
});
return { success: false, reason: "ERROR" };
} finally {
await cleanup();
if (controller.signal.aborted) {
if (signal.aborted) {
this.#logger.error("Checkpoint canceled: Cleanup", { options });
// Overrides any prior return value
@@ -576,6 +603,16 @@ export class Checkpointer {
}
}
async unpause(runId: string, attemptNumber?: number): Promise<void> {
try {
const containterNameWithAttempt = this.#getRunContainerName(runId, attemptNumber);
const exec = new Exec({ logger: this.#logger });
await exec.x("docker", ["unpause", containterNameWithAttempt]);
} catch (error) {
this.#logger.error("[Docker] Error during unpause", { runId, attemptNumber, error });
}
}
async #createDockerCheckpoint(
abortSignal: AbortSignal,
runId: string,
+434 -55
View File
@@ -11,10 +11,10 @@ import {
} from "@trigger.dev/core/v3";
import { ZodNamespace } from "@trigger.dev/core/v3/zodNamespace";
import { ZodSocketConnection } from "@trigger.dev/core/v3/zodSocket";
import { HttpReply, getTextBody } from "@trigger.dev/core/v3/apps";
import { ExponentialBackoff, HttpReply, getTextBody } from "@trigger.dev/core/v3/apps";
import { ChaosMonkey } from "./chaosMonkey";
import { Checkpointer } from "./checkpointer";
import { boolFromEnv, numFromEnv } from "./util";
import { boolFromEnv, numFromEnv, safeJsonParse } from "./util";
import { collectDefaultMetrics, register, Gauge } from "prom-client";
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
@@ -30,6 +30,16 @@ const PLATFORM_WS_PORT = process.env.PLATFORM_WS_PORT || 3030;
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || "coordinator-secret";
const SECURE_CONNECTION = ["1", "true"].includes(process.env.SECURE_CONNECTION ?? "false");
const TASK_RUN_COMPLETED_WITH_ACK_TIMEOUT_MS =
parseInt(process.env.TASK_RUN_COMPLETED_WITH_ACK_TIMEOUT_MS || "") || 30_000;
const TASK_RUN_COMPLETED_WITH_ACK_MAX_RETRIES =
parseInt(process.env.TASK_RUN_COMPLETED_WITH_ACK_MAX_RETRIES || "") || 7;
const WAIT_FOR_TASK_CHECKPOINT_DELAY_MS =
parseInt(process.env.WAIT_FOR_TASK_CHECKPOINT_DELAY_MS || "") || 0;
const WAIT_FOR_BATCH_CHECKPOINT_DELAY_MS =
parseInt(process.env.WAIT_FOR_BATCH_CHECKPOINT_DELAY_MS || "") || 0;
const logger = new SimpleStructuredLogger("coordinator", undefined, { nodeName: NODE_NAME });
const chaosMonkey = new ChaosMonkey(
!!process.env.CHAOS_MONKEY_ENABLED,
@@ -42,6 +52,8 @@ class CheckpointCancelError extends Error {}
class TaskCoordinator {
#httpServer: ReturnType<typeof createServer>;
#internalHttpServer: ReturnType<typeof createServer>;
#checkpointer = new Checkpointer({
dockerMode: !process.env.KUBERNETES_PORT,
forceSimulate: boolFromEnv("FORCE_CHECKPOINT_SIMULATION", false),
@@ -79,6 +91,8 @@ class TaskCoordinator {
private host = "0.0.0.0"
) {
this.#httpServer = this.#createHttpServer();
this.#internalHttpServer = this.#createInternalHttpServer();
this.#checkpointer.init();
this.#platformSocket = this.#createPlatformSocket();
@@ -134,6 +148,7 @@ class TaskCoordinator {
authToken: PLATFORM_SECRET,
logHandlerPayloads: false,
handlers: {
// This is used by resumeAttempt
RESUME_AFTER_DEPENDENCY: async (message) => {
const log = platformLogger.child({
eventName: "RESUME_AFTER_DEPENDENCY",
@@ -159,11 +174,15 @@ class TaskCoordinator {
await chaosMonkey.call();
// In case the task resumed faster than we could checkpoint
this.#cancelCheckpoint(message.runId);
// In case the task resumes before the checkpoint is created
this.#cancelCheckpoint(message.runId, {
event: "RESUME_AFTER_DEPENDENCY",
completions: message.completions.length,
});
taskSocket.emit("RESUME_AFTER_DEPENDENCY", message);
},
// This is used by sharedQueueConsumer
RESUME_AFTER_DEPENDENCY_WITH_ACK: async (message) => {
const log = platformLogger.child({
eventName: "RESUME_AFTER_DEPENDENCY_WITH_ACK",
@@ -209,8 +228,11 @@ class TaskCoordinator {
await chaosMonkey.call();
// In case the task resumed faster than we could checkpoint
this.#cancelCheckpoint(message.runId);
// In case the task resumes before the checkpoint is created
this.#cancelCheckpoint(message.runId, {
event: "RESUME_AFTER_DEPENDENCY_WITH_ACK",
completions: message.completions.length,
});
taskSocket.emit("RESUME_AFTER_DEPENDENCY", message);
@@ -278,7 +300,7 @@ class TaskCoordinator {
log.addFields({ socketId: taskSocket.id, socketData: taskSocket.data });
log.log("Found task socket for REQUEST_RUN_CANCELLATION");
this.#cancelCheckpoint(message.runId);
this.#cancelCheckpoint(message.runId, { event: "REQUEST_RUN_CANCELLATION", ...message });
if (message.delayInMs) {
taskSocket.emit("REQUEST_EXIT", {
@@ -641,9 +663,25 @@ class TaskCoordinator {
await chaosMonkey.call();
const lazyPayload = {
...lazyAttempt.lazyPayload,
metrics: [
...(message.startTime
? [
{
name: "start",
event: "lazy_payload",
timestamp: message.startTime,
duration: Date.now() - message.startTime,
},
]
: []),
],
};
socket.emit("EXECUTE_TASK_RUN_LAZY_ATTEMPT", {
version: "v1",
lazyPayload: lazyAttempt.lazyPayload,
lazyPayload,
});
} catch (error) {
if (error instanceof ChaosMonkey.Error) {
@@ -653,11 +691,11 @@ class TaskCoordinator {
log.error("READY_FOR_LAZY_ATTEMPT error", { error });
await crashRun({
name: "ReadyForLazyAttemptError",
message:
error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
});
// await crashRun({
// name: "ReadyForLazyAttemptError",
// message:
// error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
// });
return;
}
@@ -712,23 +750,91 @@ class TaskCoordinator {
const { completion, execution } = message;
// Cancel all in-progress checkpoints (if any)
this.#cancelCheckpoint(socket.data.runId);
this.#cancelCheckpoint(socket.data.runId, {
event: "TASK_RUN_COMPLETED",
attemptNumber: execution.attempt.number,
});
await chaosMonkey.call({ throwErrors: false });
const completeWithoutCheckpoint = (shouldExit: boolean) => {
const sendCompletionWithAck = async (): Promise<boolean> => {
try {
const response = await this.#platformSocket?.sendWithAck(
"TASK_RUN_COMPLETED_WITH_ACK",
{
version: "v2",
execution,
completion,
},
TASK_RUN_COMPLETED_WITH_ACK_TIMEOUT_MS
);
if (!response) {
log.error("TASK_RUN_COMPLETED_WITH_ACK: no response");
return false;
}
if (!response.success) {
log.error("TASK_RUN_COMPLETED_WITH_ACK: error response", {
error: response.error,
});
return false;
}
log.log("TASK_RUN_COMPLETED_WITH_ACK: successful response");
return true;
} catch (error) {
log.error("TASK_RUN_COMPLETED_WITH_ACK: threw error", { error });
return false;
}
};
const completeWithoutCheckpoint = async (shouldExit: boolean) => {
const supportsRetryCheckpoints = message.version === "v1";
this.#platformSocket?.send("TASK_RUN_COMPLETED", {
version: supportsRetryCheckpoints ? "v1" : "v2",
execution,
completion,
});
callback({ willCheckpointAndRestore: false, shouldExit });
if (supportsRetryCheckpoints) {
// This is only here for backwards compat
this.#platformSocket?.send("TASK_RUN_COMPLETED", {
version: "v1",
execution,
completion,
});
} else {
// 99.99% of runs should end up here
const completedWithAckBackoff = new ExponentialBackoff("FullJitter").maxRetries(
TASK_RUN_COMPLETED_WITH_ACK_MAX_RETRIES
);
const result = await completedWithAckBackoff.execute(
async ({ retry, delay, elapsedMs }) => {
logger.log("TASK_RUN_COMPLETED_WITH_ACK: sending with backoff", {
retry,
delay,
elapsedMs,
});
const success = await sendCompletionWithAck();
if (!success) {
throw new Error("Failed to send completion with ack");
}
}
);
if (!result.success) {
logger.error("TASK_RUN_COMPLETED_WITH_ACK: failed to send with backoff", result);
return;
}
logger.log("TASK_RUN_COMPLETED_WITH_ACK: sent with backoff", result);
}
};
if (completion.ok) {
completeWithoutCheckpoint(true);
await completeWithoutCheckpoint(true);
return;
}
@@ -736,17 +842,17 @@ class TaskCoordinator {
completion.error.type === "INTERNAL_ERROR" &&
completion.error.code === "TASK_RUN_CANCELLED"
) {
completeWithoutCheckpoint(true);
await completeWithoutCheckpoint(true);
return;
}
if (completion.retry === undefined) {
completeWithoutCheckpoint(true);
await completeWithoutCheckpoint(true);
return;
}
if (completion.retry.delay < this.#delayThresholdInMs) {
completeWithoutCheckpoint(false);
await completeWithoutCheckpoint(false);
// Prevents runs that fail fast from never sending a heartbeat
this.#sendRunHeartbeat(socket.data.runId);
@@ -755,7 +861,7 @@ class TaskCoordinator {
}
if (message.version === "v2") {
completeWithoutCheckpoint(true);
await completeWithoutCheckpoint(true);
return;
}
@@ -764,7 +870,7 @@ class TaskCoordinator {
const willCheckpointAndRestore = canCheckpoint || willSimulate;
if (!willCheckpointAndRestore) {
completeWithoutCheckpoint(false);
await completeWithoutCheckpoint(false);
return;
}
@@ -788,7 +894,7 @@ class TaskCoordinator {
if (!checkpoint) {
log.error("Failed to checkpoint");
completeWithoutCheckpoint(false);
await completeWithoutCheckpoint(false);
return;
}
@@ -832,7 +938,10 @@ class TaskCoordinator {
try {
// Cancel all in-progress checkpoints (if any)
this.#cancelCheckpoint(socket.data.runId);
this.#cancelCheckpoint(socket.data.runId, {
event: "TASK_RUN_FAILED_TO_RUN",
errorType: completion.error.type,
});
this.#platformSocket?.send("TASK_RUN_FAILED_TO_RUN", {
version: "v1",
@@ -885,12 +994,15 @@ class TaskCoordinator {
try {
if (message.version === "v1") {
this.#cancelCheckpoint(socket.data.runId);
this.#cancelCheckpoint(socket.data.runId, { event: "CANCEL_CHECKPOINT", ...message });
// v1 has no callback
return;
}
const checkpointCanceled = this.#cancelCheckpoint(socket.data.runId);
const checkpointCanceled = this.#cancelCheckpoint(socket.data.runId, {
event: "CANCEL_CHECKPOINT",
...message,
});
callback({ version: "v2", checkpointCanceled });
} catch (error) {
@@ -934,11 +1046,14 @@ class TaskCoordinator {
return;
}
const runId = socket.data.runId;
const attemptNumber = getAttemptNumber();
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber: getAttemptNumber(),
attemptNumber,
});
if (!checkpoint) {
@@ -964,6 +1079,13 @@ class TaskCoordinator {
if (ack?.keepRunAlive) {
log.log("keeping run alive after duration checkpoint");
if (checkpoint.docker && willSimulate) {
// The container is still paused so we need to unpause it
log.log("unpausing container after duration checkpoint");
this.#checkpointer.unpause(runId, attemptNumber);
}
return;
}
@@ -1022,12 +1144,18 @@ class TaskCoordinator {
}
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber: getAttemptNumber(),
});
const runId = socket.data.runId;
const attemptNumber = getAttemptNumber();
const checkpoint = await this.#checkpointer.checkpointAndPush(
{
runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber,
},
WAIT_FOR_TASK_CHECKPOINT_DELAY_MS
);
if (!checkpoint) {
log.error("Failed to checkpoint");
@@ -1057,6 +1185,13 @@ class TaskCoordinator {
if (ack?.keepRunAlive) {
socket.data.requiresCheckpointResumeWithMessage = undefined;
log.log("keeping run alive after task checkpoint");
if (checkpoint.docker && willSimulate) {
// The container is still paused so we need to unpause it
log.log("unpausing container after duration checkpoint");
this.#checkpointer.unpause(runId, attemptNumber);
}
return;
}
@@ -1115,12 +1250,18 @@ class TaskCoordinator {
}
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: socket.data.runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber: getAttemptNumber(),
});
const runId = socket.data.runId;
const attemptNumber = getAttemptNumber();
const checkpoint = await this.#checkpointer.checkpointAndPush(
{
runId,
projectRef: socket.data.projectRef,
deploymentVersion: socket.data.deploymentVersion,
attemptNumber,
},
WAIT_FOR_BATCH_CHECKPOINT_DELAY_MS
);
if (!checkpoint) {
log.error("Failed to checkpoint");
@@ -1151,6 +1292,13 @@ class TaskCoordinator {
if (ack?.keepRunAlive) {
socket.data.requiresCheckpointResumeWithMessage = undefined;
log.log("keeping run alive after batch checkpoint");
if (checkpoint.docker && willSimulate) {
// The container is still paused so we need to unpause it
log.log("unpausing container after batch checkpoint");
this.#checkpointer.unpause(runId, attemptNumber);
}
return;
}
@@ -1338,7 +1486,9 @@ class TaskCoordinator {
});
}
#cancelCheckpoint(runId: string): boolean {
#cancelCheckpoint(runId: string, reason?: any): boolean {
logger.log("cancelCheckpoint: call", { runId, reason });
const checkpointWait = this.#checkpointableTasks.get(runId);
if (checkpointWait) {
@@ -1347,9 +1497,14 @@ class TaskCoordinator {
}
// Cancel checkpointing procedure
const checkpointCanceled = this.#checkpointer.cancelCheckpoint(runId);
const checkpointCanceled = this.#checkpointer.cancelAllCheckpointsForRun(runId);
logger.log("cancelCheckpoint()", { runId, checkpointCanceled });
logger.log("cancelCheckpoint: result", {
runId,
reason,
checkpointCanceled,
hadCheckpointWait: !!checkpointWait,
});
return checkpointCanceled;
}
@@ -1368,14 +1523,6 @@ class TaskCoordinator {
case "/metrics": {
return reply.text(await register.metrics(), 200, register.contentType);
}
case "/whoami": {
return reply.text(NODE_NAME);
}
case "/checkpoint": {
const body = await getTextBody(req);
// await this.#checkpointer.checkpointAndPush(body);
return reply.text(`sent restore request: ${body}`);
}
default: {
return reply.empty(404);
}
@@ -1393,8 +1540,240 @@ class TaskCoordinator {
return httpServer;
}
#createInternalHttpServer() {
const httpServer = createServer(async (req, res) => {
logger.log(`[${req.method}]`, { url: req.url });
const reply = new HttpReply(res);
switch (req.url) {
case "/whoami": {
return reply.text(NODE_NAME);
}
case "/checkpoint/duration": {
try {
const body = await getTextBody(req);
const json = safeJsonParse(body);
if (typeof json !== "object" || !json) {
return reply.text("Invalid body", 400);
}
if (!("runId" in json) || typeof json.runId !== "string") {
return reply.text("Missing or invalid: runId", 400);
}
if (!("now" in json) || typeof json.now !== "number") {
return reply.text("Missing or invalid: now", 400);
}
if (!("ms" in json) || typeof json.ms !== "number") {
return reply.text("Missing or invalid: ms", 400);
}
let keepRunAlive = false;
if ("keepRunAlive" in json && typeof json.keepRunAlive === "boolean") {
keepRunAlive = json.keepRunAlive;
}
let async = false;
if ("async" in json && typeof json.async === "boolean") {
async = json.async;
}
const { runId, now, ms } = json;
if (!runId) {
return reply.text("Missing runId", 400);
}
const runSocket = await this.#getRunSocket(runId);
if (!runSocket) {
return reply.text("Run socket not found", 404);
}
const { data } = runSocket;
console.log("Manual duration checkpoint", data);
if (async) {
reply.text("Creating checkpoint in the background", 202);
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: data.runId,
projectRef: data.projectRef,
deploymentVersion: data.deploymentVersion,
attemptNumber: data.attemptNumber ? parseInt(data.attemptNumber) : undefined,
});
if (!checkpoint) {
return reply.text("Failed to checkpoint", 500);
}
if (!data.attemptFriendlyId) {
return reply.text("Socket data missing attemptFriendlyId", 500);
}
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
version: "v1",
runId,
attemptFriendlyId: data.attemptFriendlyId,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "WAIT_FOR_DURATION",
ms,
now,
},
});
if (ack?.keepRunAlive || keepRunAlive) {
return reply.json({
message: `keeping run ${runId} alive after checkpoint`,
checkpoint,
requestJson: json,
platformAck: ack,
});
}
runSocket.emit("REQUEST_EXIT", {
version: "v1",
});
return reply.json({
message: `checkpoint created for run ${runId}`,
checkpoint,
requestJson: json,
platformAck: ack,
});
} catch (error) {
return reply.json({
message: `error`,
error,
});
}
}
case "/checkpoint/manual": {
try {
const body = await getTextBody(req);
const json = safeJsonParse(body);
if (typeof json !== "object" || !json) {
return reply.text("Invalid body", 400);
}
if (!("runId" in json) || typeof json.runId !== "string") {
return reply.text("Missing or invalid: runId", 400);
}
let restoreAtUnixTimeMs: number | undefined;
if ("restoreAtUnixTimeMs" in json && typeof json.restoreAtUnixTimeMs === "number") {
restoreAtUnixTimeMs = json.restoreAtUnixTimeMs;
}
let keepRunAlive = false;
if ("keepRunAlive" in json && typeof json.keepRunAlive === "boolean") {
keepRunAlive = json.keepRunAlive;
}
let async = false;
if ("async" in json && typeof json.async === "boolean") {
async = json.async;
}
const { runId } = json;
if (!runId) {
return reply.text("Missing runId", 400);
}
const runSocket = await this.#getRunSocket(runId);
if (!runSocket) {
return reply.text("Run socket not found", 404);
}
const { data } = runSocket;
console.log("Manual checkpoint", data);
if (async) {
reply.text("Creating checkpoint in the background", 202);
}
const checkpoint = await this.#checkpointer.checkpointAndPush({
runId: data.runId,
projectRef: data.projectRef,
deploymentVersion: data.deploymentVersion,
attemptNumber: data.attemptNumber ? parseInt(data.attemptNumber) : undefined,
});
if (!checkpoint) {
return reply.text("Failed to checkpoint", 500);
}
if (!data.attemptFriendlyId) {
return reply.text("Socket data missing attemptFriendlyId", 500);
}
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
version: "v1",
runId,
attemptFriendlyId: data.attemptFriendlyId,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "MANUAL",
restoreAtUnixTimeMs,
},
});
if (ack?.keepRunAlive || keepRunAlive) {
return reply.json({
message: `keeping run ${runId} alive after checkpoint`,
checkpoint,
requestJson: json,
platformAck: ack,
});
}
runSocket.emit("REQUEST_EXIT", {
version: "v1",
});
return reply.json({
message: `checkpoint created for run ${runId}`,
checkpoint,
requestJson: json,
platformAck: ack,
});
} catch (error) {
return reply.json({
message: `error`,
error,
});
}
}
default: {
return reply.empty(404);
}
}
});
httpServer.on("clientError", (err, socket) => {
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
});
httpServer.on("listening", () => {
logger.log("internal server listening on port", { port: HTTP_SERVER_PORT + 100 });
});
return httpServer;
}
listen() {
this.#httpServer.listen(this.port, this.host);
this.#internalHttpServer.listen(this.port + 100, "127.0.0.1");
}
}
+12
View File
@@ -17,3 +17,15 @@ export const numFromEnv = (env: string, defaultValue: number): number => {
return parseInt(value, 10);
};
export function safeJsonParse(json?: string): unknown {
if (!json) {
return;
}
try {
return JSON.parse(json);
} catch (e) {
return null;
}
}
+1 -3
View File
@@ -1,8 +1,6 @@
{
"include": ["./src/**/*.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"target": "es2016",
"target": "es2018",
"module": "commonjs",
"esModuleInterop": true,
"resolveJsonModule": true,
+1 -3
View File
@@ -20,10 +20,8 @@
"execa": "^8.0.1"
},
"devDependencies": {
"@types/node": "^18.19.8",
"dotenv": "^16.4.2",
"esbuild": "^0.19.11",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
"tsx": "^4.7.0"
}
}
+7 -1
View File
@@ -7,7 +7,8 @@ import {
TaskOperationsRestoreOptions,
} from "@trigger.dev/core/v3/apps";
import { SimpleLogger } from "@trigger.dev/core/v3/apps";
import { isExecaChildProcess, testDockerCheckpoint } from "@trigger.dev/core/v3/apps";
import { isExecaChildProcess } from "@trigger.dev/core/v3/apps";
import { testDockerCheckpoint } from "@trigger.dev/core/v3/serverOnly";
import { setTimeout } from "node:timers/promises";
import { PostStartCauses, PreStopCauses } from "@trigger.dev/core/v3";
@@ -122,6 +123,7 @@ class DockerTaskOperations implements TaskOperations {
`--env=POD_NAME=${containerName}`,
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
`--env=TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
`--name=${containerName}`,
];
@@ -129,6 +131,10 @@ class DockerTaskOperations implements TaskOperations {
runArgs.push(`--cpus=${opts.machine.cpu}`, `--memory=${opts.machine.memory}G`);
}
if (opts.dequeuedAt) {
runArgs.push(`--env=TRIGGER_RUN_DEQUEUED_AT_MS=${opts.dequeuedAt}`);
}
runArgs.push(`${opts.image}`);
try {
+1 -2
View File
@@ -23,7 +23,6 @@
"devDependencies": {
"dotenv": "^16.4.2",
"esbuild": "^0.19.11",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
"tsx": "^4.7.0"
}
}
+58 -15
View File
@@ -17,6 +17,8 @@ import {
import { PodCleaner } from "./podCleaner";
import { TaskMonitor } from "./taskMonitor";
import { UptimeHeartbeat } from "./uptimeHeartbeat";
import { assertExhaustive } from "@trigger.dev/core";
import { CustomLabelHelper } from "./labelHelper";
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
const NODE_NAME = process.env.NODE_NAME || "local";
@@ -37,7 +39,14 @@ const UPTIME_MAX_PENDING_ERRORS = Number(process.env.UPTIME_MAX_PENDING_ERRORS |
const POD_EPHEMERAL_STORAGE_SIZE_LIMIT = process.env.POD_EPHEMERAL_STORAGE_SIZE_LIMIT || "10Gi";
const POD_EPHEMERAL_STORAGE_SIZE_REQUEST = process.env.POD_EPHEMERAL_STORAGE_SIZE_REQUEST || "2Gi";
// Image config
const PRE_PULL_DISABLED = process.env.PRE_PULL_DISABLED === "true";
const ADDITIONAL_PULL_SECRETS = process.env.ADDITIONAL_PULL_SECRETS;
const PAUSE_IMAGE = process.env.PAUSE_IMAGE || "registry.k8s.io/pause:3.9";
const BUSYBOX_IMAGE = process.env.BUSYBOX_IMAGE || "registry.digitalocean.com/trigger/busybox";
const DEPLOYMENT_IMAGE_PREFIX = process.env.DEPLOYMENT_IMAGE_PREFIX;
const RESTORE_IMAGE_PREFIX = process.env.RESTORE_IMAGE_PREFIX;
const UTILITY_IMAGE_PREFIX = process.env.UTILITY_IMAGE_PREFIX;
const logger = new SimpleLogger(`[${NODE_NAME}]`);
logger.log(`running in ${RUNTIME_ENV} mode`);
@@ -65,6 +74,8 @@ class KubernetesTaskOperations implements TaskOperations {
apps: k8s.AppsV1Api;
};
#labelHelper = new CustomLabelHelper();
constructor(opts: { namespace?: string } = {}) {
if (opts.namespace) {
this.#namespace.metadata.name = opts.namespace;
@@ -103,7 +114,7 @@ class KubernetesTaskOperations implements TaskOperations {
containers: [
{
name: this.#getIndexContainerName(opts.shortCode),
image: opts.imageRef,
image: getImageRef("deployment", opts.imageRef),
ports: [
{
containerPort: 8000,
@@ -157,6 +168,7 @@ class KubernetesTaskOperations implements TaskOperations {
name: containerName,
namespace: this.#namespace.metadata.name,
labels: {
...this.#labelHelper.getAdditionalLabels("create"),
...this.#getSharedLabels(opts),
app: "task-run",
"app.kubernetes.io/part-of": "trigger-worker",
@@ -170,7 +182,7 @@ class KubernetesTaskOperations implements TaskOperations {
containers: [
{
name: containerName,
image: opts.image,
image: getImageRef("deployment", opts.image),
ports: [
{
containerPort: 8000,
@@ -190,6 +202,9 @@ class KubernetesTaskOperations implements TaskOperations {
name: "TRIGGER_RUN_ID",
value: opts.runId,
},
...(opts.dequeuedAt
? [{ name: "TRIGGER_RUN_DEQUEUED_AT_MS", value: String(opts.dequeuedAt) }]
: []),
],
volumeMounts: [
{
@@ -218,6 +233,7 @@ class KubernetesTaskOperations implements TaskOperations {
name: `${this.#getRunContainerName(opts.runId)}-${opts.checkpointId.slice(-8)}`,
namespace: this.#namespace.metadata.name,
labels: {
...this.#labelHelper.getAdditionalLabels("restore"),
...this.#getSharedLabels(opts),
app: "task-run",
"app.kubernetes.io/part-of": "trigger-worker",
@@ -231,12 +247,12 @@ class KubernetesTaskOperations implements TaskOperations {
initContainers: [
{
name: "pull-base-image",
image: opts.imageRef,
image: getImageRef("deployment", opts.imageRef),
command: ["sleep", "0"],
},
{
name: "populate-taskinfo",
image: "registry.digitalocean.com/trigger/busybox",
image: getImageRef("utility", BUSYBOX_IMAGE),
imagePullPolicy: "IfNotPresent",
command: ["/bin/sh", "-c"],
args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"],
@@ -252,7 +268,7 @@ class KubernetesTaskOperations implements TaskOperations {
containers: [
{
name: this.#getRunContainerName(opts.runId),
image: opts.checkpointRef,
image: getImageRef("restore", opts.checkpointRef),
ports: [
{
containerPort: 8000,
@@ -358,7 +374,7 @@ class KubernetesTaskOperations implements TaskOperations {
initContainers: [
{
name: "prepull",
image: opts.imageRef,
image: getImageRef("deployment", opts.imageRef),
command: ["/usr/bin/true"],
resources: {
limits: {
@@ -372,7 +388,7 @@ class KubernetesTaskOperations implements TaskOperations {
containers: [
{
name: "pause",
image: "registry.k8s.io/pause:3.9",
image: getImageRef("utility", PAUSE_IMAGE),
resources: {
limits: {
cpu: "1m",
@@ -403,17 +419,20 @@ class KubernetesTaskOperations implements TaskOperations {
}
get #defaultPodSpec(): Omit<k8s.V1PodSpec, "containers"> {
const pullSecrets = ["registry-trigger", "registry-trigger-failover"];
if (ADDITIONAL_PULL_SECRETS) {
pullSecrets.push(...ADDITIONAL_PULL_SECRETS.split(","));
}
const imagePullSecrets = pullSecrets.map(
(name) => ({ name }) satisfies k8s.V1LocalObjectReference
);
return {
restartPolicy: "Never",
automountServiceAccountToken: false,
imagePullSecrets: [
{
name: "registry-trigger",
},
{
name: "registry-trigger-failover",
},
],
imagePullSecrets,
nodeSelector: {
nodetype: "worker",
},
@@ -501,6 +520,10 @@ class KubernetesTaskOperations implements TaskOperations {
},
},
},
{
name: "TRIGGER_POD_SCHEDULED_AT_MS",
value: Date.now().toString(),
},
...this.#coordinatorEnvVars,
];
}
@@ -673,6 +696,26 @@ class KubernetesTaskOperations implements TaskOperations {
}
}
type ImageType = "deployment" | "restore" | "utility";
function getImagePrefix(type: ImageType) {
switch (type) {
case "deployment":
return DEPLOYMENT_IMAGE_PREFIX;
case "restore":
return RESTORE_IMAGE_PREFIX;
case "utility":
return UTILITY_IMAGE_PREFIX;
default:
assertExhaustive(type);
}
}
function getImageRef(type: ImageType, ref: string) {
const prefix = getImagePrefix(type);
return prefix ? `${prefix}/${ref}` : ref;
}
const provider = new ProviderShell({
tasks: new KubernetesTaskOperations({
namespace: KUBERNETES_NAMESPACE,
+153
View File
@@ -0,0 +1,153 @@
import { assertExhaustive } from "@trigger.dev/core";
const CREATE_LABEL_ENV_VAR_PREFIX = "DEPLOYMENT_LABEL_";
const RESTORE_LABEL_ENV_VAR_PREFIX = "RESTORE_LABEL_";
const LABEL_SAMPLE_RATE_POSTFIX = "_SAMPLE_RATE";
type OperationType = "create" | "restore";
type CustomLabel = {
key: string;
value: string;
sampleRate: number;
};
export class CustomLabelHelper {
// Labels and sample rates are defined in environment variables so only need to be computed once
private createLabels?: CustomLabel[];
private restoreLabels?: CustomLabel[];
private getLabelPrefix(type: OperationType) {
const prefix = type === "create" ? CREATE_LABEL_ENV_VAR_PREFIX : RESTORE_LABEL_ENV_VAR_PREFIX;
return prefix.toLowerCase();
}
private getLabelSampleRatePostfix() {
return LABEL_SAMPLE_RATE_POSTFIX.toLowerCase();
}
// Can only range from 0 to 1
private fractionFromPercent(percent: number) {
return Math.min(1, Math.max(0, percent / 100));
}
private isLabelSampleRateEnvVar(key: string) {
return key.toLowerCase().endsWith(this.getLabelSampleRatePostfix());
}
private isLabelEnvVar(type: OperationType, key: string) {
const prefix = this.getLabelPrefix(type);
return key.toLowerCase().startsWith(prefix) && !this.isLabelSampleRateEnvVar(key);
}
private getSampleRateEnvVarKey(type: OperationType, envKey: string) {
return `${envKey.toLowerCase()}${this.getLabelSampleRatePostfix()}`;
}
private getLabelNameFromEnvVarKey(type: OperationType, key: string) {
return key
.slice(this.getLabelPrefix(type).length)
.toLowerCase()
.replace(/___/g, ".")
.replace(/__/g, "/")
.replace(/_/g, "-");
}
private getCaseInsensitiveEnvValue(key: string) {
for (const [envKey, value] of Object.entries(process.env)) {
if (envKey.toLowerCase() === key.toLowerCase()) {
return value;
}
}
}
/** Returns the sample rate for a given label as fraction of 100 */
private getSampleRateFromEnvVarKey(type: OperationType, envKey: string) {
// Apply default: always sample
const DEFAULT_SAMPLE_RATE_PERCENT = 100;
const defaultSampleRateFraction = this.fractionFromPercent(DEFAULT_SAMPLE_RATE_PERCENT);
const value = this.getCaseInsensitiveEnvValue(this.getSampleRateEnvVarKey(type, envKey));
if (!value) {
return defaultSampleRateFraction;
}
const sampleRatePercent = parseFloat(value || String(DEFAULT_SAMPLE_RATE_PERCENT));
if (isNaN(sampleRatePercent)) {
return defaultSampleRateFraction;
}
const fractionalSampleRate = this.fractionFromPercent(sampleRatePercent);
return fractionalSampleRate;
}
private getCustomLabels(type: OperationType): CustomLabel[] {
switch (type) {
case "create":
if (this.createLabels) {
return this.createLabels;
}
break;
case "restore":
if (this.restoreLabels) {
return this.restoreLabels;
}
break;
default:
assertExhaustive(type);
}
const customLabels: CustomLabel[] = [];
for (const [envKey, value] of Object.entries(process.env)) {
const key = envKey.toLowerCase();
// Only process env vars that start with the expected prefix
if (!this.isLabelEnvVar(type, key)) {
continue;
}
// Skip sample rates - deal with them separately
if (this.isLabelSampleRateEnvVar(key)) {
continue;
}
const labelName = this.getLabelNameFromEnvVarKey(type, key);
const sampleRate = this.getSampleRateFromEnvVarKey(type, key);
const label = {
key: labelName,
value: value || "",
sampleRate,
} satisfies CustomLabel;
customLabels.push(label);
}
return customLabels;
}
getAdditionalLabels(type: OperationType): Record<string, string> {
const labels = this.getCustomLabels(type);
const additionalLabels: Record<string, string> = {};
for (const { key, value, sampleRate } of labels) {
// Always apply label if sample rate is 1
if (sampleRate === 1) {
additionalLabels[key] = value;
continue;
}
if (Math.random() <= sampleRate) {
additionalLabels[key] = value;
continue;
}
}
return additionalLabels;
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es2016",
"target": "es2018",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
@@ -8,6 +8,8 @@
"strict": true,
"skipLibCheck": true,
"paths": {
"@trigger.dev/core": ["../../packages/core/src"],
"@trigger.dev/core/*": ["../../packages/core/src/*"],
"@trigger.dev/core/v3": ["../../packages/core/src/v3"],
"@trigger.dev/core/v3/*": ["../../packages/core/src/v3/*"]
}
-7
View File
@@ -1,7 +0,0 @@
REWRITE_HOSTNAME=
AWS_SQS_ACCESS_KEY_ID=
AWS_SQS_SECRET_ACCESS_KEY=
AWS_SQS_QUEUE_URL=
AWS_SQS_REGION=
#optional
#REWRITE_PORT=
-13
View File
@@ -1,13 +0,0 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
tab_width = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space
-172
View File
@@ -1,172 +0,0 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars
.wrangler/
-11
View File
@@ -1,11 +0,0 @@
{
"semi": true,
"singleQuote": false,
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
-72
View File
@@ -1,72 +0,0 @@
# proxy
## 0.0.11
### Patch Changes
- @trigger.dev/core@2.3.5
## 0.0.10
### Patch Changes
- @trigger.dev/core@2.3.4
## 0.0.9
### Patch Changes
- @trigger.dev/core@2.3.3
## 0.0.8
### Patch Changes
- @trigger.dev/core@2.3.2
## 0.0.7
### Patch Changes
- Updated dependencies [f3efcc0c]
- @trigger.dev/core@2.3.1
## 0.0.6
### Patch Changes
- Updated dependencies [17f6f29d]
- @trigger.dev/core@2.3.0
## 0.0.5
### Patch Changes
- @trigger.dev/core@2.2.11
## 0.0.4
### Patch Changes
- @trigger.dev/core@2.2.10
## 0.0.3
### Patch Changes
- Updated dependencies [6ebd435e]
- @trigger.dev/core@2.2.9
## 0.0.2
### Patch Changes
- Updated dependencies [067e19fe]
- @trigger.dev/core@2.2.8
## 0.0.1
### Patch Changes
- Updated dependencies [756024da]
- @trigger.dev/core@2.2.7
-68
View File
@@ -1,68 +0,0 @@
# Trigger.dev proxy
This is an optional module that can be used to proxy and queue requests to the Trigger.dev API.
## Why?
The Trigger.dev API is designed to be fast and reliable. However, if you have a lot of traffic, you may want to use this proxy to queue requests to the API. It intercepts some requests to the API and adds them to an AWS SQS queue, then the webapp can be setup to process the queue.
## Current features
- Intercepts `sendEvent` requests and adds them to an AWS SQS queue. The webapp then reads from the queue and creates the events.
## Setup
### Create an AWS SQS queue
In AWS you should create a new AWS SQS queue with appropriate security settings. You will need the queue URL for the next step.
### Environment variables
#### Cloudflare secrets
Locally you should copy the `.dev.var.example` file to `.dev.var` and fill in the values.
When deploying you should use `wrangler` (the Cloudflare CLI tool) to set secrets. Make sure you set the correct --env ("staging" or "prod")
```bash
wrangler secret put REWRITE_HOSTNAME --env staging
wrangler secret put AWS_SQS_ACCESS_KEY_ID --env staging
wrangler secret put AWS_SQS_SECRET_ACCESS_KEY --env staging
wrangler secret put AWS_SQS_QUEUE_URL --env staging
wrangler secret put AWS_SQS_REGION --env staging
```
You need to set your API CNAME entry to be proxied by Cloudflare. You can do this in the Cloudflare dashboard.
#### Webapp
These env vars also need setting in the webapp.
```bash
AWS_SQS_REGION
AWS_SQS_ACCESS_KEY_ID
AWS_SQS_SECRET_ACCESS_KEY
AWS_SQS_QUEUE_URL
AWS_SQS_BATCH_SIZE
```
## Deployment
Staging:
```bash
npx wrangler@latest deploy --route "<your-api-subdomain>/*" --env staging
```
Prod:
```bash
npx wrangler@latest deploy --route "<your-api-subdomain>/*" --env prod
```
## Development
Set the environment variables as described above.
1. `pnpm install`
2. `pnpm run dev --filter proxy`
-22
View File
@@ -1,22 +0,0 @@
{
"name": "proxy",
"version": "0.0.11",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"dry-run:staging": "wrangler deploy --dry-run --outdir=dist --env staging"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240512.0",
"typescript": "^5.0.4",
"wrangler": "^3.57.1"
},
"dependencies": {
"@aws-sdk/client-sqs": "^3.445.0",
"@trigger.dev/core": "workspace:*",
"ulidx": "^2.2.1",
"zod": "3.23.8",
"zod-error": "1.5.0"
}
}
-20
View File
@@ -1,20 +0,0 @@
import { z } from "zod";
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export function getApiKeyFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
return;
}
const apiKey = authorization.data.replace(/^Bearer /, "");
const type = isPrivateApiKey(apiKey) ? ("PRIVATE" as const) : ("PUBLIC" as const);
return { apiKey, type };
}
function isPrivateApiKey(key: string) {
return key.startsWith("tr_");
}
-87
View File
@@ -1,87 +0,0 @@
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { ApiEventLog, SendEventBodySchema } from "@trigger.dev/core";
import { generateErrorMessage } from "zod-error";
import { Env } from "..";
import { getApiKeyFromRequest } from "../apikey";
import { json } from "../json";
import { calculateDeliverAt } from "./utils";
/** Adds the event to an AWS SQS queue, so it can be consumed from the main Trigger.dev API */
export async function queueEvent(request: Request, env: Env): Promise<Response> {
//check there's a private API key
const apiKeyResult = getApiKeyFromRequest(request);
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
return json(
{ error: "Invalid or Missing API key" },
{
status: 401,
}
);
}
//parse the request body
try {
const anyBody = await request.json();
const body = SendEventBodySchema.safeParse(anyBody);
if (!body.success) {
return json(
{ error: generateErrorMessage(body.error.issues) },
{
status: 422,
}
);
}
// The AWS SDK tries to use crypto from off of the window,
// so we need to trick it into finding it where it expects it
globalThis.global = globalThis;
const client = new SQSClient({
region: env.AWS_SQS_REGION,
credentials: {
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
},
});
const timestamp = body.data.event.timestamp ?? new Date();
//add the event to the queue
const send = new SendMessageCommand({
// use wrangler secrets to provide this global variable
QueueUrl: env.AWS_SQS_QUEUE_URL,
MessageBody: JSON.stringify({
event: { ...body.data.event, timestamp },
options: body.data.options,
apiKey: apiKeyResult.apiKey,
}),
});
const queuedEvent = await client.send(send);
console.log("Queued event", queuedEvent);
//respond with the event
const event: ApiEventLog = {
id: body.data.event.id,
name: body.data.event.name,
payload: body.data.event.payload,
context: body.data.event.context,
timestamp,
deliverAt: calculateDeliverAt(body.data.options),
};
return json(event, {
status: 200,
});
} catch (e) {
console.error("queueEvent error", e);
return json(
{
error: `Failed to send event: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
},
{
status: 422,
}
);
}
}
-112
View File
@@ -1,112 +0,0 @@
import { SQSClient, SendMessageBatchCommand } from "@aws-sdk/client-sqs";
import { ApiEventLog, SendBulkEventsBodySchema } from "@trigger.dev/core";
import { generateErrorMessage } from "zod-error";
import { Env } from "..";
import { getApiKeyFromRequest } from "../apikey";
import { json } from "../json";
import { calculateDeliverAt } from "./utils";
/** Adds the event to an AWS SQS queue, so it can be consumed from the main Trigger.dev API */
export async function queueEvents(request: Request, env: Env): Promise<Response> {
//check there's a private API key
const apiKeyResult = getApiKeyFromRequest(request);
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
return json(
{ error: "Invalid or Missing API key" },
{
status: 401,
}
);
}
//parse the request body
try {
const anyBody = await request.json();
const body = SendBulkEventsBodySchema.safeParse(anyBody);
if (!body.success) {
return json(
{ error: generateErrorMessage(body.error.issues) },
{
status: 422,
}
);
}
// The AWS SDK tries to use crypto from off of the window,
// so we need to trick it into finding it where it expects it
globalThis.global = globalThis;
const client = new SQSClient({
region: env.AWS_SQS_REGION,
credentials: {
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
},
});
const updatedEvents: ApiEventLog[] = body.data.events.map((event) => {
const timestamp = event.timestamp ?? new Date();
return {
...event,
payload: event.payload,
timestamp,
};
});
//divide updatedEvents into multiple batches of 10 (max size SQS accepts)
const batches: ApiEventLog[][] = [];
let currentBatch: ApiEventLog[] = [];
for (let i = 0; i < updatedEvents.length; i++) {
currentBatch.push(updatedEvents[i]);
if (currentBatch.length === 10) {
batches.push(currentBatch);
currentBatch = [];
}
}
if (currentBatch.length > 0) {
batches.push(currentBatch);
}
//loop through the batches and send them
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
//add the event to the queue
const send = new SendMessageBatchCommand({
// use wrangler secrets to provide this global variable
QueueUrl: env.AWS_SQS_QUEUE_URL,
Entries: batch.map((event, index) => ({
Id: `event-${index}`,
MessageBody: JSON.stringify({
event,
options: body.data.options,
apiKey: apiKeyResult.apiKey,
}),
})),
});
const queuedEvent = await client.send(send);
console.log("Queued events", queuedEvent);
}
//respond with the events
const events: ApiEventLog[] = updatedEvents.map((event) => ({
...event,
payload: event.payload,
deliverAt: calculateDeliverAt(body.data.options),
}));
return json(events, {
status: 200,
});
} catch (e) {
console.error("queueEvents error", e);
return json(
{
error: `Failed to send events: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
},
{
status: 422,
}
);
}
}
-15
View File
@@ -1,15 +0,0 @@
import { SendEventOptions } from "@trigger.dev/core";
export function calculateDeliverAt(options?: SendEventOptions) {
// If deliverAt is a string and a valid date, convert it to a Date object
if (options?.deliverAt) {
return options?.deliverAt;
}
// deliverAfter is the number of seconds to wait before delivering the event
if (options?.deliverAfter) {
return new Date(Date.now() + options.deliverAfter * 1000);
}
return undefined;
}
-53
View File
@@ -1,53 +0,0 @@
import { queueEvent } from "./events/queueEvent";
import { queueEvents } from "./events/queueEvents";
import { applyRateLimit } from "./rateLimit";
import { Ratelimit } from "./rateLimiter";
export interface Env {
/** The hostname needs to be changed to allow requests to pass to the Trigger.dev platform */
REWRITE_HOSTNAME: string;
REWRITE_PORT?: string;
AWS_SQS_ACCESS_KEY_ID: string;
AWS_SQS_SECRET_ACCESS_KEY: string;
AWS_SQS_QUEUE_URL: string;
AWS_SQS_REGION: string;
//rate limiter
API_RATE_LIMITER: Ratelimit;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (!queueingIsEnabled(env)) {
console.log("Missing AWS credentials. Passing through to the origin.");
return fetch(request);
}
const url = new URL(request.url);
switch (url.pathname) {
case "/api/v1/events": {
if (request.method === "POST") {
return applyRateLimit(request, env, () => queueEvent(request, env));
}
break;
}
case "/api/v1/events/bulk": {
if (request.method === "POST") {
return applyRateLimit(request, env, () => queueEvents(request, env));
}
break;
}
}
//the same request but with the hostname (and port) changed
return fetch(request);
},
};
function queueingIsEnabled(env: Env) {
return (
env.AWS_SQS_ACCESS_KEY_ID &&
env.AWS_SQS_SECRET_ACCESS_KEY &&
env.AWS_SQS_QUEUE_URL &&
env.AWS_SQS_REGION
);
}
-13
View File
@@ -1,13 +0,0 @@
export function json(body: any, init?: ResponseInit) {
const headers = {
"content-type": "application/json",
...(init?.headers ?? {}),
};
const responseInit: ResponseInit = {
...(init ?? {}),
headers,
};
return new Response(JSON.stringify(body), responseInit);
}
-46
View File
@@ -1,46 +0,0 @@
import { Env } from "src";
import { getApiKeyFromRequest } from "./apikey";
import { json } from "./json";
export async function applyRateLimit(
request: Request,
env: Env,
fn: () => Promise<Response>
): Promise<Response> {
const apiKey = getApiKeyFromRequest(request);
if (apiKey) {
const result = await env.API_RATE_LIMITER.limit({ key: `apikey-${apiKey.apiKey}` });
const { success } = result;
console.log(`Rate limiter`, {
success,
key: `${apiKey.apiKey.substring(0, 12)}...`,
});
if (!success) {
//60s in the future
const reset = Date.now() + 60 * 1000;
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return json(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
error: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
reset,
},
{
status: 429,
headers: {
"x-ratelimit-reset": reset.toString(),
},
}
);
}
} else {
console.log(`Rate limiter: no API key for request`);
}
//call the original function
return fn();
}
-23
View File
@@ -1,23 +0,0 @@
export interface Ratelimit {
/*
* The ratelimit function
* @param {RatelimitOptions} options
* @returns {Promise<RatelimitResponse>}
*/
limit: (options: RatelimitOptions) => Promise<RatelimitResponse>;
}
export interface RatelimitOptions {
/*
* The key to identify the user, can be an IP address, user ID, etc.
*/
key: string;
}
export interface RatelimitResponse {
/*
* The ratelimit success status
* @returns {boolean}
*/
success: boolean;
}
-35
View File
@@ -1,35 +0,0 @@
{
"compilerOptions": {
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
"module": "es2022" /* Specify what module code is generated. */,
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
"types": [
"@cloudflare/workers-types"
] /* Specify type package names to be included without being referenced in a source file. */,
"resolveJsonModule": true /* Enable importing .json files */,
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
"noEmit": true /* Disable emitting files from a compilation. */,
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
"baseUrl": ".",
"paths": {
"@trigger.dev/core": ["../../packages/core/src/index"],
"@trigger.dev/core/*": ["../../packages/core/src/*"]
}
}
}
-33
View File
@@ -1,33 +0,0 @@
name = "proxy"
main = "src/index.ts"
compatibility_date = "2024-05-13"
compatibility_flags = [ "nodejs_compat" ]
[env.staging]
# The rate limiting API is in open beta.
[[env.staging.unsafe.bindings]]
name = "API_RATE_LIMITER"
type = "ratelimit"
# An identifier you define, that is unique to your Cloudflare account.
# Must be an integer.
namespace_id = "1"
# Limit: the number of tokens allowed within a given period in a single
# Cloudflare location
# Period: the duration of the period, in seconds. Must be either 10 or 60
simple = { limit = 100, period = 60 }
[env.prod]
# The rate limiting API is in open beta.
[[env.prod.unsafe.bindings]]
name = "API_RATE_LIMITER"
type = "ratelimit"
# An identifier you define, that is unique to your Cloudflare account.
# Must be an integer.
namespace_id = "2"
# Limit: the number of tokens allowed within a given period in a single
# Cloudflare location
# Period: the duration of the period, in seconds. Must be either 10 or 60
simple = { limit = 300, period = 60 }
+18
View File
@@ -0,0 +1,18 @@
# This needs to match the token of the worker group you want to connect to
TRIGGER_WORKER_TOKEN=
# This needs to match the MANAGED_WORKER_SECRET env var on the webapp
MANAGED_WORKER_SECRET=managed-secret
# Point this at the webapp in prod
TRIGGER_API_URL=http://localhost:3030
# Point this at the OTel collector in prod
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:3030/otel
# Use this on macOS
# OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:3030/otel
# Optional settings
DEBUG=1
ENFORCE_MACHINE_PRESETS=1
TRIGGER_DEQUEUE_INTERVAL_MS=1000
+1
View File
@@ -0,0 +1 @@
v22.12.0
+53
View File
@@ -0,0 +1,53 @@
FROM node:22-alpine@sha256:9bef0ef1e268f60627da9ba7d7605e8831d5b56ad07487d24d1aa386336d1944 AS node-22-alpine
WORKDIR /app
FROM node-22-alpine AS pruner
COPY --chown=node:node . .
RUN npx -q turbo@1.10.9 prune --scope=supervisor --docker
FROM node-22-alpine AS base
RUN apk add --no-cache dumb-init
COPY --chown=node:node .gitignore .gitignore
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
FROM base AS deps-fetcher
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile
FROM deps-fetcher AS dev-deps
ENV NODE_ENV development
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --offline --ignore-scripts
FROM base AS builder
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
COPY --chown=node:node .configs/tsconfig.base.json .configs/tsconfig.base.json
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
FROM base AS runner
ENV NODE_ENV production
COPY --from=builder /prod/supervisor /app/apps/supervisor
EXPOSE 8000
USER node
# ensure pnpm is installed during build and not silently downloaded at runtime
RUN pnpm -v
CMD [ "/usr/bin/dumb-init", "--", "pnpm", "run", "--filter", "supervisor", "start"]
+67
View File
@@ -0,0 +1,67 @@
# Supervisor
## Dev setup
1. Create a worker group
```sh
api_url=http://localhost:3030
wg_name=my-worker
# edit this
admin_pat=tr_pat_...
curl -sS \
-X POST \
"$api_url/admin/api/v1/workers" \
-H "Authorization: Bearer $admin_pat" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$wg_name\"}"
```
2. Create `.env` and set the worker token
```sh
cp .env.example .env
# Then edit your .env and set this to the token.plaintext value
TRIGGER_WORKER_TOKEN=tr_wgt_...
```
3. Start the supervisor
```sh
pnpm dev
```
4. Build CLI, then deploy a reference project
```sh
pnpm exec trigger deploy --self-hosted
# The additional network flag is required on linux
pnpm exec trigger deploy --self-hosted --network host
```
## Additional worker groups
When adding more worker groups you might also want to make them the default for a specific project. This will allow you to test it without having to change the global default:
```sh
api_url=http://localhost:3030
wg_name=my-worker
# edit these
admin_pat=tr_pat_...
project_id=clsw6q8wz...
curl -sS \
-X POST \
"$api_url/admin/api/v1/workers" \
-H "Authorization: Bearer $admin_pat" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$wg_name\",
\"makeDefaultForProjectId\": \"$project_id\"
}"
```
+31
View File
@@ -0,0 +1,31 @@
{
"name": "supervisor",
"private": true,
"version": "0.0.1",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx --require dotenv/config --watch src/index.ts || (echo '!! Remember to run: nvm use'; exit 1)",
"start": "node dist/index.js",
"test:run": "vitest --no-file-parallelism --run",
"test:watch": "vitest --no-file-parallelism",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
"@trigger.dev/core": "workspace:*",
"dockerode": "^4.0.3",
"nanoid": "^5.0.9",
"prom-client": "^15.1.0",
"socket.io": "4.7.4",
"std-env": "^3.8.0",
"tinyexec": "^0.3.1",
"zod": "3.23.8"
},
"devDependencies": {
"@types/dockerode": "^3.3.33",
"docker-api-ts": "^0.2.2",
"vitest": "^1.4.0"
}
}
+52
View File
@@ -0,0 +1,52 @@
import * as k8s from "@kubernetes/client-node";
import { Informer } from "@kubernetes/client-node";
import { ListPromise } from "@kubernetes/client-node";
import { KubernetesObject } from "@kubernetes/client-node";
import { assertExhaustive } from "@trigger.dev/core/utils";
export const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
export function createK8sApi() {
const kubeConfig = getKubeConfig();
function makeInformer<T extends KubernetesObject>(
path: string,
listPromiseFn: ListPromise<T>,
labelSelector?: string,
fieldSelector?: string
): Informer<T> {
return k8s.makeInformer(kubeConfig, path, listPromiseFn, labelSelector, fieldSelector);
}
const api = {
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
batch: kubeConfig.makeApiClient(k8s.BatchV1Api),
apps: kubeConfig.makeApiClient(k8s.AppsV1Api),
makeInformer,
};
return api;
}
export type K8sApi = ReturnType<typeof createK8sApi>;
function getKubeConfig() {
console.log("getKubeConfig()", { RUNTIME_ENV });
const kubeConfig = new k8s.KubeConfig();
switch (RUNTIME_ENV) {
case "local":
kubeConfig.loadFromDefault();
break;
case "kubernetes":
kubeConfig.loadFromCluster();
break;
default:
assertExhaustive(RUNTIME_ENV);
}
return kubeConfig;
}
export { k8s };
+77
View File
@@ -0,0 +1,77 @@
import { randomUUID } from "crypto";
import { env as stdEnv } from "std-env";
import { z } from "zod";
import { AdditionalEnvVars, BoolEnv } from "./envUtil.js";
const Env = z.object({
// This will come from `spec.nodeName` in k8s
TRIGGER_WORKER_INSTANCE_NAME: z.string().default(randomUUID()),
// Required settings
TRIGGER_API_URL: z.string().url(),
TRIGGER_WORKER_TOKEN: z.string(),
MANAGED_WORKER_SECRET: z.string(),
// 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_PROTOCOL: z
.string()
.transform((s) => z.enum(["http", "https"]).parse(s.toLowerCase()))
.default("http"),
TRIGGER_WORKLOAD_API_DOMAIN: z.string().optional(), // If unset, will use orchestrator-specific default
TRIGGER_WORKLOAD_API_HOST_INTERNAL: z.string().default("0.0.0.0"),
TRIGGER_WORKLOAD_API_PORT_INTERNAL: z.coerce.number().default(8020), // This is the port the workload API listens on
TRIGGER_WORKLOAD_API_PORT_EXTERNAL: z.coerce.number().default(8020), // This is the exposed port passed to the run controller
// Runner settings
RUNNER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().optional(),
RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS: z.coerce.number().optional(),
RUNNER_ADDITIONAL_ENV_VARS: AdditionalEnvVars, // optional (csv)
// Dequeue settings (provider mode)
TRIGGER_DEQUEUE_ENABLED: BoolEnv.default("true"),
TRIGGER_DEQUEUE_INTERVAL_MS: z.coerce.number().int().default(1000),
TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(10),
// Optional services
TRIGGER_WARM_START_URL: z.string().optional(),
TRIGGER_CHECKPOINT_URL: z.string().optional(),
TRIGGER_METADATA_URL: z.string().optional(),
// Used by the workload manager, e.g docker/k8s
DOCKER_NETWORK: z.string().default("host"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(),
ENFORCE_MACHINE_PRESETS: z.coerce.boolean().default(false),
KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv
// Used by the resource monitor
OVERRIDE_CPU_TOTAL: z.coerce.number().optional(),
OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(),
// Kubernetes specific settings
KUBERNETES_FORCE_ENABLED: BoolEnv.default(false),
KUBERNETES_NAMESPACE: z.string().default("default"),
KUBERNETES_WORKER_NODETYPE_LABEL: z.string().default("v4-worker"),
EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"),
EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"),
// Metrics
METRICS_ENABLED: BoolEnv.default(true),
METRICS_COLLECT_DEFAULTS: BoolEnv.default(true),
METRICS_HOST: z.string().default("127.0.0.1"),
METRICS_PORT: z.coerce.number().int().default(9090),
// Pod cleaner
POD_CLEANER_ENABLED: BoolEnv.default(true),
POD_CLEANER_INTERVAL_MS: z.coerce.number().int().default(10000),
POD_CLEANER_BATCH_SIZE: z.coerce.number().int().default(500),
// Failed pod handler
FAILED_POD_HANDLER_ENABLED: BoolEnv.default(true),
FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS: z.coerce.number().int().default(1000),
// Debug
DEBUG: BoolEnv.default(false),
});
export const env = Env.parse(stdEnv);
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import { BoolEnv, AdditionalEnvVars } from "./envUtil.js";
describe("BoolEnv", () => {
it("should parse string 'true' as true", () => {
expect(BoolEnv.parse("true")).toBe(true);
expect(BoolEnv.parse("TRUE")).toBe(true);
expect(BoolEnv.parse("True")).toBe(true);
});
it("should parse string '1' as true", () => {
expect(BoolEnv.parse("1")).toBe(true);
});
it("should parse string 'false' as false", () => {
expect(BoolEnv.parse("false")).toBe(false);
expect(BoolEnv.parse("FALSE")).toBe(false);
expect(BoolEnv.parse("False")).toBe(false);
});
it("should handle whitespace", () => {
expect(BoolEnv.parse(" true ")).toBe(true);
expect(BoolEnv.parse(" 1 ")).toBe(true);
});
it("should pass through boolean values", () => {
expect(BoolEnv.parse(true)).toBe(true);
expect(BoolEnv.parse(false)).toBe(false);
});
it("should return false for invalid inputs", () => {
expect(BoolEnv.parse("invalid")).toBe(false);
expect(BoolEnv.parse("")).toBe(false);
});
});
describe("AdditionalEnvVars", () => {
it("should parse single key-value pair", () => {
expect(AdditionalEnvVars.parse("FOO=bar")).toEqual({ FOO: "bar" });
});
it("should parse multiple key-value pairs", () => {
expect(AdditionalEnvVars.parse("FOO=bar,BAZ=qux")).toEqual({
FOO: "bar",
BAZ: "qux",
});
});
it("should handle whitespace", () => {
expect(AdditionalEnvVars.parse(" FOO = bar , BAZ = qux ")).toEqual({
FOO: "bar",
BAZ: "qux",
});
});
it("should return undefined for empty string", () => {
expect(AdditionalEnvVars.parse("")).toBeUndefined();
});
it("should return undefined for invalid format", () => {
expect(AdditionalEnvVars.parse("invalid")).toBeUndefined();
});
it("should skip invalid pairs but include valid ones", () => {
expect(AdditionalEnvVars.parse("FOO=bar,INVALID,BAZ=qux")).toEqual({
FOO: "bar",
BAZ: "qux",
});
});
it("should pass through undefined", () => {
expect(AdditionalEnvVars.parse(undefined)).toBeUndefined();
});
it("should handle empty values", () => {
expect(AdditionalEnvVars.parse("FOO=,BAR=value")).toEqual({
BAR: "value",
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { z } from "zod";
export const BoolEnv = z.preprocess((val) => {
if (typeof val !== "string") {
return val;
}
return ["true", "1"].includes(val.toLowerCase().trim());
}, z.boolean());
export const AdditionalEnvVars = z.preprocess((val) => {
if (typeof val !== "string") {
return val;
}
if (!val) {
return undefined;
}
try {
const result = val.split(",").reduce(
(acc, pair) => {
const [key, value] = pair.split("=");
if (!key || !value) {
return acc;
}
acc[key.trim()] = value.trim();
return acc;
},
{} as Record<string, string>
);
// Return undefined if no valid key-value pairs were found
return Object.keys(result).length === 0 ? undefined : result;
} catch (error) {
console.warn("Failed to parse additional env vars", { error, val });
return undefined;
}
}, z.record(z.string(), z.string()).optional());
+363
View File
@@ -0,0 +1,363 @@
import { SupervisorSession } from "@trigger.dev/core/v3/workers";
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import { env } from "./env.js";
import { WorkloadServer } from "./workloadServer/index.js";
import type { WorkloadManagerOptions, WorkloadManager } from "./workloadManager/types.js";
import Docker from "dockerode";
import { z } from "zod";
import { type DequeuedMessage } from "@trigger.dev/core/v3";
import {
DockerResourceMonitor,
KubernetesResourceMonitor,
type ResourceMonitor,
} from "./resourceMonitor.js";
import { KubernetesWorkloadManager } from "./workloadManager/kubernetes.js";
import { DockerWorkloadManager } from "./workloadManager/docker.js";
import {
HttpServer,
CheckpointClient,
isKubernetesEnvironment,
} from "@trigger.dev/core/v3/serverOnly";
import { createK8sApi } from "./clients/kubernetes.js";
import { collectDefaultMetrics } from "prom-client";
import { register } from "./metrics.js";
import { PodCleaner } from "./services/podCleaner.js";
import { FailedPodHandler } from "./services/failedPodHandler.js";
if (env.METRICS_COLLECT_DEFAULTS) {
collectDefaultMetrics({ register });
}
class ManagedSupervisor {
private readonly workerSession: SupervisorSession;
private readonly metricsServer?: HttpServer;
private readonly workloadServer: WorkloadServer;
private readonly workloadManager: WorkloadManager;
private readonly logger = new SimpleStructuredLogger("managed-worker");
private readonly resourceMonitor: ResourceMonitor;
private readonly checkpointClient?: CheckpointClient;
private readonly podCleaner?: PodCleaner;
private readonly failedPodHandler?: FailedPodHandler;
private readonly isKubernetes = isKubernetesEnvironment(env.KUBERNETES_FORCE_ENABLED);
private readonly warmStartUrl = env.TRIGGER_WARM_START_URL;
constructor() {
const { TRIGGER_WORKER_TOKEN, MANAGED_WORKER_SECRET, ...envWithoutSecrets } = env;
if (env.DEBUG) {
console.debug("[ManagedSupervisor] Starting up", { envWithoutSecrets });
}
if (this.warmStartUrl) {
this.logger.log("[ManagedWorker] 🔥 Warm starts enabled", {
warmStartUrl: this.warmStartUrl,
});
}
const workloadManagerOptions = {
workloadApiProtocol: env.TRIGGER_WORKLOAD_API_PROTOCOL,
workloadApiDomain: env.TRIGGER_WORKLOAD_API_DOMAIN,
workloadApiPort: env.TRIGGER_WORKLOAD_API_PORT_EXTERNAL,
warmStartUrl: this.warmStartUrl,
metadataUrl: env.TRIGGER_METADATA_URL,
imagePullSecrets: env.KUBERNETES_IMAGE_PULL_SECRETS?.split(","),
heartbeatIntervalSeconds: env.RUNNER_HEARTBEAT_INTERVAL_SECONDS,
snapshotPollIntervalSeconds: env.RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS,
additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS,
} satisfies WorkloadManagerOptions;
if (this.isKubernetes) {
if (env.POD_CLEANER_ENABLED) {
this.logger.log("[ManagedWorker] 🧹 Pod cleaner enabled", {
namespace: env.KUBERNETES_NAMESPACE,
batchSize: env.POD_CLEANER_BATCH_SIZE,
intervalMs: env.POD_CLEANER_INTERVAL_MS,
});
this.podCleaner = new PodCleaner({
register,
namespace: env.KUBERNETES_NAMESPACE,
batchSize: env.POD_CLEANER_BATCH_SIZE,
intervalMs: env.POD_CLEANER_INTERVAL_MS,
});
} else {
this.logger.warn("[ManagedWorker] Pod cleaner disabled");
}
if (env.FAILED_POD_HANDLER_ENABLED) {
this.logger.log("[ManagedWorker] 🔁 Failed pod handler enabled", {
namespace: env.KUBERNETES_NAMESPACE,
reconnectIntervalMs: env.FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS,
});
this.failedPodHandler = new FailedPodHandler({
register,
namespace: env.KUBERNETES_NAMESPACE,
reconnectIntervalMs: env.FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS,
});
} else {
this.logger.warn("[ManagedWorker] Failed pod handler disabled");
}
this.resourceMonitor = new KubernetesResourceMonitor(
createK8sApi(),
env.TRIGGER_WORKER_INSTANCE_NAME
);
this.workloadManager = new KubernetesWorkloadManager(workloadManagerOptions);
} else {
this.resourceMonitor = new DockerResourceMonitor(new Docker());
this.workloadManager = new DockerWorkloadManager(workloadManagerOptions);
}
this.workerSession = new SupervisorSession({
workerToken: env.TRIGGER_WORKER_TOKEN,
apiUrl: env.TRIGGER_API_URL,
instanceName: env.TRIGGER_WORKER_INSTANCE_NAME,
managedWorkerSecret: env.MANAGED_WORKER_SECRET,
dequeueIntervalMs: env.TRIGGER_DEQUEUE_INTERVAL_MS,
queueConsumerEnabled: env.TRIGGER_DEQUEUE_ENABLED,
maxRunCount: env.TRIGGER_DEQUEUE_MAX_RUN_COUNT,
runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED,
preDequeue: async () => {
if (this.isKubernetes) {
// Not used in k8s for now
return {};
}
const resources = await this.resourceMonitor.getNodeResources();
return {
maxResources: {
cpu: resources.cpuAvailable,
memory: resources.memoryAvailable,
},
skipDequeue: resources.cpuAvailable < 0.25 || resources.memoryAvailable < 0.25,
};
},
preSkip: async () => {
// When the node is full, it should still try to warm start runs
// await this.tryWarmStartAllThisNode();
},
});
if (env.TRIGGER_CHECKPOINT_URL) {
this.logger.log("[ManagedWorker] 🥶 Checkpoints enabled", {
checkpointUrl: env.TRIGGER_CHECKPOINT_URL,
});
this.checkpointClient = new CheckpointClient({
apiUrl: new URL(env.TRIGGER_CHECKPOINT_URL),
workerClient: this.workerSession.httpClient,
orchestrator: this.isKubernetes ? "KUBERNETES" : "DOCKER",
});
}
// setInterval(async () => {
// const resources = await this.resourceMonitor.getNodeResources(true);
// this.logger.debug("[ManagedWorker] Current resources", { resources });
// }, 1000);
this.workerSession.on("runNotification", async ({ time, run }) => {
this.logger.log("[ManagedWorker] runNotification", { time, run });
this.workloadServer.notifyRun({ run });
});
this.workerSession.on("runQueueMessage", async ({ time, message }) => {
this.logger.log(
`[ManagedWorker] Received message with timestamp ${time.toLocaleString()}`,
message
);
if (message.completedWaitpoints.length > 0) {
this.logger.debug("[ManagedWorker] Run has completed waitpoints", {
runId: message.run.id,
completedWaitpoints: message.completedWaitpoints.length,
});
// TODO: Do something with them or if we don't need the data here, maybe we shouldn't even send it
}
if (!message.image) {
this.logger.error("[ManagedWorker] Run has no image", { runId: message.run.id });
return;
}
const { checkpoint, ...rest } = message;
if (checkpoint) {
this.logger.log("[ManagedWorker] Restoring run", { runId: message.run.id });
if (!this.checkpointClient) {
this.logger.error("[ManagedWorker] No checkpoint client", { runId: message.run.id });
return;
}
try {
const didRestore = await this.checkpointClient.restoreRun({
runFriendlyId: message.run.friendlyId,
snapshotFriendlyId: message.snapshot.friendlyId,
body: {
...rest,
checkpoint,
},
});
if (didRestore) {
this.logger.log("[ManagedWorker] Restore successful", { runId: message.run.id });
} else {
this.logger.error("[ManagedWorker] Restore failed", { runId: message.run.id });
}
} catch (error) {
this.logger.error("[ManagedWorker] Failed to restore run", { error });
}
return;
}
this.logger.log("[ManagedWorker] Scheduling run", { runId: message.run.id });
const didWarmStart = await this.tryWarmStart(message);
if (didWarmStart) {
this.logger.log("[ManagedWorker] Warm start successful", { runId: message.run.id });
return;
}
try {
await this.workloadManager.create({
dequeuedAt: message.dequeuedAt,
envId: message.environment.id,
envType: message.environment.type,
image: message.image,
machine: message.run.machine,
orgId: message.organization.id,
projectId: message.project.id,
runId: message.run.id,
runFriendlyId: message.run.friendlyId,
version: message.version,
nextAttemptNumber: message.run.attemptNumber,
snapshotId: message.snapshot.id,
snapshotFriendlyId: message.snapshot.friendlyId,
});
// Disabled for now
// this.resourceMonitor.blockResources({
// cpu: message.run.machine.cpu,
// memory: message.run.machine.memory,
// });
} catch (error) {
this.logger.error("[ManagedWorker] Failed to create workload", { error });
}
});
if (env.METRICS_ENABLED) {
this.metricsServer = new HttpServer({
port: env.METRICS_PORT,
host: env.METRICS_HOST,
metrics: {
register,
expose: true,
},
});
}
// Responds to workload requests only
this.workloadServer = new WorkloadServer({
port: env.TRIGGER_WORKLOAD_API_PORT_INTERNAL,
host: env.TRIGGER_WORKLOAD_API_HOST_INTERNAL,
workerClient: this.workerSession.httpClient,
checkpointClient: this.checkpointClient,
});
this.workloadServer.on("runConnected", this.onRunConnected.bind(this));
this.workloadServer.on("runDisconnected", this.onRunDisconnected.bind(this));
}
async onRunConnected({ run }: { run: { friendlyId: string } }) {
this.logger.debug("[ManagedWorker] Run connected", { run });
this.workerSession.subscribeToRunNotifications([run.friendlyId]);
}
async onRunDisconnected({ run }: { run: { friendlyId: string } }) {
this.logger.debug("[ManagedWorker] Run disconnected", { run });
this.workerSession.unsubscribeFromRunNotifications([run.friendlyId]);
}
private async tryWarmStart(dequeuedMessage: DequeuedMessage): Promise<boolean> {
if (!this.warmStartUrl) {
return false;
}
const warmStartUrlWithPath = new URL("/warm-start", this.warmStartUrl);
try {
const res = await fetch(warmStartUrlWithPath.href, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ dequeuedMessage }),
});
if (!res.ok) {
this.logger.error("[ManagedWorker] Warm start failed", {
runId: dequeuedMessage.run.id,
});
return false;
}
const data = await res.json();
const parsedData = z.object({ didWarmStart: z.boolean() }).safeParse(data);
if (!parsedData.success) {
this.logger.error("[ManagedWorker] Warm start response invalid", {
runId: dequeuedMessage.run.id,
data,
});
return false;
}
return parsedData.data.didWarmStart;
} catch (error) {
this.logger.error("[ManagedWorker] Warm start error", {
runId: dequeuedMessage.run.id,
error,
});
return false;
}
}
async start() {
this.logger.log("[ManagedWorker] Starting up");
// Optional services
await this.podCleaner?.start();
await this.failedPodHandler?.start();
await this.metricsServer?.start();
if (env.TRIGGER_WORKLOAD_API_ENABLED) {
this.logger.log("[ManagedWorker] Workload API enabled", {
protocol: env.TRIGGER_WORKLOAD_API_PROTOCOL,
domain: env.TRIGGER_WORKLOAD_API_DOMAIN,
port: env.TRIGGER_WORKLOAD_API_PORT_INTERNAL,
});
await this.workloadServer.start();
} else {
this.logger.warn("[ManagedWorker] Workload API disabled");
}
await this.workerSession.start();
}
async stop() {
this.logger.log("[ManagedWorker] Shutting down");
await this.workerSession.stop();
// Optional services
await this.podCleaner?.stop();
await this.failedPodHandler?.stop();
await this.metricsServer?.stop();
}
}
const worker = new ManagedSupervisor();
worker.start();
+3
View File
@@ -0,0 +1,3 @@
import { Registry } from "prom-client";
export const register = new Registry();
+246
View File
@@ -0,0 +1,246 @@
import type Docker from "dockerode";
import type * as TDocker from "docker-api-ts";
import type { MachineResources } from "@trigger.dev/core/v3";
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import { env } from "./env.js";
import type { K8sApi } from "./clients/kubernetes.js";
const logger = new SimpleStructuredLogger("resource-monitor");
interface NodeResources {
cpuTotal: number; // in cores
cpuAvailable: number;
memoryTotal: number; // in bytes
memoryAvailable: number;
}
interface ResourceRequest {
cpu: number; // in cores
memory: number; // in bytes
}
export abstract class ResourceMonitor {
protected cacheTimeoutMs = 5_000;
protected lastUpdateMs = 0;
protected cachedResources: NodeResources = {
cpuTotal: 0,
cpuAvailable: 0,
memoryTotal: 0,
memoryAvailable: 0,
};
protected resourceParser: ResourceParser;
constructor(Parser: new () => ResourceParser) {
this.resourceParser = new Parser();
}
abstract getNodeResources(fromCache?: boolean): Promise<NodeResources>;
blockResources(resources: MachineResources): void {
const { cpu, memory } = this.toResourceRequest(resources);
logger.debug("[ResourceMonitor] Blocking resources", {
raw: resources,
converted: { cpu, memory },
});
this.cachedResources.cpuAvailable -= cpu;
this.cachedResources.memoryAvailable -= memory;
}
async wouldFit(request: ResourceRequest): Promise<boolean> {
const resources = await this.getNodeResources();
return resources.cpuAvailable >= request.cpu && resources.memoryAvailable >= request.memory;
}
private toResourceRequest(resources: MachineResources): ResourceRequest {
return {
cpu: resources.cpu ?? 0,
memory: this.gbToBytes(resources.memory ?? 0),
};
}
private gbToBytes(gb: number): number {
return gb * 1024 * 1024 * 1024;
}
protected isCacheValid(): boolean {
return this.cachedResources !== null && Date.now() - this.lastUpdateMs < this.cacheTimeoutMs;
}
protected applyOverrides(resources: NodeResources): NodeResources {
if (!env.OVERRIDE_CPU_TOTAL && !env.OVERRIDE_MEMORY_TOTAL_GB) {
return resources;
}
logger.debug("[ResourceMonitor] 🛡️ Applying resource overrides", {
cpuTotal: env.OVERRIDE_CPU_TOTAL,
memoryTotalGb: env.OVERRIDE_MEMORY_TOTAL_GB,
});
const cpuTotal = env.OVERRIDE_CPU_TOTAL ?? resources.cpuTotal;
const memoryTotal = env.OVERRIDE_MEMORY_TOTAL_GB
? this.gbToBytes(env.OVERRIDE_MEMORY_TOTAL_GB)
: resources.memoryTotal;
const cpuDiff = cpuTotal - resources.cpuTotal;
const memoryDiff = memoryTotal - resources.memoryTotal;
const cpuAvailable = Math.max(0, resources.cpuAvailable + cpuDiff);
const memoryAvailable = Math.max(0, resources.memoryAvailable + memoryDiff);
return {
cpuTotal,
cpuAvailable,
memoryTotal,
memoryAvailable,
};
}
}
export class DockerResourceMonitor extends ResourceMonitor {
private docker: Docker;
constructor(docker: Docker) {
super(DockerResourceParser);
this.docker = docker;
}
async getNodeResources(fromCache?: boolean): Promise<NodeResources> {
if (this.isCacheValid() || fromCache) {
// logger.debug("[ResourceMonitor] Using cached resources");
return this.cachedResources;
}
const info: TDocker.SystemInfo = await this.docker.info();
const stats = await this.docker.listContainers({ all: true });
// Get system-wide resources
const cpuTotal = info.NCPU ?? 0;
const memoryTotal = info.MemTotal ?? 0;
// Calculate used resources from running containers
let cpuUsed = 0;
let memoryUsed = 0;
for (const container of stats) {
if (container.State === "running") {
const c = this.docker.getContainer(container.Id);
const { HostConfig } = await c.inspect();
const cpu = this.resourceParser.cpu(HostConfig.NanoCpus ?? 0);
const memory = this.resourceParser.memory(HostConfig.Memory ?? 0);
cpuUsed += cpu;
memoryUsed += memory;
}
}
this.cachedResources = this.applyOverrides({
cpuTotal,
cpuAvailable: cpuTotal - cpuUsed,
memoryTotal,
memoryAvailable: memoryTotal - memoryUsed,
});
this.lastUpdateMs = Date.now();
return this.cachedResources;
}
}
export class KubernetesResourceMonitor extends ResourceMonitor {
private k8s: K8sApi;
private nodeName: string;
constructor(k8s: K8sApi, nodeName: string) {
super(KubernetesResourceParser);
this.k8s = k8s;
this.nodeName = nodeName;
}
async getNodeResources(fromCache?: boolean): Promise<NodeResources> {
if (this.isCacheValid() || fromCache) {
logger.debug("[ResourceMonitor] Using cached resources");
return this.cachedResources;
}
const node = await this.k8s.core.readNode({ name: this.nodeName });
const pods = await this.k8s.core.listPodForAllNamespaces({
// TODO: ensure this includes all pods that consume resources
fieldSelector: `spec.nodeName=${this.nodeName},status.phase=Running`,
});
const allocatable = node.status?.allocatable;
const cpuTotal = this.resourceParser.cpu(allocatable?.cpu ?? "0");
const memoryTotal = this.resourceParser.memory(allocatable?.memory ?? "0");
// Sum up resources requested by all pods on this node
let cpuRequested = 0;
let memoryRequested = 0;
for (const pod of pods.items) {
if (pod.status?.phase === "Running") {
if (!pod.spec) {
continue;
}
for (const container of pod.spec.containers) {
const resources = container.resources?.requests ?? {};
cpuRequested += this.resourceParser.cpu(resources.cpu ?? "0");
memoryRequested += this.resourceParser.memory(resources.memory ?? "0");
}
}
}
this.cachedResources = this.applyOverrides({
cpuTotal,
cpuAvailable: cpuTotal - cpuRequested,
memoryTotal,
memoryAvailable: memoryTotal - memoryRequested,
});
this.lastUpdateMs = Date.now();
return this.cachedResources;
}
}
abstract class ResourceParser {
abstract cpu(cpu: number | string): number;
abstract memory(memory: number | string): number;
}
class DockerResourceParser extends ResourceParser {
cpu(cpu: number): number {
return cpu / 1e9;
}
memory(memory: number): number {
return memory;
}
}
class KubernetesResourceParser extends ResourceParser {
cpu(cpu: string): number {
if (cpu.endsWith("m")) {
return parseInt(cpu.slice(0, -1)) / 1000;
}
return parseInt(cpu);
}
memory(memory: string): number {
if (memory.endsWith("Ki")) {
return parseInt(memory.slice(0, -2)) * 1024;
}
if (memory.endsWith("Mi")) {
return parseInt(memory.slice(0, -2)) * 1024 * 1024;
}
if (memory.endsWith("Gi")) {
return parseInt(memory.slice(0, -2)) * 1024 * 1024 * 1024;
}
return parseInt(memory);
}
}
@@ -0,0 +1,580 @@
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { FailedPodHandler } from "./failedPodHandler.js";
import { K8sApi, createK8sApi } from "../clients/kubernetes.js";
import { Registry } from "prom-client";
import { setTimeout } from "timers/promises";
describe("FailedPodHandler Integration Tests", () => {
const k8s = createK8sApi();
const namespace = "integration-test";
const register = new Registry();
beforeAll(async () => {
// Create the test namespace if it doesn't exist
try {
await k8s.core.readNamespace({ name: namespace });
} catch (error) {
await k8s.core.createNamespace({
body: {
metadata: {
name: namespace,
},
},
});
}
// Clear any existing pods in the namespace
await deleteAllPodsInNamespace({ k8sApi: k8s, namespace });
});
afterEach(async () => {
// Clear metrics to avoid conflicts
register.clear();
// Delete any remaining pods in the namespace
await deleteAllPodsInNamespace({ k8sApi: k8s, namespace });
});
it("should process and delete failed pods with app=task-run label", async () => {
const handler = new FailedPodHandler({ namespace, k8s, register });
try {
// Create failed pods with the correct label
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 2,
shouldFail: true,
});
// Wait for pods to reach Failed state
await waitForPodsPhase({
k8sApi: k8s,
namespace,
podNames,
phase: "Failed",
});
// Start the handler
await handler.start();
// Wait for pods to be deleted
await waitForPodsDeletion({
k8sApi: k8s,
namespace,
podNames,
});
// Verify metrics
const metrics = handler.getMetrics();
// Check informer events were recorded
const informerEvents = await metrics.informerEventsTotal.get();
expect(informerEvents.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
verb: "add",
}),
value: 2,
})
);
expect(informerEvents.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
verb: "connect",
}),
value: 1,
})
);
expect(informerEvents.values).not.toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
verb: "error",
}),
})
);
// Check pods were processed
const processedPods = await metrics.processedPodsTotal.get();
expect(processedPods.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
status: "Failed",
}),
value: 2,
})
);
// Check pods were deleted
const deletedPods = await metrics.deletedPodsTotal.get();
expect(deletedPods.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
status: "Failed",
}),
value: 2,
})
);
// Check no deletion errors were recorded
const deletionErrors = await metrics.deletionErrorsTotal.get();
expect(deletionErrors.values).toHaveLength(0);
// Check processing durations were recorded
const durations = await metrics.processingDurationSeconds.get();
const failedDurations = durations.values.filter(
(v) => v.labels.namespace === namespace && v.labels.status === "Failed"
);
expect(failedDurations.length).toBeGreaterThan(0);
} finally {
await handler.stop();
}
}, 30000);
it("should ignore pods without app=task-run label", async () => {
const handler = new FailedPodHandler({ namespace, k8s, register });
try {
// Create failed pods without the task-run label
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 1,
shouldFail: true,
labels: { app: "not-task-run" },
});
// Wait for pod to reach Failed state
await waitForPodsPhase({
k8sApi: k8s,
namespace,
podNames,
phase: "Failed",
});
await handler.start();
// Wait a reasonable time to ensure pod isn't deleted
await setTimeout(5000);
// Verify pod still exists
const exists = await podExists({ k8sApi: k8s, namespace, podName: podNames[0]! });
expect(exists).toBe(true);
// Verify no metrics were recorded
const metrics = handler.getMetrics();
const processedPods = await metrics.processedPodsTotal.get();
expect(processedPods.values).toHaveLength(0);
} finally {
await handler.stop();
}
}, 30000);
it("should not process pods that are being deleted", async () => {
const handler = new FailedPodHandler({ namespace, k8s, register });
try {
// Create a failed pod that we'll mark for deletion
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 1,
shouldFail: true,
command: ["/bin/sh", "-c", "sleep 30"],
});
// Wait for pod to reach Failed state
await waitForPodsPhase({
k8sApi: k8s,
namespace,
podNames,
phase: "Running",
});
// Delete the pod but don't wait for deletion
await k8s.core.deleteNamespacedPod({
namespace,
name: podNames[0]!,
gracePeriodSeconds: 5,
});
// Start the handler
await handler.start();
// Wait for pod to be fully deleted
await waitForPodsDeletion({
k8sApi: k8s,
namespace,
podNames,
});
// Verify metrics show we skipped processing
const metrics = handler.getMetrics();
const processedPods = await metrics.processedPodsTotal.get();
expect(processedPods.values).toHaveLength(0);
} finally {
await handler.stop();
}
}, 30000);
it("should detect and process pods that fail after handler starts", async () => {
const handler = new FailedPodHandler({ namespace, k8s, register });
try {
// Start the handler
await handler.start();
// Create failed pods with the correct label
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 3,
shouldFail: true,
});
// Wait for pods to be deleted
await waitForPodsDeletion({
k8sApi: k8s,
namespace,
podNames,
});
// Verify metrics
const metrics = handler.getMetrics();
// Check informer events were recorded
const informerEvents = await metrics.informerEventsTotal.get();
expect(informerEvents.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
verb: "add",
}),
value: 3,
})
);
expect(informerEvents.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
verb: "connect",
}),
value: 1,
})
);
expect(informerEvents.values).not.toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
verb: "error",
}),
})
);
// Check pods were processed
const processedPods = await metrics.processedPodsTotal.get();
expect(processedPods.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
status: "Failed",
}),
value: 3,
})
);
// Check pods were deleted
const deletedPods = await metrics.deletedPodsTotal.get();
expect(deletedPods.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
status: "Failed",
}),
value: 3,
})
);
// Check no deletion errors were recorded
const deletionErrors = await metrics.deletionErrorsTotal.get();
expect(deletionErrors.values).toHaveLength(0);
// Check processing durations were recorded
const durations = await metrics.processingDurationSeconds.get();
const failedDurations = durations.values.filter(
(v) => v.labels.namespace === namespace && v.labels.status === "Failed"
);
expect(failedDurations.length).toBeGreaterThan(0);
} finally {
await handler.stop();
}
}, 60000);
it("should handle graceful shutdown pods differently", async () => {
const handler = new FailedPodHandler({ namespace, k8s, register });
try {
// Create first batch of pods before starting handler
const firstBatchPodNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 2,
exitCode: FailedPodHandler.GRACEFUL_SHUTDOWN_EXIT_CODE,
});
// Wait for pods to reach Failed state
await waitForPodsPhase({
k8sApi: k8s,
namespace,
podNames: firstBatchPodNames,
phase: "Failed",
});
// Start the handler
await handler.start();
// Wait for first batch to be deleted
await waitForPodsDeletion({
k8sApi: k8s,
namespace,
podNames: firstBatchPodNames,
});
// Create second batch of pods after handler is running
const secondBatchPodNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 3,
exitCode: FailedPodHandler.GRACEFUL_SHUTDOWN_EXIT_CODE,
});
// Wait for second batch to be deleted
await waitForPodsDeletion({
k8sApi: k8s,
namespace,
podNames: secondBatchPodNames,
});
// Verify metrics
const metrics = handler.getMetrics();
// Check informer events were recorded for both batches
const informerEvents = await metrics.informerEventsTotal.get();
expect(informerEvents.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
verb: "add",
}),
value: 5, // 2 from first batch + 3 from second batch
})
);
// Check pods were processed as graceful shutdowns
const processedPods = await metrics.processedPodsTotal.get();
// Should not be marked as Failed
const failedPods = processedPods.values.find(
(v) => v.labels.namespace === namespace && v.labels.status === "Failed"
);
expect(failedPods).toBeUndefined();
// Should be marked as GracefulShutdown
const gracefulShutdowns = processedPods.values.find(
(v) => v.labels.namespace === namespace && v.labels.status === "GracefulShutdown"
);
expect(gracefulShutdowns).toBeDefined();
expect(gracefulShutdowns?.value).toBe(5); // Total from both batches
// Check pods were still deleted
const deletedPods = await metrics.deletedPodsTotal.get();
expect(deletedPods.values).toContainEqual(
expect.objectContaining({
labels: expect.objectContaining({
namespace,
status: "Failed",
}),
value: 5, // Total from both batches
})
);
// Check no deletion errors were recorded
const deletionErrors = await metrics.deletionErrorsTotal.get();
expect(deletionErrors.values).toHaveLength(0);
} finally {
await handler.stop();
}
}, 30000);
});
async function createTestPods({
k8sApi,
namespace,
count,
labels = { app: "task-run" },
shouldFail = false,
namePrefix = "test-pod",
command = ["/bin/sh", "-c", shouldFail ? "exit 1" : "exit 0"],
randomizeName = true,
exitCode,
}: {
k8sApi: K8sApi;
namespace: string;
count: number;
labels?: Record<string, string>;
shouldFail?: boolean;
namePrefix?: string;
command?: string[];
randomizeName?: boolean;
exitCode?: number;
}) {
const createdPods: string[] = [];
// If exitCode is specified, override the command
if (exitCode !== undefined) {
command = ["/bin/sh", "-c", `exit ${exitCode}`];
}
for (let i = 0; i < count; i++) {
const podName = randomizeName
? `${namePrefix}-${i}-${Math.random().toString(36).substring(2, 15)}`
: `${namePrefix}-${i}`;
await k8sApi.core.createNamespacedPod({
namespace,
body: {
metadata: {
name: podName,
labels,
},
spec: {
restartPolicy: "Never",
containers: [
{
name: "run-controller", // Changed to match the name we check in failedPodHandler
image: "busybox:1.37.0",
command,
},
],
},
},
});
createdPods.push(podName);
}
return createdPods;
}
async function waitForPodsDeletion({
k8sApi,
namespace,
podNames,
timeoutMs = 10000,
waitMs = 1000,
}: {
k8sApi: K8sApi;
namespace: string;
podNames: string[];
timeoutMs?: number;
waitMs?: number;
}) {
const startTime = Date.now();
const pendingPods = new Set(podNames);
while (pendingPods.size > 0 && Date.now() - startTime < timeoutMs) {
const pods = await k8sApi.core.listNamespacedPod({ namespace });
const existingPods = new Set(pods.items.map((pod) => pod.metadata?.name ?? ""));
for (const podName of pendingPods) {
if (!existingPods.has(podName)) {
pendingPods.delete(podName);
}
}
if (pendingPods.size > 0) {
await setTimeout(waitMs);
}
}
if (pendingPods.size > 0) {
throw new Error(
`Pods [${Array.from(pendingPods).join(", ")}] were not deleted within ${timeoutMs}ms`
);
}
}
async function podExists({
k8sApi,
namespace,
podName,
}: {
k8sApi: K8sApi;
namespace: string;
podName: string;
}) {
const pods = await k8sApi.core.listNamespacedPod({ namespace });
return pods.items.some((p) => p.metadata?.name === podName);
}
async function waitForPodsPhase({
k8sApi,
namespace,
podNames,
phase,
timeoutMs = 10000,
waitMs = 1000,
}: {
k8sApi: K8sApi;
namespace: string;
podNames: string[];
phase: "Pending" | "Running" | "Succeeded" | "Failed" | "Unknown";
timeoutMs?: number;
waitMs?: number;
}) {
const startTime = Date.now();
const pendingPods = new Set(podNames);
while (pendingPods.size > 0 && Date.now() - startTime < timeoutMs) {
const pods = await k8sApi.core.listNamespacedPod({ namespace });
for (const pod of pods.items) {
if (pendingPods.has(pod.metadata?.name ?? "") && pod.status?.phase === phase) {
pendingPods.delete(pod.metadata?.name ?? "");
}
}
if (pendingPods.size > 0) {
await setTimeout(waitMs);
}
}
if (pendingPods.size > 0) {
throw new Error(
`Pods [${Array.from(pendingPods).join(
", "
)}] did not reach phase ${phase} within ${timeoutMs}ms`
);
}
}
async function deleteAllPodsInNamespace({
k8sApi,
namespace,
}: {
k8sApi: K8sApi;
namespace: string;
}) {
// Get all pods
const pods = await k8sApi.core.listNamespacedPod({ namespace });
const podNames = pods.items.map((p) => p.metadata?.name ?? "");
// Delete all pods
await k8sApi.core.deleteCollectionNamespacedPod({ namespace, gracePeriodSeconds: 0 });
// Wait for all pods to be deleted
await waitForPodsDeletion({ k8sApi, namespace, podNames });
}
@@ -0,0 +1,298 @@
import { LogLevel, SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import { K8sApi } from "../clients/kubernetes.js";
import { createK8sApi } from "../clients/kubernetes.js";
import { Informer, V1Pod } from "@kubernetes/client-node";
import { Counter, Registry, Histogram } from "prom-client";
import { register } from "../metrics.js";
import { setTimeout } from "timers/promises";
type PodStatus = "Pending" | "Running" | "Succeeded" | "Failed" | "Unknown" | "GracefulShutdown";
export type FailedPodHandlerOptions = {
namespace: string;
reconnectIntervalMs?: number;
k8s?: K8sApi;
register?: Registry;
};
export class FailedPodHandler {
private readonly id: string;
private readonly logger: SimpleStructuredLogger;
private readonly k8s: K8sApi;
private readonly namespace: string;
private isRunning = false;
private readonly informer: Informer<V1Pod>;
private readonly reconnectIntervalMs: number;
// Metrics
private readonly register: Registry;
private readonly processedPodsTotal: Counter;
private readonly deletedPodsTotal: Counter;
private readonly deletionErrorsTotal: Counter;
private readonly processingDurationSeconds: Histogram<string>;
private readonly informerEventsTotal: Counter;
static readonly GRACEFUL_SHUTDOWN_EXIT_CODE = 200;
constructor(opts: FailedPodHandlerOptions) {
this.id = Math.random().toString(36).substring(2, 15);
this.logger = new SimpleStructuredLogger("failed-pod-handler", LogLevel.debug, {
id: this.id,
});
this.k8s = opts.k8s ?? createK8sApi();
this.namespace = opts.namespace;
this.reconnectIntervalMs = opts.reconnectIntervalMs ?? 1000;
this.informer = this.k8s.makeInformer(
`/api/v1/namespaces/${this.namespace}/pods`,
() =>
this.k8s.core.listNamespacedPod({
namespace: this.namespace,
labelSelector: "app=task-run",
fieldSelector: "status.phase=Failed",
}),
"app=task-run",
"status.phase=Failed"
);
// Whenever a matching pod is added to the informer cache
this.informer.on("add", this.onPodCompleted.bind(this));
// Informer events
this.informer.on("connect", this.makeOnConnect("failed-pod-informer").bind(this));
this.informer.on("error", this.makeOnError("failed-pod-informer").bind(this));
// Initialize metrics
this.register = opts.register ?? register;
this.processedPodsTotal = new Counter({
name: "failed_pod_handler_processed_pods_total",
help: "Total number of failed pods processed",
labelNames: ["namespace", "status"],
registers: [this.register],
});
this.deletedPodsTotal = new Counter({
name: "failed_pod_handler_deleted_pods_total",
help: "Total number of pods deleted",
labelNames: ["namespace", "status"],
registers: [this.register],
});
this.deletionErrorsTotal = new Counter({
name: "failed_pod_handler_deletion_errors_total",
help: "Total number of errors encountered while deleting pods",
labelNames: ["namespace", "error_type"],
registers: [this.register],
});
this.processingDurationSeconds = new Histogram({
name: "failed_pod_handler_processing_duration_seconds",
help: "The duration of pod processing",
labelNames: ["namespace", "status"],
registers: [this.register],
});
this.informerEventsTotal = new Counter({
name: "failed_pod_handler_informer_events_total",
help: "Total number of informer events",
labelNames: ["namespace", "verb"],
registers: [this.register],
});
}
async start() {
if (this.isRunning) {
this.logger.warn("failed pod handler already running");
return;
}
this.isRunning = true;
this.logger.info("starting failed pod handler");
await this.informer.start();
}
async stop() {
if (!this.isRunning) {
this.logger.warn("failed pod handler not running");
return;
}
this.isRunning = false;
this.logger.info("stopping failed pod handler");
await this.informer.stop();
}
private async withHistogram<T>(
histogram: Histogram<string>,
promise: Promise<T>,
labels?: Record<string, string>
): Promise<T> {
const end = histogram.startTimer({ namespace: this.namespace, ...labels });
try {
return await promise;
} finally {
end();
}
}
/**
* Returns the non-nullable status of a pod
*/
private podStatus(pod: V1Pod): PodStatus {
return (pod.status?.phase ?? "Unknown") as PodStatus;
}
private async onPodCompleted(pod: V1Pod) {
this.logger.info("pod-completed", this.podSummary(pod));
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "add" });
if (!pod.metadata?.name) {
this.logger.error("pod-completed: no name", this.podSummary(pod));
return;
}
if (!pod.status) {
this.logger.error("pod-completed: no status", this.podSummary(pod));
return;
}
if (pod.metadata?.deletionTimestamp) {
this.logger.info("pod-completed: pod is being deleted", this.podSummary(pod));
return;
}
const podStatus = this.podStatus(pod);
switch (podStatus) {
case "Succeeded":
await this.withHistogram(this.processingDurationSeconds, this.onPodSucceeded(pod), {
status: podStatus,
});
break;
case "Failed":
await this.withHistogram(this.processingDurationSeconds, this.onPodFailed(pod), {
status: podStatus,
});
break;
default:
this.logger.error("pod-completed: unknown phase", this.podSummary(pod));
}
}
private async onPodSucceeded(pod: V1Pod) {
this.logger.info("pod-succeeded", this.podSummary(pod));
this.processedPodsTotal.inc({
namespace: this.namespace,
status: this.podStatus(pod),
});
}
private async onPodFailed(pod: V1Pod) {
this.logger.info("pod-failed", this.podSummary(pod));
try {
await this.processFailedPod(pod);
} catch (error) {
this.logger.error("pod-failed: error processing pod", this.podSummary(pod), { error });
} finally {
await this.deletePod(pod);
}
}
private async processFailedPod(pod: V1Pod) {
this.logger.info("pod-failed: processing pod", this.podSummary(pod));
const mainContainer = pod.status?.containerStatuses?.find((c) => c.name === "run-controller");
// If it's our special "graceful shutdown" exit code, don't process it further, just delete it
if (
mainContainer?.state?.terminated?.exitCode === FailedPodHandler.GRACEFUL_SHUTDOWN_EXIT_CODE
) {
this.logger.debug("pod-failed: graceful shutdown detected", this.podSummary(pod));
this.processedPodsTotal.inc({
namespace: this.namespace,
status: "GracefulShutdown",
});
return;
}
this.processedPodsTotal.inc({
namespace: this.namespace,
status: this.podStatus(pod),
});
}
private async deletePod(pod: V1Pod) {
this.logger.info("pod-failed: deleting pod", this.podSummary(pod));
try {
await this.k8s.core.deleteNamespacedPod({
name: pod.metadata!.name!,
namespace: this.namespace,
});
this.deletedPodsTotal.inc({
namespace: this.namespace,
status: this.podStatus(pod),
});
} catch (error) {
this.logger.error("pod-failed: error deleting pod", this.podSummary(pod), { error });
this.deletionErrorsTotal.inc({
namespace: this.namespace,
error_type: error instanceof Error ? error.name : "unknown",
});
}
}
private makeOnError(informerName: string) {
return () => this.onError(informerName);
}
private async onError(informerName: string) {
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" });
// Reconnect on errors
await setTimeout(this.reconnectIntervalMs);
await this.informer.start();
}
private makeOnConnect(informerName: string) {
return () => this.onConnect(informerName);
}
private async onConnect(informerName: string) {
this.logger.info(`informer connected: ${informerName}`);
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "connect" });
}
private podSummary(pod: V1Pod) {
return {
name: pod.metadata?.name,
namespace: pod.metadata?.namespace,
status: pod.status?.phase,
deletionTimestamp: pod.metadata?.deletionTimestamp,
};
}
// Method to expose metrics for testing
public getMetrics() {
return {
processedPodsTotal: this.processedPodsTotal,
deletedPodsTotal: this.deletedPodsTotal,
deletionErrorsTotal: this.deletionErrorsTotal,
informerEventsTotal: this.informerEventsTotal,
processingDurationSeconds: this.processingDurationSeconds,
};
}
}
@@ -0,0 +1,472 @@
import { PodCleaner } from "./podCleaner.js";
import { K8sApi, createK8sApi } from "../clients/kubernetes.js";
import { setTimeout } from "timers/promises";
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { Registry } from "prom-client";
describe("PodCleaner Integration Tests", () => {
const k8s = createK8sApi();
const namespace = "integration-test";
const register = new Registry();
beforeAll(async () => {
// Create the test namespace, only if it doesn't exist
try {
await k8s.core.readNamespace({ name: namespace });
} catch (error) {
await k8s.core.createNamespace({
body: {
metadata: {
name: namespace,
},
},
});
}
});
afterEach(async () => {
// Clear metrics to avoid conflicts
register.clear();
// Delete all pods in the namespace
await k8s.core.deleteCollectionNamespacedPod({ namespace, gracePeriodSeconds: 0 });
});
it("should clean up succeeded pods", async () => {
const podCleaner = new PodCleaner({ namespace, k8s, register });
try {
// Create a test pod that's in succeeded state
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 1,
namePrefix: "test-succeeded-pod",
});
if (!podNames[0]) {
throw new Error("Failed to create test pod");
}
const podName = podNames[0];
// Wait for pod to complete
await waitForPodPhase({
k8sApi: k8s,
namespace,
podName,
phase: "Succeeded",
});
// Start the pod cleaner
await podCleaner.start();
// Wait for pod to be deleted
await waitForPodDeletion({
k8sApi: k8s,
namespace,
podName,
});
// Verify pod was deleted
expect(await podExists({ k8sApi: k8s, namespace, podName })).toBe(false);
} finally {
await podCleaner.stop();
}
}, 30000);
it("should accurately track deletion metrics", async () => {
const podCleaner = new PodCleaner({ namespace, k8s, register });
try {
// Create a test pod that's in succeeded state
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 1,
namePrefix: "test-succeeded-pod",
});
// Wait for pod to be in succeeded state
await waitForPodsPhase({
k8sApi: k8s,
namespace,
podNames,
phase: "Succeeded",
});
await podCleaner.start();
// Wait for pod to be deleted
await waitForPodsDeletion({
k8sApi: k8s,
namespace,
podNames,
});
const metrics = podCleaner.getMetrics();
const deletionCycles = await metrics.deletionCyclesTotal.get();
const deletionTimestamp = await metrics.lastDeletionTimestamp.get();
expect(deletionCycles?.values[0]?.value).toBeGreaterThan(0);
expect(deletionTimestamp?.values[0]?.value).toBeGreaterThan(0);
} finally {
await podCleaner.stop();
}
}, 30000);
it("should handle different batch sizes - small", async () => {
const podCleaner = new PodCleaner({
namespace,
k8s,
register,
batchSize: 1,
});
try {
// Create some pods that will succeed
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 2,
});
await waitForPodsPhase({
k8sApi: k8s,
namespace,
podNames,
phase: "Succeeded",
});
await podCleaner.start();
await waitForPodsDeletion({
k8sApi: k8s,
namespace,
podNames,
});
const metrics = podCleaner.getMetrics();
const cycles = await metrics.deletionCyclesTotal.get();
expect(cycles?.values[0]?.value).toBe(2);
} finally {
await podCleaner.stop();
}
}, 30000);
it("should handle different batch sizes - large", async () => {
const podCleaner = new PodCleaner({
namespace,
k8s,
register,
batchSize: 5000,
});
try {
// Create some pods that will succeed
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 10,
});
await waitForPodsPhase({
k8sApi: k8s,
namespace,
podNames,
phase: "Succeeded",
});
await podCleaner.start();
await waitForPodsDeletion({
k8sApi: k8s,
namespace,
podNames,
});
const metrics = podCleaner.getMetrics();
const cycles = await metrics.deletionCyclesTotal.get();
expect(cycles?.values[0]?.value).toBe(1);
} finally {
await podCleaner.stop();
}
}, 30000);
it("should not delete pods without app=task-run label", async () => {
const podCleaner = new PodCleaner({ namespace, k8s, register });
try {
// Create a test pod without the task-run label
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 1,
labels: { app: "different-label" },
namePrefix: "non-task-run-pod",
});
if (!podNames[0]) {
throw new Error("Failed to create test pod");
}
const podName = podNames[0];
// Wait for pod to complete
await waitForPodPhase({
k8sApi: k8s,
namespace,
podName,
phase: "Succeeded",
});
await podCleaner.start();
// Wait a reasonable time to ensure pod isn't deleted
await setTimeout(5000);
// Verify pod still exists
expect(await podExists({ k8sApi: k8s, namespace, podName })).toBe(true);
} finally {
await podCleaner.stop();
}
}, 30000);
it("should not delete pods that are still running", async () => {
const podCleaner = new PodCleaner({ namespace, k8s, register });
try {
// Create a test pod with a long-running command
const podNames = await createTestPods({
k8sApi: k8s,
namespace,
count: 1,
namePrefix: "running-pod",
command: ["sleep", "30"], // Will keep pod running
});
if (!podNames[0]) {
throw new Error("Failed to create test pod");
}
const podName = podNames[0];
// Wait for pod to be running
await waitForPodPhase({
k8sApi: k8s,
namespace,
podName,
phase: "Running",
});
await podCleaner.start();
// Wait a reasonable time to ensure pod isn't deleted
await setTimeout(5000);
// Verify pod still exists
expect(await podExists({ k8sApi: k8s, namespace, podName })).toBe(true);
} finally {
await podCleaner.stop();
}
}, 30000);
});
// Helper functions
async function waitForPodPhase({
k8sApi,
namespace,
podName,
phase,
timeoutMs = 10000,
waitMs = 1000,
}: {
k8sApi: K8sApi;
namespace: string;
podName: string;
phase: string;
timeoutMs?: number;
waitMs?: number;
}) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const pod = await k8sApi.core.readNamespacedPod({
namespace,
name: podName,
});
if (pod.status?.phase === phase) {
return;
}
await setTimeout(waitMs);
}
throw new Error(`Pod ${podName} did not reach phase ${phase} within ${timeoutMs}ms`);
}
async function waitForPodDeletion({
k8sApi,
namespace,
podName,
timeoutMs = 10000,
waitMs = 1000,
}: {
k8sApi: K8sApi;
namespace: string;
podName: string;
timeoutMs?: number;
waitMs?: number;
}) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
try {
await k8sApi.core.readNamespacedPod({
namespace,
name: podName,
});
await setTimeout(waitMs);
} catch (error) {
// Pod was deleted
return;
}
}
throw new Error(`Pod ${podName} was not deleted within ${timeoutMs}ms`);
}
async function createTestPods({
k8sApi,
namespace,
count,
labels = { app: "task-run" },
shouldFail = false,
namePrefix = "test-pod",
command = ["/bin/sh", "-c", shouldFail ? "exit 1" : "exit 0"],
}: {
k8sApi: K8sApi;
namespace: string;
count: number;
labels?: Record<string, string>;
shouldFail?: boolean;
namePrefix?: string;
command?: string[];
}) {
const createdPods: string[] = [];
for (let i = 0; i < count; i++) {
const podName = `${namePrefix}-${i}`;
await k8sApi.core.createNamespacedPod({
namespace,
body: {
metadata: {
name: podName,
labels,
},
spec: {
restartPolicy: "Never",
containers: [
{
name: "test",
image: "busybox:1.37.0",
command,
},
],
},
},
});
createdPods.push(podName);
}
return createdPods;
}
async function waitForPodsPhase({
k8sApi,
namespace,
podNames,
phase,
timeoutMs = 10000,
waitMs = 1000,
}: {
k8sApi: K8sApi;
namespace: string;
podNames: string[];
phase: "Pending" | "Running" | "Succeeded" | "Failed" | "Unknown";
timeoutMs?: number;
waitMs?: number;
}) {
const startTime = Date.now();
const pendingPods = new Set(podNames);
while (pendingPods.size > 0 && Date.now() - startTime < timeoutMs) {
const pods = await k8sApi.core.listNamespacedPod({ namespace });
for (const pod of pods.items) {
if (pendingPods.has(pod.metadata?.name ?? "") && pod.status?.phase === phase) {
pendingPods.delete(pod.metadata?.name ?? "");
}
}
if (pendingPods.size > 0) {
await setTimeout(waitMs);
}
}
if (pendingPods.size > 0) {
throw new Error(
`Pods [${Array.from(pendingPods).join(
", "
)}] did not reach phase ${phase} within ${timeoutMs}ms`
);
}
}
async function waitForPodsDeletion({
k8sApi,
namespace,
podNames,
timeoutMs = 10000,
waitMs = 1000,
}: {
k8sApi: K8sApi;
namespace: string;
podNames: string[];
timeoutMs?: number;
waitMs?: number;
}) {
const startTime = Date.now();
const pendingPods = new Set(podNames);
while (pendingPods.size > 0 && Date.now() - startTime < timeoutMs) {
const pods = await k8sApi.core.listNamespacedPod({ namespace });
const existingPods = new Set(pods.items.map((pod) => pod.metadata?.name ?? ""));
for (const podName of pendingPods) {
if (!existingPods.has(podName)) {
pendingPods.delete(podName);
}
}
if (pendingPods.size > 0) {
await setTimeout(waitMs);
}
}
if (pendingPods.size > 0) {
throw new Error(
`Pods [${Array.from(pendingPods).join(", ")}] were not deleted within ${timeoutMs}ms`
);
}
}
async function podExists({
k8sApi,
namespace,
podName,
}: {
k8sApi: K8sApi;
namespace: string;
podName: string;
}) {
const pods = await k8sApi.core.listNamespacedPod({ namespace });
return pods.items.some((p) => p.metadata?.name === podName);
}
+118
View File
@@ -0,0 +1,118 @@
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import { K8sApi } from "../clients/kubernetes.js";
import { createK8sApi } from "../clients/kubernetes.js";
import { HeartbeatService } from "@trigger.dev/core/v3";
import { Counter, Gauge, Registry } from "prom-client";
import { register } from "../metrics.js";
export type PodCleanerOptions = {
namespace: string;
k8s?: K8sApi;
register?: Registry;
batchSize?: number;
intervalMs?: number;
};
export class PodCleaner {
private readonly logger = new SimpleStructuredLogger("pod-cleaner");
private readonly k8s: K8sApi;
private readonly namespace: string;
private readonly batchSize: number;
private readonly deletionHeartbeat: HeartbeatService;
// Metrics
private readonly register: Registry;
private readonly deletionCyclesTotal: Counter;
private readonly lastDeletionTimestamp: Gauge;
constructor(opts: PodCleanerOptions) {
this.k8s = opts.k8s ?? createK8sApi();
this.namespace = opts.namespace;
this.batchSize = opts.batchSize ?? 500;
this.deletionHeartbeat = new HeartbeatService({
intervalMs: opts.intervalMs ?? 10000,
leadingEdge: true,
heartbeat: this.deleteCompletedPods.bind(this),
});
// Initialize metrics
this.register = opts.register ?? register;
this.deletionCyclesTotal = new Counter({
name: "pod_cleaner_deletion_cycles_total",
help: "Total number of pod deletion cycles run",
labelNames: ["namespace", "status", "batch_size"],
registers: [this.register],
});
this.lastDeletionTimestamp = new Gauge({
name: "pod_cleaner_last_deletion_timestamp",
help: "Timestamp of the last deletion cycle",
labelNames: ["namespace"],
registers: [this.register],
});
}
async start() {
this.deletionHeartbeat.start();
}
async stop() {
this.deletionHeartbeat.stop();
}
private async deleteCompletedPods() {
let continuationToken: string | undefined;
do {
try {
const result = await this.k8s.core.deleteCollectionNamespacedPod({
namespace: this.namespace,
labelSelector: "app=task-run",
fieldSelector: "status.phase=Succeeded",
limit: this.batchSize,
_continue: continuationToken,
gracePeriodSeconds: 0,
propagationPolicy: "Background",
timeoutSeconds: 30,
});
// Update continuation token for next batch
continuationToken = result.metadata?._continue;
// Increment the deletion cycles counter
this.deletionCyclesTotal.inc({
namespace: this.namespace,
batch_size: this.batchSize,
status: "succeeded",
});
this.logger.info("Deleted batch of pods", { continuationToken });
} catch (err) {
this.logger.error("Failed to delete batch of pods", {
err: err instanceof Error ? err.message : String(err),
});
this.deletionCyclesTotal.inc({
namespace: this.namespace,
batch_size: this.batchSize,
status: "failed",
});
break;
}
} while (continuationToken);
this.lastDeletionTimestamp.set({ namespace: this.namespace }, Date.now());
}
// Method to expose metrics for testing
public getMetrics() {
return {
deletionCyclesTotal: this.deletionCyclesTotal,
lastDeletionTimestamp: this.lastDeletionTimestamp,
};
}
}
+16
View File
@@ -0,0 +1,16 @@
export function getDockerHostDomain() {
const isMacOs = process.platform === "darwin";
const isWindows = process.platform === "win32";
return isMacOs || isWindows ? "host.docker.internal" : "localhost";
}
export function getRunnerId(runId: string, attemptNumber?: number) {
const parts = ["runner", runId.replace("run_", "")];
if (attemptNumber && attemptNumber > 1) {
parts.push(`attempt-${attemptNumber}`);
}
return parts.join("-");
}
@@ -0,0 +1,87 @@
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import {
type WorkloadManager,
type WorkloadManagerCreateOptions,
type WorkloadManagerOptions,
} from "./types.js";
import { x } from "tinyexec";
import { env } from "../env.js";
import { getDockerHostDomain, getRunnerId } from "../util.js";
export class DockerWorkloadManager implements WorkloadManager {
private readonly logger = new SimpleStructuredLogger("docker-workload-provider");
constructor(private opts: WorkloadManagerOptions) {
if (opts.workloadApiDomain) {
this.logger.warn("[DockerWorkloadProvider] ⚠️ Custom workload API domain", {
domain: opts.workloadApiDomain,
});
}
}
async create(opts: WorkloadManagerCreateOptions) {
this.logger.log("[DockerWorkloadProvider] Creating container", { opts });
const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber);
const runArgs = [
"run",
"--detach",
`--network=${env.DOCKER_NETWORK}`,
`--env=TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
`--env=TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
`--env=TRIGGER_ENV_ID=${opts.envId}`,
`--env=TRIGGER_RUN_ID=${opts.runFriendlyId}`,
`--env=TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
`--env=TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
`--env=TRIGGER_SUPERVISOR_API_PORT=${this.opts.workloadApiPort}`,
`--env=TRIGGER_SUPERVISOR_API_DOMAIN=${this.opts.workloadApiDomain ?? getDockerHostDomain()}`,
`--env=TRIGGER_WORKER_INSTANCE_NAME=${env.TRIGGER_WORKER_INSTANCE_NAME}`,
`--env=OTEL_EXPORTER_OTLP_ENDPOINT=${env.OTEL_EXPORTER_OTLP_ENDPOINT}`,
`--env=TRIGGER_RUNNER_ID=${runnerId}`,
`--hostname=${runnerId}`,
`--name=${runnerId}`,
];
if (this.opts.warmStartUrl) {
runArgs.push(`--env=TRIGGER_WARM_START_URL=${this.opts.warmStartUrl}`);
}
if (this.opts.metadataUrl) {
runArgs.push(`--env=TRIGGER_METADATA_URL=${this.opts.metadataUrl}`);
}
if (this.opts.heartbeatIntervalSeconds) {
runArgs.push(
`--env=TRIGGER_HEARTBEAT_INTERVAL_SECONDS=${this.opts.heartbeatIntervalSeconds}`
);
}
if (this.opts.snapshotPollIntervalSeconds) {
runArgs.push(
`--env=TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS=${this.opts.snapshotPollIntervalSeconds}`
);
}
if (this.opts.additionalEnvVars) {
Object.entries(this.opts.additionalEnvVars).forEach(([key, value]) => {
runArgs.push(`--env=${key}=${value}`);
});
}
if (env.ENFORCE_MACHINE_PRESETS) {
runArgs.push(`--cpus=${opts.machine.cpu}`, `--memory=${opts.machine.memory}G`);
runArgs.push(`--env=TRIGGER_MACHINE_CPU=${opts.machine.cpu}`);
runArgs.push(`--env=TRIGGER_MACHINE_MEMORY=${opts.machine.memory}`);
}
runArgs.push(`${opts.image}`);
try {
const { stdout, stderr } = await x("docker", runArgs);
this.logger.debug("[DockerWorkloadProvider] Create succeeded", { stdout, stderr });
} catch (error) {
this.logger.error("[DockerWorkloadProvider] Create failed:", { opts, error });
}
}
}
@@ -0,0 +1,284 @@
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import {
type WorkloadManager,
type WorkloadManagerCreateOptions,
type WorkloadManagerOptions,
} from "./types.js";
import type { EnvironmentType, MachinePreset } from "@trigger.dev/core/v3";
import { env } from "../env.js";
import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js";
import { getRunnerId } from "../util.js";
type ResourceQuantities = {
[K in "cpu" | "memory" | "ephemeral-storage"]?: string;
};
export class KubernetesWorkloadManager implements WorkloadManager {
private readonly logger = new SimpleStructuredLogger("kubernetes-workload-provider");
private k8s: K8sApi;
private namespace = env.KUBERNETES_NAMESPACE;
constructor(private opts: WorkloadManagerOptions) {
this.k8s = createK8sApi();
if (opts.workloadApiDomain) {
this.logger.warn("[KubernetesWorkloadManager] ⚠️ Custom workload API domain", {
domain: opts.workloadApiDomain,
});
}
}
async create(opts: WorkloadManagerCreateOptions) {
this.logger.log("[KubernetesWorkloadManager] Creating container", { opts });
const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber);
try {
await this.k8s.core.createNamespacedPod({
namespace: this.namespace,
body: {
metadata: {
name: runnerId,
namespace: this.namespace,
labels: {
...this.#getSharedLabels(opts),
app: "task-run",
"app.kubernetes.io/part-of": "trigger-worker",
"app.kubernetes.io/component": "create",
},
},
spec: {
...this.#defaultPodSpec,
terminationGracePeriodSeconds: 60 * 60,
containers: [
{
name: "run-controller",
image: opts.image,
ports: [
{
containerPort: 8000,
},
],
resources: this.#getResourcesForMachine(opts.machine),
env: [
{
name: "TRIGGER_DEQUEUED_AT_MS",
value: opts.dequeuedAt.getTime().toString(),
},
{
name: "TRIGGER_POD_SCHEDULED_AT_MS",
value: Date.now().toString(),
},
{
name: "TRIGGER_RUN_ID",
value: opts.runFriendlyId,
},
{
name: "TRIGGER_ENV_ID",
value: opts.envId,
},
{
name: "TRIGGER_SNAPSHOT_ID",
value: opts.snapshotFriendlyId,
},
{
name: "TRIGGER_SUPERVISOR_API_PROTOCOL",
value: this.opts.workloadApiProtocol,
},
{
name: "TRIGGER_SUPERVISOR_API_PORT",
value: `${this.opts.workloadApiPort}`,
},
{
name: "TRIGGER_SUPERVISOR_API_DOMAIN",
...(this.opts.workloadApiDomain
? {
value: this.opts.workloadApiDomain,
}
: {
valueFrom: {
fieldRef: {
fieldPath: "status.hostIP",
},
},
}),
},
{
name: "TRIGGER_WORKER_INSTANCE_NAME",
valueFrom: {
fieldRef: {
fieldPath: "spec.nodeName",
},
},
},
{
name: "OTEL_EXPORTER_OTLP_ENDPOINT",
value: env.OTEL_EXPORTER_OTLP_ENDPOINT,
},
{
name: "TRIGGER_RUNNER_ID",
value: runnerId,
},
{
name: "TRIGGER_MACHINE_CPU",
value: `${opts.machine.cpu}`,
},
{
name: "TRIGGER_MACHINE_MEMORY",
value: `${opts.machine.memory}`,
},
{
name: "LIMITS_CPU",
valueFrom: {
resourceFieldRef: {
resource: "limits.cpu",
},
},
},
{
name: "LIMITS_MEMORY",
valueFrom: {
resourceFieldRef: {
resource: "limits.memory",
},
},
},
...(this.opts.warmStartUrl
? [{ name: "TRIGGER_WARM_START_URL", value: this.opts.warmStartUrl }]
: []),
...(this.opts.metadataUrl
? [{ name: "TRIGGER_METADATA_URL", value: this.opts.metadataUrl }]
: []),
...(this.opts.heartbeatIntervalSeconds
? [
{
name: "TRIGGER_HEARTBEAT_INTERVAL_SECONDS",
value: `${this.opts.heartbeatIntervalSeconds}`,
},
]
: []),
...(this.opts.snapshotPollIntervalSeconds
? [
{
name: "TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS",
value: `${this.opts.snapshotPollIntervalSeconds}`,
},
]
: []),
...(this.opts.additionalEnvVars
? Object.entries(this.opts.additionalEnvVars).map(([key, value]) => ({
name: key,
value: value,
}))
: []),
],
},
],
},
},
});
} catch (err: unknown) {
this.#handleK8sError(err);
}
}
#throwUnlessRecord(candidate: unknown): asserts candidate is Record<string, unknown> {
if (typeof candidate !== "object" || candidate === null) {
throw candidate;
}
}
#handleK8sError(err: unknown) {
this.#throwUnlessRecord(err);
if ("body" in err && err.body) {
this.logger.error("[KubernetesWorkloadManager] Create failed", { rawError: err.body });
this.#throwUnlessRecord(err.body);
if (typeof err.body.message === "string") {
throw new Error(err.body?.message);
} else {
throw err.body;
}
} else {
this.logger.error("[KubernetesWorkloadManager] Create failed", { rawError: err });
throw err;
}
}
#envTypeToLabelValue(type: EnvironmentType) {
switch (type) {
case "PRODUCTION":
return "prod";
case "STAGING":
return "stg";
case "DEVELOPMENT":
return "dev";
case "PREVIEW":
return "preview";
}
}
private getImagePullSecrets(): k8s.V1LocalObjectReference[] | undefined {
return this.opts.imagePullSecrets?.map((name) => ({ name }));
}
get #defaultPodSpec(): Omit<k8s.V1PodSpec, "containers"> {
return {
restartPolicy: "Never",
automountServiceAccountToken: false,
imagePullSecrets: this.getImagePullSecrets(),
nodeSelector: {
nodetype: env.KUBERNETES_WORKER_NODETYPE_LABEL,
},
};
}
get #defaultResourceRequests(): ResourceQuantities {
return {
"ephemeral-storage": env.EPHEMERAL_STORAGE_SIZE_REQUEST,
};
}
get #defaultResourceLimits(): ResourceQuantities {
return {
"ephemeral-storage": env.EPHEMERAL_STORAGE_SIZE_LIMIT,
};
}
#getSharedLabels(opts: WorkloadManagerCreateOptions): Record<string, string> {
return {
env: opts.envId,
envtype: this.#envTypeToLabelValue(opts.envType),
org: opts.orgId,
project: opts.projectId,
};
}
#getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities {
return {
cpu: `${preset.cpu * 0.75}`,
memory: `${preset.memory}G`,
};
}
#getResourceLimitsForMachine(preset: MachinePreset): ResourceQuantities {
return {
cpu: `${preset.cpu}`,
memory: `${preset.memory}G`,
};
}
#getResourcesForMachine(preset: MachinePreset): k8s.V1ResourceRequirements {
return {
requests: {
...this.#defaultResourceRequests,
...this.#getResourceRequestsForMachine(preset),
},
limits: {
...this.#defaultResourceLimits,
...this.#getResourceLimitsForMachine(preset),
},
};
}
}
@@ -0,0 +1,34 @@
import { type EnvironmentType, type MachinePreset } from "@trigger.dev/core/v3";
export interface WorkloadManagerOptions {
workloadApiProtocol: "http" | "https";
workloadApiDomain?: string; // If unset, will use orchestrator-specific default
workloadApiPort: number;
warmStartUrl?: string;
metadataUrl?: string;
imagePullSecrets?: string[];
heartbeatIntervalSeconds?: number;
snapshotPollIntervalSeconds?: number;
additionalEnvVars?: Record<string, string>;
}
export interface WorkloadManager {
create: (opts: WorkloadManagerCreateOptions) => Promise<unknown>;
}
export interface WorkloadManagerCreateOptions {
image: string;
machine: MachinePreset;
version: string;
nextAttemptNumber?: number;
dequeuedAt: Date;
// identifiers
envId: string;
envType: EnvironmentType;
orgId: string;
projectId: string;
runId: string;
runFriendlyId: string;
snapshotId: string;
snapshotFriendlyId: string;
}
+582
View File
@@ -0,0 +1,582 @@
import { type Namespace, Server, type Socket } from "socket.io";
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import EventEmitter from "node:events";
import { z } from "zod";
import {
type SupervisorHttpClient,
WORKLOAD_HEADERS,
type WorkloadClientSocketData,
type WorkloadClientToServerEvents,
type WorkloadContinueRunExecutionResponseBody,
WorkloadDebugLogRequestBody,
type WorkloadDequeueFromVersionResponseBody,
WorkloadHeartbeatRequestBody,
type WorkloadHeartbeatResponseBody,
WorkloadRunAttemptCompleteRequestBody,
type WorkloadRunAttemptCompleteResponseBody,
WorkloadRunAttemptStartRequestBody,
type WorkloadRunAttemptStartResponseBody,
type WorkloadRunLatestSnapshotResponseBody,
type WorkloadServerToClientEvents,
type WorkloadSuspendRunResponseBody,
} from "@trigger.dev/core/v3/workers";
import { HttpServer, type CheckpointClient } from "@trigger.dev/core/v3/serverOnly";
import { type IncomingMessage } from "node:http";
import { register } from "../metrics.js";
// Use the official export when upgrading to socket.io@4.8.0
interface DefaultEventsMap {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[event: string]: (...args: any[]) => void;
}
const WorkloadActionParams = z.object({
runFriendlyId: z.string(),
snapshotFriendlyId: z.string(),
});
type WorkloadServerEvents = {
runConnected: [
{
run: {
friendlyId: string;
};
},
];
runDisconnected: [
{
run: {
friendlyId: string;
};
},
];
};
type WorkloadServerOptions = {
port: number;
host?: string;
workerClient: SupervisorHttpClient;
checkpointClient?: CheckpointClient;
};
export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
private checkpointClient?: CheckpointClient;
private readonly httpServer: HttpServer;
private readonly websocketServer: Namespace<
WorkloadClientToServerEvents,
WorkloadServerToClientEvents,
DefaultEventsMap,
WorkloadClientSocketData
>;
private readonly runSockets = new Map<
string,
Socket<
WorkloadClientToServerEvents,
WorkloadServerToClientEvents,
DefaultEventsMap,
WorkloadClientSocketData
>
>();
private readonly workerClient: SupervisorHttpClient;
constructor(opts: WorkloadServerOptions) {
super();
const host = opts.host ?? "0.0.0.0";
const port = opts.port;
this.workerClient = opts.workerClient;
this.checkpointClient = opts.checkpointClient;
this.httpServer = this.createHttpServer({ host, port });
this.websocketServer = this.createWebsocketServer();
}
private headerValueFromRequest(req: IncomingMessage, headerName: string): string | undefined {
const value = req.headers[headerName];
if (Array.isArray(value)) {
return value[0];
}
return value;
}
private runnerIdFromRequest(req: IncomingMessage): string | undefined {
return this.headerValueFromRequest(req, WORKLOAD_HEADERS.RUNNER_ID);
}
private deploymentIdFromRequest(req: IncomingMessage): string | undefined {
return this.headerValueFromRequest(req, WORKLOAD_HEADERS.DEPLOYMENT_ID);
}
private deploymentVersionFromRequest(req: IncomingMessage): string | undefined {
return this.headerValueFromRequest(req, WORKLOAD_HEADERS.DEPLOYMENT_VERSION);
}
private projectRefFromRequest(req: IncomingMessage): string | undefined {
return this.headerValueFromRequest(req, WORKLOAD_HEADERS.PROJECT_REF);
}
private createHttpServer({ host, port }: { host: string; port: number }) {
return new HttpServer({
port,
host,
metrics: {
register,
expose: false,
},
})
.route("/health", "GET", {
handler: async ({ reply }) => {
reply.text("OK");
},
})
.route(
"/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/attempts/start",
"POST",
{
paramsSchema: WorkloadActionParams,
bodySchema: WorkloadRunAttemptStartRequestBody,
handler: async ({ req, reply, params, body }) => {
const startResponse = await this.workerClient.startRunAttempt(
params.runFriendlyId,
params.snapshotFriendlyId,
body,
this.runnerIdFromRequest(req)
);
if (!startResponse.success) {
console.error("Failed to start run", {
params,
error: startResponse.error,
});
reply.empty(500);
return;
}
reply.json(startResponse.data satisfies WorkloadRunAttemptStartResponseBody);
return;
},
}
)
.route(
"/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/attempts/complete",
"POST",
{
paramsSchema: WorkloadActionParams,
bodySchema: WorkloadRunAttemptCompleteRequestBody,
handler: async ({ req, reply, params, body }) => {
console.log("headers", req.headers);
const completeResponse = await this.workerClient.completeRunAttempt(
params.runFriendlyId,
params.snapshotFriendlyId,
body,
this.runnerIdFromRequest(req)
);
if (!completeResponse.success) {
console.error("Failed to complete run", {
params,
error: completeResponse.error,
});
reply.empty(500);
return;
}
reply.json(completeResponse.data satisfies WorkloadRunAttemptCompleteResponseBody);
return;
},
}
)
.route(
"/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/heartbeat",
"POST",
{
paramsSchema: WorkloadActionParams,
bodySchema: WorkloadHeartbeatRequestBody,
handler: async ({ req, reply, params, body }) => {
const heartbeatResponse = await this.workerClient.heartbeatRun(
params.runFriendlyId,
params.snapshotFriendlyId,
body,
this.runnerIdFromRequest(req)
);
if (!heartbeatResponse.success) {
console.error("Failed to heartbeat run", {
params,
error: heartbeatResponse.error,
});
reply.empty(500);
return;
}
reply.json({
ok: true,
} satisfies WorkloadHeartbeatResponseBody);
},
}
)
.route(
"/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/suspend",
"GET",
{
paramsSchema: WorkloadActionParams,
handler: async ({ reply, params, req }) => {
console.debug("Suspend request", { params, headers: req.headers });
if (!this.checkpointClient) {
reply.json(
{
ok: false,
error: "Checkpoints disabled",
} satisfies WorkloadSuspendRunResponseBody,
false,
400
);
return;
}
const runnerId = this.runnerIdFromRequest(req);
const deploymentVersion = this.deploymentVersionFromRequest(req);
const projectRef = this.projectRefFromRequest(req);
if (!runnerId || !deploymentVersion || !projectRef) {
console.error("Invalid headers for suspend request", {
...params,
headers: req.headers,
});
reply.json(
{
ok: false,
error: "Invalid headers",
} satisfies WorkloadSuspendRunResponseBody,
false,
400
);
return;
}
reply.json(
{
ok: true,
} satisfies WorkloadSuspendRunResponseBody,
false,
202
);
const suspendResult = await this.checkpointClient.suspendRun({
runFriendlyId: params.runFriendlyId,
snapshotFriendlyId: params.snapshotFriendlyId,
body: {
runnerId,
runId: params.runFriendlyId,
snapshotId: params.snapshotFriendlyId,
projectRef,
deploymentVersion,
},
});
if (!suspendResult) {
console.error("Failed to suspend run", { params });
return;
}
},
}
)
.route(
"/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/continue",
"GET",
{
paramsSchema: WorkloadActionParams,
handler: async ({ req, reply, params }) => {
console.debug("Run continuation request", { params });
const continuationResult = await this.workerClient.continueRunExecution(
params.runFriendlyId,
params.snapshotFriendlyId,
this.runnerIdFromRequest(req)
);
if (!continuationResult.success) {
console.error("Failed to continue run execution", { params });
reply.json(
{
ok: false,
error: "Failed to continue run execution",
},
false,
400
);
return;
}
reply.json(continuationResult.data as WorkloadContinueRunExecutionResponseBody);
},
}
)
.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) {
console.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/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)
);
},
})
.route("/api/v1/workload-actions/deployments/:deploymentId/dequeue", "GET", {
paramsSchema: z.object({
deploymentId: z.string(),
}),
handler: async ({ req, reply, params }) => {
const dequeueResponse = await this.workerClient.dequeueFromVersion(
params.deploymentId,
1,
this.runnerIdFromRequest(req)
);
if (!dequeueResponse.success) {
console.error("Failed to get latest snapshot", {
deploymentId: params.deploymentId,
error: dequeueResponse.error,
});
reply.empty(500);
return;
}
reply.json(dequeueResponse.data satisfies WorkloadDequeueFromVersionResponseBody);
},
});
}
private createWebsocketServer() {
const io = new Server(this.httpServer.server);
const websocketServer: Namespace<
WorkloadClientToServerEvents,
WorkloadServerToClientEvents,
DefaultEventsMap,
WorkloadClientSocketData
> = io.of("/workload");
websocketServer.on("disconnect", (socket) => {
console.log("[WorkloadSocket] disconnect", socket.id);
});
websocketServer.use(async (socket, next) => {
function setSocketDataFromHeader(
dataKey: keyof typeof socket.data,
headerName: string,
required: boolean = true
) {
const value = socket.handshake.headers[headerName];
if (value) {
if (Array.isArray(value)) {
if (value[0]) {
socket.data[dataKey] = value[0];
return;
}
} else {
socket.data[dataKey] = value;
return;
}
}
if (required) {
console.error("[WorkloadSocket] missing required header", { headerName });
throw new Error("missing header");
}
}
try {
setSocketDataFromHeader("deploymentId", WORKLOAD_HEADERS.DEPLOYMENT_ID);
setSocketDataFromHeader("runnerId", WORKLOAD_HEADERS.RUNNER_ID);
} catch (error) {
console.error("[WorkloadSocket] setSocketDataFromHeader error", { error });
socket.disconnect(true);
return;
}
console.debug("[WorkloadSocket] auth success", socket.data);
next();
});
websocketServer.on("connection", (socket) => {
const logger = new SimpleStructuredLogger("workload-namespace", undefined, {
namespace: "workload",
socketId: socket.id,
socketData: socket.data,
});
const getSocketMetadata = () => {
return {
deploymentId: socket.data.deploymentId,
runId: socket.data.runFriendlyId,
snapshotId: socket.data.snapshotId,
runnerId: socket.data.runnerId,
};
};
const runConnected = (friendlyId: string) => {
logger.debug("runConnected", { ...getSocketMetadata() });
// If there's already a run ID set, we should "disconnect" it from this socket
if (socket.data.runFriendlyId) {
logger.debug("runConnected: disconnecting existing run", {
...getSocketMetadata(),
newRunId: friendlyId,
oldRunId: socket.data.runFriendlyId,
});
runDisconnected(socket.data.runFriendlyId);
}
this.runSockets.set(friendlyId, socket);
this.emit("runConnected", { run: { friendlyId } });
socket.data.runFriendlyId = friendlyId;
};
const runDisconnected = (friendlyId: string) => {
logger.debug("runDisconnected", { ...getSocketMetadata() });
this.runSockets.delete(friendlyId);
this.emit("runDisconnected", { run: { friendlyId } });
socket.data.runFriendlyId = undefined;
};
logger.log("wsServer socket connected", { ...getSocketMetadata() });
// FIXME: where does this get set?
if (socket.data.runFriendlyId) {
runConnected(socket.data.runFriendlyId);
}
socket.on("disconnecting", (reason, description) => {
logger.log("Socket disconnecting", { ...getSocketMetadata(), reason, description });
if (socket.data.runFriendlyId) {
runDisconnected(socket.data.runFriendlyId);
}
});
socket.on("disconnect", (reason, description) => {
logger.log("Socket disconnected", { ...getSocketMetadata(), reason, description });
});
socket.on("error", (error) => {
logger.error("Socket error", {
...getSocketMetadata(),
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
});
socket.on("run:start", async (message) => {
const log = logger.child({
eventName: "run:start",
...getSocketMetadata(),
...message,
});
log.log("Handling run:start");
try {
runConnected(message.run.friendlyId);
} catch (error) {
log.error("run:start error", { error });
}
});
socket.on("run:stop", async (message) => {
const log = logger.child({
eventName: "run:stop",
...getSocketMetadata(),
...message,
});
log.log("Handling run:stop");
try {
runDisconnected(message.run.friendlyId);
} catch (error) {
log.error("run:stop error", { error });
}
});
});
return websocketServer;
}
notifyRun({ run }: { run: { friendlyId: string } }) {
try {
const runSocket = this.runSockets.get(run.friendlyId);
if (!runSocket) {
console.debug("[WorkloadServer] notifyRun: Run socket not found", { run });
this.workerClient.sendDebugLog(run.friendlyId, {
time: new Date(),
message: "run:notify socket not found on supervisor",
});
return;
}
runSocket.emit("run:notify", { version: "1", run });
console.debug("[WorkloadServer] run:notify sent", { run });
this.workerClient.sendDebugLog(run.friendlyId, {
time: new Date(),
message: "run:notify supervisor -> runner",
});
} catch (error) {
console.error("[WorkloadServer] Error in notifyRun", { run, error });
this.workerClient.sendDebugLog(run.friendlyId, {
time: new Date(),
message: "run:notify error on supervisor",
});
}
}
async start() {
await this.httpServer.start();
}
async stop() {
await this.httpServer.stop();
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../.configs/tsconfig.base.json",
"include": ["src/**/*.ts"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
}
}
+2 -7
View File
@@ -1,17 +1,12 @@
{
"plugins": [
"@trigger.dev/eslint-plugin",
"react-hooks",
"@typescript-eslint/eslint-plugin",
"import"
],
"plugins": ["react-hooks", "@typescript-eslint/eslint-plugin", "import"],
"parser": "@typescript-eslint/parser",
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"rules": {
// Autofixes imports from "@trigger.dev/core" to fine grained modules
"@trigger.dev/no-trigger-core-import": "error",
// "@trigger.dev/no-trigger-core-import": "error",
// Normalize `import type {}` and `import { type }`
"@typescript-eslint/consistent-type-imports": [
"warn",
-15
View File
@@ -1,15 +0,0 @@
import { ApiEventLog } from "@trigger.dev/core";
import { EventRecord } from "@trigger.dev/database";
export function eventRecordToApiJson(eventRecord: EventRecord): ApiEventLog {
return {
id: eventRecord.eventId,
name: eventRecord.name,
payload: eventRecord.payload as any,
context: eventRecord.context as any,
timestamp: eventRecord.timestamp,
deliverAt: eventRecord.deliverAt,
deliveredAt: eventRecord.deliveredAt,
cancelledAt: eventRecord.cancelledAt,
};
}
@@ -0,0 +1,27 @@
import { useAnimate } from "framer-motion";
import { HourglassIcon } from "lucide-react";
import { useEffect } from "react";
export function AnimatedHourglassIcon({
className,
delay,
}: {
className?: string;
delay?: number;
}) {
const [scope, animate] = useAnimate();
useEffect(() => {
animate(
[
[scope.current, { rotate: 0 }, { duration: 0.7 }],
[scope.current, { rotate: 180 }, { duration: 0.3 }],
[scope.current, { rotate: 180 }, { duration: 0.7 }],
[scope.current, { rotate: 360 }, { duration: 0.3 }],
],
{ repeat: Infinity, delay }
);
}, []);
return <HourglassIcon ref={scope} className={className} />;
}
+9 -19
View File
@@ -2,28 +2,18 @@ export function AttemptIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="16"
height="16"
viewBox="0 0 16 16"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_9964_113464)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 0H0V16H16V0ZM7.09906 4.4L4.53906 11.5H6.11906L6.63906 10H9.35906L9.87906 11.5H11.4591L8.89906 4.4H7.09906ZM7.99906 6L8.92906 8.73H7.06906L7.99906 6Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_9964_113464">
<path
d="M0 2C0 0.895431 0.895431 0 2 0H14C15.1046 0 16 0.895431 16 2V14C16 15.1046 15.1046 16 14 16H2C0.895431 16 0 15.1046 0 14V2Z"
fill="white"
/>
</clipPath>
</defs>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5 2C3.34315 2 2 3.34315 2 5V19C2 20.6569 3.34315 22 5 22H19C20.6569 22 22 20.6569 22 19V5C22 3.34315 20.6569 2 19 2H5ZM7.65398 16.4367C7.64829 16.4871 7.64671 16.5374 7.64905 16.5873C7.66648 16.959 7.90169 17.3021 8.27281 17.4389C8.7645 17.6202 9.31042 17.3713 9.49607 16.8813L10.2088 15H13.8788L14.5915 16.8813C14.7772 17.3713 15.3231 17.6202 15.8148 17.4389C16.3096 17.2565 16.5628 16.7075 16.3804 16.2127L13.2478 7.71625C13.1255 7.30217 12.7423 7 12.2886 7H11.8041C11.5337 7 11.2884 7.10733 11.1084 7.28171C11.0015 7.38343 10.9157 7.51053 10.8613 7.65817L7.70722 16.2126C7.67996 16.2865 7.66243 16.3617 7.65398 16.4367ZM12.0438 10.1564L10.9665 13H13.1211L12.0438 10.1564Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,73 @@
export function ConnectedIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="0.5"
y="-0.5"
width="19"
height="1"
rx="0.5"
transform="matrix(1 0 0 -1 1.99998 20)"
stroke="#878C99"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.4187 5L6.00002 5C4.89545 5 4.00002 5.89543 4.00002 7L4.00001 15C4.00002 16.1046 4.89545 17 6.00002 17L18 17C19.1046 17 20 16.1046 20 15L20 7.9816L18.5 9.4816L18.5 15C18.5 15.2761 18.2762 15.5 18 15.5L6.00001 15.5C5.72387 15.5 5.50001 15.2761 5.50001 15L5.50002 7C5.50002 6.72386 5.72387 6.5 6.00002 6.5L12.9187 6.5L14.4187 5Z"
fill="#878C99"
/>
<path
d="M8.50002 9.75L11.5 12.75L18 6"
stroke="#28BF5C"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DisconnectedIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.673 19L2.99998 19C2.44769 19 1.99998 19.4477 1.99998 20C1.99998 20.5523 2.44769 21 2.99998 21L16.673 21L14.673 19Z"
fill="#878C99"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.99999 8.32703L3.99999 15C3.99999 16.1046 4.89542 17 5.99999 17L12.673 17L11.173 15.5L5.99999 15.5C5.72385 15.5 5.49999 15.2761 5.49999 15L5.49999 9.82703L3.99999 8.32703ZM18.5 14.2641L18.5 7C18.5 6.72386 18.2761 6.5 18 6.5L10.7358 6.5L9.23585 5L18 5C19.1046 5 20 5.89543 20 7L20 15C20 15.2292 19.9614 15.4495 19.8904 15.6546L18.5 14.2641Z"
fill="#878C99"
/>
<path d="M3.00001 3L21 21" stroke="#E11D48" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export function CheckingConnectionIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="0.5"
y="-0.5"
width="19"
height="1"
rx="0.5"
transform="matrix(1 0 0 -1 1.99998 20)"
stroke="#878C99"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18 6.5L6.00001 6.5C5.72387 6.5 5.50001 6.72386 5.50001 7L5.50001 15C5.50001 15.2761 5.72387 15.5 6.00001 15.5L18 15.5C18.2762 15.5 18.5 15.2761 18.5 15V7C18.5 6.72386 18.2762 6.5 18 6.5ZM6.00001 5C4.89545 5 4.00001 5.89543 4.00001 7L4.00001 15C4.00001 16.1046 4.89545 17 6.00001 17L18 17C19.1046 17 20 16.1046 20 15V7C20 5.89543 19.1046 5 18 5L6.00001 5Z"
fill="#878C99"
/>
<circle cx="9" cy="11" r="1" fill="#878C99" />
<circle cx="12" cy="11" r="1" fill="#878C99" />
<circle cx="15" cy="11" r="1" fill="#878C99" />
</svg>
);
}
@@ -0,0 +1,20 @@
export function DropdownIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.5 9L12 5L16.5 9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.5 15L12 19L16.5 15"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,153 @@
export function DevEnvironmentIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="3" fill="currentColor" />
<path
d="M8.35714 3H5C3.89543 3 3 3.89543 3 5V8.35714"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21 8.35714L21 5C21 3.89543 20.1046 3 19 3L15.6429 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.6429 21L19 21C20.1046 21 21 20.1046 21 19L21 15.6429"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 15.6429L3 19C3 20.1046 3.89543 21 5 21L8.35714 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DevEnvironmentIconSmall({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="2" fill="currentColor" />
<path
d="M7 3H5C3.89543 3 3 3.89543 3 5V7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M17 7L17 5C17 3.89543 16.1046 3 15 3L13 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 17L15 17C16.1046 17 17 16.1046 17 15L17 13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 13L3 15C3 16.1046 3.89543 17 5 17L7 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function ProdEnvironmentIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.5037 7.33603C12.3174 6.88799 11.6827 6.88799 11.4963 7.33603L10.4338 9.89064L7.6759 10.1117C7.1922 10.1505 6.99606 10.7542 7.36459 11.0698L9.46583 12.8698L8.82387 15.561C8.71128 16.033 9.22477 16.4061 9.63888 16.1532L12 14.711L14.3612 16.1532C14.7753 16.4061 15.2888 16.0331 15.1762 15.561L14.5343 12.8698L16.6355 11.0698C17.004 10.7542 16.8079 10.1505 16.3242 10.1117L13.5663 9.89064L12.5037 7.33603Z"
fill="white"
/>
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function ProdEnvironmentIconSmall({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="3"
y="3"
width="14"
height="14"
rx="3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.4174 6.23514C10.263 5.86384 9.73701 5.86384 9.58258 6.23514L8.70207 8.35213L6.4166 8.53536C6.01575 8.56749 5.85322 9.06773 6.15862 9.32934L7.89991 10.8209L7.36792 13.0512C7.27461 13.4423 7.70014 13.7515 8.04332 13.5419L10 12.3467L11.9567 13.5419C12.2999 13.7515 12.7254 13.4423 12.6321 13.0512L12.1001 10.8209L13.8414 9.32934C14.1468 9.06773 13.9842 8.56749 13.5834 8.53536L11.2979 8.35213L10.4174 6.23514Z"
fill="currentColor"
/>
</svg>
);
}
export function DeployedEnvironmentIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="3" fill="currentColor" />
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DeployedEnvironmentIconSmall({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="2" fill="currentColor" />
<rect
x="3"
y="3"
width="14"
height="14"
rx="3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,21 @@
export function FunctionIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="2.5"
y="2.5"
width="19"
height="19"
rx="2.5"
stroke="currentColor"
strokeOpacity={0.5}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.9987 7.99477C13.8955 7.99477 13.0012 8.88907 13.0012 9.99225V10.6649H14.3363H15.3375V12.6675H14.3363H13.0012V14.0084C13.0012 16.2171 11.2107 18.0077 9.0019 18.0077H8.00061V16.0051H9.0019C10.1047 16.0051 10.9986 15.1111 10.9986 14.0084V12.6675H9.66357H8.66228V10.6649H9.66357H10.9986V9.99225C10.9986 7.78308 12.7895 5.99219 14.9987 5.99219H16V7.99477H14.9987Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,21 @@
export function MiddlewareIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="2.5"
y="2.5"
width="19"
height="19"
rx="2.5"
stroke="currentColor"
strokeOpacity={0.5}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.72775 14.8717V11.1087C8.72775 10.4761 9.24051 9.96338 9.87303 9.96338C10.5056 9.96338 11.0183 10.4761 11.0183 11.1087V15.5262V16.5079H12.9817V15.5262V11.1087C12.9817 10.4761 13.4944 9.96334 14.1269 9.96334C14.7594 9.96334 15.2722 10.4761 15.2722 11.1086V15.5262V16.5079H17.2356V15.5262V11.1086C17.2356 9.39177 15.8438 8 14.1269 8C13.3041 8 12.556 8.31966 12 8.84155C11.4439 8.31968 10.6958 8.00004 9.87303 8.00004C9.46858 8.00004 9.08217 8.07728 8.72775 8.21781V8.00004H6.7644V8.98171V11.1087V14.8717V15.5262V15.8534V16.5079H8.72775V15.8534V15.5262V14.8717Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,19 @@
export function PauseIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM8 8.25C8 7.55964 8.55964 7 9.25 7C9.94036 7 10.5 7.55964 10.5 8.25V15.75C10.5 16.4404 9.94036 17 9.25 17C8.55964 17 8 16.4404 8 15.75V8.25ZM14.75 7C14.0596 7 13.5 7.55964 13.5 8.25V15.75C13.5 16.4404 14.0596 17 14.75 17C15.4404 17 16 16.4404 16 15.75V8.25C16 7.55964 15.4404 7 14.75 7Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,24 @@
export function PromoteIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.5 15.75H5.25493C4.4265 15.75 3.75493 16.4216 3.75493 17.25V19.741C3.75493 20.5695 4.4265 21.241 5.25493 21.241H18.7582C19.5866 21.241 20.2582 20.5695 20.2582 19.741V17.25C20.2582 16.4216 19.5866 15.75 18.7582 15.75H14.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M9.5 7.75H5.25493C4.4265 7.75 3.75493 8.42157 3.75493 9.25V11.741C3.75493 12.5695 4.4265 13.241 5.25493 13.241H9.5M14.5 7.75002H18.7582C19.5866 7.75002 20.2582 8.42159 20.2582 9.25002V11.741C20.2582 12.5695 19.5866 13.241 18.7582 13.241H14.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path d="M12 18L12 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path
d="M9 5.22892L12 2.75L15 5.22892"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,21 @@
export function PythonLogoIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_16722_101857)">
<path
d="M11.9281 2C6.85063 2 7.1675 4.20188 7.1675 4.20188L7.17375 6.48313H12.0187V7.1675H5.24813C5.24813 7.1675 2 6.79875 2 11.9225C2 17.045 4.83563 16.8638 4.83563 16.8638H6.52813V14.4863C6.52813 14.4863 6.43687 11.6506 9.31875 11.6506H14.1237C14.1237 11.6506 16.8237 11.6944 16.8237 9.04125V4.65438C16.8237 4.65438 17.2338 2 11.9281 2ZM9.25625 3.53375C9.73813 3.53375 10.1281 3.92375 10.1281 4.40563C10.1281 4.8875 9.73813 5.2775 9.25625 5.2775C9.14171 5.27766 9.02826 5.25523 8.9224 5.21147C8.81655 5.16771 8.72037 5.10349 8.63937 5.0225C8.55838 4.94151 8.49417 4.84533 8.45041 4.73947C8.40665 4.63362 8.38421 4.52017 8.38438 4.40563C8.38438 3.92375 8.77438 3.53375 9.25625 3.53375Z"
fill="#387EB8"
/>
<path
d="M12.0719 21.9839C17.1494 21.9839 16.8325 19.782 16.8325 19.782L16.8263 17.5008H11.9813V16.8164H18.7513C18.7513 16.8164 22 17.1852 22 12.062C22 6.93892 19.1644 7.12079 19.1644 7.12079H17.4719V9.49767C17.4719 9.49767 17.5631 12.3333 14.6813 12.3333H9.87626C9.87626 12.3333 7.17626 12.2895 7.17626 14.9427V19.3295C7.17626 19.3295 6.76626 21.9839 12.0719 21.9839ZM14.7438 20.4502C14.6292 20.4503 14.5158 20.4279 14.4099 20.3841C14.3041 20.3404 14.2079 20.2762 14.1269 20.1952C14.0459 20.1142 13.9817 20.018 13.9379 19.9121C13.8942 19.8063 13.8717 19.6928 13.8719 19.5783C13.8719 19.097 14.2619 18.707 14.7438 18.707C15.2256 18.707 15.6156 19.0964 15.6156 19.5783C15.6156 20.0608 15.2256 20.4502 14.7438 20.4502Z"
fill="#F4D441"
/>
</g>
<defs>
<clipPath id="clip0_16722_101857">
<rect width="20" height="20" fill="white" transform="translate(2 2)" />
</clipPath>
</defs>
</svg>
);
}
@@ -0,0 +1,21 @@
export function RunFunctionIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="2.5"
y="2.5"
width="19"
height="19"
rx="2.5"
stroke="currentColor"
strokeOpacity={0.5}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 8H10V12V16.5159H12V12C12 10.8954 12.8954 10 14 10H15.5V8H14C13.2714 8 12.5883 8.19479 12 8.53513V8Z"
fill="currentColor"
/>
</svg>
);
}
+44 -6
View File
@@ -2,15 +2,53 @@ export function RunsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.2964 3.66962C11.97 3.83662 11.6961 4.09048 11.5047 4.40323C11.3134 4.71598 11.2121 5.07547 11.212 5.44211L11.211 8.0162L9.22047 8.0172C8.95637 8.0172 8.70309 8.12211 8.51635 8.30886C8.3296 8.4956 8.22469 8.74888 8.22469 9.01298V14.9877L8.23166 15.1042C8.26019 15.3464 8.37664 15.5697 8.5589 15.7318C8.74116 15.8939 8.97657 15.9834 9.22047 15.9834L11.211 15.9825L11.212 18.5585C11.2121 18.9524 11.329 19.3373 11.5478 19.6648C11.7666 19.9922 12.0777 20.2474 12.4415 20.3981C12.8054 20.5488 13.2057 20.5882 13.592 20.5114C13.9783 20.4346 14.3331 20.245 14.6116 19.9666L21.1698 13.4084C21.5432 13.0349 21.7529 12.5284 21.7529 12.0003C21.7529 11.4722 21.5432 10.9658 21.1698 10.5923L14.6116 4.03408C14.3331 3.75539 13.9782 3.56558 13.5918 3.48865C13.2054 3.41172 12.8048 3.45114 12.4408 3.60191L12.2964 3.66962Z"
fillRule="evenodd"
clipRule="evenodd"
d="M21.4075 8.80505C21.9499 8.40538 21.9499 7.59459 21.4075 7.19493L14.5932 2.17391C13.9329 1.68741 13 2.15883 13 2.97897V5.99999L4.5 5.99999C4.22386 5.99999 4 6.22385 4 6.49999L4 9.49999C4 9.77613 4.22386 9.99999 4.5 9.99999L13 9.99999V13.021C13 13.8411 13.9329 14.3126 14.5932 13.8261L21.4075 8.80505Z"
fill="currentColor"
/>
<path
d="M3.24578 8.0172C3.48968 8.01723 3.72509 8.10677 3.90735 8.26885C4.08961 8.43092 4.20606 8.65425 4.23459 8.89647L4.24156 9.01298V14.9877C4.24128 15.2415 4.1441 15.4856 3.96986 15.6701C3.79563 15.8547 3.55751 15.9658 3.30414 15.9806C3.05077 15.9955 2.80128 15.9131 2.60666 15.7502C2.41203 15.5873 2.28695 15.3562 2.25697 15.1042L2.25 14.9877V9.01298C2.25 8.74888 2.35491 8.4956 2.54166 8.30885C2.7284 8.12211 2.98168 8.0172 3.24578 8.0172Z"
fill="currentColor"
/>
<path
d="M6.23312 8.0172C6.47702 8.01723 6.71243 8.10677 6.89469 8.26885C7.07696 8.43092 7.1934 8.65425 7.22193 8.89647L7.2289 9.01298V14.9877C7.22862 15.2415 7.13144 15.4856 6.95721 15.6701C6.78298 15.8547 6.54485 15.9658 6.29148 15.9806C6.03811 15.9955 5.78863 15.9131 5.594 15.7502C5.39937 15.5873 5.27429 15.3562 5.24431 15.1042L5.23734 14.9877V9.01298C5.23734 8.74888 5.34226 8.4956 5.529 8.30885C5.71575 8.12211 5.96903 8.0172 6.23312 8.0172Z"
fillRule="evenodd"
clipRule="evenodd"
d="M12.2494 17.5898C12.6316 17.2895 12.6316 16.7106 12.2494 16.4103L7.21336 12.4534C6.72127 12.0668 6 12.4173 6 13.0431L6 15.5L2.5 15.5C2.22386 15.5 2 15.7239 2 16V18C2 18.2762 2.22386 18.5 2.5 18.5L6 18.5L6 20.9569C6 21.5828 6.72127 21.9333 7.21336 21.5467L12.2494 17.5898Z"
fill="currentColor"
/>
</svg>
);
}
export function RunsIconSmall({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.6527 7.18006C18.145 6.86034 18.145 6.1396 17.6527 5.81987L12.2525 2.31329C11.713 1.96299 11 2.35016 11 2.99339L11 4.99998L4.40545 4.99998C4.18153 4.99998 4 5.1815 4 5.40543V7.59453C4 7.81845 4.18153 7.99998 4.40545 7.99998L11 7.99998L11 10.0065C11 10.6498 11.713 11.037 12.2525 10.6866L17.6527 7.18006Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.4064 14.4748C10.7107 14.2314 10.7107 13.7685 10.4064 13.525L6.98808 10.7904C6.58987 10.4718 5.99998 10.7554 5.99998 11.2653L5.99998 13L2.40539 13C2.18147 13 1.99994 13.1815 1.99994 13.4054V14.5945C1.99994 14.8185 2.18147 15 2.40539 15L5.99998 15L5.99999 16.7346C5.99999 17.2445 6.58987 17.528 6.98808 17.2095L10.4064 14.4748Z"
fill="currentColor"
/>
</svg>
);
}
export function RunsIconExtraSmall({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.6332 6.60006C16.0524 6.30985 16.0525 5.69018 15.6333 5.39997L11.1452 2.29277C10.6612 1.95767 9.99998 2.3041 9.99998 2.89281L9.99998 4.5L4.3649 4.5C4.16337 4.5 4 4.66337 4 4.8649V7.13509C4 7.33663 4.16337 7.5 4.3649 7.5L9.99998 7.5V9.10703C9.99998 9.69574 10.6612 10.0422 11.1452 9.70708L15.6332 6.60006Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.46572 12.9274C9.73962 12.7083 9.73961 12.2917 9.46572 12.0726L5.88926 9.21142C5.53087 8.92471 4.99998 9.17988 4.99998 9.63884V11.25L2.3649 11.25C2.16337 11.25 2 11.4134 2 11.6149V13.3851C2 13.5866 2.16337 13.75 2.3649 13.75L4.99998 13.75V15.3612C4.99998 15.8202 5.53088 16.0754 5.88927 15.7887L9.46572 12.9274Z"
fill="currentColor"
/>
</svg>
@@ -0,0 +1,9 @@
import { cn } from "~/utils/cn";
export function StatusIcon({ className }: { className?: string }) {
return (
<div className={cn("grid place-items-center", className)}>
<div className="size-[75%] rounded-full border-2 border-text-dimmed" />
</div>
);
}
@@ -0,0 +1,49 @@
export function TaskCachedIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_15584_76102)">
<path
d="M0.5 3.5L0.5 2.5C0.5 1.39543 1.39543 0.5 2.5 0.5H3.5"
stroke="#3B82F6"
strokeLinecap="square"
strokeLinejoin="round"
/>
<path
d="M15.5 12.5L15.5 13.5C15.5 14.6046 14.6046 15.5 13.5 15.5L12.5 15.5"
stroke="#3B82F6"
strokeLinecap="square"
strokeLinejoin="round"
/>
<path
d="M12.5 0.5L13.5 0.5C14.6046 0.5 15.5 1.39543 15.5 2.5L15.5 3.5"
stroke="#3B82F6"
strokeLinecap="square"
strokeLinejoin="round"
/>
<path
d="M3.5 15.5L2.5 15.5C1.39543 15.5 0.5 14.6046 0.5 13.5L0.5 12.5"
stroke="#3B82F6"
strokeLinecap="square"
strokeLinejoin="round"
/>
<path d="M11.1799 4.19V5.598H8.8479V12H7.1649V5.598H4.8219V4.19H11.1799Z" fill="#3B82F6" />
<line x1="6" y1="15.5" x2="10" y2="15.5" stroke="#3B82F6" />
<line x1="6" y1="0.5" x2="10" y2="0.5" stroke="#3B82F6" />
<line x1="15.5" y1="6" x2="15.5" y2="10" stroke="#3B82F6" />
<line x1="0.5" y1="6" x2="0.5" y2="10" stroke="#3B82F6" />
</g>
<defs>
<clipPath id="clip0_15584_76102">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
}
+20 -24
View File
@@ -1,29 +1,25 @@
export function TaskIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_9221_99732)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 0H0V16H16V0ZM10.8901 5.73995V4.44995H5.11011V5.73995H7.23011V11.55H8.77011V5.73995H10.8901Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_9221_99732">
<path
d="M0 2C0 0.895431 0.895431 0 2 0H14C15.1046 0 16 0.895431 16 2V14C16 15.1046 15.1046 16 14 16H2C0.895431 16 0 15.1046 0 14V2Z"
fill="white"
/>
</clipPath>
</defs>
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 5C2 3.34315 3.34315 2 5 2H19C20.6569 2 22 3.34315 22 5V19C22 20.6569 20.6569 22 19 22H5C3.34315 22 2 20.6569 2 19V5ZM7.5035 8C7.5035 8.55228 7.95122 9 8.5035 9H11V16.4954C11 17.0476 11.4477 17.4954 12 17.4954C12.5523 17.4954 13 17.0476 13 16.4954V9H15.4965C16.0488 9 16.4965 8.55228 16.4965 8C16.4965 7.44772 16.0488 7 15.4965 7H8.5035C7.95122 7 7.5035 7.44772 7.5035 8Z"
fill="currentColor"
/>
</svg>
);
}
export function TaskIconSmall({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 4C2 2.89543 2.89543 2 4 2H16C17.1046 2 18 2.89543 18 4V16C18 17.1046 17.1046 18 16 18H4C2.89543 18 2 17.1046 2 16V4ZM6 7C6 7.55228 6.44772 8 7 8H9L9 14C9 14.5523 9.44772 15 10 15C10.5523 15 11 14.5523 11 14V8H13C13.5523 8 14 7.55228 14 7C14 6.44772 13.5523 6 13 6H10H7C6.44772 6 6 6.44772 6 7Z"
fill="currentColor"
/>
</svg>
);
}

Some files were not shown because too many files have changed in this diff Show More