Compare commits

...

89 Commits

Author SHA1 Message Date
nicktrn b53a575464 Release 3.0.0-beta.46 2024-07-04 14:09:46 +01:00
github-actions[bot] ad68a3cc05 chore: Update version for release (beta) (#1199)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-07-04 14:07:42 +01:00
nicktrn be58638904 Documentation edits made through Mintlify web editor 2024-07-04 13:34:30 +01:00
nicktrn 8f43aecacc Handle redis errors more gracefully 2024-07-04 11:43:11 +01:00
nicktrn 6fd7560d5c Add core timers export 2024-07-03 19:14:06 +01:00
nicktrn eb3b7b6f9e Fix logger import 2024-07-03 17:15:16 +01:00
nicktrn 7aed154854 Update pnpm setup action 2024-07-03 17:06:57 +01:00
Matt Aitken 50506dce9f Documentation edits made through Mintlify web editor 2024-07-03 16:58:57 +01:00
nicktrn d5772e57bd Remove rogue zod error 2024-07-03 16:57:13 +01:00
nicktrn 14c2bdf89b v3: checkpoint and reliability improvements (#1198)
* only checkpoint retries with delays greater than threshold

* rename checkpoint threshold env var

* log task monitor ignores

* crash runs with unbounded attempts

* fix retry check in shared queue consumer

* add missing stop for env var sync spinner

* prod entry point refactor

* missing awaits

* more verbose prod flush and exit logs

* reduce checkpoint support logs

* heartbeat while checkpointing between retries

* dynamic coordinator config

* measure lazy attempt creation time in prod

* simplify delay threshold

* heartbeat clarifications

* crash run if it doesn't reach checkpointable state

* require dynamic config threshold

* fix retry prep, await previous worker kill

* unify wait mechanics

* fix prod worker without tasks error

* ensure worker is ready to be checkpointed for dependency waits

* improve worker attempt creation logging

* prevent crashes caused by failed socket schema parsing

* fix dynamic imports in v3 catalog

* clarify attempt retry mechanics

* move backoff helper to core-apps

* remove core-apps barrel file

* add backoff execute with callback

* deprecate non-lazy attempt messages

* update socket.io-client to v4.7.5

* fix socket.io types for emits with timeout

* retry all the things

* remove todo

* fix retry restores

* improve index failure logs

* retry incomplete dependency waits

* fix checkpoint in-progress detection

* prevent losing messages during reconnect

* checkpoint when greater or equal to threshold

* improve handling of duration wait edge cases

* add ready for lazy attempt replay

* retry attempt completion

* allow failing runs with unfriendly run id

* fix min max jitter

* cancel checkpoints on run failure

* improve attempt creation errors

* prevent crashing run on failed cleanup

* handle at-least-once execute lazy attempt delivery

* log exit code on prepare for retry

* fix timeout promise

* mark some things

* chaos monkey superpowers

* refactor checkpointer

* set chaos monkey defaults

* less chaos

* fix backoff

* handle uncaught entry point exceptions

* only replay rpcs on true reconnects

* allow resume unless final run status

* add changeset

* small fixes
2024-07-03 16:54:09 +01:00
Matt Aitken 7976d924fb Marqs rebalancing is based on the env var again 2024-07-03 09:54:50 +01:00
nicktrn 1e667ec28f v3: cluster uptime heartbeat (#1194)
* add basic uptime heartbeat

* add more heartbeat metrics
2024-07-02 12:33:12 +01:00
Eric Allam 7e97dcb93e Release 3.0.0-beta.45 2024-07-01 17:17:11 +01:00
github-actions[bot] b9b86c89a7 chore: Update version for release (beta) (#1188)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-07-01 17:16:11 +01:00
nicktrn cd5d2ae92b Override completion on OOM crashes 2024-07-01 13:22:35 +01:00
Eric Allam 0e77e7ef7d v3: Delayed runs and run ttl expiration (#1193)
* v3: Trigger delayed runs and reschedule them

* Create a `@trigger.dev/core/v3/schemas` export

* fixed the `@trigger.dev/core/v3/schemas` export

* Small docs tweak

* Add ttl option when triggering tasks, expire runs after ttl

Dev runs expire in 10m by default
2024-07-01 12:01:35 +01:00
Kai 76a5c6204f fix: prevent filter tooltip from opening on autofocus (#1185)
* Prevent tooltip from opening on autofocus

* Remove autofocus

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-07-01 10:50:07 +01:00
nicktrn b171fde483 Fix index failure completion override 2024-06-29 19:23:32 +01:00
nicktrn 5ae3da6b4e v3: various small fixes (#1192)
* Await file watcher cleanup in dev

* Fix artifact detection logs

* Fix next runs table when schedule disabled

* Improve OOM error messages

* Add test link to completed deployment message

* Fix OOM detection, again

* Add changeset
2024-06-29 16:48:01 +01:00
Eric Allam f565829959 v3: Copy over more of the project's package.json keys into the deployed package.json (support for custom config like zenstack) 2024-06-28 20:13:54 +01:00
Eric Allam d57dec6919 Remove e2e tests from CI for now 2024-06-28 16:17:58 +01:00
Eric Allam 75ec4ac6a6 v3: postInstall config option now replaces the postinstall script found in package.json 2024-06-28 15:59:41 +01:00
nicktrn 374b6b9c0c Increase dev worker timeout 2024-06-28 15:56:51 +01:00
nicktrn d0d3a64bd6 v3: misc CLI improvements (#1173)
* prevent downgrades during update check

* detect bun and use npm instead

* detect missing tsconfig during init and print helpful error

* add changeset

* add links to dev worker started message

* allow users to add custom pkg manager args during init

* update changeset

* fix links in unsupported terminals

* deprecate terminalLink

* update changeset
2024-06-28 15:51:17 +01:00
nicktrn 568da01785 v3: improve non-zero exit errors (#1179)
* docker provider will optionally enforce machine presets

* update task monitor oom message

* add oom task to v3 catalog

* improve handling of non-zero exit errors

* add changeset
2024-06-28 15:50:04 +01:00
nicktrn c75e29a9a7 v3: add audio binaries to worker images (#1183)
* add sox

* add audiowaveform

* add changeset
2024-06-28 15:49:24 +01:00
Émile Ré e5d26bd12d Fix infisical-sdk fixture, use in config instead (#1190) 2024-06-28 09:50:24 +01:00
Eric Allam b6f31ab651 Fixed lock file of "Update docs theme (#1189)"
This reverts commit 4cc61ac0ec.
2024-06-27 20:39:39 +01:00
Eric Allam 50d46a8513 Fixed lock file of "Update docs theme (#1189)"
This reverts commit 4cc61ac0ec.
2024-06-27 20:39:24 +01:00
Eric Allam 4cc61ac0ec Revert "Update docs theme (#1189)"
This reverts commit a696359c3e.
2024-06-27 20:31:25 +01:00
James Ritchie a696359c3e Update docs theme (#1189)
* Changed theme to venus and updated primary colours

* Replaced ‘v3 (Developer Preview)’ with ‘v3’

* Removed webhooks and zod from tasks overview and intro

* pnpm lock

* Changed the theme and background + removed the tabs

---------

Co-authored-by: D-K-P <8297864+D-K-P@users.noreply.github.com>
2024-06-27 18:31:47 +01:00
Émile Ré 52b6f48a94 Cli v3 e2e: fixtures (#1184)
* Run tests

* Remove useless comments

* Fix readme

* Add fixtures for config step

* Add fixture for additionalDependencies name parsing scope issue

* Add URL to commit fix

* Some fixtures will only use 1 package manager

* Add resolve-trigger-deps fixture

* use rimraf in test setup

* Add legacy-peer-deps fixture

* Use vitest fixtures

* Add nested peer dep locking issue

* Add missing peer dep resolving fixture

* Get rid of global namespace, fix parallelism

* Add console logs to debug log level

* Add changeset

* Remove comments

* Update README.md
2024-06-27 13:55:54 +01:00
Eric Allam d22a460555 Release 3.0.0-beta.44 2024-06-27 13:41:01 +01:00
github-actions[bot] 9ba2a217a4 chore: Update version for release (beta) (#1187)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-27 13:38:57 +01:00
Eric Allam 39885a427f v3: fix missing init output in task run function when no middleware is defined 2024-06-27 11:49:28 +01:00
Eric Allam ccb0bc510a Release 3.0.0-beta.43 2024-06-27 11:09:27 +01:00
github-actions[bot] 56d66ee07c chore: Update version for release (beta) (#1182)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-27 11:08:30 +01:00
Eric Allam 4ca8887972 v3: fix issue where in progress events weren’t being shown 2024-06-26 16:15:44 +01:00
Eric Allam 89bffc066c Remove the [traceId, startTime] index on TaskEvent for now (not necessary) 2024-06-26 15:32:43 +01:00
Eric Allam 34ca7667d3 Various perf improvements to prevent event loop lag (#1186)
* WIP

* Handle tasks that have failed but are being auto yielded

* Limit trace view to 25k event records, add a download run logs button

Also added two new indexes to TaskEvent:

```
/// Used on eventRepository.getTraceSummary()
  @@index([traceId, startTime])
  // Used for getting all logs for a run
  @@index([runId])
```

* perf improvements on eventRepository.getSpan()

* v2: Add a 5 minute timeout for run execution requests in dev

* v3: Include presigned urls for downloading large payloads and outputs when using runs.retrieve

* v3: better handle large task payloads and outputs

* Change to 512KB

* v2: paginate trigger schedules endpoint

* v3: add 3MB limit on batch and single payloads

* Update task payload and output limits
2024-06-26 15:22:35 +01:00
nicktrn 3e327acc0f Catch zod message sender exceptions 2024-06-26 15:20:33 +01:00
Eric Allam 77ad4127cb v3: Improved ESM module require error detection logic 2024-06-25 09:08:43 +01:00
Eric Allam 8a5076aacf Release 3.0.0-beta.42 2024-06-24 19:52:22 +01:00
github-actions[bot] 5399f6bfb7 chore: Update version for release (beta) (#1181)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-24 19:51:48 +01:00
Eric Allam ecef199660 v3: Use global setTimeout to ensure cross-runtime support 2024-06-24 19:49:22 +01:00
Eric Allam 4acfb8f4bb Release 3.0.0-beta.41 2024-06-24 17:30:01 +01:00
github-actions[bot] 2ef278db67 chore: Update version for release (beta) (#1178)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-24 17:29:32 +01:00
Eric Allam c7a55804d9 v3: fix jsonc-parser import in cli
Lock jsonc-parser to 3.2.1

Revert that change
2024-06-24 17:24:23 +01:00
Eric Allam da6a66efff v3: fix task queues with concurrency = 0 getting cleared on task trigger 2024-06-24 16:37:30 +01:00
Eric Allam 7c36a1a4b0 v3: Adding SDK functions for triggering tasks in a typesafe way (#1177)
* v3: Adding SDK functions for triggering tasks in a typesafe way, without importing task file

* Add type usages
2024-06-24 12:37:34 +01:00
Eric Allam 225effb599 Add task endpoints to the endpoint authentication table 2024-06-21 15:11:50 +01:00
Eric Allam 98eb6ed4f9 Add trigger and batch trigger API docs 2024-06-21 15:08:06 +01:00
Eric Allam 3069ebf0d8 Release 3.0.0-beta.40 2024-06-21 14:29:04 +01:00
github-actions[bot] e133e628ca chore: Update version for release (beta) (#1170)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-21 14:28:25 +01:00
Eric Allam 098932ea96 v3: edge runtime support (#1172)
* v3: remove node:stream and simplify env var upload API to better work with non-node runtimes

* Remove file/Response envvars upload docs

* Add changeset
2024-06-21 13:33:38 +01:00
Eric Allam 65f960e883 Move the max live reload setting into an env var instead of hardcoding it 2024-06-21 12:55:33 +01:00
Eric Allam ccbeff47e6 Improve trace view performance 2024-06-21 12:46:46 +01:00
Matt Aitken 7c8f2df105 After 500 logs we stop live reloading on the run page 2024-06-21 11:26:02 +01:00
Eric Allam fd44dabfe0 Disable the event loop monitor 2024-06-20 23:56:29 +01:00
Eric Allam 5daed3f69d Turn off rebalancing 2024-06-20 23:42:54 +01:00
Eric Allam 596bf78e55 Add an event loop monitor 2024-06-20 23:26:38 +01:00
Eric Allam 6ca66b76f4 Remove unnecessary transaction from the runtime environment session handling 2024-06-20 22:47:12 +01:00
nicktrn 29ef0395ce Only import types from db in task status 2024-06-20 15:25:18 +01:00
nicktrn 55d1f8c677 v3: fix dependency checkpoint race (#1171)
* consolidate task statuses and utils

* check dependency completion when creating checkpoints

* add changeset
2024-06-20 10:10:39 +01:00
Eric Allam 9835f4ec55 Adding changeset 2024-06-19 15:11:50 +01:00
Eric Allam 7fae10db23 v3: make sure force flush timeout is set so flushing doesn’t hang forever 2024-06-19 14:56:10 +01:00
Eric Allam 8cf1f0a37d v3: don’t set a default forceFlushTimeoutMillis 2024-06-19 14:29:50 +01:00
Eric Allam dba4313c5c v3: more visibility into flushing worker otel data in prod 2024-06-19 14:22:06 +01:00
Eric Allam 506613dc92 v3: prod workers should always log everything from forked node process 2024-06-19 14:05:30 +01:00
Eric Allam 764df23d19 Release 3.0.0-beta.39 2024-06-19 12:22:19 +01:00
github-actions[bot] 4b961a6ae2 chore: Update version for release (beta) (#1169)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-19 12:21:30 +01:00
Eric Allam 8757fdceef v3: [prod] force flush timeout should be 1s 2024-06-19 12:16:11 +01:00
Eric Allam 2404e88ac5 Add a IMPORTANT note to the ProdTaskRunExecution 2024-06-19 11:16:40 +01:00
Eric Allam 88b36f5090 Add a default on machine preset 2024-06-19 11:07:57 +01:00
Eric Allam b73ae3f927 Release 3.0.0-beta.38 2024-06-19 10:40:29 +01:00
github-actions[bot] b605b892ac chore: Update version for release (beta) (#1166)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-19 10:39:24 +01:00
Eric Allam 233316f7e8 TaskRun.startedAt will now be lazily migrated 2024-06-18 15:49:14 +01:00
Eric Allam 1b90ffbb8c Add a changeset for the usage tracking PR (I forgot) 2024-06-18 14:56:25 +01:00
Matt Aitken b45ca4e146 If the timezone has changed then we need to reschedule the next scheduled run 2024-06-18 14:49:16 +01:00
Eric Allam 25d15578f7 Remove the update migration 2024-06-18 13:51:29 +01:00
Eric Allam fe865a0f49 Make startedAt backwards compat by giving it a default 2024-06-18 13:16:36 +01:00
Eric Allam 0ed93a748e v3: Remove aggressive otel flush timeouts in dev/prod 2024-06-18 11:48:35 +01:00
Michael Li e02320f65d fix: allow command login to read api url from cli args (#1168)
* fix: allow login to read api url from args

* Create strong-phones-smoke.md

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2024-06-18 09:43:42 +01:00
Eric Allam 85a543d8ec v3: usage tracking (#1163)
* Starting to measure wall time and cpu time in the workers, and reporting that via otel and to completed task run attempts

* Move usage tracking outside of the executor

* WIP prod usage tracking

* WIP

* WIP custom fetch to openmeter

* Create a usage client

* WIP

* WIP

* Implement new machine preset stuff and send usage reports to OpenMeter from webapp

* WIP

* Expose usage info to the client

* Add usage and cost to TaskEvent

* Add ability to globally configure the task machine preset

* Report start run usage

* Change the machine docs to use presets

* setExpirationTime to 24h

* Removed logs

* Update machines.mdx

* Removed console.logs

* Handle revalidating JWT tokens

* Couple tweaks

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-06-18 09:40:23 +01:00
Ryan Lee 10ceb85a92 fix: cloudfront timeouts not being caught (#1162) 2024-06-14 15:11:39 +01:00
Matt Aitken c405ae7117 Schedule limits and timezone support (#1165)
* Added maximumScheduleInstancesLimit column to Org, default to 20

* Docs on the schedule limits and improved soft-limit communication

* Added limit info to the schedules list page

* Created a task that creates schedules, useful for testing

* Make deduplicationKey required when creating/updating a schedule using the SDK

* New schedule button shows an alert if you’re over the limit

* Added timezone to the form and db

* WIP on the timezone dropdown for the create/edit schedule form

* Use the new filter search for timezones

* Made the timezone dropdown faster by fixing the virtualization

* The preview table is working and added a nice message about daylight savings

* Created a page where you can view the full list of timezones

The URL is included in the error message if you send an invalid time using the SDK

* Creating tasks with the timezone

* Added timezone support the the scheduler and the schedules list

* Added timezone support to more of the schedules UI

* The timezone comes through to scheduled runs with nice JSDocs

* Allow setting the timezone from the SDK

* Always have a timezone on a schedule

* Updated jsdocs

* Updated catalog example

* Changed the column to be a string, not null. Added the timezone across the SDK

* API endpoint for getting the timezones

* Added an SDK function to get the list of timezones

* Added timezones to the docs

* Changeset: Added timezone support to schedules

* Added support for testing timezone

* Tidied up imports

* Imports

* Imports

* Update limits.mdx

* Fixed a couple type issues and use the already exported zodfetch

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2024-06-14 13:31:27 +01:00
nicktrn 3687fcb61e Make pod cleaner interval configurable 2024-06-14 12:12:42 +01:00
Émile Ré d4ccdf7105 v3 CLI compiling E2E test suite (#1135)
* Boilerplate server-only use case

* wip: integration suite instrumentation setup

* Working poc testing compileProject

* Add pnpm script to run e2e tests only

* Use vitest globals

* Remove commented line

* Remove useless export

* Add modifier to test only one fixture project

* Handle package manager and log level choice

* Update server-only example

* Setup / teardown + split compile for package manager capabilities

* Ignore yarn files

* Fix issue with corepack, store version in engines field

* Rename test file

* Fix npm updates yarn.lock

* Move typecheking in a dedicated test

* Stop bundling the compile command to allow for more granular testing

* Put config resolving in separate test

* Add no-config test case and add test case expected errors configuration

* Add wantCompilationError option

* Add dependencies handling

* Use packageManager passed as option to resolve required deps

* Remove unused guard clauses

* Add postinstall & hash handling step

* Add worker start test

* Handle yarn.lock copy renaming on sigterm and sigkill

* Update vitest and use concurrent option

* Add a readme file

* Add CI workflow

* Fix handle cli deps

* Run cli v3 e2e tests on publish action

* Increase timeout on deps resolving step

* Add changeset

* Remove .pnp.cjs as we use yarn with nodeLinker node-modules

* Add missing .yarnrc.yml file

* No need to build CLI to run E2E tests

* Remove bun.lockb files

* Update beige-pears-explode.md

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2024-06-14 11:28:36 +01:00
nicktrn e08b4569e5 Improve checkpoint restore logging 2024-06-13 10:18:41 +01:00
419 changed files with 246138 additions and 3462 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Add an e2e suite to test compiling with v3 CLI.
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Increase dev worker timeout
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Add sox and audiowaveform binaries to worker images
+7
View File
@@ -0,0 +1,7 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
---
v3: Usage tracking
+30
View File
@@ -0,0 +1,30 @@
---
"@trigger.dev/core-apps": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Tasks should now be much more robust and resilient to reconnects during crucial operations and other failure scenarios.
Task runs now have to signal checkpointable state prior to ALL checkpoints. This ensures flushing always happens.
All important socket.io RPCs will now be retried with backoff. Actions relying on checkpoints will be replayed if we haven't been checkpointed and restored as expected, e.g. after reconnect.
Other changes:
- Fix retry check in shared queue
- Fix env var sync spinner
- Heartbeat between retries
- Fix retry prep
- Fix prod worker no tasks detection
- Fail runs above `MAX_TASK_RUN_ATTEMPTS`
- Additional debug logs in all places
- Prevent crashes due to failed socket schema parsing
- Remove core-apps barrel
- Upgrade socket.io-client to fix an ACK memleak
- Additional index failure logs
- Prevent message loss during reconnect
- Prevent burst of heartbeats on reconnect
- Prevent crash on failed cleanup
- Handle at-least-once lazy execute message delivery
- Handle uncaught entry point exceptions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
v3: Remove aggressive otel flush timeouts in dev/prod
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
v3: Trigger delayed runs and reschedule them
+9
View File
@@ -0,0 +1,9 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
- Improve non-zero exit code error messages
- Detect OOM conditions within worker child processes
- Internal errors can have optional stack traces
- Docker provider can be set to enforce machine presets
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Use global setTimeout to ensure cross-runtime support
+7
View File
@@ -0,0 +1,7 @@
---
"trigger.dev": patch
---
Add e2e fixtures corresponding to past issues
Implement e2e suite parallelism
Enhance log level for specific e2e suite messages
+27
View File
@@ -46,6 +46,7 @@
"changesets": [
"afraid-sheep-joke",
"angry-eagles-trade",
"beige-pears-explode",
"beige-pens-dance",
"big-tomatoes-deliver",
"blue-pumas-whisper",
@@ -64,13 +65,16 @@
"eight-pumas-float",
"eleven-paws-join",
"famous-boats-tease",
"fast-colts-relax",
"few-students-share",
"five-toes-destroy",
"friendly-walls-repair",
"funny-swans-destroy",
"gorgeous-gorillas-compete",
"green-bags-wink",
"hot-buckets-behave",
"hot-fishes-retire",
"hot-wasps-sin",
"itchy-chairs-itch",
"khaki-apricots-design",
"khaki-poems-lay",
@@ -86,8 +90,12 @@
"lovely-drinks-flash",
"many-ligers-pump",
"mighty-camels-joke",
"mighty-eggs-grab",
"mighty-flowers-train",
"mighty-parrots-sin",
"modern-stingrays-end",
"nasty-jars-pump",
"nervous-planets-sparkle",
"new-pants-beg",
"new-rivers-tell",
"nice-bulldogs-turn",
@@ -96,9 +104,12 @@
"pink-pumas-rhyme",
"plenty-ducks-beam",
"polite-ducks-switch",
"polite-pears-grow",
"polite-pots-walk",
"polite-rockets-matter",
"poor-flowers-cross",
"purple-garlics-shop",
"purple-spiders-care",
"rare-lamps-promise",
"rare-roses-float",
"real-planets-stare",
@@ -107,31 +118,45 @@
"rotten-dryers-exercise",
"rude-toys-compare",
"selfish-ducks-sort",
"serious-hats-rest",
"shaggy-spoons-taste",
"sharp-emus-compare",
"sharp-zebras-serve",
"shiny-coats-cry",
"silly-buses-obey",
"silly-forks-kiss",
"silly-suits-switch",
"silver-doors-juggle",
"six-ligers-exist",
"six-rats-hunt",
"sixty-insects-watch",
"slow-buses-own",
"slow-kiwis-hide",
"slow-sloths-retire",
"smart-needles-move",
"smart-olives-eat",
"sour-pugs-teach",
"spicy-frogs-remain",
"spicy-lamps-smoke",
"spicy-terms-bow",
"strange-ghosts-matter",
"strange-sheep-pull",
"strong-lemons-add",
"strong-owls-know",
"strong-phones-smoke",
"stupid-adults-sniff",
"stupid-bulldogs-applaud",
"sweet-ducks-remember",
"sweet-lizards-press",
"swift-dragons-peel",
"tall-bees-wave",
"tall-masks-repeat",
"tame-apricots-clap",
"tame-guests-know",
"tender-moose-tell",
"tender-oranges-rhyme",
"tender-turkeys-compete",
"thick-carrots-sneeze",
"thin-parents-heal",
"thirty-islands-kiss",
"tidy-balloons-suffer",
@@ -142,7 +167,9 @@
"tricky-bulldogs-heal",
"tricky-keys-attack",
"tricky-ladybugs-unite",
"twelve-knives-notice",
"two-pumas-wait",
"violet-clocks-notice",
"warm-olives-provide",
"warm-planes-taste",
"young-snails-sell"
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Await file watcher cleanup in dev
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
v3: [prod] force flush timeout should be 1s
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add callback to checkpoint created message
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
v3: Copy over more of the project's package.json keys into the deployed package.json (support for custom config like zenstack)
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Make deduplicationKey required when creating/updating a schedule
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Improved ESM module require error detection logic
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
v3: vercel edge runtime support
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
v3: fix otel flushing causing CLEANUP ack timeout errors by always setting a forceFlushTimeoutMillis value
+10
View File
@@ -0,0 +1,10 @@
---
"trigger.dev": patch
---
- Prevent downgrades during update check and advise to upgrade CLI
- Detect bun and use npm instead
- During init, fail early and advise if not a TypeScript project
- During init, allow specifying custom package manager args
- Add links to dev worker started message
- Fix links in unsupported terminals
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
fix: allow command login to read api url from cli args
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
v3: Adding SDK functions for triggering tasks in a typesafe way, without importing task file
+9
View File
@@ -0,0 +1,9 @@
---
"@trigger.dev/core-apps": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
- Fix artifact detection logs
- Fix OOM detection and error messages
- Add test link to cli deployment completion
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
v3: postInstall config option now replaces the postinstall script found in package.json
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Added timezone support to schedules
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
---
v3: Include presigned urls for downloading large payloads and outputs when using runs.retrieve
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
v3: fix missing init output in task run function when no middleware is defined
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fix jsonc-parser import
+1 -1
View File
@@ -71,7 +71,7 @@ COORDINATOR_SECRET=coordinator-secret # generate the actual secret with `openssl
# OBJECT_STORE_BASE_URL="https://{bucket}.{accountId}.r2.cloudflarestorage.com"
# OBJECT_STORE_ACCESS_KEY_ID=
# OBJECT_STORE_SECRET_ACCESS_KEY=
# RUNTIME_WAIT_THRESHOLD_IN_MS=10000
# CHECKPOINT_THRESHOLD_IN_MS=10000
# These control the server-side internal telemetry
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
+48 -4
View File
@@ -1,9 +1,53 @@
name: "🧪 E2E Tests"
name: "E2E"
on:
workflow_call:
inputs:
package:
description: The identifier of the job to run
default: webapp
required: false
type: string
jobs:
e2e:
name: "🧪 E2E Tests"
cli-v3:
name: "🧪 CLI v3 tests"
if: inputs.package == 'cli-v3' || inputs.package == ''
runs-on: buildjet-8vcpu-ubuntu-2204
strategy:
fail-fast: false
matrix:
package-manager: ["npm", "pnpm", "yarn"]
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
- name: ⎔ Setup node
uses: buildjet/setup-node@v3
with:
node-version: 20.11.1
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile --filter trigger.dev...
- name: 🔧 Build v3 cli monorepo dependencies
run: pnpm run build --filter trigger.dev^...
- name: 🔧 Build worker template files
run: pnpm --filter trigger.dev run build:workers
- name: Run E2E Tests
run: |
PM=${{ matrix.package-manager }} pnpm --filter trigger.dev run test:e2e
webapp:
name: "🧪 Webapp tests"
if: inputs.package == 'webapp' || inputs.package == ''
runs-on: buildjet-16vcpu-ubuntu-2204
steps:
- name: 🐳 Login to Docker Hub
@@ -19,7 +63,7 @@ jobs:
submodules: recursive
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v4
with:
version: 8.15.5
+2
View File
@@ -29,4 +29,6 @@ jobs:
# e2e:
# uses: ./.github/workflows/e2e.yml
# with:
# package: webapp
# secrets: inherit
+2
View File
@@ -51,6 +51,8 @@ jobs:
# e2e:
# uses: ./.github/workflows/e2e.yml
# with:
# package: cli-v3
# secrets: inherit
publish:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v4
with:
version: 8.15.5
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v4
with:
version: 8.15.5
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v4
with:
version: 8.15.5
+1 -2
View File
@@ -21,8 +21,7 @@
"execa": "^8.0.1",
"nanoid": "^5.0.6",
"prom-client": "^15.1.0",
"socket.io": "4.7.4",
"socket.io-client": "4.7.4"
"socket.io": "4.7.4"
},
"devDependencies": {
"@types/node": "^18",
+95
View File
@@ -0,0 +1,95 @@
import type { Execa$ } from "execa";
import { setTimeout as timeout } from "node:timers/promises";
class ChaosMonkeyError extends Error {
constructor(message: string) {
super(message);
this.name = "ChaosMonkeyError";
}
}
export class ChaosMonkey {
private chaosEventRate = 0.2;
private delayInSeconds = 45;
constructor(private enabled = false) {
if (this.enabled) {
console.log("🍌 Chaos monkey enabled");
}
}
static Error = ChaosMonkeyError;
enable() {
this.enabled = true;
console.log("🍌 Chaos monkey enabled");
}
disable() {
this.enabled = false;
console.log("🍌 Chaos monkey disabled");
}
async call({
$,
throwErrors = true,
addDelays = true,
}: {
$?: Execa$<string>;
throwErrors?: boolean;
addDelays?: boolean;
} = {}) {
if (!this.enabled) {
return;
}
const random = Math.random();
if (random > this.chaosEventRate) {
// Don't interfere with normal operation
return;
}
const chaosEvents: Array<() => Promise<any>> = [];
if (addDelays) {
chaosEvents.push(async () => {
console.log("🍌 Chaos monkey: Add delay");
if ($) {
await $`sleep ${this.delayInSeconds}`;
} else {
await timeout(this.delayInSeconds * 1000);
}
});
}
if (throwErrors) {
chaosEvents.push(async () => {
console.log("🍌 Chaos monkey: Throw error");
if ($) {
await $`false`;
} else {
throw new ChaosMonkey.Error("🍌 Chaos monkey: Throw error");
}
});
}
if (chaosEvents.length === 0) {
console.error("🍌 Chaos monkey: No events selected");
return;
}
const randomIndex = Math.floor(Math.random() * chaosEvents.length);
const chaosEvent = chaosEvents[randomIndex];
if (!chaosEvent) {
console.error("🍌 Chaos monkey: No event found");
return;
}
await chaosEvent();
}
}
+587
View File
@@ -0,0 +1,587 @@
import { ExponentialBackoff } from "@trigger.dev/core-apps/backoff";
import { isExecaChildProcess, testDockerCheckpoint } from "@trigger.dev/core-apps/checkpoints";
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
import { $ } from "execa";
import { nanoid } from "nanoid";
import fs from "node:fs/promises";
import { ChaosMonkey } from "./chaosMonkey";
type CheckpointerInitializeReturn = {
canCheckpoint: boolean;
willSimulate: boolean;
};
type CheckpointAndPushOptions = {
runId: string;
leaveRunning?: boolean;
projectRef: string;
deploymentVersion: string;
shouldHeartbeat?: boolean;
};
type CheckpointAndPushResult =
| { success: true; checkpoint: CheckpointData }
| {
success: false;
reason?: "CANCELED" | "DISABLED" | "ERROR" | "IN_PROGRESS" | "NO_SUPPORT" | "SKIP_RETRYING";
};
type CheckpointData = {
location: string;
docker: boolean;
};
type CheckpointerOptions = {
dockerMode: boolean;
forceSimulate: boolean;
heartbeat: (runId: string) => void;
registryHost?: string;
registryNamespace?: string;
registryTlsVerify?: boolean;
disableCheckpointSupport?: boolean;
checkpointPath?: string;
simulateCheckpointFailure?: boolean;
simulateCheckpointFailureSeconds?: number;
simulatePushFailure?: boolean;
simulatePushFailureSeconds?: number;
chaosMonkey?: ChaosMonkey;
};
async function getFileSize(filePath: string): Promise<number> {
try {
const stats = await fs.stat(filePath);
return stats.size;
} catch (error) {
console.error("Error getting file size:", error);
return -1;
}
}
async function getParsedFileSize(filePath: string) {
const sizeInBytes = await getFileSize(filePath);
let message = `Size in bytes: ${sizeInBytes}`;
if (sizeInBytes > 1024 * 1024) {
const sizeInMB = (sizeInBytes / 1024 / 1024).toFixed(2);
message = `Size in MB (rounded): ${sizeInMB}`;
} else if (sizeInBytes > 1024) {
const sizeInKB = (sizeInBytes / 1024).toFixed(2);
message = `Size in KB (rounded): ${sizeInKB}`;
}
return {
path: filePath,
sizeInBytes,
message,
};
}
export class Checkpointer {
#initialized = false;
#canCheckpoint = false;
#dockerMode: boolean;
#logger = new SimpleLogger("[checkptr]");
#abortControllers = new Map<string, AbortController>();
#failedCheckpoints = new Map<string, unknown>();
#waitingForRetry = new Set<string>();
private registryHost: string;
private registryNamespace: string;
private registryTlsVerify: boolean;
private disableCheckpointSupport: boolean;
private checkpointPath: string;
private simulateCheckpointFailure: boolean;
private simulateCheckpointFailureSeconds: number;
private simulatePushFailure: boolean;
private simulatePushFailureSeconds: number;
private chaosMonkey: ChaosMonkey;
constructor(private opts: CheckpointerOptions) {
this.#dockerMode = opts.dockerMode;
this.registryHost = opts.registryHost ?? "localhost:5000";
this.registryNamespace = opts.registryNamespace ?? "trigger";
this.registryTlsVerify = opts.registryTlsVerify ?? true;
this.disableCheckpointSupport = opts.disableCheckpointSupport ?? false;
this.checkpointPath = opts.checkpointPath ?? "/checkpoints";
this.simulateCheckpointFailure = opts.simulateCheckpointFailure ?? false;
this.simulateCheckpointFailureSeconds = opts.simulateCheckpointFailureSeconds ?? 300;
this.simulatePushFailure = opts.simulatePushFailure ?? false;
this.simulatePushFailureSeconds = opts.simulatePushFailureSeconds ?? 300;
this.chaosMonkey = opts.chaosMonkey ?? new ChaosMonkey(!!process.env.CHAOS_MONKEY_ENABLED);
}
async init(): Promise<CheckpointerInitializeReturn> {
if (this.#initialized) {
return this.#getInitReturn(this.#canCheckpoint);
}
this.#logger.log(`${this.#dockerMode ? "Docker" : "Kubernetes"} mode`);
if (this.#dockerMode) {
const testCheckpoint = await testDockerCheckpoint();
if (testCheckpoint.ok) {
return this.#getInitReturn(true);
}
this.#logger.error(testCheckpoint.message, testCheckpoint.error ?? "");
return this.#getInitReturn(false);
} else {
try {
await $`buildah login --get-login ${this.registryHost}`;
} catch (error) {
this.#logger.error(`No checkpoint support: Not logged in to registry ${this.registryHost}`);
return this.#getInitReturn(false);
}
}
return this.#getInitReturn(true);
}
#getInitReturn(canCheckpoint: boolean): CheckpointerInitializeReturn {
this.#canCheckpoint = canCheckpoint;
if (canCheckpoint) {
if (!this.#initialized) {
this.#logger.log("Full checkpoint support!");
}
}
this.#initialized = true;
const willSimulate = this.#dockerMode && (!this.#canCheckpoint || this.opts.forceSimulate);
if (willSimulate) {
this.#logger.log("Simulation mode enabled. Containers will be paused, not checkpointed.", {
forceSimulate: this.opts.forceSimulate,
});
}
return {
canCheckpoint,
willSimulate,
};
}
#getImageRef(projectRef: string, deploymentVersion: string, shortCode: string) {
return `${this.registryHost}/${this.registryNamespace}/${projectRef}:${deploymentVersion}.prod-${shortCode}`;
}
#getExportLocation(projectRef: string, deploymentVersion: string, shortCode: string) {
const basename = `${projectRef}-${deploymentVersion}-${shortCode}`;
if (this.#dockerMode) {
return basename;
} else {
return `${this.checkpointPath}/${basename}.tar`;
}
}
async checkpointAndPush(opts: CheckpointAndPushOptions): Promise<CheckpointData | undefined> {
const start = performance.now();
this.#logger.log(`checkpointAndPush() start`, { start, opts });
let interval: NodeJS.Timer | undefined;
if (opts.shouldHeartbeat) {
interval = setInterval(() => {
this.#logger.log("Sending heartbeat", { runId: opts.runId });
this.opts.heartbeat(opts.runId);
}, 20_000);
}
try {
const result = await this.#checkpointAndPushWithBackoff(opts);
const end = performance.now();
this.#logger.log(`checkpointAndPush() end`, {
start,
end,
diff: end - start,
opts,
success: result.success,
});
if (!result.success) {
return;
}
return result.checkpoint;
} finally {
if (opts.shouldHeartbeat) {
clearInterval(interval);
}
}
}
isCheckpointing(runId: string) {
return this.#abortControllers.has(runId) || this.#waitingForRetry.has(runId);
}
cancelCheckpoint(runId: string): boolean {
// 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.#clearFailedCheckpoint(runId);
return true;
}
if (this.#waitingForRetry.has(runId)) {
this.#waitingForRetry.delete(runId);
return true;
}
const controller = this.#abortControllers.get(runId);
if (!controller) {
this.#logger.debug("Nothing to cancel", { runId });
return false;
}
controller.abort("cancelCheckpointing()");
this.#abortControllers.delete(runId);
return true;
}
async #checkpointAndPushWithBackoff({
runId,
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
projectRef,
deploymentVersion,
}: CheckpointAndPushOptions): Promise<CheckpointAndPushResult> {
this.#logger.log("Checkpointing with backoff", {
runId,
leaveRunning,
projectRef,
deploymentVersion,
});
const backoff = new ExponentialBackoff()
.type("EqualJitter")
.base(3)
.max(3 * 3600)
.maxElapsed(48 * 3600);
for await (const { delay, retry } of backoff) {
try {
if (retry > 0) {
this.#logger.error("Retrying checkpoint", {
runId,
retry,
delay,
});
this.#waitingForRetry.add(runId);
await new Promise((resolve) => setTimeout(resolve, delay.milliseconds));
if (!this.#waitingForRetry.has(runId)) {
this.#logger.log("Checkpoint canceled while waiting for retry", { runId });
return { success: false, reason: "CANCELED" };
} else {
this.#waitingForRetry.delete(runId);
}
}
const result = await this.#checkpointAndPush({
runId,
leaveRunning,
projectRef,
deploymentVersion,
});
if (result.success) {
return result;
}
if (result.reason === "CANCELED") {
this.#logger.log("Checkpoint canceled, won't retry", { runId });
// Don't fail the checkpoint, as it was canceled
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;
}
continue;
} catch (error) {
this.#logger.error("Checkpoint error", {
retry,
runId,
delay,
error: error instanceof Error ? error.message : error,
});
}
}
this.#logger.error(`Checkpoint failed after exponential backoff`, {
runId,
leaveRunning,
projectRef,
deploymentVersion,
});
this.#failCheckpoint(runId, "ERROR");
return { success: false, reason: "ERROR" };
}
async #checkpointAndPush({
runId,
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
projectRef,
deploymentVersion,
}: CheckpointAndPushOptions): Promise<CheckpointAndPushResult> {
await this.init();
const options = {
runId,
leaveRunning,
projectRef,
deploymentVersion,
};
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 $$ = $({ signal: controller.signal });
const shortCode = nanoid(8);
const imageRef = this.#getImageRef(projectRef, deploymentVersion, shortCode);
const exportLocation = this.#getExportLocation(projectRef, deploymentVersion, shortCode);
const cleanup = async () => {
if (this.#dockerMode) {
return;
}
try {
await $`rm ${exportLocation}`;
this.#logger.log("Deleted checkpoint archive", { exportLocation });
await $`buildah rmi ${imageRef}`;
this.#logger.log("Deleted checkpoint image", { imageRef });
} catch (error) {
this.#logger.error("Failure during checkpoint cleanup", { exportLocation, error });
}
};
try {
await this.chaosMonkey.call({ $: $$ });
this.#logger.log("Checkpointing:", { options });
const containterName = this.#getRunContainerName(runId);
// Create checkpoint (docker)
if (this.#dockerMode) {
try {
if (this.opts.forceSimulate || !this.#canCheckpoint) {
this.#logger.log("Simulating checkpoint");
this.#logger.debug(await $$`docker pause ${containterName}`);
} else {
if (this.simulateCheckpointFailure) {
if (performance.now() < this.simulateCheckpointFailureSeconds * 1000) {
this.#logger.error("Simulating checkpoint failure", { options });
throw new Error("SIMULATE_CHECKPOINT_FAILURE");
}
}
if (leaveRunning) {
this.#logger.debug(
await $$`docker checkpoint create --leave-running ${containterName} ${exportLocation}`
);
} else {
this.#logger.debug(
await $$`docker checkpoint create ${containterName} ${exportLocation}`
);
}
}
} catch (error) {
this.#logger.error("Failed while creating docker checkpoint", { exportLocation });
throw error;
}
this.#logger.log("checkpoint created:", {
runId,
location: exportLocation,
});
return {
success: true,
checkpoint: {
location: exportLocation,
docker: true,
},
};
}
// Create checkpoint (CRI)
if (!this.#canCheckpoint) {
this.#logger.error("No checkpoint support in kubernetes mode.");
return { success: false, reason: "SKIP_RETRYING" };
}
const containerId = this.#logger.debug(
// @ts-expect-error
await $$`crictl ps`
.pipeStdout($$({ stdin: "pipe" })`grep ${containterName}`)
.pipeStdout($$({ stdin: "pipe" })`cut -f1 ${"-d "}`)
);
if (!containerId.stdout) {
this.#logger.error("could not find container id", { options, containterName });
return { success: false, reason: "SKIP_RETRYING" };
}
const start = performance.now();
if (this.simulateCheckpointFailure) {
if (performance.now() < this.simulateCheckpointFailureSeconds * 1000) {
this.#logger.error("Simulating checkpoint failure", { options });
throw new Error("SIMULATE_CHECKPOINT_FAILURE");
}
}
// Create checkpoint
this.#logger.debug(await $$`crictl checkpoint --export=${exportLocation} ${containerId}`);
const postCheckpoint = performance.now();
// Print checkpoint size
const size = await getParsedFileSize(exportLocation);
this.#logger.log("checkpoint archive created", { size, options });
// Create image from checkpoint
const container = this.#logger.debug(await $$`buildah from scratch`);
const postFrom = performance.now();
this.#logger.debug(await $$`buildah add ${container} ${exportLocation} /`);
const postAdd = performance.now();
this.#logger.debug(
await $$`buildah config --annotation=io.kubernetes.cri-o.annotations.checkpoint.name=counter ${container}`
);
const postConfig = performance.now();
this.#logger.debug(await $$`buildah commit ${container} ${imageRef}`);
const postCommit = performance.now();
this.#logger.debug(await $$`buildah rm ${container}`);
const postRm = performance.now();
if (this.simulatePushFailure) {
if (performance.now() < this.simulatePushFailureSeconds * 1000) {
this.#logger.error("Simulating push failure", { options });
throw new Error("SIMULATE_PUSH_FAILURE");
}
}
// Push checkpoint image
this.#logger.debug(
await $$`buildah push --tls-verify=${String(this.registryTlsVerify)} ${imageRef}`
);
const postPush = performance.now();
const perf = {
"crictl checkpoint": postCheckpoint - start,
"buildah from": postFrom - postCheckpoint,
"buildah add": postAdd - postFrom,
"buildah config": postConfig - postAdd,
"buildah commit": postCommit - postConfig,
"buildah rm": postRm - postCommit,
"buildah push": postPush - postRm,
};
this.#logger.log("Checkpointed and pushed image to:", { location: imageRef, perf });
return {
success: true,
checkpoint: {
location: imageRef,
docker: false,
},
};
} catch (error) {
if (isExecaChildProcess(error)) {
if (error.isCanceled) {
this.#logger.error("Checkpoint canceled", { options, error });
return { success: false, reason: "CANCELED" };
}
this.#logger.error("Checkpoint command error", { options, error });
return { success: false, reason: "ERROR" };
}
this.#logger.error("Unhandled checkpoint error", { options, error });
return { success: false, reason: "ERROR" };
} finally {
this.#abortControllers.delete(runId);
await cleanup();
}
}
#failCheckpoint(runId: string, error: unknown) {
this.#failedCheckpoints.set(runId, error);
}
#clearFailedCheckpoint(runId: string) {
this.#failedCheckpoints.delete(runId);
}
#hasFailedCheckpoint(runId: string) {
return this.#failedCheckpoints.has(runId);
}
#getRunContainerName(suffix: string) {
return `task-run-${suffix}`;
}
}
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -18,8 +18,7 @@
"dependencies": {
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-apps": "workspace:*",
"execa": "^8.0.1",
"socket.io-client": "^4.7.4"
"execa": "^8.0.1"
},
"devDependencies": {
"@types/node": "^18.19.8",
+30 -23
View File
@@ -1,14 +1,13 @@
import { $, type ExecaChildProcess, execa } from "execa";
import {
SimpleLogger,
TaskOperations,
ProviderShell,
TaskOperationsRestoreOptions,
TaskOperations,
TaskOperationsCreateOptions,
TaskOperationsIndexOptions,
isExecaChildProcess,
testDockerCheckpoint,
} from "@trigger.dev/core-apps";
TaskOperationsRestoreOptions,
} from "@trigger.dev/core-apps/provider";
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
import { isExecaChildProcess, testDockerCheckpoint } from "@trigger.dev/core-apps/checkpoints";
import { setTimeout } from "node:timers/promises";
import { PostStartCauses, PreStopCauses } from "@trigger.dev/core/v3";
@@ -54,13 +53,16 @@ class DockerTaskOperations implements TaskOperations {
}
#getInitReturn(canCheckpoint: boolean): TaskOperationsInitReturn {
this.#initialized = true;
this.#canCheckpoint = canCheckpoint;
if (canCheckpoint) {
logger.log("Full checkpoint support!");
if (!this.#initialized) {
logger.log("Full checkpoint support!");
}
}
this.#initialized = true;
const willSimulate = !canCheckpoint || this.opts.forceSimulate;
if (willSimulate) {
@@ -109,22 +111,27 @@ class DockerTaskOperations implements TaskOperations {
const containerName = this.#getRunContainerName(opts.runId);
const runArgs = [
"run",
"--network=host",
"--detach",
`--env=TRIGGER_ENV_ID=${opts.envId}`,
`--env=TRIGGER_RUN_ID=${opts.runId}`,
`--env=OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}`,
`--env=POD_NAME=${containerName}`,
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
`--name=${containerName}`,
];
if (process.env.ENFORCE_MACHINE_PRESETS) {
runArgs.push(`--cpus=${opts.machine.cpu}`, `--memory=${opts.machine.memory}G`);
}
runArgs.push(`${opts.image}`);
try {
logger.debug(
await execa("docker", [
"run",
"--network=host",
"--detach",
`--env=TRIGGER_ENV_ID=${opts.envId}`,
`--env=TRIGGER_RUN_ID=${opts.runId}`,
`--env=OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}`,
`--env=POD_NAME=${containerName}`,
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
`--name=${containerName}`,
`${opts.image}`,
])
);
logger.debug(await execa("docker", runArgs));
} catch (error) {
if (!isExecaChildProcess(error)) {
throw error;
+1 -2
View File
@@ -19,8 +19,7 @@
"@kubernetes/client-node": "^0.20.0",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-apps": "workspace:*",
"p-queue": "^8.0.1",
"socket.io-client": "^4.7.4"
"p-queue": "^8.0.1"
},
"devDependencies": {
"dotenv": "^16.4.2",
+46 -15
View File
@@ -1,22 +1,36 @@
import * as k8s from "@kubernetes/client-node";
import {
ProviderShell,
SimpleLogger,
TaskOperations,
TaskOperationsCreateOptions,
TaskOperationsIndexOptions,
TaskOperationsRestoreOptions,
} from "@trigger.dev/core-apps";
import { Machine, PostStartCauses, PreStopCauses, EnvironmentType } from "@trigger.dev/core/v3";
} from "@trigger.dev/core-apps/provider";
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
import {
MachinePreset,
PostStartCauses,
PreStopCauses,
EnvironmentType,
} from "@trigger.dev/core/v3";
import { randomUUID } from "crypto";
import { TaskMonitor } from "./taskMonitor";
import { PodCleaner } from "./podCleaner";
import { UptimeHeartbeat } from "./uptimeHeartbeat";
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
const NODE_NAME = process.env.NODE_NAME || "local";
const OTEL_EXPORTER_OTLP_ENDPOINT =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318";
const POD_CLEANER_INTERVAL_SECONDS = Number(process.env.POD_CLEANER_INTERVAL_SECONDS || "300");
const UPTIME_HEARTBEAT_URL = process.env.UPTIME_HEARTBEAT_URL;
const UPTIME_INTERVAL_SECONDS = Number(process.env.UPTIME_INTERVAL_SECONDS || "60");
const UPTIME_MAX_PENDING_RUNS = Number(process.env.UPTIME_MAX_PENDING_RUNS || "25");
const UPTIME_MAX_PENDING_INDECES = Number(process.env.UPTIME_MAX_PENDING_INDECES || "10");
const UPTIME_MAX_PENDING_ERRORS = Number(process.env.UPTIME_MAX_PENDING_ERRORS || "10");
const logger = new SimpleLogger(`[${NODE_NAME}]`);
logger.log(`running in ${RUNTIME_ENV} mode`);
@@ -398,10 +412,10 @@ class KubernetesTaskOperations implements TaskOperations {
};
}
#getResourcesFromMachineConfig(config: Machine): ComputeResources {
#getResourcesFromMachineConfig(preset: MachinePreset): ComputeResources {
return {
cpu: `${config.cpu}`,
memory: `${config.memory}G`,
cpu: `${preset.cpu}`,
memory: `${preset.memory}G`,
};
}
@@ -523,27 +537,28 @@ provider.listen();
const taskMonitor = new TaskMonitor({
runtimeEnv: RUNTIME_ENV,
onIndexFailure: async (deploymentId, failureInfo) => {
logger.log("Indexing failed", { deploymentId, failureInfo });
onIndexFailure: async (deploymentId, details) => {
logger.log("Indexing failed", { deploymentId, details });
try {
provider.platformSocket.send("INDEXING_FAILED", {
deploymentId,
error: {
name: `Crashed with exit code ${failureInfo.exitCode}`,
message: failureInfo.reason,
stack: failureInfo.logs,
name: `Crashed with exit code ${details.exitCode}`,
message: details.reason,
stack: details.logs,
},
overrideCompletion: details.overrideCompletion,
});
} catch (error) {
logger.error(error);
}
},
onRunFailure: async (runId, failureInfo) => {
logger.log("Run failed:", { runId, failureInfo });
onRunFailure: async (runId, details) => {
logger.log("Run failed:", { runId, details });
try {
provider.platformSocket.send("WORKER_CRASHED", { runId, ...failureInfo });
provider.platformSocket.send("WORKER_CRASHED", { runId, ...details });
} catch (error) {
logger.error(error);
}
@@ -555,7 +570,23 @@ taskMonitor.start();
const podCleaner = new PodCleaner({
runtimeEnv: RUNTIME_ENV,
namespace: "default",
intervalInSeconds: 300,
intervalInSeconds: POD_CLEANER_INTERVAL_SECONDS,
});
podCleaner.start();
if (UPTIME_HEARTBEAT_URL) {
const uptimeHeartbeat = new UptimeHeartbeat({
runtimeEnv: RUNTIME_ENV,
namespace: "default",
intervalInSeconds: UPTIME_INTERVAL_SECONDS,
pingUrl: UPTIME_HEARTBEAT_URL,
maxPendingRuns: UPTIME_MAX_PENDING_RUNS,
maxPendingIndeces: UPTIME_MAX_PENDING_INDECES,
maxPendingErrors: UPTIME_MAX_PENDING_ERRORS,
});
uptimeHeartbeat.start();
} else {
logger.log("Uptime heartbeat is disabled, set UPTIME_HEARTBEAT_URL to enable.");
}
+1 -1
View File
@@ -1,5 +1,5 @@
import * as k8s from "@kubernetes/client-node";
import { SimpleLogger } from "@trigger.dev/core-apps";
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
type PodCleanerOptions = {
runtimeEnv: "local" | "kubernetes";
+23 -21
View File
@@ -1,25 +1,20 @@
import * as k8s from "@kubernetes/client-node";
import { SimpleLogger } from "@trigger.dev/core-apps";
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
import { EXIT_CODE_ALREADY_HANDLED, EXIT_CODE_CHILD_NONZERO } from "@trigger.dev/core-apps/process";
import { setTimeout } from "timers/promises";
import PQueue from "p-queue";
import type { Prettify } from "@trigger.dev/core/v3";
type IndexFailureHandler = (
deploymentId: string,
failureInfo: {
exitCode: number;
reason: string;
logs: string;
}
) => Promise<any>;
type FailureDetails = Prettify<{
exitCode: number;
reason: string;
logs: string;
overrideCompletion: boolean;
}>;
type RunFailureHandler = (
runId: string,
failureInfo: {
exitCode: number;
reason: string;
logs: string;
}
) => Promise<any>;
type IndexFailureHandler = (deploymentId: string, details: FailureDetails) => Promise<any>;
type RunFailureHandler = (runId: string, details: FailureDetails) => Promise<any>;
type TaskMonitorOptions = {
runtimeEnv: "local" | "kubernetes";
@@ -144,8 +139,10 @@ export class TaskMonitor {
const containerState = this.#getContainerStateSummary(containerStatus.state);
const exitCode = containerState.exitCode ?? -1;
// We use this special exit code to signal any errors were already handled elsewhere
if (exitCode === 111) {
if (exitCode === EXIT_CODE_ALREADY_HANDLED) {
this.#logger.debug("Ignoring pod failure, already handled by worker", {
podName,
});
return;
}
@@ -162,6 +159,7 @@ export class TaskMonitor {
let reason = rawReason || "Unknown error";
let logs = rawLogs || "";
let overrideCompletion = false;
switch (rawReason) {
case "Error":
@@ -181,7 +179,10 @@ export class TaskMonitor {
}
break;
case "OOMKilled":
reason = "Out of memory! Try increasing the memory on this task.";
overrideCompletion = true;
reason = `${
exitCode === EXIT_CODE_CHILD_NONZERO ? "Child process" : "Parent process"
} ran out of memory! Try choosing a machine preset with more memory for this task.`;
break;
default:
break;
@@ -191,7 +192,8 @@ export class TaskMonitor {
exitCode,
reason,
logs,
};
overrideCompletion,
} satisfies FailureDetails;
const app = pod.metadata?.labels?.app;
@@ -0,0 +1,272 @@
import * as k8s from "@kubernetes/client-node";
import { SimpleLogger } from "@trigger.dev/core-apps/logger";
type UptimeHeartbeatOptions = {
runtimeEnv: "local" | "kubernetes";
pingUrl: string;
namespace?: string;
intervalInSeconds?: number;
maxPendingRuns?: number;
maxPendingIndeces?: number;
maxPendingErrors?: number;
leadingEdge?: boolean;
};
export class UptimeHeartbeat {
private enabled = false;
private namespace: string;
private intervalInSeconds: number;
private maxPendingRuns: number;
private maxPendingIndeces: number;
private maxPendingErrors: number;
private leadingEdge = true;
private logger = new SimpleLogger("[UptimeHeartbeat]");
private k8sClient: {
core: k8s.CoreV1Api;
kubeConfig: k8s.KubeConfig;
};
constructor(private opts: UptimeHeartbeatOptions) {
this.namespace = opts.namespace ?? "default";
this.intervalInSeconds = opts.intervalInSeconds ?? 60;
this.maxPendingRuns = opts.maxPendingRuns ?? 25;
this.maxPendingIndeces = opts.maxPendingIndeces ?? 10;
this.maxPendingErrors = opts.maxPendingErrors ?? 10;
this.k8sClient = this.#createK8sClient();
}
#createK8sClient() {
const kubeConfig = new k8s.KubeConfig();
if (this.opts.runtimeEnv === "local") {
kubeConfig.loadFromDefault();
} else if (this.opts.runtimeEnv === "kubernetes") {
kubeConfig.loadFromCluster();
} else {
throw new Error(`Unsupported runtime environment: ${this.opts.runtimeEnv}`);
}
return {
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
kubeConfig: kubeConfig,
};
}
#isRecord(candidate: unknown): candidate is Record<string, unknown> {
if (typeof candidate !== "object" || candidate === null) {
return false;
} else {
return true;
}
}
#logK8sError(err: unknown, debugOnly = false) {
if (debugOnly) {
this.logger.debug("K8s API Error", err);
} else {
this.logger.error("K8s API Error", err);
}
}
#handleK8sError(err: unknown) {
if (!this.#isRecord(err) || !this.#isRecord(err.body)) {
this.#logK8sError(err);
return;
}
this.#logK8sError(err, true);
if (typeof err.body.message === "string") {
this.#logK8sError({ message: err.body.message });
return;
}
this.#logK8sError({ body: err.body });
}
async #getPods(opts: {
namespace: string;
fieldSelector?: string;
labelSelector?: string;
}): Promise<Array<k8s.V1Pod> | undefined> {
const listReturn = await this.k8sClient.core
.listNamespacedPod(
opts.namespace,
undefined, // pretty
undefined, // allowWatchBookmarks
undefined, // _continue
opts.fieldSelector,
opts.labelSelector,
this.maxPendingRuns * 2, // limit
undefined, // resourceVersion
undefined, // resourceVersionMatch
undefined, // sendInitialEvents
this.intervalInSeconds, // timeoutSeconds,
undefined // watch
)
.catch(this.#handleK8sError.bind(this));
return listReturn?.body.items;
}
async #getPendingIndeces(): Promise<Array<k8s.V1Pod> | undefined> {
return await this.#getPods({
namespace: this.namespace,
fieldSelector: "status.phase=Pending",
labelSelector: "app=task-index",
});
}
async #getPendingTasks(): Promise<Array<k8s.V1Pod> | undefined> {
return await this.#getPods({
namespace: this.namespace,
fieldSelector: "status.phase=Pending",
labelSelector: "app=task-run",
});
}
#countPods(pods: Array<k8s.V1Pod>): number {
return pods.length;
}
#filterPendingPods(
pods: Array<k8s.V1Pod>,
waitingReason: "CreateContainerError" | "RunContainerError"
): Array<k8s.V1Pod> {
return pods.filter((pod) => {
const containerStatus = pod.status?.containerStatuses?.[0];
return containerStatus?.state?.waiting?.reason === waitingReason;
});
}
async #sendPing() {
this.logger.log("Sending ping");
const start = Date.now();
const controller = new AbortController();
const timeoutMs = (this.intervalInSeconds * 1000) / 2;
const fetchTimeout = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(this.opts.pingUrl, {
signal: controller.signal,
});
if (!response.ok) {
this.logger.error("Failed to send ping, response not OK", {
status: response.status,
});
return;
}
const elapsedMs = Date.now() - start;
this.logger.log("Ping sent", { elapsedMs });
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
this.logger.log("Ping timeout", { timeoutSeconds: timeoutMs });
return;
}
this.logger.error("Failed to send ping", error);
} finally {
clearTimeout(fetchTimeout);
}
}
async #heartbeat() {
this.logger.log("Performing heartbeat");
const start = Date.now();
const pendingTasks = await this.#getPendingTasks();
if (!pendingTasks) {
this.logger.error("Failed to get pending tasks");
return;
}
const totalPendingTasks = this.#countPods(pendingTasks);
const pendingIndeces = await this.#getPendingIndeces();
if (!pendingIndeces) {
this.logger.error("Failed to get pending indeces");
return;
}
const totalPendingIndeces = this.#countPods(pendingIndeces);
const elapsedMs = Date.now() - start;
this.logger.log("Finished heartbeat checks", { elapsedMs });
if (totalPendingTasks > this.maxPendingRuns) {
this.logger.log("Too many pending tasks, skipping heartbeat", { totalPendingTasks });
return;
}
if (totalPendingIndeces > this.maxPendingIndeces) {
this.logger.log("Too many pending indeces, skipping heartbeat", { totalPendingIndeces });
return;
}
const totalCreateContainerErrors = this.#countPods(
this.#filterPendingPods(pendingTasks, "CreateContainerError")
);
const totalRunContainerErrors = this.#countPods(
this.#filterPendingPods(pendingTasks, "RunContainerError")
);
if (totalCreateContainerErrors + totalRunContainerErrors > this.maxPendingErrors) {
this.logger.log("Too many pending tasks with errors, skipping heartbeat", {
totalRunContainerErrors,
totalCreateContainerErrors,
});
return;
}
await this.#sendPing();
this.logger.log("Heartbeat done", { totalPendingTasks, elapsedMs });
}
async start() {
this.enabled = true;
this.logger.log("Starting");
if (this.leadingEdge) {
await this.#heartbeat();
}
const heartbeat = setInterval(async () => {
if (!this.enabled) {
clearInterval(heartbeat);
return;
}
try {
await this.#heartbeat();
} catch (error) {
this.logger.error("Error while heartbeating", error);
}
}, this.intervalInSeconds * 1000);
}
async stop() {
if (!this.enabled) {
return;
}
this.enabled = false;
this.logger.log("Shutting down..");
}
}
@@ -6,6 +6,7 @@ type DateTimeProps = {
timeZone?: string;
includeSeconds?: boolean;
includeTime?: boolean;
showTimezone?: boolean;
};
export const DateTime = ({
@@ -13,6 +14,7 @@ export const DateTime = ({
timeZone,
includeSeconds = true,
includeTime = true,
showTimezone = false,
}: DateTimeProps) => {
const locales = useLocales();
@@ -42,7 +44,12 @@ export const DateTime = ({
);
}, [locales, includeSeconds, realDate]);
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
return (
<Fragment>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
</Fragment>
);
};
export function formatDateTime(
@@ -8,6 +8,7 @@ import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
import { ChevronDown } from "lucide-react";
import { MatchSorterOptions, matchSorter } from "match-sorter";
const sizes = {
small: {
@@ -75,7 +76,10 @@ export interface SelectProps<TValue extends string | string[], TItem>
showHeading?: boolean;
items?: TItem[] | Section<TItem>[];
empty?: React.ReactNode;
filter?: (item: ItemFromSection<TItem>, search: string, title?: string) => boolean;
filter?:
| boolean
| MatchSorterOptions<TItem>
| ((item: ItemFromSection<TItem>, search: string, title?: string) => boolean);
children:
| React.ReactNode
| ((
@@ -129,18 +133,44 @@ export function Select<TValue extends string | string[], TItem>({
if (!items) return [];
if (!searchValue || !filter) return items;
if (typeof filter === "function") {
if (isSection(items)) {
return items
.map((section) => ({
...section,
items: section.items.filter((item) =>
filter(item as ItemFromSection<TItem>, searchValue, section.title)
),
}))
.filter((section) => section.items.length > 0);
}
return items.filter((item) => filter(item as ItemFromSection<TItem>, searchValue));
}
if (typeof filter === "boolean" && filter) {
if (isSection(items)) {
return items
.map((section) => ({
...section,
items: matchSorter(section.items, searchValue),
}))
.filter((section) => section.items.length > 0);
}
return matchSorter(items, searchValue);
}
if (isSection(items)) {
return items
.map((section) => ({
...section,
items: section.items.filter((item) =>
filter(item as ItemFromSection<TItem>, searchValue, section.title)
),
items: matchSorter(section.items, searchValue, filter),
}))
.filter((section) => section.items.length > 0);
}
return items.filter((item) => filter(item as ItemFromSection<TItem>, searchValue));
return matchSorter(items, searchValue, filter);
}, [searchValue, items]);
const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length;
@@ -528,7 +528,7 @@ export type Tree<TData> = {
/** A tree but flattened so it can easily be used for DOM elements */
export type FlatTreeItem<TData> = {
id: string;
parentId: string | undefined;
parentId?: string | undefined;
children: string[];
hasChildren: boolean;
/** The indentation level, the root is 0 */
@@ -146,7 +146,6 @@ function FilterMenu(props: RunFiltersProps) {
const filterTrigger = (
<SelectTrigger
autoFocus
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
@@ -3,10 +3,12 @@ import {
BoltSlashIcon,
BugAntIcon,
CheckCircleIcon,
ClockIcon,
FireIcon,
NoSymbolIcon,
PauseCircleIcon,
RectangleStackIcon,
TrashIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import { TaskRunStatus } from "@trigger.dev/database";
@@ -16,6 +18,7 @@ import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export const allTaskRunStatuses = [
"DELAYED",
"WAITING_FOR_DEPLOY",
"PENDING",
"EXECUTING",
@@ -28,10 +31,12 @@ export const allTaskRunStatuses = [
"PAUSED",
"INTERRUPTED",
"SYSTEM_FAILURE",
"EXPIRED",
] as const satisfies Readonly<Array<TaskRunStatus>>;
export const filterableTaskRunStatuses = [
"WAITING_FOR_DEPLOY",
"DELAYED",
"PENDING",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
@@ -42,9 +47,11 @@ export const filterableTaskRunStatuses = [
"CRASHED",
"INTERRUPTED",
"SYSTEM_FAILURE",
"EXPIRED",
] as const satisfies Readonly<Array<TaskRunStatus>>;
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
DELAYED: "Task has been delayed and is waiting to be executed",
PENDING: "Task is waiting to be executed",
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing",
EXECUTING: "Task is currently being executed",
@@ -57,9 +64,10 @@ const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
SYSTEM_FAILURE: "Task has failed due to a system failure",
PAUSED: "Task has been paused by the user",
CRASHED: "Task has crashed and won't be retried",
EXPIRED: "Task has surpassed its ttl and won't be executed",
};
export const QUEUED_STATUSES: TaskRunStatus[] = ["PENDING", "WAITING_FOR_DEPLOY"];
export const QUEUED_STATUSES: TaskRunStatus[] = ["PENDING", "WAITING_FOR_DEPLOY", "DELAYED"];
export const RUNNING_STATUSES: TaskRunStatus[] = [
"EXECUTING",
@@ -74,6 +82,7 @@ export const FINISHED_STATUSES: TaskRunStatus[] = [
"INTERRUPTED",
"SYSTEM_FAILURE",
"CRASHED",
"EXPIRED",
];
export function descriptionForTaskRunStatus(status: TaskRunStatus): string {
@@ -109,6 +118,8 @@ export function TaskRunStatusIcon({
className: string;
}) {
switch (status) {
case "DELAYED":
return <ClockIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PENDING":
return <RectangleStackIcon className={cn(runStatusClassNameColor(status), className)} />;
case "WAITING_FOR_DEPLOY":
@@ -133,6 +144,8 @@ export function TaskRunStatusIcon({
return <BugAntIcon className={cn(runStatusClassNameColor(status), className)} />;
case "CRASHED":
return <FireIcon className={cn(runStatusClassNameColor(status), className)} />;
case "EXPIRED":
return <TrashIcon className={cn(runStatusClassNameColor(status), className)} />;
default: {
assertNever(status);
@@ -143,6 +156,7 @@ export function TaskRunStatusIcon({
export function runStatusClassNameColor(status: TaskRunStatus): string {
switch (status) {
case "PENDING":
case "DELAYED":
return "text-charcoal-500";
case "WAITING_FOR_DEPLOY":
return "text-amber-500";
@@ -154,6 +168,7 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
case "PAUSED":
return "text-amber-300";
case "CANCELED":
case "EXPIRED":
return "text-charcoal-500";
case "INTERRUPTED":
return "text-error";
@@ -173,6 +188,8 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
export function runStatusTitle(status: TaskRunStatus): string {
switch (status) {
case "DELAYED":
return "Delayed";
case "PENDING":
return "Queued";
case "WAITING_FOR_DEPLOY":
@@ -197,6 +214,8 @@ export function runStatusTitle(status: TaskRunStatus): string {
return "System failure";
case "CRASHED":
return "Crashed";
case "EXPIRED":
return "Expired";
default: {
assertNever(status);
}
@@ -118,6 +118,8 @@ export function TaskRunsTable({
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell>Test</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell>
<TableHeaderCell>Delayed until</TableHeaderCell>
<TableHeaderCell>TTL</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Go to page</span>
</TableHeaderCell>
@@ -125,7 +127,7 @@ export function TaskRunsTable({
</TableHeader>
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={9}>
<TableBlankRow colSpan={10}>
{!isLoading && <NoRuns title="No runs found" />}
</TableBlankRow>
) : runs.length === 0 ? (
@@ -187,6 +189,10 @@ export function TaskRunsTable({
<TableCell to={path}>
{run.createdAt ? <DateTime date={run.createdAt} /> : ""}
</TableCell>
<TableCell to={path}>
{run.delayUntil ? <DateTime date={run.delayUntil} /> : ""}
</TableCell>
<TableCell to={path}>{run.ttl ?? ""}</TableCell>
<RunActionsCell run={run} path={path} />
</TableRow>
);
@@ -0,0 +1,63 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
import { SelectItem } from "../primitives/Select";
export function TimezoneList({ timezones }: { timezones: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: timezones.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28,
});
return (
<div
ref={parentRef}
className="max-h-[calc(min(480px,var(--popover-available-height))-2.35rem)] overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
<TimezoneCell
key={virtualItem.key}
size={virtualItem.size}
start={virtualItem.start}
timezone={timezones[virtualItem.index]}
/>
))}
</div>
</div>
);
}
function TimezoneCell({
timezone,
size,
start,
}: {
timezone: string;
size: number;
start: number;
}) {
return (
<SelectItem
value={timezone}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${size}px`,
transform: `translateY(${start}px)`,
}}
>
{timezone}
</SelectItem>
);
}
+2
View File
@@ -40,6 +40,8 @@ export const TaskRunStatus = {
COMPLETED_WITH_ERRORS: "COMPLETED_WITH_ERRORS",
SYSTEM_FAILURE: "SYSTEM_FAILURE",
CRASHED: "CRASHED",
DELAYED: "DELAYED",
EXPIRED: "EXPIRED",
} as const satisfies Record<TaskRunStatusType, TaskRunStatusType>;
export const JobRunStatus = {
+6
View File
@@ -186,3 +186,9 @@ export { apiRateLimiter } from "./services/apiRateLimit.server";
export { socketIo } from "./v3/handleSocketIo.server";
export { wss } from "./v3/handleWebsockets.server";
export { registryProxy } from "./v3/registryProxy.server";
import { eventLoopMonitor } from "./eventLoopMonitor.server";
import { env } from "./env.server";
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
eventLoopMonitor.enable();
}
+22 -2
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { SecretStoreOptionsSchema } from "./services/secrets/secretStoreOptionsSchema.server";
import { z } from "zod";
import { isValidRegex } from "./utils/regex";
import { isValidDatabaseUrl } from "./utils/db";
@@ -151,7 +151,7 @@ const EnvironmentSchema = z.object({
PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
PROD_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
RUNTIME_WAIT_THRESHOLD_IN_MS: z.coerce.number().int().default(30000),
CHECKPOINT_THRESHOLD_IN_MS: z.coerce.number().int().default(30000),
// Internal OTEL environment variables
INTERNAL_OTEL_TRACE_EXPORTER_URL: z.string().optional(),
@@ -189,6 +189,26 @@ const EnvironmentSchema = z.object({
V2_MARQS_VERBOSE: z.string().default("0"),
V3_MARQS_CONCURRENCY_MONITOR_ENABLED: z.string().default("0"),
V2_MARQS_CONCURRENCY_MONITOR_ENABLED: z.string().default("0"),
/* Usage settings */
USAGE_EVENT_URL: z.string().optional(),
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
CENTS_PER_HOUR_MICRO: z.coerce.number().default(0),
CENTS_PER_HOUR_SMALL_1X: z.coerce.number().default(0),
CENTS_PER_HOUR_SMALL_2X: z.coerce.number().default(0),
CENTS_PER_HOUR_MEDIUM_1X: z.coerce.number().default(0),
CENTS_PER_HOUR_MEDIUM_2X: z.coerce.number().default(0),
CENTS_PER_HOUR_LARGE_1X: z.coerce.number().default(0),
CENTS_PER_HOUR_LARGE_2X: z.coerce.number().default(0),
BASE_RUN_COST_IN_CENTS: z.coerce.number().default(0),
USAGE_OPEN_METER_API_KEY: z.string().optional(),
USAGE_OPEN_METER_BASE_URL: z.string().optional(),
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000),
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
});
export type Environment = z.infer<typeof EnvironmentSchema>;
@@ -0,0 +1,77 @@
import { createHook } from "node:async_hooks";
import { singleton } from "./utils/singleton";
import { tracer } from "./v3/tracer.server";
const THRESHOLD_NS = 1e8; // 100ms
const cache = new Map<number, { type: string; start?: [number, number] }>();
function init(asyncId: number, type: string, triggerAsyncId: number, resource: any) {
cache.set(asyncId, {
type,
});
}
function destroy(asyncId: number) {
cache.delete(asyncId);
}
function before(asyncId: number) {
const cached = cache.get(asyncId);
if (!cached) {
return;
}
cache.set(asyncId, {
...cached,
start: process.hrtime(),
});
}
function after(asyncId: number) {
const cached = cache.get(asyncId);
if (!cached) {
return;
}
cache.delete(asyncId);
if (!cached.start) {
return;
}
const diff = process.hrtime(cached.start);
const diffNs = diff[0] * 1e9 + diff[1];
if (diffNs > THRESHOLD_NS) {
const time = diffNs / 1e6; // in ms
const newSpan = tracer.startSpan("event-loop-blocked", {
startTime: new Date(new Date().getTime() - time),
attributes: {
asyncType: cached.type,
label: "EventLoopMonitor",
},
});
newSpan.end();
}
}
export const eventLoopMonitor = singleton("eventLoopMonitor", () => {
const hook = createHook({ init, before, after, destroy });
return {
enable: () => {
console.log("🥸 Initializing event loop monitor");
hook.enable();
},
disable: () => {
console.log("🥸 Disabling event loop monitor");
hook.disable();
},
};
});
+1 -1
View File
@@ -26,7 +26,7 @@ export function useEventSource(
const eventSource = new EventSource(url, init);
eventSource.addEventListener(event ?? "message", handler);
// rest data if dependencies change
// reset data if dependencies change
setData(null);
function handler(event: MessageEvent) {
+7 -1
View File
@@ -84,7 +84,9 @@ export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
return `pk_${envSlug(envType)}_${apiKeyId(20)}`;
}
export function envSlug(environmentType: RuntimeEnvironment["type"]) {
export type EnvSlug = "dev" | "stg" | "prod" | "prev";
export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug {
switch (environmentType) {
case "DEVELOPMENT": {
return "dev";
@@ -100,3 +102,7 @@ export function envSlug(environmentType: RuntimeEnvironment["type"]) {
}
}
}
export function isEnvSlug(maybeSlug: string): maybeSlug is EnvSlug {
return ["dev", "stg", "prod", "prev"].includes(maybeSlug);
}
@@ -27,6 +27,7 @@ export function detectResponseIsTimeout(rawBody: string, response?: Response) {
return (
isResponseVercelTimeout(response) ||
isResponseCloudfrontTimeout(response) ||
isResponseDenoDeployTimeout(rawBody, response) ||
isResponseCloudflareTimeout(rawBody, response)
);
@@ -50,3 +51,7 @@ function isResponseVercelTimeout(response: Response) {
function isResponseDenoDeployTimeout(rawBody: string, response: Response) {
return response.status === 502 && rawBody.includes("TIME_LIMIT");
}
function isResponseCloudfrontTimeout(response: Response) {
return response.status === 504 && typeof response.headers.get("x-amz-cf-id") === "string";
}
@@ -65,59 +65,55 @@ export async function findEnvironmentById(id: string) {
}
export async function createNewSession(environment: RuntimeEnvironment, ipAddress: string) {
return prisma.$transaction(async (tx) => {
const session = await tx.runtimeEnvironmentSession.create({
data: {
environmentId: environment.id,
ipAddress,
},
});
await tx.runtimeEnvironment.update({
where: {
id: environment.id,
},
data: {
currentSessionId: session.id,
},
});
return session;
const session = await prisma.runtimeEnvironmentSession.create({
data: {
environmentId: environment.id,
ipAddress,
},
});
await prisma.runtimeEnvironment.update({
where: {
id: environment.id,
},
data: {
currentSessionId: session.id,
},
});
return session;
}
export async function disconnectSession(environmentId: string) {
return prisma.$transaction(async (tx) => {
const environment = await tx.runtimeEnvironment.findUnique({
where: {
id: environmentId,
},
});
if (!environment || !environment.currentSessionId) {
return null;
}
const session = await tx.runtimeEnvironmentSession.update({
where: {
id: environment.currentSessionId,
},
data: {
disconnectedAt: new Date(),
},
});
await tx.runtimeEnvironment.update({
where: {
id: environment.id,
},
data: {
currentSessionId: null,
},
});
return session;
const environment = await prisma.runtimeEnvironment.findUnique({
where: {
id: environmentId,
},
});
if (!environment || !environment.currentSessionId) {
return null;
}
const session = await prisma.runtimeEnvironmentSession.update({
where: {
id: environment.currentSessionId,
},
data: {
disconnectedAt: new Date(),
},
});
await prisma.runtimeEnvironment.update({
where: {
id: environment.id,
},
data: {
currentSessionId: null,
},
});
return session;
}
type DisplayableInputEnvironment = Prisma.RuntimeEnvironmentGetPayload<{
+2
View File
@@ -118,6 +118,7 @@ export function batchTaskRunItemStatusForRunStatus(
case TaskRunStatus.COMPLETED_WITH_ERRORS:
case TaskRunStatus.SYSTEM_FAILURE:
case TaskRunStatus.CRASHED:
case TaskRunStatus.EXPIRED:
return BatchTaskRunItemStatus.FAILED;
case TaskRunStatus.PENDING:
case TaskRunStatus.WAITING_FOR_DEPLOY:
@@ -125,6 +126,7 @@ export function batchTaskRunItemStatusForRunStatus(
case TaskRunStatus.RETRYING_AFTER_FAILURE:
case TaskRunStatus.EXECUTING:
case TaskRunStatus.PAUSED:
case TaskRunStatus.DELAYED:
return BatchTaskRunItemStatus.PENDING;
default:
assertNever(status);
@@ -1,11 +1,9 @@
import { z } from "zod";
import {
Direction,
FilterableEnvironment,
FilterableStatus,
filterableStatuses,
} from "~/components/runs/RunStatuses";
import { PrismaClient, prisma } from "~/db.server";
import { getUsername } from "~/utils/username";
import { BasePresenter } from "./v3/basePresenter.server";
@@ -29,8 +27,6 @@ const DEFAULT_PAGE_SIZE = 20;
export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
export class RunListPresenter extends BasePresenter {
public async call({
userId,
eventId,
@@ -1,27 +1,52 @@
import { User } from "@trigger.dev/database";
import { ScheduleMetadataSchema } from "@trigger.dev/core";
import { PrismaClient, prisma } from "~/db.server";
import { User } from "@trigger.dev/database";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { calculateNextScheduledEvent } from "~/services/schedules/nextScheduledEvent.server";
import { BasePresenter } from "./v3/basePresenter.server";
export class ScheduledTriggersPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
const DEFAULT_PAGE_SIZE = 20;
export class ScheduledTriggersPresenter extends BasePresenter {
public async call({
userId,
projectSlug,
organizationSlug,
direction = "forward",
pageSize = DEFAULT_PAGE_SIZE,
cursor,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
direction?: "forward" | "backward";
pageSize?: number;
cursor?: string;
}) {
const scheduled = await this.#prismaClient.scheduleSource.findMany({
const organization = await this._replica.organization.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: { some: { userId } },
},
});
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: projectSlug,
organizationId: organization.id,
},
});
const directionMultiplier = direction === "forward" ? 1 : -1;
const scheduled = await this._replica.scheduleSource.findMany({
select: {
id: true,
key: true,
@@ -50,23 +75,50 @@ export class ScheduledTriggersPresenter {
},
},
],
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
project: {
slug: projectSlug,
},
projectId: project.id,
},
},
orderBy: [{ id: "desc" }],
//take an extra record to tell if there are more
take: directionMultiplier * (pageSize + 1),
//skip the cursor if there is one
skip: cursor ? 1 : 0,
cursor: cursor
? {
id: cursor,
}
: undefined,
});
const hasMore = scheduled.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? scheduled.at(0)?.id : undefined;
if (hasMore) {
next = scheduled[pageSize - 1]?.id;
}
break;
case "backward":
if (hasMore) {
previous = scheduled[1]?.id;
next = scheduled[pageSize]?.id;
} else {
next = scheduled[pageSize - 1]?.id;
}
break;
}
const scheduledToReturn =
direction === "backward" && hasMore
? scheduled.slice(1, pageSize + 1)
: scheduled.slice(0, pageSize);
return {
scheduled: scheduled.map((s) => {
scheduled: scheduledToReturn.map((s) => {
const schedule = ScheduleMetadataSchema.parse(s.schedule);
const nextEventTimestamp = s.active
? calculateNextScheduledEvent(schedule, s.lastEventTimestamp)
@@ -78,6 +130,10 @@ export class ScheduledTriggersPresenter {
nextEventTimestamp,
};
}),
pagination: {
next,
previous,
},
};
}
}
@@ -12,6 +12,7 @@ import {
import { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { generatePresignedUrl } from "~/v3/r2.server";
import { BasePresenter } from "./basePresenter.server";
export class ApiRetrieveRunPresenter extends BasePresenter {
@@ -44,7 +45,9 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
}
let $payload: any;
let $payloadPresignedUrl: string | undefined;
let $output: any;
let $outputPresignedUrl: string | undefined;
if (showSecretDetails) {
const payloadPacket = await conditionallyImportPacket({
@@ -52,7 +55,19 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
dataType: taskRun.payloadType,
});
$payload = await parsePacket(payloadPacket);
if (
payloadPacket.dataType === "application/store" &&
typeof payloadPacket.data === "string"
) {
$payloadPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
payloadPacket.data,
"GET"
);
} else {
$payload = await parsePacket(payloadPacket);
}
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
const completedAttempt = taskRun.attempts.find(
@@ -65,7 +80,19 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
dataType: completedAttempt.outputType,
});
$output = await parsePacket(outputPacket);
if (
outputPacket.dataType === "application/store" &&
typeof outputPacket.data === "string"
) {
$outputPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
outputPacket.data,
"GET"
);
} else {
$output = await parsePacket(outputPacket);
}
}
}
}
@@ -80,13 +107,18 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
version: taskRun.lockedToVersion ? taskRun.lockedToVersion.version : undefined,
createdAt: taskRun.createdAt ?? undefined,
updatedAt: taskRun.updatedAt ?? undefined,
startedAt: taskRun.lockedAt ?? undefined,
startedAt: taskRun.startedAt ?? taskRun.lockedAt ?? undefined,
finishedAt: ApiRetrieveRunPresenter.isStatusFinished(apiStatus)
? taskRun.updatedAt
: undefined,
delayedUntil: taskRun.delayUntil ?? undefined,
payload: $payload,
payloadPresignedUrl: $payloadPresignedUrl,
output: $output,
outputPresignedUrl: $outputPresignedUrl,
isTest: taskRun.isTest,
ttl: taskRun.ttl ?? undefined,
expiredAt: taskRun.expiredAt ?? undefined,
schedule: taskRun.schedule
? {
id: taskRun.schedule.friendlyId,
@@ -142,6 +174,9 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
static apiStatusFromRunStatus(status: TaskRunStatus): RunStatus {
switch (status) {
case "DELAYED": {
return "DELAYED";
}
case "WAITING_FOR_DEPLOY": {
return "WAITING_FOR_DEPLOY";
}
@@ -176,6 +211,9 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
case "COMPLETED_WITH_ERRORS": {
return "FAILED";
}
case "EXPIRED": {
return "EXPIRED";
}
default: {
assertNever(status);
}
@@ -183,7 +221,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
}
static apiBooleanHelpersFromRunStatus(status: RunStatus) {
const isQueued = status === "QUEUED" || status === "WAITING_FOR_DEPLOY";
const isQueued = status === "QUEUED" || status === "WAITING_FOR_DEPLOY" || status === "DELAYED";
const isExecuting = status === "EXECUTING" || status === "REATTEMPTING" || status === "FROZEN";
const isCompleted =
status === "COMPLETED" ||
@@ -209,7 +209,10 @@ export class ApiRunListPresenter extends BasePresenter {
updatedAt: new Date(run.updatedAt),
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
isTest: run.isTest,
ttl: run.ttl ?? undefined,
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
env: {
id: run.environment.id,
name: run.environment.slug,
@@ -233,6 +236,8 @@ export class ApiRunListPresenter extends BasePresenter {
static apiStatusToRunStatuses(status: RunStatus): TaskRunStatus[] | TaskRunStatus {
switch (status) {
case "DELAYED":
return "DELAYED";
case "WAITING_FOR_DEPLOY": {
return "WAITING_FOR_DEPLOY";
}
@@ -266,6 +271,9 @@ export class ApiRunListPresenter extends BasePresenter {
case "FAILED": {
return "COMPLETED_WITH_ERRORS";
}
case "EXPIRED": {
return "EXPIRED";
}
default: {
assertNever(status);
}
@@ -1,6 +1,7 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getTimezones } from "~/utils/timezones.server";
type EditScheduleOptions = {
userId: string;
@@ -74,6 +75,7 @@ export class EditSchedulePresenter {
return {
possibleTasks: possibleTasks.map((task) => task.slug),
possibleEnvironments,
possibleTimezones: getTimezones(),
schedule: await this.#getExistingSchedule(friendlyId, possibleEnvironments),
};
}
@@ -91,6 +93,7 @@ export class EditSchedulePresenter {
externalId: true,
deduplicationKey: true,
userProvidedDeduplicationKey: true,
timezone: true,
taskIdentifier: true,
instances: {
select: {
@@ -4,8 +4,8 @@ import { Direction } from "~/components/runs/RunStatuses";
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { sqlDatabaseSchema } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { CANCELLABLE_STATUSES } from "~/v3/services/cancelTaskRun.server";
import { BasePresenter } from "./basePresenter.server";
import { isCancellableRunStatus } from "~/v3/taskStatus";
export type RunListOptions = {
userId?: string;
@@ -156,11 +156,15 @@ export class RunListPresenter extends BasePresenter {
runtimeEnvironmentId: string;
status: TaskRunStatus;
createdAt: Date;
startedAt: Date | null;
lockedAt: Date | null;
delayUntil: Date | null;
updatedAt: Date;
isTest: boolean;
spanId: string;
idempotencyKey: string | null;
ttl: string | null;
expiredAt: Date | null;
}[]
>`
SELECT
@@ -172,11 +176,15 @@ export class RunListPresenter extends BasePresenter {
tr."runtimeEnvironmentId" AS "runtimeEnvironmentId",
tr.status AS status,
tr."createdAt" AS "createdAt",
tr."startedAt" AS "startedAt",
tr."delayUntil" AS "delayUntil",
tr."lockedAt" AS "lockedAt",
tr."updatedAt" AS "updatedAt",
tr."isTest" AS "isTest",
tr."spanId" AS "spanId",
tr."idempotencyKey" AS "idempotencyKey"
tr."idempotencyKey" AS "idempotencyKey",
tr."ttl" AS "ttl",
tr."expiredAt" AS "expiredAt"
FROM
${sqlDatabaseSchema}."TaskRun" tr
LEFT JOIN
@@ -272,13 +280,16 @@ export class RunListPresenter extends BasePresenter {
const hasFinished = FINISHED_STATUSES.includes(run.status);
const startedAt = run.startedAt ?? run.lockedAt;
return {
id: run.id,
friendlyId: run.runFriendlyId,
number: Number(run.number),
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
startedAt: run.lockedAt ? run.lockedAt.toISOString() : undefined,
startedAt: startedAt ? startedAt.toISOString() : undefined,
delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined,
hasFinished,
finishedAt: hasFinished ? run.updatedAt.toISOString() : undefined,
isTest: run.isTest,
@@ -287,9 +298,11 @@ export class RunListPresenter extends BasePresenter {
taskIdentifier: run.taskIdentifier,
spanId: run.spanId,
isReplayable: true,
isCancellable: CANCELLABLE_STATUSES.includes(run.status),
isCancellable: isCancellableRunStatus(run.status),
environment: displayableEnvironment(environment, userId),
idempotencyKey: run.idempotencyKey ? run.idempotencyKey : undefined,
ttl: run.ttl ? run.ttl : undefined,
expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined,
};
}),
pagination: {
@@ -94,17 +94,24 @@ export class RunStreamPresenter {
eventEmitter.removeAllListeners();
unsubscribe().catch((error) => {
logger.error("RunStreamPresenter.abort.unsubscribe", {
runFriendlyId,
traceId: run.traceId,
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
unsubscribe()
.then(() => {
logger.info("RunStreamPresenter.abort.unsubscribe succeeded", {
runFriendlyId,
traceId: run.traceId,
});
})
.catch((error) => {
logger.error("RunStreamPresenter.abort.unsubscribe failed", {
runFriendlyId,
traceId: run.traceId,
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
});
});
};
});
}
@@ -22,6 +22,7 @@ export type ScheduleListItem = {
userProvidedDeduplicationKey: boolean;
cron: string;
cronDescription: string;
timezone: string;
externalId: string | null;
nextRun: Date;
lastRun: Date | undefined;
@@ -36,7 +37,6 @@ export type ScheduleList = Awaited<ReturnType<ScheduleListPresenter["call"]>>;
export type ScheduleListAppliedFilters = ScheduleList["filters"];
export class ScheduleListPresenter extends BasePresenter {
public async call({
userId,
projectId,
@@ -71,12 +71,23 @@ export class ScheduleListPresenter extends BasePresenter {
},
},
},
organization: {
select: {
maximumSchedulesLimit: true,
},
},
},
where: {
id: projectId,
},
});
const schedulesCount = await this._prisma.taskSchedule.count({
where: {
projectId,
},
});
//get all possible scheduled tasks
const possibleTasks = await this._replica.backgroundWorkerTask.findMany({
distinct: ["slug"],
@@ -140,6 +151,7 @@ export class ScheduleListPresenter extends BasePresenter {
userProvidedDeduplicationKey: true,
generatorExpression: true,
generatorDescription: true,
timezone: true,
externalId: true,
instances: {
select: {
@@ -218,10 +230,11 @@ export class ScheduleListPresenter extends BasePresenter {
userProvidedDeduplicationKey: schedule.userProvidedDeduplicationKey,
cron: schedule.generatorExpression,
cronDescription: schedule.generatorDescription,
timezone: schedule.timezone,
active: schedule.active,
externalId: schedule.externalId,
lastRun: latestRun?.createdAt,
nextRun: calculateNextScheduledTimestamp(schedule.generatorExpression),
nextRun: calculateNextScheduledTimestamp(schedule.generatorExpression, schedule.timezone),
environments: schedule.instances.map((instance) => {
const environment = project.environments.find((env) => env.id === instance.environmentId);
if (!environment) {
@@ -245,6 +258,10 @@ export class ScheduleListPresenter extends BasePresenter {
return displayableEnvironment(environment, userId);
}),
hasFilters,
limits: {
used: schedulesCount,
limit: project.organization.maximumSchedulesLimit,
},
filters: {
tasks,
environments,
@@ -311,7 +311,7 @@ export class TaskListPresenter extends BasePresenter {
>`
SELECT
tr."taskIdentifier",
AVG(EXTRACT(EPOCH FROM (tr."updatedAt" - tr."lockedAt"))) as duration
AVG(EXTRACT(EPOCH FROM (tr."updatedAt" - COALESCE(tr."startedAt", tr."lockedAt")))) as duration
FROM
${sqlDatabaseSchema}."TaskRun" as tr
WHERE
@@ -6,6 +6,7 @@ import {
TaskTriggerSource,
} from "@trigger.dev/database";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { getTimezones } from "~/utils/timezones.server";
import { getUsername } from "~/utils/username";
type TestTaskOptions = {
@@ -37,6 +38,7 @@ export type TestTask =
| {
triggerSource: "SCHEDULED";
task: Task;
possibleTimezones: string[];
runs: ScheduledRun[];
};
@@ -61,6 +63,7 @@ export type ScheduledRun = Omit<RawRun, "number" | "payload"> & {
timestamp: Date;
lastTimestamp?: Date;
externalId?: string;
timezone: string;
};
};
@@ -168,9 +171,11 @@ export class TestTaskPresenter {
),
};
case "SCHEDULED":
const possibleTimezones = getTimezones();
return {
triggerSource: "SCHEDULED",
task: taskWithEnvironment,
possibleTimezones,
runs: (
await Promise.all(
latestRuns.map(async (r) => {
@@ -195,6 +200,9 @@ export class TestTaskPresenter {
async function getScheduleTaskRunPayload(run: RawRun) {
const payload = await parsePacket({ data: run.payload, dataType: run.payloadType });
if (!payload.timezone) {
payload.timezone = "UTC";
}
const parsed = ScheduledTaskPayload.safeParse(payload);
return parsed;
}
@@ -1,8 +1,8 @@
import { ScheduleObject } from "@trigger.dev/core/v3";
import { PrismaClient, prisma } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
import { RunListPresenter } from "./RunListPresenter.server";
import { ScheduleObject } from "@trigger.dev/core/v3";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
type ViewScheduleOptions = {
userId?: string;
@@ -24,6 +24,7 @@ export class ViewSchedulePresenter {
friendlyId: true,
generatorExpression: true,
generatorDescription: true,
timezone: true,
externalId: true,
deduplicationKey: true,
userProvidedDeduplicationKey: true,
@@ -68,7 +69,7 @@ export class ViewSchedulePresenter {
}
const nextRuns = schedule.active
? nextScheduledTimestamps(schedule.generatorExpression, new Date(), 5)
? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5)
: [];
const runPresenter = new RunListPresenter(this.#prismaClient);
@@ -82,6 +83,7 @@ export class ViewSchedulePresenter {
return {
schedule: {
...schedule,
timezone: schedule.timezone,
cron: schedule.generatorExpression,
cronDescription: schedule.generatorDescription,
nextRuns,
@@ -105,6 +107,7 @@ export class ViewSchedulePresenter {
expression: result.schedule.cron,
description: result.schedule.cronDescription,
},
timezone: result.schedule.timezone,
externalId: result.schedule.externalId ?? undefined,
deduplicationKey: result.schedule.userProvidedDeduplicationKey
? result.schedule.deduplicationKey ?? undefined
@@ -2,6 +2,8 @@ import { NoSymbolIcon } from "@heroicons/react/20/solid";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ListPagination } from "~/components/ListPagination";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { DateTime } from "~/components/primitives/DateTime";
import { LabelValueStack } from "~/components/primitives/LabelValueStack";
@@ -17,30 +19,38 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { DirectionSchema } from "~/components/runs/RunStatuses";
import { ScheduledTriggersPresenter } from "~/presenters/ScheduledTriggersPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, docsPath } from "~/utils/pathBuilder";
const SearchSchema = z.object({
cursor: z.string().optional(),
direction: DirectionSchema.optional(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const url = new URL(request.url);
const s = Object.fromEntries(url.searchParams.entries());
const searchParams = SearchSchema.parse(s);
const presenter = new ScheduledTriggersPresenter();
const data = await presenter.call({
userId,
organizationSlug,
projectSlug: projectParam,
direction: searchParams.direction,
cursor: searchParams.cursor,
});
return typedjson(data);
};
export default function Integrations() {
const { scheduled } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
export default function Route() {
const { scheduled, pagination } = useTypedLoaderData<typeof loader>();
return (
<>
@@ -49,6 +59,10 @@ export default function Integrations() {
expression or an interval.
</Paragraph>
{scheduled.length > 0 && (
<ListPagination list={{ pagination }} className="mt-2 justify-end" />
)}
<Table containerClassName="mt-4">
<TableHeader>
<TableRow>
@@ -1,12 +1,13 @@
import {
BoltSlashIcon,
ChevronDownIcon,
ChevronRightIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} from "@heroicons/react/20/solid";
import type { Location } from "@remix-run/react";
import { useParams, useRevalidator } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useLoaderData, useParams, useRevalidator } from "@remix-run/react";
import { LoaderFunctionArgs, SerializeFrom } from "@remix-run/server-runtime";
import { Virtualizer } from "@tanstack/react-virtual";
import {
formatDurationMilliseconds,
@@ -17,10 +18,10 @@ import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon";
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
@@ -32,6 +33,7 @@ import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
@@ -54,7 +56,7 @@ import { useProject } from "~/hooks/useProject";
import { useReplaceLocation } from "~/hooks/useReplaceLocation";
import { Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useUser } from "~/hooks/useUser";
import { RunEvent, RunPresenter } from "~/presenters/v3/RunPresenter.server";
import { RunPresenter } from "~/presenters/v3/RunPresenter.server";
import { getResizableRunSettings, setResizableRunSettings } from "~/services/resizablePanel";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
@@ -67,8 +69,10 @@ import {
v3RunsPath,
} from "~/utils/pathBuilder";
import { SpanView } from "../resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { env } from "~/env.server";
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -85,10 +89,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
//resizable settings
const resizeSettings = await getResizableRunSettings(request);
return typedjson({
...result,
return {
run: result.run,
trace: result.trace,
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
resizeSettings,
});
};
};
function getSpanId(location: Location<any>): string | undefined {
@@ -97,7 +103,8 @@ function getSpanId(location: Location<any>): string | undefined {
}
export default function Page() {
const { run, trace, resizeSettings } = useTypedLoaderData<typeof loader>();
const { run, trace, resizeSettings, maximumLiveReloadingSetting } =
useLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const user = useUser();
@@ -167,6 +174,7 @@ export default function Page() {
}
const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt } = trace;
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
const changeToSpan = useDebounce((selectedSpan: string) => {
replaceSearchParam("span", selectedSpan);
@@ -175,6 +183,7 @@ export default function Page() {
const revalidator = useRevalidator();
const streamedEvents = useEventSource(v3RunStreamingPath(organization, project, run), {
event: "message",
disabled: !shouldLiveReload,
});
useEffect(() => {
if (streamedEvents !== null) {
@@ -252,8 +261,10 @@ export default function Page() {
}}
totalDuration={duration}
rootSpanStatus={rootSpanStatus}
rootStartedAt={rootStartedAt}
rootStartedAt={rootStartedAt ? new Date(rootStartedAt) : undefined}
environmentType={run.environment.type}
shouldLiveReload={shouldLiveReload}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
/>
</ResizablePanel>
<ResizableHandle withHandle />
@@ -274,7 +285,7 @@ export default function Page() {
}
type TasksTreeViewProps = {
events: RunEvent[];
events: TraceEvent[];
selectedId?: string;
parentRunFriendlyId?: string;
onSelectedIdChanged: (selectedId: string | undefined) => void;
@@ -282,6 +293,8 @@ type TasksTreeViewProps = {
rootSpanStatus: "executing" | "completed" | "failed";
rootStartedAt: Date | undefined;
environmentType: RuntimeEnvironmentType;
shouldLiveReload: boolean;
maximumLiveReloadingSetting: number;
};
function TasksTreeView({
@@ -293,6 +306,8 @@ function TasksTreeView({
rootSpanStatus,
rootStartedAt,
environmentType,
shouldLiveReload,
maximumLiveReloadingSetting,
}: TasksTreeViewProps) {
const [filterText, setFilterText] = useState("");
const [errorsOnly, setErrorsOnly] = useState(false);
@@ -367,7 +382,11 @@ function TasksTreeView({
This is the root task
</Paragraph>
)}
<LiveReloadingStatus rootSpanCompleted={rootSpanStatus !== "executing"} />
<LiveReloadingStatus
rootSpanCompleted={rootSpanStatus !== "executing"}
isLiveReloading={shouldLiveReload}
settingValue={maximumLiveReloadingSetting}
/>
</div>
<TreeView
parentRef={parentRef}
@@ -750,7 +769,7 @@ function TimelineView({
);
}
function NodeText({ node }: { node: RunEvent }) {
function NodeText({ node }: { node: TraceEvent }) {
const className = "truncate";
return (
<Paragraph variant="small" className={cn(className)}>
@@ -759,7 +778,7 @@ function NodeText({ node }: { node: RunEvent }) {
);
}
function NodeStatusIcon({ node }: { node: RunEvent }) {
function NodeStatusIcon({ node }: { node: TraceEvent }) {
if (node.data.level !== "TRACE") return null;
if (node.data.style.variant !== "primary") return null;
@@ -834,16 +853,40 @@ function ShowParentLink({ runFriendlyId }: { runFriendlyId: string }) {
);
}
function LiveReloadingStatus({ rootSpanCompleted }: { rootSpanCompleted: boolean }) {
function LiveReloadingStatus({
rootSpanCompleted,
isLiveReloading,
settingValue,
}: {
rootSpanCompleted: boolean;
isLiveReloading: boolean;
settingValue: number;
}) {
if (rootSpanCompleted) return null;
return (
<div className="flex items-center gap-1">
<PulsingDot />
<Paragraph variant="extra-small" className="whitespace-nowrap text-blue-500">
Live reloading
</Paragraph>
</div>
<>
{isLiveReloading ? (
<div className="flex items-center gap-1">
<PulsingDot />
<Paragraph variant="extra-small" className="whitespace-nowrap text-blue-500">
Live reloading
</Paragraph>
</div>
) : (
<SimpleTooltip
content={`Live reloading is disabled because you've exceeded ${settingValue} logs.`}
button={
<div className="flex items-center gap-1">
<BoltSlashIcon className="size-3.5 text-text-dimmed" />
<Paragraph variant="extra-small" className="whitespace-nowrap text-text-dimmed">
Live reloading disabled
</Paragraph>
</div>
}
></SimpleTooltip>
)}
</>
);
}
@@ -862,7 +905,7 @@ function SpanWithDuration({
showDuration,
node,
...props
}: Timeline.SpanProps & { node: RunEvent; showDuration: boolean }) {
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean }) {
return (
<Timeline.Span {...props}>
<motion.div
@@ -23,6 +23,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
@@ -180,12 +181,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
};
function PlaceholderText({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
<Paragraph className="w-auto">{title}</Paragraph>
</div>
);
}
export default function Page() {
const { schedule } = useTypedLoaderData<typeof loader>();
const location = useLocation();
const organization = useOrganization();
const project = useProject();
const user = useUser();
const isUtc = schedule.timezone === "UTC";
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
@@ -210,6 +220,7 @@ export default function Page() {
<Paragraph variant="small">{schedule.cronDescription}</Paragraph>
</div>
</Property>
<Property label="Timezone">{schedule.timezone}</Property>
<Property label="Environments">
<EnvironmentLabels size="small" environments={schedule.environments} />
</Property>
@@ -245,21 +256,35 @@ export default function Page() {
<Table>
<TableHeader>
<TableRow>
{!isUtc && <TableHeaderCell>{schedule.timezone}</TableHeaderCell>}
<TableHeaderCell>UTC</TableHeaderCell>
<TableHeaderCell>Local time</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{schedule.nextRuns.map((run, index) => (
<TableRow key={index}>
<TableCell>
<DateTime date={run} timeZone="UTC" />
</TableCell>
<TableCell>
<DateTime date={run} />
</TableCell>
</TableRow>
))}
{schedule.active ? (
schedule.nextRuns.length ? (
schedule.nextRuns.map((run, index) => (
<TableRow key={index}>
{!isUtc && (
<TableCell>
<DateTime date={run} timeZone={schedule.timezone} />
</TableCell>
)}
<TableCell>
<DateTime date={run} timeZone="UTC" />
</TableCell>
</TableRow>
))
) : (
<TableBlankRow colSpan={1}>
<PlaceholderText title="You found a bug" />
</TableBlankRow>
)
) : (
<TableBlankRow colSpan={1}>
<PlaceholderText title="Schedule disabled" />
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
@@ -21,7 +21,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function Page() {
const { schedule, possibleTasks, possibleEnvironments, showGenerateField } =
const { schedule, possibleTasks, possibleEnvironments, possibleTimezones, showGenerateField } =
useTypedLoaderData<typeof loader>();
return (
@@ -29,6 +29,7 @@ export default function Page() {
schedule={schedule}
possibleTasks={possibleTasks}
possibleEnvironments={possibleEnvironments}
possibleTimezones={possibleTimezones}
showGenerateField={showGenerateField}
/>
);
@@ -20,7 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function Page() {
const { schedule, possibleTasks, possibleEnvironments, showGenerateField } =
const { schedule, possibleTasks, possibleEnvironments, possibleTimezones, showGenerateField } =
useTypedLoaderData<typeof loader>();
return (
@@ -29,6 +29,7 @@ export default function Page() {
possibleTasks={possibleTasks}
possibleEnvironments={possibleEnvironments}
showGenerateField={showGenerateField}
possibleTimezones={possibleTimezones}
/>
);
}
@@ -4,12 +4,21 @@ import { Outlet, useLocation, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { Feedback } from "~/components/Feedback";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -78,6 +87,7 @@ export default function Page() {
possibleEnvironments,
hasFilters,
filters,
limits,
currentPage,
totalPages,
} = useTypedLoaderData<typeof loader>();
@@ -107,15 +117,43 @@ export default function Page() {
</PropertyTable>
</AdminDebugTooltip>
<LinkButton
LeadingIcon={PlusIcon}
to={`${v3NewSchedulePath(organization, project)}${location.search}`}
variant="primary/small"
shortcut={{ key: "n" }}
disabled={possibleTasks.length === 0 || isShowingNewPane}
>
New schedule
</LinkButton>
{limits.used >= limits.limit ? (
<Dialog>
<DialogTrigger asChild>
<Button
LeadingIcon={PlusIcon}
variant="primary/small"
shortcut={{ key: "n" }}
disabled={possibleTasks.length === 0 || isShowingNewPane}
>
New schedule
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your limit</DialogHeader>
<DialogDescription>
You've used {limits.used}/{limits.limit} of your schedules. You can request more
schedules.
</DialogDescription>
<DialogFooter>
<Feedback
button={<Button variant="primary/medium">Request more</Button>}
defaultValue="help"
/>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<LinkButton
LeadingIcon={PlusIcon}
to={`${v3NewSchedulePath(organization, project)}${location.search}`}
variant="primary/small"
shortcut={{ key: "n" }}
disabled={possibleTasks.length === 0 || isShowingNewPane}
>
New schedule
</LinkButton>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
@@ -142,7 +180,21 @@ export default function Page() {
</div>
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
<div className="mt-2 justify-end">
<div className="mt-2 justify-between">
<Paragraph variant="extra-small" className="mt-3">
<span className={limits.used >= limits.limit ? "text-warning" : ""}>
You've used {limits.used}/{limits.limit} of your schedules.
</span>{" "}
<Feedback
button={
<button className=" text-secondary transition hover:text-indigo-400">
Request more
</button>
}
defaultValue="help"
/>
.
</Paragraph>
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
@@ -236,12 +288,13 @@ function SchedulesTable({
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Task ID</TableHeaderCell>
<TableHeaderCell>External ID</TableHeaderCell>
<TableHeaderCell>CRON</TableHeaderCell>
<TableHeaderCell hiddenLabel>CRON description</TableHeaderCell>
<TableHeaderCell>External ID</TableHeaderCell>
<TableHeaderCell>Timezone</TableHeaderCell>
<TableHeaderCell>Next run</TableHeaderCell>
<TableHeaderCell>Last run</TableHeaderCell>
<TableHeaderCell>Deduplication key</TableHeaderCell>
<TableHeaderCell>Next run (UTC)</TableHeaderCell>
<TableHeaderCell>Last run (UTC)</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
</TableRow>
@@ -262,6 +315,9 @@ function SchedulesTable({
<TableCell to={path} className={cellClass}>
{schedule.taskIdentifier}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.externalId ? schedule.externalId : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.cron}
</TableCell>
@@ -269,17 +325,21 @@ function SchedulesTable({
{schedule.cronDescription}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.externalId ? schedule.externalId : ""}
{schedule.timezone}
</TableCell>
<TableCell to={path} className={cellClass}>
<DateTime date={schedule.nextRun} timeZone={schedule.timezone} />
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.lastRun ? (
<DateTime date={schedule.lastRun} timeZone={schedule.timezone} />
) : (
""
)}
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
<DateTime date={schedule.nextRun} timeZone="utc" />
</TableCell>
<TableCell to={path} className={cellClass}>
{schedule.lastRun ? <DateTime date={schedule.lastRun} timeZone="utc" /> : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
<EnvironmentLabels environments={schedule.environments} size="small" />
</TableCell>
@@ -26,8 +26,10 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Select } from "~/components/primitives/Select";
import { TextLink } from "~/components/primitives/TextLink";
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
import { TimezoneList } from "~/components/scheduled/timezones";
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import {
ScheduledRun,
@@ -95,7 +97,13 @@ export default function Page() {
return <StandardTaskForm task={result.task} runs={result.runs} />;
}
case "SCHEDULED": {
return <ScheduledTaskForm task={result.task} runs={result.runs} />;
return (
<ScheduledTaskForm
task={result.task}
runs={result.runs}
possibleTimezones={result.possibleTimezones}
/>
);
}
}
}
@@ -215,12 +223,21 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
);
}
function ScheduledTaskForm({ task, runs }: { task: TestTask["task"]; runs: ScheduledRun[] }) {
function ScheduledTaskForm({
task,
runs,
possibleTimezones,
}: {
task: TestTask["task"];
runs: ScheduledRun[];
possibleTimezones: string[];
}) {
const lastSubmission = useActionData();
const [selectedCodeSampleId, setSelectedCodeSampleId] = useState(runs.at(0)?.id);
const [timestampValue, setTimestampValue] = useState<Date | undefined>();
const [lastTimestampValue, setLastTimestampValue] = useState<Date | undefined>();
const [externalIdValue, setExternalIdValue] = useState<string | undefined>();
const [timezoneValue, setTimezoneValue] = useState<string>("UTC");
//set initial values
useEffect(() => {
@@ -233,11 +250,20 @@ function ScheduledTaskForm({ task, runs }: { task: TestTask["task"]; runs: Sched
setTimestampValue(initialRun.payload.timestamp);
setLastTimestampValue(initialRun.payload.lastTimestamp);
setExternalIdValue(initialRun.payload.externalId);
setTimezoneValue(initialRun.payload.timezone);
}, [selectedCodeSampleId]);
const [
form,
{ timestamp, lastTimestamp, externalId, triggerSource, taskIdentifier, environmentId },
{
timestamp,
lastTimestamp,
externalId,
triggerSource,
taskIdentifier,
environmentId,
timezone,
},
] = useForm({
id: "test-task-scheduled",
// TODO: type this
@@ -314,6 +340,30 @@ function ScheduledTaskForm({ task, runs }: { task: TestTask["task"]; runs: Sched
</Hint>
<FormError id={lastTimestamp.errorId}>{lastTimestamp.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={timezone.id}>Timezone</Label>
<Select
{...conform.select(timezone)}
placeholder="Select a timezone"
defaultValue={timezoneValue}
value={timezoneValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setTimezoneValue(e);
}}
items={possibleTimezones}
filter={{ keys: [(item) => item.replace(/\//g, " ").replace(/_/g, " ")] }}
dropdownIcon
variant="tertiary/medium"
>
{(matches) => <TimezoneList timezones={matches} />}
</Select>
<Hint>
The Timestamp and Last timestamp are in UTC so this just changes the timezone
string that comes through in the payload.
</Hint>
<FormError id={timezone.errorId}>{timezone.error}</FormError>
</InputGroup>
<InputGroup>
<Label required={false} htmlFor={externalId.id}>
External ID
@@ -0,0 +1,35 @@
import { Link } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { LogoIcon } from "~/components/LogoIcon";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { getTimezones } from "~/utils/timezones.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
return typedjson({
timezones: getTimezones(),
});
};
export default function Page() {
const { timezones } = useTypedLoaderData<typeof loader>();
return (
<div className="grid grid-rows-[2.5rem,1fr]">
<div className="flex items-center border-b border-b-grid-dimmed px-3">
<Link to="/">
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
</Link>
</div>
<div className="overflow-y-auto p-8 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Header1 spacing>Supported timezones</Header1>
<Paragraph spacing>We support these timezones when creating a schedule.</Paragraph>
<ul className="">
{timezones.map((timezone) => (
<li key={timezone}>{timezone}</li>
))}
</ul>
</div>
</div>
);
}
+9 -8
View File
@@ -1,5 +1,8 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { InitializeDeploymentRequestBody, InitializeDeploymentResponseBody } from "@trigger.dev/core/v3";
import {
InitializeDeploymentRequestBody,
InitializeDeploymentResponseBody,
} from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
@@ -37,13 +40,11 @@ export async function action({ request, params }: ActionFunctionArgs) {
contentHash: deployment.contentHash,
shortCode: deployment.shortCode,
version: deployment.version,
externalBuildData: deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
externalBuildData:
deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
imageTag,
registryHost: env.DEPLOY_REGISTRY_HOST
}
registryHost: env.DEPLOY_REGISTRY_HOST,
};
return json(
responseBody,
{ status: 200 }
);
return json(responseBody, { status: 200 });
}
+20 -55
View File
@@ -1,10 +1,8 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { env } from "~/env.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { r2 } from "~/v3/r2.server";
import { generatePresignedUrl } from "~/v3/r2.server";
const ParamsSchema = z.object({
"*": z.string(),
@@ -26,34 +24,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.parse(params);
const filename = parsedParams["*"];
if (!env.OBJECT_STORE_BASE_URL) {
return json({ error: "Object store base URL is not set" }, { status: 500 });
}
if (!r2) {
return json({ error: "Object store credentials are not set" }, { status: 500 });
}
const url = new URL(env.OBJECT_STORE_BASE_URL);
url.pathname = `/packets/${authenticationResult.environment.project.externalRef}/${authenticationResult.environment.slug}/${filename}`;
url.searchParams.set("X-Amz-Expires", "300"); // 5 minutes
const signed = await r2.sign(
new Request(url, {
method: "PUT",
}),
{
aws: { signQuery: true },
}
const presignedUrl = await generatePresignedUrl(
authenticationResult.environment.project.externalRef,
authenticationResult.environment.slug,
filename,
"PUT"
);
logger.debug("Generated presigned URL", {
url: signed.url,
headers: Object.fromEntries(signed.headers),
});
if (!presignedUrl) {
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
}
// Caller can now use this URL to upload to that object.
return json({ presignedUrl: signed.url });
return json({ presignedUrl });
}
export async function loader({ request, params }: ActionFunctionArgs) {
@@ -67,35 +50,17 @@ export async function loader({ request, params }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.parse(params);
const filename = parsedParams["*"];
if (!env.OBJECT_STORE_BASE_URL) {
return json({ error: "Object store base URL is not set" }, { status: 500 });
}
if (!r2) {
return json({ error: "Object store credentials are not set" }, { status: 500 });
}
const url = new URL(env.OBJECT_STORE_BASE_URL);
url.pathname = `/packets/${authenticationResult.environment.project.externalRef}/${authenticationResult.environment.slug}/${filename}`;
url.searchParams.set("X-Amz-Expires", "300"); // 5 minutes
const signed = await r2.sign(
new Request(url, {
method: request.method,
}),
{
aws: { signQuery: true },
}
const presignedUrl = await generatePresignedUrl(
authenticationResult.environment.project.externalRef,
authenticationResult.environment.slug,
filename,
"GET"
);
logger.debug("Generated presigned URL", {
url: signed.url,
headers: Object.fromEntries(signed.headers),
});
if (!presignedUrl) {
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
}
const getUrl = new URL(url.href);
getUrl.searchParams.delete("X-Amz-Expires");
// Caller can now use this URL to upload to that object.
return json({ presignedUrl: signed.url });
// Caller can now use this URL to fetch that object.
return json({ presignedUrl });
}
@@ -123,7 +123,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const repository = new EnvironmentVariablesRepository();
const variables = await repository.getEnvironment(environment.project.id, environment.id, true);
const variables = await repository.getEnvironment(environment.project.id, environment.id);
const environmentVariable = variables.find((v) => v.key === parsedParams.data.name);
@@ -80,7 +80,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const repository = new EnvironmentVariablesRepository();
const variables = await repository.getEnvironment(environment.project.id, environment.id, true);
const variables = await repository.getEnvironment(environment.project.id, environment.id);
return json(variables.map((variable) => ({ name: variable.key, value: variable.value })));
}
@@ -2,7 +2,7 @@ import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { resolveVariablesForEnvironment } from "~/v3/environmentVariables/environmentVariablesRepository.server";
const ParamsSchema = z.object({
projectRef: z.string(),
@@ -41,9 +41,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
return json({ error: "Project not found" }, { status: 404 });
}
const repository = new EnvironmentVariablesRepository();
const variables = await repository.getEnvironmentVariables(project.id, authenticatedEnv.id);
const variables = await resolveVariablesForEnvironment(authenticatedEnv);
return json({
variables: variables.reduce((acc: Record<string, string>, variable) => {
@@ -3,6 +3,7 @@ import { PrismaClientOrTransaction, prisma } from "~/db.server";
import { taskWithAttemptsToServerTask } from "~/models/task.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { startActiveSpan } from "~/v3/tracer.server";
export class CompleteRunTaskService {
#prismaClient: PrismaClientOrTransaction;
@@ -17,76 +18,81 @@ export class CompleteRunTaskService {
id: string,
taskBody: CompleteTaskBodyOutput
): Promise<ServerTask | undefined> {
const existingTask = await this.#prismaClient.task.findUnique({
where: {
id,
},
include: {
run: true,
attempts: {
where: {
status: "PENDING",
},
orderBy: {
number: "desc",
},
take: 1,
return startActiveSpan("CompleteRunTaskService.call", async (span) => {
span.setAttribute("runId", runId);
span.setAttribute("taskId", id);
const existingTask = await this.#prismaClient.task.findUnique({
where: {
id,
},
include: {
run: true,
attempts: {
where: {
status: "PENDING",
},
orderBy: {
number: "desc",
},
take: 1,
},
},
},
});
if (!existingTask) {
return;
}
if (existingTask.runId !== runId) {
return;
}
if (existingTask.run.environmentId !== environment.id) {
return;
}
if (
existingTask.status === "COMPLETED" ||
existingTask.status === "ERRORED" ||
existingTask.status === "CANCELED"
) {
logger.debug("Task already completed", {
existingTask,
});
return taskWithAttemptsToServerTask(existingTask);
}
if (!existingTask) {
return;
}
if (existingTask.attempts.length === 1) {
await this.#prismaClient.taskAttempt.update({
if (existingTask.runId !== runId) {
return;
}
if (existingTask.run.environmentId !== environment.id) {
return;
}
if (
existingTask.status === "COMPLETED" ||
existingTask.status === "ERRORED" ||
existingTask.status === "CANCELED"
) {
logger.debug("Task already completed", {
taskId: id,
});
return taskWithAttemptsToServerTask(existingTask);
}
if (existingTask.attempts.length === 1) {
await this.#prismaClient.taskAttempt.update({
where: {
id: existingTask.attempts[0].id,
},
data: {
status: "COMPLETED",
},
});
}
const updatedTask = await this.#prismaClient.task.update({
where: {
id: existingTask.attempts[0].id,
id,
},
data: {
status: "COMPLETED",
output: taskBody.output as any,
outputIsUndefined: typeof taskBody.output === "undefined",
completedAt: new Date(),
outputProperties: taskBody.properties,
},
include: {
attempts: true,
run: true,
},
});
}
const updatedTask = await this.#prismaClient.task.update({
where: {
id,
},
data: {
status: "COMPLETED",
output: taskBody.output as any,
outputIsUndefined: typeof taskBody.output === "undefined",
completedAt: new Date(),
outputProperties: taskBody.properties,
},
include: {
attempts: true,
run: true,
},
return taskWithAttemptsToServerTask(updatedTask);
});
return taskWithAttemptsToServerTask(updatedTask);
}
}
@@ -9,8 +9,10 @@ import {
import { z } from "zod";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { CompleteRunTaskService } from "./CompleteRunTaskService.server";
import { startActiveSpan } from "~/v3/tracer.server";
import { parseRequestJsonAsync } from "~/utils/parseRequestJson.server";
import { FailRunTaskService } from "../api.v1.runs.$runId.tasks.$id.fail/FailRunTaskService.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -44,46 +46,51 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Invalid headers" }, { status: 400 });
}
// Check the content size of the request and make sure it's not too large
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > 3 * 1024 * 1024) {
const service = new FailRunTaskService();
await service.call(authenticatedEnv, runId, id, {
error: {
message: "Task output is too large. The limit is 3MB",
},
});
return json({ error: "Task output is too large. The limit is 3MB" }, { status: 413 });
}
const { "trigger-version": triggerVersion } = headers.data;
// Now parse the request body
const anyBody = await request.json();
logger.debug("CompleteRunTaskService.call() request body", {
body: anyBody,
runId,
id,
});
const anyBody = await parseRequestJsonAsync(request, { runId });
if (triggerVersion === API_VERSIONS.SERIALIZED_TASK_OUTPUT) {
const body = CompleteTaskBodyV2InputSchema.safeParse(anyBody);
const body = await startActiveSpan("CompleteTaskBodyV2InputSchema.safeParse()", async () => {
return CompleteTaskBodyV2InputSchema.safeParse(anyBody);
});
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
// Make sure the length of the output is less than 3MB
if (body.data.output && body.data.output.length > 3 * 1024 * 1024) {
return json({ error: "Output must be less than 3MB" }, { status: 400 });
}
return await completeRunTask(authenticatedEnv, runId, id, {
...body.data,
output: body.data.output ? (JSON.parse(body.data.output) as any) : undefined,
});
} else {
const body = CompleteTaskBodyInputSchema.safeParse(anyBody);
const body = await startActiveSpan("CompleteTaskBodyInputSchema.safeParse()", async () => {
return CompleteTaskBodyInputSchema.omit({ output: true }).safeParse(anyBody);
});
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
// Make sure the length of the output is less than 3MB
if (JSON.stringify(body.data.output).length > 3 * 1024 * 1024) {
return json({ error: "Output must be less than 3MB" }, { status: 400 });
}
const output = (anyBody as any).output;
return await completeRunTask(authenticatedEnv, runId, id, body.data);
return await completeRunTask(authenticatedEnv, runId, id, { ...body.data, output });
}
}
@@ -98,12 +105,6 @@ async function completeRunTask(
try {
const task = await service.call(environment, runId, id, taskBody);
logger.debug("CompleteRunTaskService.call() response body", {
runId,
id,
task,
});
if (!task) {
return json({ message: "Task not found" }, { status: 404 });
}
@@ -57,10 +57,6 @@ export class FailRunTaskService {
existingTask.status === "ERRORED" ||
existingTask.status === "CANCELED"
) {
logger.debug("Task already completed", {
existingTask,
});
return existingTask;
}
@@ -6,6 +6,8 @@ import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { RunTaskService } from "~/services/tasks/runTask.server";
import { ChangeRequestLazyLoadedCachedTasks } from "./ChangeRequestLazyLoadedCachedTasks.server";
import { startActiveSpan } from "~/v3/tracer.server";
import { parseRequestJsonAsync } from "~/utils/parseRequestJson.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -17,6 +19,8 @@ const HeadersSchema = z.object({
"x-cached-tasks-cursor": z.string().optional().nullable(),
});
const BodySchema = RunTaskBodyOutputSchema.omit({ params: true });
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
@@ -44,18 +48,26 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { runId } = ParamsSchema.parse(params);
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > 3 * 1024 * 1024) {
return json({ error: "Request body too large" }, { status: 413 });
}
// Now parse the request body
const anyBody = await request.json();
const anyBody = await parseRequestJsonAsync(request, { runId });
logger.debug("RunTaskService.call() request body", {
body: anyBody,
runId,
idempotencyKey,
triggerVersion,
cachedTasksCursor,
});
const body = RunTaskBodyOutputSchema.safeParse(anyBody);
const body = await startActiveSpan(
"BodySchema.safeParse",
async () => {
return BodySchema.safeParse(anyBody);
},
{
attributes: {
runId,
},
}
);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
@@ -64,12 +76,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
const service = new RunTaskService();
try {
const task = await service.call(runId, idempotencyKey, body.data);
logger.debug("RunTaskService.call() response body", {
runId,
idempotencyKey,
task,
const task = await service.call(runId, idempotencyKey, {
...body.data,
params: (anyBody as any).params,
});
if (!task) {
@@ -84,7 +93,6 @@ export async function action({ request, params }: ActionFunctionArgs) {
logger.debug(
"RunTaskService.call() response migrating with ChangeRequestLazyLoadedCachedTasks",
{
responseBody,
cachedTasksCursor,
}
);
@@ -0,0 +1,85 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { RescheduleRunRequestBody } from "@trigger.dev/core/v3/schemas";
import { z } from "zod";
import { prisma } from "~/db.server";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { RescheduleTaskRunService } from "~/v3/services/rescheduleTaskRun.server";
const ParamsSchema = z.object({
runParam: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or missing API Key" }, { status: 401 });
}
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return json({ error: "Invalid or missing run ID" }, { status: 400 });
}
const { runParam } = parsed.data;
const taskRun = await prisma.taskRun.findUnique({
where: {
friendlyId: runParam,
runtimeEnvironmentId: authenticationResult.environment.id,
},
});
if (!taskRun) {
return json({ error: "Run not found" }, { status: 404 });
}
const anyBody = await request.json();
const body = RescheduleRunRequestBody.safeParse(anyBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const service = new RescheduleTaskRunService();
try {
const updatedRun = await service.call(taskRun, body.data);
if (!updatedRun) {
return json({ error: "An unknown error occurred" }, { status: 500 });
}
const presenter = new ApiRetrieveRunPresenter();
const result = await presenter.call(
updatedRun.friendlyId,
authenticationResult.environment,
true
);
if (!result) {
return json({ error: "Run not found" }, { status: 404 });
}
return json(result);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 400 });
} else if (error instanceof Error) {
return json({ error: error.message }, { status: 500 });
} else {
return json({ error: "An unknown error occurred" }, { status: 500 });
}
}
}
@@ -79,9 +79,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
friendlyId: parsedParams.data.scheduleId,
taskIdentifier: body.data.task,
cron: body.data.cron,
timezone: body.data.timezone,
environments: [authenticationResult.environment.id],
externalId: body.data.externalId,
deduplicationKey: body.data.deduplicationKey,
};
const schedule = await service.call(authenticationResult.environment.projectId, options);
@@ -95,6 +95,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
expression: schedule.cron,
description: schedule.cronDescription,
},
timezone: schedule.timezone,
externalId: schedule.externalId ?? undefined,
deduplicationKey: schedule.deduplicationKey,
environments: schedule.environments,
@@ -43,6 +43,7 @@ export async function action({ request }: ActionFunctionArgs) {
environments: [authenticationResult.environment.id],
externalId: body.data.externalId,
deduplicationKey: body.data.deduplicationKey,
timezone: body.data.timezone,
};
const schedule = await service.call(authenticationResult.environment.projectId, options);
@@ -56,6 +57,7 @@ export async function action({ request }: ActionFunctionArgs) {
expression: schedule.cron,
description: schedule.cronDescription,
},
timezone: schedule.timezone,
externalId: schedule.externalId ?? undefined,
deduplicationKey: schedule.deduplicationKey,
environments: schedule.environments,
@@ -111,6 +113,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
expression: schedule.cron,
description: schedule.cronDescription,
},
timezone: schedule.timezone,
deduplicationKey: schedule.userProvidedDeduplicationKey
? schedule.deduplicationKey
: undefined,
@@ -7,6 +7,7 @@ import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { BatchTriggerTaskService } from "~/v3/services/batchTriggerTask.server";
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
import { env } from "~/env.server";
const ParamsSchema = z.object({
taskId: z.string(),
@@ -43,6 +44,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { taskId } = ParamsSchema.parse(params);
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) {
return json({ error: "Request body too large" }, { status: 413 });
}
// Now parse the request body
const anyBody = await request.json();
@@ -2,9 +2,12 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { TriggerTaskRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { env } from "~/env.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { parseRequestJsonAsync } from "~/utils/parseRequestJson.server";
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
import { startActiveSpan } from "~/v3/tracer.server";
const ParamsSchema = z.object({
taskId: z.string(),
@@ -32,6 +35,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) {
return json({ error: "Request body too large" }, { status: 413 });
}
const rawHeaders = Object.fromEntries(request.headers);
const headers = HeadersSchema.safeParse(rawHeaders);
@@ -52,9 +61,11 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { taskId } = ParamsSchema.parse(params);
// Now parse the request body
const anyBody = await request.json();
const anyBody = await parseRequestJsonAsync(request, { taskId });
const body = TriggerTaskRequestBody.safeParse(anyBody);
const body = await startActiveSpan("TriggerTaskRequestBody.safeParse()", async (span) => {
return TriggerTaskRequestBody.safeParse(anyBody);
});
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
@@ -76,17 +87,23 @@ export async function action({ request, params }: ActionFunctionArgs) {
idempotencyKey,
triggerVersion,
headers: Object.fromEntries(request.headers),
body: body.data,
options: body.data.options,
isFromWorker,
traceContext,
});
const run = await service.call(taskId, authenticationResult.environment, body.data, {
idempotencyKey: idempotencyKey ?? undefined,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
});
const run = await service.call(
taskId,
authenticationResult.environment,
{ ...body.data },
// { ...body.data, payload: (anyBody as any).payload },
{
idempotencyKey: idempotencyKey ?? undefined,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
}
);
if (!run) {
return json({ error: "Task not found" }, { status: 404 });
@@ -0,0 +1,28 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { apiCors } from "~/utils/apiCors";
import { getTimezones } from "~/utils/timezones.server";
const SearchParamsSchema = z.object({
excludeUtc: z.preprocess((value) => value === "true", z.boolean()).default(false),
});
export async function loader({ request }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const rawSearchParams = new URL(request.url).searchParams;
const params = SearchParamsSchema.safeParse(Object.fromEntries(rawSearchParams.entries()));
if (!params.success) {
return apiCors(
request,
json({ error: "Invalid request parameters", issues: params.error.issues }, { status: 400 })
);
}
const timezones = getTimezones(!params.data.excludeUtc);
return apiCors(request, json({ timezones }));
}
@@ -0,0 +1,97 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { MachinePresetName } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { validateJWTTokenAndRenew } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { workerQueue } from "~/services/worker.server";
import { machinePresetFromName } from "~/v3/machinePresets.server";
import { reportUsageEvent } from "~/v3/openMeter.server";
const JWTPayloadSchema = z.object({
environment_id: z.string(),
org_id: z.string(),
project_id: z.string(),
run_id: z.string(),
machine_preset: z.string(),
});
const BodySchema = z.object({
durationMs: z.number(),
});
export async function action({ request }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const jwtResult = await validateJWTTokenAndRenew(request, JWTPayloadSchema);
if (!jwtResult) {
return { status: 401, body: "Unauthorized" };
}
const rawJson = await request.json();
const json = BodySchema.safeParse(rawJson);
if (!json.success) {
logger.error("Failed to parse request body", { rawJson });
return { status: 400, body: "Bad Request" };
}
const preset = machinePresetFromName(jwtResult.payload.machine_preset as MachinePresetName);
logger.debug("[/api/v1/usage/ingest] Reporting usage", { jwtResult, json: json.data, preset });
if (json.data.durationMs > 0) {
const costInCents = json.data.durationMs * preset.centsPerMs;
await prisma.taskRun.update({
where: {
id: jwtResult.payload.run_id,
},
data: {
usageDurationMs: {
increment: json.data.durationMs,
},
costInCents: {
increment: json.data.durationMs * preset.centsPerMs,
},
},
});
try {
await reportUsageEvent({
source: "webapp",
type: "usage",
subject: jwtResult.payload.org_id,
data: {
durationMs: json.data.durationMs,
costInCents: String(costInCents),
},
});
} catch (e) {
logger.error("Failed to report usage event, enqueing v3.reportUsage", { error: e });
await workerQueue.enqueue("v3.reportUsage", {
orgId: jwtResult.payload.org_id,
data: {
costInCents: String(costInCents),
},
additionalData: {
durationMs: json.data.durationMs,
},
});
}
}
return new Response(null, {
status: 200,
headers: {
"x-trigger-jwt": jwtResult.jwt,
},
});
}
@@ -33,6 +33,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
const taskRun = await prisma.taskRun.findUnique({
where: {
friendlyId: runParam,
runtimeEnvironmentId: authenticationResult.environment.id,
},
});
@@ -0,0 +1,74 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { EnvSlug, isEnvSlug } from "~/models/api-key.server";
import { requireUserId } from "~/services/session.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { projectRef } = ParamsSchema.parse(params);
const project = await prisma.project.findFirst({
where: {
externalRef: projectRef,
organization: {
members: {
some: {
userId,
},
},
},
},
include: {
organization: true,
},
});
if (!project) {
return new Response("Project not found", { status: 404 });
}
const url = new URL(request.url);
const envSlug = url.searchParams.get("envSlug");
// Get the environment from the slug
if (envSlug && isEnvSlug(envSlug)) {
const env = await getEnvFromSlug(project.id, userId, envSlug);
if (env) {
url.searchParams.set("environments", env.id);
}
url.searchParams.delete("envSlug");
}
return redirect(
`/orgs/${project.organization.slug}/projects/v3/${project.slug}/runs${url.search}`
);
}
async function getEnvFromSlug(projectId: string, userId: string, envSlug: EnvSlug) {
if (envSlug === "dev") {
return await prisma.runtimeEnvironment.findFirst({
where: {
projectId,
slug: envSlug,
orgMember: {
userId,
},
},
});
}
return await prisma.runtimeEnvironment.findFirst({
where: {
projectId,
slug: envSlug,
},
});
}
@@ -0,0 +1,40 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const validatedParams = ParamsSchema.parse(params);
const project = await prisma.project.findFirst({
where: {
externalRef: validatedParams.projectRef,
organization: {
members: {
some: {
userId,
},
},
},
},
include: {
organization: true,
},
});
if (!project) {
return new Response("Not found", { status: 404 });
}
const url = new URL(request.url);
return redirect(
`/orgs/${project.organization.slug}/projects/v3/${project.slug}/test${url.search}`
);
}

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