Compare commits

...

74 Commits

Author SHA1 Message Date
github-actions[bot] a03783d1a0 Release v4.0.4
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / units (push) Failing after 19s
* chore: Update version for release

* Release v4.0.4

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <eallam@icloud.com>
2025-09-18 23:18:59 +01:00
Eric Allam 0178bdbb00 fix(packages): remove effect from optional peer dependencies (#2527)
Fixes ERROR: Could not resolve "effect"
2025-09-18 23:09:37 +01:00
github-actions[bot] ad51168181 Release v4.0.3 (#2486)
* chore: Update version for release

* Release v4.0.3

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-09-18 21:28:30 +01:00
nicktrn db87295049 fix(runner): reduce restore recovery time and deprecated runner false positives (#2523)
* fix(runner): improve restore detection

* chore(supervisor): skip schema parsing when debug logs disabled

* fix(runner): deprecation race condition

* add changeset
2025-09-18 16:59:44 +01:00
Saadi Myftija a3ef6ea236 feat: separate deployment initialize and start steps (#2522)
* Enable setting the initial status on deployment creation

* Expose endpoint to start deployments

* Extend build timeout on deployment start

* Use separate timeout value for queued deployments

* Add startedAt to the deployment schema

* Show the new startedAt instead of createdAt in the dashboard

* Show github user tag also in the deployment details page

* Show `pending` deployment status as `queued` in the dashboard

* Apply some good 🐰 suggestions

* Add missing return
2025-09-18 16:12:17 +02:00
James Ritchie ae22000409 chore(webapp): pricing page improvements (#2457)
* Improve styling of onboarding pricing plans

* type only import

* Improve the onboarding plan page so it scrolls on smaller screens

* Adds additional pricing for bolt-ons for the Pro plan

* fix text wrapping issue

* add bg color back in
2025-09-18 14:31:06 +01:00
James Ritchie 2bbf8ec6cd chore(webapp): a new cancelation reason (#2465)
* Adds a new cancelation option

* Better wording
2025-09-18 14:30:25 +01:00
James Ritchie c8858edf0a New jump to parent or root run buttons (#2067)
* Change the color to indigo

* Pro tier pricing information now matches the marketing site

* Update the button styles to secondary

* WIP adding separate links to Parent and Root runs

* TextLink now supports optional shortcuts

* Adds shortcut keys to the root and parent links + the shortcut help panel

* Adds new icons for root and parent

* root friendlyId works

* Updates icons for jump to root and parent

* Copy tweak

* Improve how the Free tier shows no preview branches

* Improve the wording in the tooltip

* Align the x icon better

* Show price for additional preview branches

* Change the shortcut key

* Fixes button alignment

* Adds nested dependencies task hello-world

* Fixes typo “Cancelled”

* Removes taskIdentifier, not needed

* Removes unused taskIdentifier
2025-09-18 14:29:47 +01:00
Eric Allam 9c087646bf fix(engine): prevent race condition that prevents triggerAndWait runs from resuming by atomically creating associated waitpoint records (#2519) 2025-09-17 13:58:17 +01:00
Saadi Myftija 691903cf58 feat(api): accept OATs in preview branch related endpoints (#2517)
Adjusts the authentication in a couple of endpoints to accept OATs too.
2025-09-17 14:10:25 +02:00
nicktrn 885ae5e06f fix(runner): immediate poll to decrease restore time (#2516)
* fix(runner): immediate poll to decrease restore time

* chore: bump default prerelease tag

* fix(cli): s is not a function

* add changeset
2025-09-16 16:17:04 +01:00
Saadi Myftija 501a383bcd feat: expose project build settings (#2507)
This PR enables setting project build settings in the settings page:
root directory, install command and trigger config file path.

For most cases there should be no need to set these explicitly.
2025-09-16 15:02:09 +02:00
nicktrn 3c199e6d9c chore(supervisor): remove deprecated route (#2513) 2025-09-16 13:47:07 +01:00
nicktrn a55294b7dd fix(run-engine): retry SIGSEGV errors (#2514) 2025-09-16 13:46:58 +01:00
Eric Allam c9d1aadfc3 core(docs): link to the v4 self-hosting in the introduction (#2512) 2025-09-16 13:26:55 +02:00
Eric Allam 08702cd710 chore(mcp): Add our MCP server to the official MCP registry (#2510)
See: 
https://blog.modelcontextprotocol.io/posts/2025-09-08-mcp-registry-preview/
2025-09-16 10:44:41 +01:00
Dan 04dcb81496 Added product image generator and replicate examples (#2511)
* Added nano banana task

* Renamed the file and updated docs.json

* Added product imgae generator demo project

* Removed old section

* Code tweak
2025-09-16 10:26:11 +01:00
Eric Allam 0f9b83db09 fix(core): prettyPrintingPacket will now do a structuredClone on non-circular references instead of outputting [Circular] (#2508)
* Mo-Stashed changes

* fix(core): prettyPrintingPacket will now do a structuredClone on non-circular references instead of outputting [Circular]

This also fixes an issue with replaying of runs that include 
non-circular references
2025-09-15 17:43:46 +01:00
Saadi Myftija 7d333e5b3c feat(cli): attach to existing deployments in the build server (#2501)
* Add external build data and image platform to the get deployment endpoint

* If provided, attach to an existing deployment in the deploy command

* Check status for existing deployments

* Add changeset
2025-09-15 17:16:26 +02:00
nicktrn 7c4ce6f76b feat(supervisor): add optional memory limit overhead (#2506) 2025-09-15 16:09:05 +01:00
Eric Allam dc42ae7aa4 chore(repo): update changeset snapshot instructions (#2505) 2025-09-15 15:27:54 +02:00
Saadi Myftija b0b88f1e05 fix(webapp): show deployments where triggeredById is missing (#2504)
Small fix for the sql query used in the deployments list page.
2025-09-15 14:12:48 +01:00
Eric Allam 6483a0f1c6 fix(core): Improves our schema to JSON Schema conversion (fix for zod 4) (#2483) 2025-09-15 14:11:40 +01:00
Eric Allam 6f1abe058b chore(docs): heartbeat timeout is now 5 minutes (#2500) 2025-09-15 15:11:32 +02:00
Eric Allam 83bd6f5f9e fix(engine): carry over completed waitpoints on PENDING_EXECUTING stalls (#2503) 2025-09-15 12:19:46 +01:00
Eric Allam e36d78e4fc fix: don't carry over the checkpoint ID when nack and requeuing (#2502) 2025-09-15 12:06:52 +01:00
Eric Allam 3188dc9b28 perf(webapp): Add BatchTaskRun index to speed up the batch list dashboard page (#2499) 2025-09-12 17:23:52 +01:00
Eric Allam 10e7985fbc Add index for waitpoint tokens dashboard query (#2498) 2025-09-12 16:48:42 +01:00
Eric Allam 2eddda1233 fix(webapp): worker actions now catch service validation errors and respond properly (#2481)
This also stops all the unnecessary error logging when throwing 
ServiceValidationErrors
2025-09-12 14:56:54 +01:00
HUORT Louis 49f2c54031 fix: webapp crash on clickhouse data corruption (#2491) 2025-09-12 14:55:29 +01:00
Eric Allam f077d49291 feat(engine): Improve execution stalls troubleshooting, align dev and prod behavior, adding heartbeats.yield utility (#2489)
* feat(engine): Improve execution stalls troubleshooting, align dev and prod behavior, adding heartbeats.yield utility

* A few improvements via the 🐇 review

* Allow treating EXECUTION stalls as OOM errors, improve the error message, add more information to the docs, improve resource monitor and add it to the docs

* Add changeset
2025-09-12 14:54:39 +01:00
Eric Allam 5db583b6cd docs: add machine option in triggering docs (#2487) 2025-09-11 15:28:26 +02:00
Matt Aitken 1227e5463e Fix for broken retrieve runs docs page (#2496)
The API path was accidentally edited as part of a /v3 purge
2025-09-10 16:59:37 +01:00
Eric Allam 8e66913e59 fix(run-engine): Preserve snapshot checkpoint ID when a PENDING_EXECUTING snapshot stalls (#2493) 2025-09-10 14:55:57 +01:00
Dan b9b17e24ac Added anchor browser example (#2488)
* Added anchor browser example project

* Updated node ver
2025-09-09 14:32:26 +01:00
Saadi Myftija 12bef0a938 feat: env command in the cli (#2485)
* Add a CLI command to list and view env vars

* Add changeset

* Restrict pemissions on env files created with `env pull`

* Escape env vars when exporting to file

* Switch changeset to patch
2025-09-09 13:13:24 +02:00
Saadi Myftija 5567f49846 feat(webapp): expose project git settings (#2464)
* Fix settigns page delete project width issue

* Apply a couple of touch-ups to the project settings page

* Add UI flow to connect gh repos

* Enabling adding another gh account in the ui

* Enable connecting a repo to a project

* Enable updating git settings

* Enable disconnecting gh repos from a project

* Remove prisma migration drifts

* Hide git settings when github app is disabled

* Fix migration order

* Avoid using `location` to avoid SSR issues

* Make branch tracking optional

* Disable save buttons when there are no field changes

* Disable delete project button unless the input matches the project slug

* Show connected repo connectedAt date

* Check that tracking branch exists when updating git settings

* Show tracking branch hint in the deployments page

* Fix positioning issue of the pagination pane in the deployments page

* Use mono font for branch names

* Add link to git settings

* Show tracking branch hint for the preview env too

* Add a confirmation prompt on repo disconnect

* Add link to configure repo access in gh

* Add rel prop to github links

* Automatically open repo connection modal after app installation

* Apply some fixes suggested by mr rabbit

* Fix flash cookie issue

* Extract project settings actions into a service

* Extract project settings loader into a presenter service

* Introduce neverthrow for error handling

* Try out neverthrow for error handling in the project setting flows

* Move env gh branch resolution to the presenter service
2025-09-09 13:03:43 +02:00
Dan 71060d93b1 Removed some remaining /v3’s from the docs (#2478)
* Removed /v3 from numerous files

* Fix AWS SDK v3 documentation link for S3 uploads
2025-09-08 10:01:09 +01:00
Eric Allam 89b1d8ba13 fix all lifecycle hooks (#2480) 2025-09-05 11:17:38 +01:00
Eric Allam 97015ba8c8 fix(docs): update sentry error tracking guide for v4 (#2479) 2025-09-05 11:13:13 +01:00
nicktrn 99660112bd feat(supervisor): add configurable resource requests (#2474) 2025-09-04 17:09:34 +01:00
Matt Aitken e6586d3c1a Remove deprecated releaseConcurrency from wait.forToken() docs (#2477) 2025-09-04 14:57:17 +01:00
Eric Allam 00d32ed4ee feat(webapp): add support for running web services (api, engine, webapp) in cluster mode for better perf (#2472)
* feat(webapp): add support for running web services (api, engine, webapp) in cluster mode for better perf

* cleaned up signal handling and resolved some valid 🐇 issues
2025-09-04 09:53:15 +01:00
nicktrn a1e9738faa fix(webapp): prevent duplicate preview env image tags (#2475)
* fix(webapp): prevent duplicate preview env image tags

* use deploy shortcode instead of new nanoid

* replace regexps in tests
2025-09-04 09:37:22 +01:00
nicktrn 59c17e04e9 feat(run-engine): worker queue resolver (#2476) 2025-09-04 09:27:06 +01:00
Eric Allam ed23615aa4 perf(webapp): add event loop utilization metric (#2471)
* perf(webapp): add event loop utilization metric

* add event loop utilization logging as well
2025-09-03 11:39:03 +01:00
Saadi Myftija 436d951b65 feat(webapp): github app installation flow (#2463)
* Add schemas for gh app installations

* Implement gh app installation flow

* Make the gh app configs optional

* Add additional org check on gh app installation callback

* Save account handle and repo default branch on install

* Do repo hard deletes in favor of simplicity

* Disable github app by default

* Fix gh env schema union issue

* Use octokit's iterator for paginating repos

* Parse gh app install callback with a discriminated union

* Remove duplicate env vars

* Use bigint for github integer IDs

* Sanitize redirect paths in the gh installation and auth flow

* Regenerate migration after rebase on main to fix ordering

* Handle gh install updates separately from new installs
2025-09-02 16:35:33 +02:00
nicktrn cf9398b56e fix(run-engine): retry SIGTERM errors (#2468) 2025-09-02 15:18:16 +01:00
Eric Allam 9b1877bef2 fix(run-engine): retry non-zero exit code errors (#2467)
We’re also now saving the retryConfig from the BackgroundWorkerTask on 
TaskRun.lockedRetryConfig when the run is first locked to the version
2025-09-02 14:12:14 +01:00
Eric Allam 0b2b73fc52 chore(docs): improve version pinning advise to use installed trigger.dev CLI (#2466) 2025-09-02 14:10:01 +01:00
nicktrn 0d1eac9406 feat(supervisor): dynamic queue consumer pool (#2461)
* feat(supervisor): dynamic queue consumer pool

* add changeset

* fix: correctly handle zero median and even samples

* feat(supervisor): consumer pool metrics

* fix tests

* more tests and fixes

* decrease default scaling cooldowns

* don't treat initial pool size as scale up

* handle scale down when queue length drops to zero

* remove changeset, supervisor changes only

* add damping factor env var
2025-09-02 13:52:42 +01:00
Willow (GHOST) ddbae6b6b4 fix(docker): clickhouse healthcheck (#2462) 2025-09-01 20:20:17 +01:00
nicktrn 2b095b1072 chore(webapp): upgrade otel packages and add more metrics (#2458)
* feat(webapp): upgrade otel packages and add more metrics

* add env var to disable additional detectors

* expose more prisma metrics

* chore(webapp): drop node 16 support
2025-09-01 16:24:12 +01:00
Dan 847ea866b6 Various docs improvements (#2456)
* Added MCP to the intro

* Added human-in-the-loop and new build extensions

* Waitpoint notes

* Moved openai guardrails example to Python

* Added a connection limit note

* Added Supabase + Prisma note to supabase auth

* Updates based on what the rabbit said

* Moved the supavisor section to the prismaExtension docs
2025-08-29 16:48:04 +01:00
James Ritchie fee31f2dc0 chore(webapp): update region message to say we are GDPR compliant (#2455)
* Updates the Region message to say we are GDPR compliant

* Hide the message if not on cloud
2025-08-29 14:33:46 +01:00
github-actions[bot] 10baa396d8 Release v4.0.2 (#2447)
* chore: Update version for release

* Release v4.0.2

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-08-29 12:58:22 +01:00
James Ritchie 27bc2ad232 chore(webapp): show a regions info panel to explain about data residency (#2428) 2025-08-29 11:35:54 +01:00
James Ritchie 3a3e863daf chore(webapp): bulk action cancel as default (#2437)
* Switches “replay” and “cancel” options in the list

* Fixed incorrect cancel icon being used

* Fixes the alignment of the radiobuttons
2025-08-29 11:30:46 +01:00
Eric Allam f5f14c4206 chore(webapp): fix @trigger.dev/database imports so it doesn't end up in the frontend bundle (#2452) 2025-08-29 11:27:01 +01:00
Eric Allam 892adfe81c fix(mcp): don't require a trigger.config.ts file to be present when installing rules (#2453) 2025-08-29 11:20:45 +01:00
nicktrn 73e7378459 feat(webapp): add ecs otel resource attributes (#2454)
* add attributes to runlock dequeue span

* feat(webapp): add ecs otel resource attributes
2025-08-29 11:05:47 +01:00
Eric Allam b0b0df69be fix(run-engine): prevent stalled SUSPENDED snapshots from endlessly retrying (#2448) 2025-08-28 14:25:50 +01:00
Saadi Myftija e629810e96 fix: dev env connection issue (#2449)
This PR fixes an issue introduced in #2391 where the dev runtime env was not being resolved correctly.
2025-08-28 14:05:10 +01:00
Ognjen Simic e6f6d93e59 docs: update triggering.mdx fixing wrongly stated argument position for options (#2283)
* docs: update triggering.mdx fixing wrong argument numeration for options

Update the docs triggering.mdx: text indicating options argument position inside `tasks.trigger` function.

Options object is a third argument instead of stated second argument.

* update triggering.mdx to change options argument position in batchTrigger
2025-08-28 10:16:42 +01:00
Eric Allam 6f6ca01584 feat(mcp): add wait_for_run_to_complete tool so agents don't spam the get_run_details call after triggering (#2445)
* feat(mcp): add wait_for_run_to_complete tool so agents don't spam the get_run_details call after triggering

This also fixes the search docs MCP tool

* Install mcp using the latest tag, not the specific version
2025-08-28 10:13:46 +01:00
James Ritchie 11cbd1b5a0 Docs: Fixes incorrect retrieving runs from batchTrigger (#2427)
* Docs: fix for retrieving runs in batchTrigger

* Removes old v3-to-v4 migration guide (we have a redirect)

* Corrects tag limit from 5 to 10
2025-08-28 10:11:42 +01:00
Eric Allam 82401ec4fe fix(run-engine): dequeue performance improvements and improved telemetry (#2446)
* more eager dequeuing, queue cooloff periods, return workerQueueLength when dequeueing

* Cache worker group authentication and remove old self-hosted worker code (only managed is currently supported)

* add additional spans during dequeue

* Add env vars and additional spans
2025-08-27 21:22:10 +01:00
Matt Aitken 24a915133e Prisma 6.14.0 upgrade (#2444)
* Initial work on upgrading to 6.14.0

Set the output to node_modules still to make it easier

* Use ./generated Prisma folder, update types to fix issues

* Docker compose restart Clickhouse

* Prisma instrumentation update

* Docker

* Removed database dockerignore file, add generated prisma client to the top-level one

* Delete v3-catalog package.json

* Resolved pnpm lock file

* Log errors for very slow queries
2025-08-27 16:52:58 +01:00
Eric Allam 688b108ec3 chore(references): remove v3-catalog (#2443) 2025-08-27 14:35:55 +01:00
Saadi Myftija 1cc62230ab feat: introduce organization access tokens (#2391)
* Create schema and migration for organization access tokens

* Add helpers for creating and authenticating OATs

* Adapt the auth service to also accept OATs

* Accept OATs in the whoami v2 endpoint

* Enable deployments with the CLI using OATs

* Avoid reading env variables directly in the token utils

* Remove duplicate cli token utils

* Validate ENCRYPTION_KEY length when parsing env vars

* Make token utils a server-only module

* Disallow revoking already revoked OATs

* Simplify generics in authenticateRequest

* Use 32 bytes mock encryption key in the test setup

* Update dummy encryption key values in tests and templates

* Add a column in the OATs table to differentiate between user and system generated

* Simplify args for v3ProjectPath

Co-authored-by: Matt Aitken <matt@mattaitken.com>

* Add index on org id and createdAt

* Avoid storing the encrypted oat token and its obfuscated version in the DB at all

It is a safer approach. Also we do not need to ever read the decrypted token value after creation.

* Fix prisma update condition

* Add token type to the OAT table index

* Accept OATs in the mcp auth flow

* Simplify env auth flow around the /projects endpoints

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-08-27 13:23:45 +02:00
Eric Allam 96243efe16 docs: MCP Server 2.0 (#2385)
* Add MCP Server documentation to documentation site

This change introduces comprehensive documentation for the Trigger.dev MCP Server, covering its installation, usage, and available tools. The addition is necessary to provide users with detailed guidance on leveraging the MCP Server for managing Trigger.dev projects, tasks, and deployment processes.

- Updated `docs.json` to include a new section for the MCP Server documentation, improving navigation and accessibility.
- Added `mcp-introduction.mdx` as an introductory guide to the MCP Server, detailing its purpose and installation steps.
- Included `mcp-tools.mdx`, a document outlining each tool available in the MCP Server, complete with descriptions and example usages.
- The new content is aimed at improving user understanding and efficiency in using Trigger.dev's MCP Server capabilities.

* Refactor MCP installation documentation

The changes were necessary to streamline the MCP installation guidance and align our documentation with the latest client support capabilities. Removing the manual installation section and redundant information helps reduce confusion for users.

- Updated installation instructions to lead with auto-configuration for supported clients.
- Cleaned up the options and examples sections to enhance clarity.
- Reformatted client configuration details for better readability.
- Removed outdated links in the "Next Steps" section, focusing on more direct resources such as available MCP tools.

* mcp docs edits

* Add debugMode to get_run_details

* add cancel_run

* Add list deployments tools

* Add list preview branches tool

* Docs: adds agent rules page

* Adds redirect for the CLI path to work

* Adds subagent install image

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-08-22 15:37:35 +01:00
Eric Allam ad26cded99 fix(v3): prevent saturated queues from dominating dequeue attempts by adding a cooloff period of successive failed dequeues (#2439)
* fix(v3): prevent saturated queues from dominating dequeue attempts by adding a cooloff period of successive failed dequeues

* Add env vars and add a marqs shutdown on SIGTERM/INT
2025-08-22 14:37:21 +01:00
Eric Allam 5e4756f8b7 fix(v3): eagerly dequeue messages from a queue when that queue is added to or removed from (v4 backport) (#2438)
* fix(v3): eagerly dequeue messages from a queue when that queue is added to or removed from (v4 backport)

* fixed configuration
2025-08-22 13:23:34 +01:00
Eric Allam c16cc577b0 fix(v3): adopt v4 dequeue strategy to increase perf (#2436) 2025-08-22 07:40:59 +01:00
348 changed files with 18053 additions and 16256 deletions
+6 -3
View File
@@ -6,7 +6,7 @@ alwaysApply: false
The main trigger.dev webapp, which powers it's API and dashboard and makes up the docker image that is produced as an OSS image, is a Remix 2.1.0 app that uses an express server, written in TypeScript. The following subsystems are either included in the webapp or are used by the webapp in another part of the monorepo:
- `@trigger.dev/database` exports a Prisma 5.4.1 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma)
- `@trigger.dev/database` exports a Prisma 6.14.0 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma)
- `@trigger.dev/core` is a published package and is used to share code between the `@trigger.dev/sdk` and the webapp. It includes functionality but also a load of Zod schemas for data validation. When importing from `@trigger.dev/core` in the webapp, we never import the root `@trigger.dev/core` path, instead we favor one of the subpath exports that you can find in [package.json](mdc:packages/core/package.json)
- `@internal/run-engine` has all the code needed to trigger a run and take it through it's lifecycle to completion.
- `@trigger.dev/redis-worker` is a custom redis based background job/worker system that's used in the webapp and also used inside the run engine.
@@ -31,7 +31,10 @@ We originally the Trigger.dev "Run Engine" not as a single system, but just spre
- The batch trigger API endpoint is [api.v1.tasks.batch.ts](mdc:apps/webapp/app/routes/api.v1.tasks.batch.ts)
- Setup code for the prisma client is in [db.server.ts](mdc:apps/webapp/app/db.server.ts)
- The run engine is configured in [runEngine.server.ts](mdc:apps/webapp/app/v3/runEngine.server.ts)
- All the "services" that are found in app/v3/services/**/*.server.ts
- All the "services" that are found in app/v3/services/\*_/_.server.ts
- The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts)
- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/**/*.server.ts
- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts
- All the "services" that are found in app/v3/services/\*_/_.server.ts
- The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts)
- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts
+2
View File
@@ -16,6 +16,8 @@
**/dist
**/node_modules
**/generated/prisma
apps/webapp/build
apps/webapp/public/build
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
SESSION_SECRET: "secret"
MAGIC_LINK_SECRET: "secret"
ENCRYPTION_KEY: "secret"
ENCRYPTION_KEY: "dummy-encryption-keeeey-32-bytes"
DEPLOY_REGISTRY_HOST: "docker.io"
CLICKHOUSE_URL: "http://default:password@localhost:8123"
+5 -5
View File
@@ -59,7 +59,7 @@
"request": "launch",
"name": "Debug V3 Dev CLI",
"command": "pnpm exec trigger dev",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
@@ -83,7 +83,7 @@
"request": "launch",
"name": "Debug V3 Deploy CLI",
"command": "pnpm exec trigger deploy --self-hosted --load-image",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
@@ -91,7 +91,7 @@
"request": "launch",
"name": "Debug V3 list-profiles CLI",
"command": "pnpm exec trigger list-profiles --log-level debug",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
@@ -99,7 +99,7 @@
"request": "launch",
"name": "Debug V3 update CLI",
"command": "pnpm exec trigger update",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
@@ -107,7 +107,7 @@
"request": "launch",
"name": "Debug V3 Management",
"command": "pnpm run management",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
+8 -6
View File
@@ -30,14 +30,16 @@ Please follow the best-practice of adding changesets in the same commit as the c
## Snapshot instructions
1. Delete the `.changeset/pre.json` file (if it exists)
1. Update the `.changeset/config.json` file to set the `"changelog"` field to this:
```json
"changelog": "@changesets/cli/changelog",
```
2. Do a temporary commit (do NOT push this, you should undo it after)
3. Copy the `GITHUB_TOKEN` line from the .env file
3. Run `./scripts/publish-prerelease.sh prerelease`
4. Run `GITHUB_TOKEN=github_pat_12345 ./scripts/publish-prerelease.sh re2`
You can choose a different tag if you want, but usually `prerelease` is fine.
Make sure to replace the token with yours. `re2` is the tag that will be used for the pre-release.
5. Undo the commit where you deleted the pre.json file.
5. Undo the commit where you updated the config.json file.
+15 -70
View File
@@ -84,17 +84,17 @@ branch are tagged into a release periodically.
2. Once the app is running click the magic link button and enter your email. You will automatically be logged in, since you are running locally. Create an Org and your first project in the dashboard.
## Manual testing using v3-catalog
## Manual testing using hello-world
We use the `<root>/references/v3-catalog` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `<root>/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `<root>packages/core`), the CLI (`trigger.dev` at `<root>/packages/cli-v3`) and the platform (The remix app at `<root>/apps/webapp`). The instructions below will get you started on using the `v3-catalog` for local development of Trigger.dev (v3).
We use the `<root>/references/hello-world` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `<root>/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `<root>packages/core`), the CLI (`trigger.dev` at `<root>/packages/cli-v3`) and the platform (The remix app at `<root>/apps/webapp`). The instructions below will get you started on using the `hello-world` for local development of Trigger.dev.
### First-time setup
First, make sure you are running the webapp according to the instructions above. Then:
1. Visit http://localhost:3030 in your browser and create a new V3 project called "v3-catalog".
1. Visit http://localhost:3030 in your browser and create a new V3 project called "hello-world".
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `yubjwjsfkxnylobaqvqz`.
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `proj_rrkpdguyagvsoktglnod`.
3. Build the CLI
@@ -105,10 +105,10 @@ pnpm run build --filter trigger.dev
pnpm i
```
4. Change into the `<root>/references/v3-catalog` directory and authorize the CLI to the local server:
4. Change into the `<root>/references/hello-world` directory and authorize the CLI to the local server:
```sh
cd references/v3-catalog
cd references/hello-world
cp .env.example .env
pnpm exec trigger login -a http://localhost:3030
```
@@ -118,7 +118,7 @@ This will open a new browser window and authorize the CLI against your local use
You can optionally pass a `--profile` flag to the `login` command, which will allow you to use the CLI with separate accounts/servers. We suggest using a profile called `local` for your local development:
```sh
cd references/v3-catalog
cd references/hello-world
pnpm exec trigger login -a http://localhost:3030 --profile local
# later when you run the dev or deploy command:
pnpm exec trigger dev --profile local
@@ -137,84 +137,29 @@ The following steps should be followed any time you start working on a new featu
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*"
```
3. Open another terminal window, and change into the `<root>/references/v3-catalog` directory.
3. Open another terminal window, and change into the `<root>/references/hello-world` directory.
4. You'll need to run the following commands to setup prisma and migrate the database:
4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution:
```sh
pnpm exec prisma migrate deploy
pnpm run generate:prisma
```
5. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution:
```sh
# in <root>/references/v3-catalog
# in <root>/references/hello-world
pnpm exec trigger dev
```
If you want additional debug logging, you can use the `--log-level debug` flag:
```sh
# in <root>/references/v3-catalog
# in <root>/references/hello-world
pnpm exec trigger dev --log-level debug
```
6. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `v3-catalog/src/trigger` dir will automatically be rebuilt by the `dev` command.
6. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `hello-world/src/trigger` dir will automatically be rebuilt by the `dev` command.
7. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks.
7. Navigate to the `hello-world` project in your local dashboard at localhost:3030 and you should see the list of tasks.
8. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/v3-catalog/src/trigger` folder. Many of them accept an empty payload.
8. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/hello-world/src/trigger` folder. Many of them accept an empty payload.
9. Feel free to add additional files in `v3-catalog/src/trigger` to test out specific aspects of the system, or add in edge cases.
## Running end-to-end webapp tests (deprecated)
To run the end-to-end tests, follow the steps below:
1. Set up environment variables (copy example envs into the correct place)
```sh
cp ./.env.example ./.env
cp ./references/nextjs-test/.env.example ./references/nextjs-test/.env.local
```
2. Set up dependencies
```sh
# Build packages
pnpm run build --filter @references/nextjs-test^...
pnpm --filter @trigger.dev/database generate
# Move trigger-cli bin to correct place
pnpm install --frozen-lockfile
# Install playwrite browsers (ONE TIME ONLY)
npx playwright install
```
3. Set up the database
```sh
pnpm run docker
pnpm run db:migrate
pnpm run db:seed
```
4. Run the end-to-end tests
```sh
pnpm run test:e2e
```
### Cleanup
The end-to-end tests use a `setup` and `teardown` script to seed the database with test data. If the test runner doesn't exit cleanly, then the database can be left in a state where the tests can't run because the `setup` script will try to create data that already exists. If this happens, you can manually delete the `users` and `organizations` from the database using prisma studio:
```sh
# With the database running (i.e. pnpm run docker)
pnpm run db:studio
```
9. Feel free to add additional files in `hello-world/src/trigger` to test out specific aspects of the system, or add in edge cases.
## Adding and running migrations
+15 -2
View File
@@ -35,8 +35,16 @@ const Env = z.object({
TRIGGER_DEQUEUE_ENABLED: BoolEnv.default(true),
TRIGGER_DEQUEUE_INTERVAL_MS: z.coerce.number().int().default(250),
TRIGGER_DEQUEUE_IDLE_INTERVAL_MS: z.coerce.number().int().default(1000),
TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(10),
TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(1),
TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(1),
TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT: z.coerce.number().int().default(1),
TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(10),
TRIGGER_DEQUEUE_SCALING_STRATEGY: z.enum(["none", "smooth", "aggressive"]).default("none"),
TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS: z.coerce.number().int().default(5000), // 5 seconds
TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS: z.coerce.number().int().default(30000), // 30 seconds
TRIGGER_DEQUEUE_SCALING_TARGET_RATIO: z.coerce.number().default(1.0), // Target ratio of queue items to consumers (1.0 = 1 item per consumer)
TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA: z.coerce.number().min(0).max(1).default(0.3), // Smooths queue length measurements (0=historical, 1=current)
TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS: z.coerce.number().int().positive().default(1000), // Batch window for metrics processing (ms)
TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR: z.coerce.number().min(0).max(1).default(0.7), // Smooths consumer count changes after EWMA (0=no scaling, 1=immediate)
// Optional services
TRIGGER_WARM_START_URL: z.string().optional(),
@@ -77,6 +85,11 @@ const Env = z.object({
KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"),
KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"),
KUBERNETES_STRIP_IMAGE_DIGEST: BoolEnv.default(false),
KUBERNETES_CPU_REQUEST_MIN_CORES: z.coerce.number().min(0).default(0),
KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit
KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0),
KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit
KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB
// Placement tags settings
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
+12 -1
View File
@@ -128,7 +128,18 @@ class ManagedSupervisor {
dequeueIdleIntervalMs: env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS,
queueConsumerEnabled: env.TRIGGER_DEQUEUE_ENABLED,
maxRunCount: env.TRIGGER_DEQUEUE_MAX_RUN_COUNT,
maxConsumerCount: env.TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT,
metricsRegistry: register,
scaling: {
strategy: env.TRIGGER_DEQUEUE_SCALING_STRATEGY,
minConsumerCount: env.TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT,
maxConsumerCount: env.TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT,
scaleUpCooldownMs: env.TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS,
scaleDownCooldownMs: env.TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS,
targetRatio: env.TRIGGER_DEQUEUE_SCALING_TARGET_RATIO,
ewmaAlpha: env.TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA,
batchWindowMs: env.TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS,
dampingFactor: env.TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR,
},
runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED,
heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS,
sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS,
@@ -20,6 +20,13 @@ export class KubernetesWorkloadManager implements WorkloadManager {
private namespace = env.KUBERNETES_NAMESPACE;
private placementTagProcessor: PlacementTagProcessor;
// Resource settings
private readonly cpuRequestMinCores = env.KUBERNETES_CPU_REQUEST_MIN_CORES;
private readonly cpuRequestRatio = env.KUBERNETES_CPU_REQUEST_RATIO;
private readonly memoryRequestMinGb = env.KUBERNETES_MEMORY_REQUEST_MIN_GB;
private readonly memoryRequestRatio = env.KUBERNETES_MEMORY_REQUEST_RATIO;
private readonly memoryOverheadGb = env.KUBERNETES_MEMORY_OVERHEAD_GB;
constructor(private opts: WorkloadManagerOptions) {
this.k8s = createK8sApi();
this.placementTagProcessor = new PlacementTagProcessor({
@@ -63,6 +70,10 @@ export class KubernetesWorkloadManager implements WorkloadManager {
return imageRef.substring(0, atIndex);
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
async create(opts: WorkloadManagerCreateOptions) {
this.logger.log("[KubernetesWorkloadManager] Creating container", { opts });
@@ -295,16 +306,27 @@ export class KubernetesWorkloadManager implements WorkloadManager {
}
#getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities {
const cpuRequest = preset.cpu * this.cpuRequestRatio;
const memoryRequest = preset.memory * this.memoryRequestRatio;
// Clamp between min and max
const clampedCpu = this.clamp(cpuRequest, this.cpuRequestMinCores, preset.cpu);
const clampedMemory = this.clamp(memoryRequest, this.memoryRequestMinGb, preset.memory);
return {
cpu: `${preset.cpu * 0.75}`,
memory: `${preset.memory}G`,
cpu: `${clampedCpu}`,
memory: `${clampedMemory}G`,
};
}
#getResourceLimitsForMachine(preset: MachinePreset): ResourceQuantities {
const memoryLimit = this.memoryOverheadGb
? preset.memory + this.memoryOverheadGb
: preset.memory;
return {
cpu: `${preset.cpu}`,
memory: `${preset.memory}G`,
memory: `${memoryLimit}G`,
};
}
+26 -41
View File
@@ -16,7 +16,6 @@ import {
type WorkloadRunAttemptCompleteResponseBody,
WorkloadRunAttemptStartRequestBody,
type WorkloadRunAttemptStartResponseBody,
type WorkloadRunLatestSnapshotResponseBody,
WorkloadRunSnapshotsSinceResponseBody,
type WorkloadServerToClientEvents,
type WorkloadSuspendRunResponseBody,
@@ -126,7 +125,7 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
}
private createHttpServer({ host, port }: { host: string; port: number }) {
return new HttpServer({
const httpServer = new HttpServer({
port,
host,
metrics: {
@@ -322,28 +321,6 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
},
}
)
.route("/api/v1/workload-actions/runs/:runFriendlyId/snapshots/latest", "GET", {
paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }),
handler: async ({ req, reply, params }) => {
const latestSnapshotResponse = await this.workerClient.getLatestSnapshot(
params.runFriendlyId,
this.runnerIdFromRequest(req)
);
if (!latestSnapshotResponse.success) {
this.logger.error("Failed to get latest snapshot", {
runId: params.runFriendlyId,
error: latestSnapshotResponse.error,
});
reply.empty(500);
return;
}
reply.json({
execution: latestSnapshotResponse.data.execution,
} satisfies WorkloadRunLatestSnapshotResponseBody);
},
})
.route(
"/api/v1/workload-actions/runs/:runFriendlyId/snapshots/since/:snapshotFriendlyId",
"GET",
@@ -369,23 +346,6 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
},
}
)
.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", {
paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }),
bodySchema: WorkloadDebugLogRequestBody,
handler: async ({ req, reply, params, body }) => {
reply.empty(204);
if (!env.SEND_RUN_DEBUG_LOGS) {
return;
}
await this.workerClient.sendDebugLog(
params.runFriendlyId,
body,
this.runnerIdFromRequest(req)
);
},
})
.route("/api/v1/workload-actions/deployments/:deploymentId/dequeue", "GET", {
paramsSchema: z.object({
deploymentId: z.string(),
@@ -410,6 +370,31 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
reply.json(dequeueResponse.data satisfies WorkloadDequeueFromVersionResponseBody);
},
});
if (env.SEND_RUN_DEBUG_LOGS) {
httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", {
paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }),
bodySchema: WorkloadDebugLogRequestBody,
handler: async ({ req, reply, params, body }) => {
reply.empty(204);
await this.workerClient.sendDebugLog(
params.runFriendlyId,
body,
this.runnerIdFromRequest(req)
);
},
});
} else {
// Lightweight mock route without schemas
httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", {
handler: async ({ reply }) => {
reply.empty(204);
},
});
}
return httpServer;
}
private createWebsocketServer() {
@@ -0,0 +1,34 @@
export function MoveToTopIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_17186_103975)">
<path
d="M12 21L12 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 3L21 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.5 11.5L12 7L7.5 11.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_17186_103975">
<rect width="24" height="24" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
}
@@ -0,0 +1,41 @@
export function MoveUpIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_17177_110851)">
<path
d="M12 21L12 13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 3L21 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 7L21 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.5 15.5L12 11L7.5 15.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_17177_110851">
<rect width="24" height="24" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
}
@@ -11,10 +11,11 @@ import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v
import { formatNumber } from "~/utils/numberFormatter";
import { SpinnerWhite } from "./primitives/Spinner";
import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid";
import { XCircleIcon as XCircleIconOutline } from "@heroicons/react/24/outline";
import assertNever from "assert-never";
import { AppliedFilter } from "./primitives/AppliedFilter";
import { runStatusTitle } from "./runs/v3/TaskRunStatus";
import { type TaskRunStatus } from "@trigger.dev/database";
import type { TaskRunStatus } from "@trigger.dev/database";
export const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]);
export type BulkActionMode = z.infer<typeof BulkActionMode>;
@@ -244,7 +245,7 @@ function Action({ action }: { action: BulkActionAction }) {
case "cancel":
return (
<span>
<XCircleIcon className="mb-0.5 inline-block size-4 text-error" />
<XCircleIconOutline className="mb-0.5 inline-block size-4 text-error" />
<span className="ml-0.5 text-text-bright">Canceled</span>
</span>
);
@@ -14,7 +14,7 @@ export function DefinitionTip({
return (
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger>
<TooltipTrigger className="text-left">
<span className="cursor-default underline decoration-charcoal-500 decoration-dashed underline-offset-4 transition hover:decoration-charcoal-400">
{children}
</span>
+6
View File
@@ -147,6 +147,12 @@ function ShortcutContent() {
</Paragraph>
<ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to root run">
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to parent run">
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Schedules page</Header3>
@@ -1,6 +1,6 @@
import { LockClosedIcon, ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { type User } from "@trigger.dev/database";
import type { User } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -6,7 +6,7 @@ import {
RocketLaunchIcon,
StarIcon,
} from "@heroicons/react/20/solid";
import { type Prisma } from "@trigger.dev/database";
import type { Prisma } from "@trigger.dev/database";
import { z } from "zod";
import { logger } from "~/services/logger.server";
import { cn } from "~/utils/cn";
@@ -39,7 +39,7 @@ const variants = {
description: {
button:
"w-full p-2.5 hover:data-[state=checked]:bg-white/[4%] data-[state=checked]:bg-white/[4%] transition data-[disabled]:opacity-70 hover:border-charcoal-600 border-charcoal-650 hover:data-[state=checked]:border-charcoal-600 border rounded-md",
label: "text-text-bright font-semibold -mt-1 text-left text-sm",
label: "text-text-bright font-semibold -mt-0.5 text-left text-sm",
description: "text-text-dimmed -mt-0 text-left",
inputPosition: "mt-0",
icon: "w-8 h-8 mb-2",
@@ -1,6 +1,10 @@
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { Icon, type RenderIcon } from "./Icon";
import { useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "./ShortcutKey";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
const variations = {
primary:
@@ -17,6 +21,9 @@ type TextLinkProps = {
trailingIconClassName?: string;
variant?: keyof typeof variations;
children: React.ReactNode;
shortcut?: ShortcutDefinition;
hideShortcutKey?: boolean;
tooltip?: React.ReactNode;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
export function TextLink({
@@ -27,20 +34,61 @@ export function TextLink({
trailingIcon,
trailingIconClassName,
variant = "primary",
shortcut,
hideShortcutKey,
tooltip,
...props
}: TextLinkProps) {
const innerRef = useRef<HTMLAnchorElement>(null);
const classes = variations[variant];
return to ? (
<Link to={to} className={cn(classes, className)} {...props}>
if (shortcut) {
useShortcutKeys({
shortcut: shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
}
const renderShortcutKey = () =>
shortcut &&
!hideShortcutKey && <ShortcutKey className="ml-1.5" shortcut={shortcut} variant="small" />;
const linkContent = (
<>
{children}{" "}
{trailingIcon && <Icon icon={trailingIcon} className={cn("size-4", trailingIconClassName)} />}
{shortcut && !tooltip && renderShortcutKey()}
</>
);
const linkElement = to ? (
<Link ref={innerRef} to={to} className={cn(classes, className)} {...props}>
{linkContent}
</Link>
) : href ? (
<a href={href} className={cn(classes, className)} {...props}>
{children}{" "}
{trailingIcon && <Icon icon={trailingIcon} className={cn("size-4", trailingIconClassName)} />}
<a ref={innerRef} href={href} className={cn(classes, className)} {...props}>
{linkContent}
</a>
) : (
<span>Need to define a path or href</span>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{linkElement}</TooltipTrigger>
<TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs">
{tooltip} {shortcut && renderShortcutKey()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return linkElement;
}
@@ -1,5 +1,5 @@
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
import { BatchTaskRunStatus } from "@trigger.dev/database";
import type { BatchTaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
@@ -1,5 +1,5 @@
import { ArrowPathIcon, CheckCircleIcon, NoSymbolIcon } from "@heroicons/react/20/solid";
import { BulkActionStatus, type BulkActionType } from "@trigger.dev/database";
import type { BulkActionStatus, BulkActionType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
@@ -2,9 +2,10 @@ import {
CheckCircleIcon,
ExclamationTriangleIcon,
NoSymbolIcon,
RectangleStackIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import { WorkerDeploymentStatus } from "@trigger.dev/database";
import type { WorkerDeploymentStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
@@ -49,6 +50,9 @@ export function DeploymentStatusIcon({
}) {
switch (status) {
case "PENDING":
return (
<RectangleStackIcon className={cn(deploymentStatusClassNameColor(status), className)} />
);
case "BUILDING":
case "DEPLOYING":
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
@@ -73,6 +77,7 @@ export function DeploymentStatusIcon({
export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
return "text-charcoal-500";
case "BUILDING":
case "DEPLOYING":
return "text-pending";
@@ -92,7 +97,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string {
switch (status) {
case "PENDING":
return "Pending…";
return "Queued…";
case "BUILDING":
return "Building…";
case "DEPLOYING":
@@ -121,6 +126,7 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
// PENDING and CANCELED are not used so are ommited from the UI
export const deploymentStatuses: WorkerDeploymentStatus[] = [
"PENDING",
"BUILDING",
"DEPLOYING",
"DEPLOYED",
@@ -1,6 +1,5 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { type RuntimeEnvironment } from "@trigger.dev/database";
import { useCallback } from "react";
import { z } from "zod";
import { Input } from "~/components/primitives/Input";
@@ -11,7 +11,7 @@ import {
TrashIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import { type TaskRunStatus } from "@trigger.dev/database";
import type { TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { HourglassIcon } from "lucide-react";
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
@@ -1,5 +1,5 @@
import { ClockIcon } from "@heroicons/react/20/solid";
import { type TaskTriggerSource } from "@trigger.dev/database";
import type { TaskTriggerSource } from "@trigger.dev/database";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import { cn } from "~/utils/cn";
+32 -9
View File
@@ -1,10 +1,11 @@
import {
Prisma,
PrismaClient,
PrismaClientOrTransaction,
PrismaReplicaClient,
PrismaTransactionClient,
PrismaTransactionOptions,
$transaction as transac,
type PrismaClientOrTransaction,
type PrismaReplicaClient,
type PrismaTransactionClient,
type PrismaTransactionOptions,
} from "@trigger.dev/database";
import invariant from "tiny-invariant";
import { z } from "zod";
@@ -12,9 +13,9 @@ import { env } from "./env.server";
import { logger } from "./services/logger.server";
import { isValidDatabaseUrl } from "./utils/db";
import { singleton } from "./utils/singleton";
import { $transaction as transac } from "@trigger.dev/database";
import { startActiveSpan } from "./v3/tracer.server";
import { Span } from "@opentelemetry/api";
import { queryPerformanceMonitor } from "./utils/queryPerformanceMonitor.server";
export type {
PrismaTransactionClient,
@@ -153,13 +154,19 @@ function getClient() {
},
]
: []) satisfies Prisma.LogDefinition[]),
// verbose
...((process.env.VERBOSE_PRISMA_LOGS === "1"
// Query performance monitoring
...((process.env.VERBOSE_PRISMA_LOGS === "1" ||
process.env.VERY_SLOW_QUERY_THRESHOLD_MS !== undefined
? [
{
emit: "event",
level: "query",
},
]
: []) satisfies Prisma.LogDefinition[]),
// verbose
...((process.env.VERBOSE_PRISMA_LOGS === "1"
? [
{
emit: "stdout",
level: "query",
@@ -206,6 +213,11 @@ function getClient() {
});
}
// Add query performance monitoring
client.$on("query", (log) => {
queryPerformanceMonitor.onQuery("writer", log);
});
// connect eagerly
client.$connect();
@@ -265,13 +277,19 @@ function getReplicaClient() {
},
]
: []) satisfies Prisma.LogDefinition[]),
// verbose
...((process.env.VERBOSE_PRISMA_LOGS === "1"
// Query performance monitoring
...((process.env.VERBOSE_PRISMA_LOGS === "1" ||
process.env.VERY_SLOW_QUERY_THRESHOLD_MS !== undefined
? [
{
emit: "event",
level: "query",
},
]
: []) satisfies Prisma.LogDefinition[]),
// verbose
...((process.env.VERBOSE_PRISMA_LOGS === "1"
? [
{
emit: "stdout",
level: "query",
@@ -317,6 +335,11 @@ function getReplicaClient() {
});
}
// Add query performance monitoring for replica client
replicaClient.$on("query", (log) => {
queryPerformanceMonitor.onQuery("replica", log);
});
// connect eagerly
replicaClient.$connect();
+1175 -1060
View File
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,9 @@ import { singleton } from "./utils/singleton";
import { tracer } from "./v3/tracer.server";
import { env } from "./env.server";
import { context, Context } from "@opentelemetry/api";
import { performance } from "node:perf_hooks";
import { logger } from "./services/logger.server";
import { signalsEmitter } from "./services/signals.server";
const THRESHOLD_NS = env.EVENT_LOOP_MONITOR_THRESHOLD_MS * 1e6;
@@ -69,16 +72,53 @@ function after(asyncId: number) {
export const eventLoopMonitor = singleton("eventLoopMonitor", () => {
const hook = createHook({ init, before, after, destroy });
let stopEventLoopUtilizationMonitoring: () => void;
return {
enable: () => {
console.log("🥸 Initializing event loop monitor");
hook.enable();
stopEventLoopUtilizationMonitoring = startEventLoopUtilizationMonitoring();
},
disable: () => {
console.log("🥸 Disabling event loop monitor");
hook.disable();
stopEventLoopUtilizationMonitoring?.();
},
};
});
function startEventLoopUtilizationMonitoring() {
let lastEventLoopUtilization = performance.eventLoopUtilization();
const interval = setInterval(() => {
const currentEventLoopUtilization = performance.eventLoopUtilization();
const diff = performance.eventLoopUtilization(
currentEventLoopUtilization,
lastEventLoopUtilization
);
const utilization = Number.isFinite(diff.utilization) ? diff.utilization : 0;
if (Math.random() < env.EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE) {
logger.info("nodejs.event_loop.utilization", { utilization });
}
lastEventLoopUtilization = currentEventLoopUtilization;
}, env.EVENT_LOOP_MONITOR_UTILIZATION_INTERVAL_MS);
signalsEmitter.on("SIGTERM", () => {
clearInterval(interval);
});
signalsEmitter.on("SIGINT", () => {
clearInterval(interval);
});
return () => {
clearInterval(interval);
};
}
@@ -1,5 +1,5 @@
import {
Prisma,
type Prisma,
type WorkerDeploymentStatus,
type WorkerInstanceGroupType,
} from "@trigger.dev/database";
@@ -9,6 +9,7 @@ import { type Project } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { processGitMetadata } from "./BranchesPresenter.server";
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
const pageSize = 20;
@@ -56,6 +57,18 @@ export class DeploymentListPresenter {
},
},
},
connectedGithubRepository: {
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
repository: {
select: {
htmlUrl: true,
fullName: true,
},
},
},
},
},
where: {
slug: projectSlug,
@@ -131,7 +144,7 @@ export class DeploymentListPresenter {
wd."git"
FROM
${sqlDatabaseSchema}."WorkerDeployment" as wd
INNER JOIN
LEFT JOIN
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
WHERE
wd."projectId" = ${project.id}
@@ -140,9 +153,28 @@ ORDER BY
string_to_array(wd."version", '.')::int[] DESC
LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
const { connectedGithubRepository } = project;
const branchTrackingOrError =
connectedGithubRepository &&
BranchTrackingConfigSchema.safeParse(connectedGithubRepository.branchTracking);
const environmentGitHubBranch =
branchTrackingOrError && branchTrackingOrError.success
? getTrackedBranchForEnvironment(
branchTrackingOrError.data,
connectedGithubRepository.previewDeploymentsEnabled,
{
type: environment.type,
branchName: environment.branchName ?? undefined,
}
)
: undefined;
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
connectedGithubRepository: project.connectedGithubRepository ?? undefined,
environmentGitHubBranch,
deployments: deployments.map((deployment, index) => {
const label = labeledDeployments.find(
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
@@ -102,6 +102,7 @@ export class DeploymentPresenter {
builtAt: true,
deployedAt: true,
createdAt: true,
startedAt: true,
git: true,
promotions: {
select: {
@@ -145,6 +146,7 @@ export class DeploymentPresenter {
version: deployment.version,
status: deployment.status,
createdAt: deployment.createdAt,
startedAt: deployment.startedAt,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
tasks: deployment.worker?.tasks,
@@ -1,6 +1,6 @@
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3";
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
import { prisma, PrismaClient } from "~/db.server";
import { prisma, type PrismaClient } from "~/db.server";
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
import { getUsername } from "~/utils/username";
import { eventRepository } from "~/v3/eventRepository.server";
@@ -58,7 +58,13 @@ export class RunPresenter {
rootTaskRun: {
select: {
friendlyId: true,
taskIdentifier: true,
spanId: true,
createdAt: true,
},
},
parentTaskRun: {
select: {
friendlyId: true,
spanId: true,
createdAt: true,
},
@@ -111,6 +117,7 @@ export class RunPresenter {
completedAt: run.completedAt,
logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt,
rootTaskRun: run.rootTaskRun,
parentTaskRun: run.parentTaskRun,
environment: {
id: run.runtimeEnvironment.id,
organizationId: run.runtimeEnvironment.organizationId,
@@ -202,8 +209,6 @@ export class RunPresenter {
trace: {
rootSpanStatus,
events: events,
parentRunFriendlyId:
tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId,
duration: totalDuration,
rootStartedAt: tree?.data.startTime,
startedAt: run.startedAt,
@@ -0,0 +1,145 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { z } from "zod";
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
import { logger } from "~/services/logger.server";
import {
redirectWithErrorMessage,
setRequestSuccessMessage,
commitSession,
} from "~/models/message.server";
import { tryCatch } from "@trigger.dev/core";
import { $replica } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { sanitizeRedirectPath } from "~/utils";
const QuerySchema = z.discriminatedUnion("setup_action", [
z.object({
setup_action: z.literal("install"),
installation_id: z.coerce.number(),
state: z.string(),
}),
z.object({
setup_action: z.literal("update"),
installation_id: z.coerce.number(),
state: z.string(),
}),
z.object({
setup_action: z.literal("request"),
state: z.string(),
}),
]);
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(url.searchParams);
const cookieHeader = request.headers.get("Cookie");
const result = QuerySchema.safeParse(queryParams);
if (!result.success) {
logger.warn("GitHub App callback with invalid params", {
queryParams,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
}
const callbackData = result.data;
const sessionResult = await validateGitHubAppInstallSession(cookieHeader, callbackData.state);
if (!sessionResult.valid) {
logger.error("GitHub App callback with invalid session", {
callbackData,
error: sessionResult.error,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
}
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
const redirectTo = sanitizeRedirectPath(unsafeRedirectTo);
const user = await requireUser(request);
const org = await $replica.organization.findFirst({
where: { id: organizationId, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
},
});
if (!org) {
// the secure cookie approach should already protect against this
// just an additional check
logger.error("GitHub app installation attempt on unauthenticated org", {
userId: user.id,
organizationId,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
}
switch (callbackData.setup_action) {
case "install": {
const [error] = await tryCatch(
linkGitHubAppInstallation(callbackData.installation_id, organizationId)
);
if (error) {
logger.error("Failed to link GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
}
const session = await setRequestSuccessMessage(request, "GitHub App installed successfully");
session.flash("gitHubAppInstalled", true);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
case "update": {
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id));
if (error) {
logger.error("Failed to update GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
}
const session = await setRequestSuccessMessage(request, "GitHub App updated successfully");
session.flash("gitHubAppInstalled", true);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
case "request": {
// This happens when a non-admin user requests installation
// The installation_id won't be available until an admin approves
logger.info("GitHub App installation requested, awaiting approval", {
callbackData,
});
const session = await setRequestSuccessMessage(request, "GitHub App installation requested");
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
default:
callbackData satisfies never;
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
}
}
@@ -0,0 +1,52 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { $replica } from "~/db.server";
import { createGitHubAppInstallSession } from "~/services/gitHubSession.server";
import { requireUser } from "~/services/session.server";
import { newOrganizationPath } from "~/utils/pathBuilder";
import { logger } from "~/services/logger.server";
import { sanitizeRedirectPath } from "~/utils";
const QuerySchema = z.object({
org_slug: z.string(),
redirect_to: z.string().refine((value) => value === sanitizeRedirectPath(value), {
message: "Invalid redirect path",
}),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const searchParams = new URL(request.url).searchParams;
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
if (!parsed.success) {
logger.warn("GitHub App installation redirect with invalid params", {
searchParams,
error: parsed.error,
});
throw redirect("/");
}
const { org_slug, redirect_to } = parsed.data;
const user = await requireUser(request);
const org = await $replica.organization.findFirst({
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
},
});
if (!org) {
throw redirect(newOrganizationPath());
}
const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to);
return redirect(url, {
headers: {
"Set-Cookie": cookieHeader,
},
});
};
@@ -14,7 +14,7 @@ import { Link, useRevalidator, useSubmit } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { type TaskRunStatus } from "@trigger.dev/database";
import type { TaskRunStatus } from "@trigger.dev/database";
import { Fragment, Suspense, useEffect, useState } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, type TooltipProps } from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
@@ -13,7 +13,7 @@ import {
import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
import { type ProjectAlertChannelType, type ProjectAlertType } from "@trigger.dev/database";
import type { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -130,7 +130,10 @@ export async function action({ request }: ActionFunctionArgs) {
}
const upsertBranchService = new UpsertBranchService();
const result = await upsertBranchService.call(userId, submission.value);
const result = await upsertBranchService.call(
{ type: "userMembership", userId },
submission.value
);
if (result.success) {
if (result.alreadyExisted) {
@@ -2,7 +2,7 @@ import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { Form, useRevalidator } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { BulkActionStatus, BulkActionType } from "@trigger.dev/database";
import type { BulkActionType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -135,7 +135,7 @@ export default function Page() {
const project = useProject();
const environment = useEnvironment();
const disabled = bulkAction.status !== BulkActionStatus.PENDING;
const disabled = bulkAction.status !== "PENDING";
const streamedEvents = useEventSource(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.id}/runs/bulkaction/${bulkAction.friendlyId}/stream`,
@@ -239,7 +239,7 @@ export default function Page() {
<BulkActionFilterSummary
selected={bulkAction.totalCount}
mode={bulkAction.mode}
action={bulkAction.type === BulkActionType.REPLAY ? "replay" : "cancel"}
action={bulkAction.type === "REPLAY" ? "replay" : "cancel"}
filters={bulkAction.filters}
final={true}
/>
@@ -327,7 +327,7 @@ function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
<div className="h-2 w-2 rounded-[1px] bg-charcoal-550" />
<Paragraph variant="extra-small">
{formatNumber(failureCount)} {typeText(type)} failed{" "}
{type === BulkActionType.CANCEL ? " (already finished)" : ""}
{type === "CANCEL" ? " (already finished)" : ""}
</Paragraph>
</div>
</div>
@@ -337,9 +337,9 @@ function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
function typeText(type: BulkActionType) {
switch (type) {
case BulkActionType.CANCEL:
case "CANCEL":
return "canceled";
case BulkActionType.REPLAY:
case "REPLAY":
return "replayed";
}
}
@@ -32,6 +32,7 @@ import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
import { capitalizeWord } from "~/utils/string";
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -187,7 +188,13 @@ export default function Page() {
<Property.Item>
<Property.Label>Started at</Property.Label>
<Property.Value>
<DateTimeAccurate date={deployment.createdAt} /> UTC
{deployment.startedAt ? (
<>
<DateTimeAccurate date={deployment.startedAt} /> UTC
</>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
@@ -226,17 +233,16 @@ export default function Page() {
<Property.Item>
<Property.Label>Deployed by</Property.Label>
<Property.Value>
{deployment.deployedBy ? (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={deployment.deployedBy.avatarUrl}
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName}
className="h-4 w-4"
/>
<Paragraph variant="small">
{deployment.deployedBy.name ?? deployment.deployedBy.displayName}
</Paragraph>
</div>
{deployment.git?.source === "trigger_github_app" ? (
<UserTag
name={deployment.git.ghUsername ?? "GitHub Integration"}
avatarUrl={deployment.git.ghUserAvatarUrl}
/>
) : deployment.deployedBy ? (
<UserTag
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName ?? ""}
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
/>
) : (
""
)}
@@ -1,11 +1,13 @@
import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { CogIcon, GitBranchIcon } from "lucide-react";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { GitMetadata } from "~/components/GitMetadata";
import { RuntimeIcon } from "~/components/RuntimeIcon";
import { UserAvatar } from "~/components/UserProfilePhoto";
@@ -50,7 +52,13 @@ import {
} from "~/presenters/v3/DeploymentListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { titleCase } from "~/utils";
import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
docsPath,
v3DeploymentPath,
v3ProjectSettingsPath,
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
@@ -122,8 +130,14 @@ export default function Page() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { deployments, currentPage, totalPages, selectedDeployment } =
useTypedLoaderData<typeof loader>();
const {
deployments,
currentPage,
totalPages,
selectedDeployment,
connectedGithubRepository,
environmentGitHubBranch,
} = useTypedLoaderData<typeof loader>();
const hasDeployments = totalPages > 0;
const { deploymentParam } = useParams();
@@ -160,8 +174,8 @@ export default function Page() {
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
<ResizablePanel id="deployments-main" min="100px" className="max-h-full">
{hasDeployments ? (
<div className="grid max-h-full grid-rows-[1fr_auto]">
<Table containerClassName="border-t-0">
<div className="flex h-full max-h-full flex-col">
<Table containerClassName="border-t-0 grow">
<TableHeader>
<TableRow>
<TableHeaderCell>Deploy</TableHeaderCell>
@@ -286,11 +300,38 @@ export default function Page() {
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="-mt-px flex justify-end border-t border-grid-dimmed py-2 pr-2">
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
)}
<div
className={cn(
"-mt-px flex flex-wrap justify-end gap-2 border-t border-grid-dimmed px-3 pb-[7px] pt-[6px]",
connectedGithubRepository && environmentGitHubBranch && "justify-between"
)}
>
{connectedGithubRepository && environmentGitHubBranch && (
<div className="flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm">
<OctoKitty className="size-4" />
Automatically triggered by pushes to{" "}
<div className="flex max-w-32 items-center gap-1 truncate rounded bg-grid-dimmed px-1 font-mono">
<GitBranchIcon className="size-3 shrink-0" />
<span className="max-w-28 truncate">{environmentGitHubBranch}</span>
</div>{" "}
in
<a
href={connectedGithubRepository.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="max-w-52 truncate text-sm text-text-dimmed underline transition-colors hover:text-text-bright"
>
{connectedGithubRepository.repository.fullName}
</a>
<LinkButton
variant="minimal/small"
LeadingIcon={CogIcon}
to={v3ProjectSettingsPath(organization, project, environment)}
/>
</div>
)}
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-md">
@@ -317,7 +358,7 @@ export default function Page() {
);
}
function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
return (
<div className="flex items-center gap-1">
<UserAvatar avatarUrl={avatarUrl} name={name} className="h-4 w-4" />
@@ -17,7 +17,7 @@ import {
type MetaFunction,
} from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -3,6 +3,7 @@ import {
ArrowUpCircleIcon,
BookOpenIcon,
ChatBubbleLeftEllipsisIcon,
InformationCircleIcon,
MapPinIcon,
} from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
@@ -31,6 +32,7 @@ import {
DialogTitle,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
@@ -45,12 +47,13 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useHasAdminAccess } from "~/hooks/useUser";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { requireUser } from "~/services/session.server";
import {
docsPath,
EnvironmentParamSchema,
@@ -130,6 +133,7 @@ export default function Page() {
const { regions, isPaying } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const isAdmin = useHasAdminAccess();
const { isManagedCloud } = useFeatures();
return (
<PageContainer>
@@ -158,7 +162,7 @@ export default function Page() {
</MainCenteredContainer>
) : (
<>
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
@@ -293,6 +297,31 @@ export default function Page() {
</TableRow>
</TableBody>
</Table>
{isManagedCloud && (
<InfoPanel
icon={InformationCircleIcon}
iconClassName="size-4"
variant="minimal"
panelClassName="max-w-full gap-1"
>
<Paragraph variant="extra-small" className="flex items-baseline gap-x-0.5">
Trigger.dev is fully GDPR compliant. Learn more in our{" "}
<TextLink to="https://security.trigger.dev">security portal</TextLink> or{" "}
<Feedback
button={
<Paragraph
variant="extra-small"
className="cursor-pointer text-indigo-500 transition hover:text-indigo-400"
>
get in touch
</Paragraph>
}
defaultValue="help"
/>
.
</Paragraph>
</InfoPanel>
)}
</div>
</>
)}
@@ -11,7 +11,7 @@ import {
MagnifyingGlassPlusIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
import { useLoaderData, useParams, useRevalidator } from "@remix-run/react";
import { useLoaderData, useRevalidator } from "@remix-run/react";
import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
import { type Virtualizer } from "@tanstack/react-virtual";
import {
@@ -20,12 +20,13 @@ import {
nanosecondsToMilliseconds,
tryCatch,
} from "@trigger.dev/core/v3";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { redirect } from "remix-typedjson";
import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon";
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
import { MoveUpIcon } from "~/assets/icons/MoveUpIcon";
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
import { DevDisconnectedBanner, useCrossEngineIsConnected } from "~/components/DevPresence";
import { WarmStartIconWithTooltip } from "~/components/WarmStarts";
@@ -87,7 +88,6 @@ import {
docsPath,
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
v3RunStreamingPath,
@@ -302,8 +302,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
return <></>;
}
const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt, queuedDuration } =
trace;
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration } = trace;
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
const changeToSpan = useDebounce((selectedSpan: string) => {
@@ -340,7 +339,6 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
selectedId={selectedSpanId}
key={events[0]?.id ?? "-"}
events={events}
parentRunFriendlyId={parentRunFriendlyId}
onSelectedIdChanged={(selectedSpan) => {
//instantly close the panel if no span is selected
if (!selectedSpan) {
@@ -358,6 +356,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
shouldLiveReload={shouldLiveReload}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
rootRun={run.rootTaskRun}
parentRun={run.parentTaskRun}
isCompleted={run.completedAt !== null}
/>
</ResizablePanel>
@@ -476,7 +475,6 @@ function NoLogsView({ run, resizable }: LoaderData) {
type TasksTreeViewProps = {
events: TraceEvent[];
selectedId?: string;
parentRunFriendlyId?: string;
onSelectedIdChanged: (selectedId: string | undefined) => void;
totalDuration: number;
rootSpanStatus: "executing" | "completed" | "failed";
@@ -487,7 +485,10 @@ type TasksTreeViewProps = {
maximumLiveReloadingSetting: number;
rootRun: {
friendlyId: string;
taskIdentifier: string;
spanId: string;
} | null;
parentRun: {
friendlyId: string;
spanId: string;
} | null;
isCompleted: boolean;
@@ -496,7 +497,6 @@ type TasksTreeViewProps = {
function TasksTreeView({
events,
selectedId,
parentRunFriendlyId,
onSelectedIdChanged,
totalDuration,
rootSpanStatus,
@@ -506,6 +506,7 @@ function TasksTreeView({
shouldLiveReload,
maximumLiveReloadingSetting,
rootRun,
parentRun,
isCompleted,
}: TasksTreeViewProps) {
const isAdmin = useHasAdminAccess();
@@ -596,20 +597,30 @@ function TasksTreeView({
id={resizableSettings.tree.tree.id}
default={resizableSettings.tree.tree.default}
min={resizableSettings.tree.tree.min}
className="pl-3"
>
<div className="grid h-full grid-rows-[2rem_1fr] overflow-hidden">
<div className="flex items-center pr-2">
{rootRun ? (
<ShowParentLink
runFriendlyId={rootRun.friendlyId}
isRoot={true}
spanId={rootRun.spanId}
<div className="flex items-center justify-between pl-1 pr-2">
{rootRun || parentRun ? (
<ShowParentOrRootLinks
relationships={{
root: rootRun
? {
friendlyId: rootRun.friendlyId,
spanId: rootRun.spanId,
isParent: parentRun ? rootRun.friendlyId === parentRun.friendlyId : true,
}
: undefined,
parent:
parentRun && rootRun?.friendlyId !== parentRun.friendlyId
? {
friendlyId: parentRun.friendlyId,
spanId: "",
}
: undefined,
}}
/>
) : parentRunFriendlyId ? (
<ShowParentLink runFriendlyId={parentRunFriendlyId} isRoot={false} />
) : (
<Paragraph variant="small" className="flex-1 text-charcoal-500">
<Paragraph variant="extra-small" className="flex-1 pl-3 text-charcoal-500">
This is the root task
</Paragraph>
)}
@@ -628,6 +639,7 @@ function TasksTreeView({
nodes={nodes}
getNodeProps={getNodeProps}
getTreeProps={getTreeProps}
parentClassName="pl-3"
renderNode={({ node, state, index }) => (
<>
<div
@@ -1139,60 +1151,108 @@ function TaskLine({ isError, isSelected }: { isError: boolean; isSelected: boole
return <div className={cn("h-8 w-2 border-r border-grid-bright")} />;
}
function ShowParentLink({
runFriendlyId,
spanId,
isRoot,
function ShowParentOrRootLinks({
relationships,
}: {
runFriendlyId: string;
spanId?: string;
isRoot: boolean;
relationships: {
root?: {
friendlyId: string;
spanId: string;
isParent?: boolean;
};
parent?: {
friendlyId: string;
spanId: string;
};
};
}) {
const [mouseOver, setMouseOver] = useState(false);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { spanParam } = useParams();
const span = spanId ? spanId : spanParam;
return (
<LinkButton
variant="minimal/medium"
to={
span
? v3RunSpanPath(
organization,
project,
environment,
{
friendlyId: runFriendlyId,
},
{ spanId: span }
)
: v3RunPath(organization, project, environment, {
friendlyId: runFriendlyId,
})
}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
fullWidth
textAlignLeft
shortcut={{ key: "p" }}
className="flex-1"
>
{mouseOver ? (
<ShowParentIconSelected className="h-4 w-4 text-indigo-500" />
) : (
<ShowParentIcon className="h-4 w-4 text-charcoal-650" />
)}
<Paragraph
variant="small"
className={cn(mouseOver ? "text-indigo-500" : "text-charcoal-500")}
// Case 1: Root is also the parent
if (relationships.root?.isParent === true) {
return (
<LinkButton
variant="minimal/small"
to={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: relationships.root.friendlyId },
{ spanId: relationships.root.spanId }
)}
LeadingIcon={MoveToTopIcon}
leadingIconClassName="gap-x-2"
shortcut={{ key: "p" }}
hideShortcutKey
tooltip={
<div className="-mr-1 flex items-center gap-1">
<Paragraph variant="extra-small">Jump to root and parent run</Paragraph>
<ShortcutKey shortcut={{ key: "p" }} variant="small" />
</div>
}
className="text-xs"
>
{isRoot ? "Show root run" : "Show parent run"}
</Paragraph>
</LinkButton>
Root/parent
</LinkButton>
);
}
// Case 2: Root and Parent are different runs
return (
<div className="flex items-center">
{relationships.root && (
<LinkButton
variant="minimal/small"
to={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: relationships.root.friendlyId },
{ spanId: relationships.root.spanId }
)}
LeadingIcon={MoveToTopIcon}
leadingIconClassName="gap-x-2"
shortcut={{ key: "t" }}
hideShortcutKey
tooltip={
<div className="-mr-1 flex items-center gap-1">
<Paragraph variant="extra-small">Jump to root run</Paragraph>
<ShortcutKey shortcut={{ key: "t" }} variant="small" />
</div>
}
className="text-xs"
>
Root
</LinkButton>
)}
{relationships.parent && (
<LinkButton
variant="minimal/small"
to={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: relationships.parent.friendlyId },
{ spanId: relationships.parent.spanId }
)}
LeadingIcon={MoveUpIcon}
leadingIconClassName="gap-x-2"
shortcut={{ key: "p" }}
hideShortcutKey
tooltip={
<div className="-mr-1 flex items-center gap-1">
<Paragraph variant="extra-small">Jump to parent run</Paragraph>
<ShortcutKey shortcut={{ key: "p" }} variant="small" />
</div>
}
className="text-xs"
>
Parent
</LinkButton>
)}
</div>
);
}
@@ -1,7 +1,7 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
import { AppContainer } from "~/components/layout/AppLayout";
import { AppContainer, MainBody, PageBody } from "~/components/layout/AppLayout";
import { Header1 } from "~/components/primitives/Headers";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
@@ -49,22 +49,24 @@ export default function ChoosePlanPage() {
useTypedLoaderData<typeof loader>();
return (
<AppContainer className="bg-charcoal-900">
<BackgroundWrapper>
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-8 p-3">
<Header1 className="text-center">Subscribe for full access</Header1>
<div className="w-full rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<PricingPlans
plans={plans}
subscription={v3Subscription}
organizationSlug={organizationSlug}
hasPromotedPlan
showGithubVerificationBadge
periodEnd={periodEnd}
/>
<AppContainer>
<PageBody className="bg-charcoal-900">
<BackgroundWrapper>
<div className="mx-auto mt-4 flex h-fit min-h-full max-w-[80rem] flex-col items-center justify-center gap-8 lg:mt-0">
<Header1 className="text-center">Subscribe for full access</Header1>
<div className="w-full rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<PricingPlans
plans={plans}
subscription={v3Subscription}
organizationSlug={organizationSlug}
hasPromotedPlan
showGithubVerificationBadge
periodEnd={periodEnd}
/>
</div>
</div>
</div>
</BackgroundWrapper>
</BackgroundWrapper>
</PageBody>
</AppContainer>
);
}
@@ -0,0 +1,71 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { StartDeploymentRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { DeploymentService } from "~/v3/services/deployment.server";
const ParamsSchema = z.object({
deploymentId: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateRequest(request, {
apiKey: true,
organizationAccessToken: false,
personalAccessToken: false,
});
if (!authenticationResult || !authenticationResult.result.ok) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const { environment: authenticatedEnv } = authenticationResult.result;
const { deploymentId } = parsedParams.data;
const rawBody = await request.json();
const body = StartDeploymentRequestBody.safeParse(rawBody);
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const deploymentService = new DeploymentService();
return await deploymentService
.startDeployment(authenticatedEnv, deploymentId, {
contentHash: body.data.contentHash,
git: body.data.gitMeta,
runtime: body.data.runtime,
})
.match(
() => {
return json(null, { status: 204 });
},
(error) => {
switch (error.type) {
case "failed_to_extend_deployment_timeout":
return json(null, { status: 204 }); // ignore these errors for now
case "deployment_not_found":
return json({ error: "Deployment not found" }, { status: 404 });
case "deployment_not_pending":
return json({ error: "Deployment is not pending" }, { status: 409 });
case "other":
default:
error.type satisfies "other";
return json({ error: "Internal server error" }, { status: 500 });
}
}
);
}
@@ -1,4 +1,5 @@
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
@@ -52,7 +53,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
shortCode: deployment.shortCode,
version: deployment.version,
imageReference: deployment.imageReference,
errorData: deployment.errorData,
imagePlatform: deployment.imagePlatform,
externalBuildData:
deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"],
errorData: deployment.errorData as GetDeploymentResponseBody["errorData"],
worker: deployment.worker
? {
id: deployment.worker.friendlyId,
@@ -65,5 +69,5 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
})),
}
: undefined,
});
} satisfies GetDeploymentResponseBody);
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
ApiDeploymentListSearchParams,
InitializeDeploymentRequestBody,
InitializeDeploymentResponseBody,
type InitializeDeploymentResponseBody,
} from "@trigger.dev/core/v3";
import { $replica } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
@@ -1,9 +1,10 @@
import { ActionFunctionArgs, json } from "@remix-run/node";
import { type ActionFunctionArgs, json } from "@remix-run/node";
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { getEnvironmentFromEnv } from "./api.v1.projects.$projectRef.$env";
import {
authenticatedEnvironmentForAuthentication,
authenticateRequest,
} from "~/services/apiAuth.server";
const ParamsSchema = z.object({
projectRef: z.string(),
@@ -20,7 +21,11 @@ const RequestBodySchema = z.object({
});
export async function action({ request, params }: ActionFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
@@ -33,35 +38,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
const { projectRef, env } = parsedParams.data;
const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined;
const project = await prisma.project.findFirst({
where: {
externalRef: projectRef,
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
},
});
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const envResult = await getEnvironmentFromEnv({
projectId: project.id,
userId: authenticationResult.userId,
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
authenticationResult,
projectRef,
env,
});
if (!envResult.success) {
return json({ error: envResult.error }, { status: 404 });
}
const runtimeEnv = envResult.environment;
triggerBranch
);
const parsedBody = RequestBodySchema.safeParse(await request.json());
@@ -72,29 +56,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
);
}
const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined;
let previewBranchEnvironmentId: string | undefined;
if (triggerBranch) {
const previewBranch = await prisma.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
branchName: triggerBranch,
parentEnvironmentId: runtimeEnv.id,
archivedAt: null,
},
});
if (previewBranch) {
previewBranchEnvironmentId = previewBranch.id;
} else {
return json({ error: `Preview branch ${triggerBranch} not found` }, { status: 404 });
}
}
const claims = {
sub: previewBranchEnvironmentId ?? runtimeEnv.id,
sub: runtimeEnv.id,
pub: true,
...parsedBody.data.claims,
};
@@ -1,11 +1,11 @@
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type GetProjectEnvResponse } from "@trigger.dev/core/v3";
import { type RuntimeEnvironment } from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env as processEnv } from "~/env.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import {
authenticatedEnvironmentForAuthentication,
authenticateRequest,
} from "~/services/apiAuth.server";
const ParamsSchema = z.object({
projectRef: z.string(),
@@ -15,14 +15,6 @@ const ParamsSchema = z.object({
type ParamsSchema = z.infer<typeof ParamsSchema>;
export async function loader({ request, params }: LoaderFunctionArgs) {
logger.info("projects get env", { url: request.url });
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
@@ -31,162 +23,24 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const { projectRef, env } = parsedParams.data;
const project = await prisma.project.findFirst({
where: {
externalRef: projectRef,
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
},
});
const authenticationResult = await authenticateRequest(request);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const envResult = await getEnvironmentFromEnv({
projectId: project.id,
userId: authenticationResult.userId,
env,
});
if (!envResult.success) {
return json({ error: envResult.error }, { status: 404 });
}
const runtimeEnv = envResult.environment;
const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
projectRef,
env
);
const result: GetProjectEnvResponse = {
apiKey: runtimeEnv.apiKey,
name: project.name,
apiKey: environment.apiKey,
name: environment.project.name,
apiUrl: processEnv.API_ORIGIN ?? processEnv.APP_ORIGIN,
projectId: project.id,
projectId: environment.project.id,
};
return json(result);
}
export async function getEnvironmentFromEnv({
projectId,
userId,
env,
branch,
}: {
projectId: string;
userId: string;
env: ParamsSchema["env"];
branch?: string;
}): Promise<
| {
success: true;
environment: RuntimeEnvironment;
}
| {
success: false;
error: string;
}
> {
if (env === "dev") {
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
projectId,
orgMember: {
userId: userId,
},
},
});
if (!environment) {
return {
success: false,
error: "Dev environment not found",
};
}
return {
success: true,
environment,
};
}
let slug: "stg" | "prod" | "preview" = "prod";
switch (env) {
case "staging":
slug = "stg";
break;
case "prod":
slug = "prod";
break;
case "preview":
slug = "preview";
break;
default:
break;
}
if (slug === "preview") {
const previewEnvironment = await prisma.runtimeEnvironment.findFirst({
where: {
projectId,
slug: "preview",
},
});
if (!previewEnvironment) {
return {
success: false,
error: "Preview environment not found",
};
}
// If no branch is provided, just return the parent preview environment
if (!branch) {
return {
success: true,
environment: previewEnvironment,
};
}
const branchEnvironment = await prisma.runtimeEnvironment.findFirst({
where: {
parentEnvironmentId: previewEnvironment.id,
branchName: branch,
},
});
if (!branchEnvironment) {
return {
success: false,
error: `Preview branch ${branch} not found`,
};
}
return {
success: true,
environment: branchEnvironment,
};
}
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
projectId,
slug,
},
});
if (!environment) {
return {
success: false,
error: `${env === "staging" ? "Staging" : "Production"} environment not found`,
};
}
return {
success: true,
environment,
};
}
@@ -1,12 +1,14 @@
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica, prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { getEnvironmentFromEnv } from "./api.v1.projects.$projectRef.$env";
import { GetWorkerByTagResponse } from "@trigger.dev/core/v3/schemas";
import { type GetWorkerByTagResponse } from "@trigger.dev/core/v3/schemas";
import { env as $env } from "~/env.server";
import { v3RunsPath } from "~/utils/pathBuilder";
import {
authenticatedEnvironmentForAuthentication,
authenticateRequest,
} from "~/services/apiAuth.server";
const ParamsSchema = z.object({
projectRef: z.string(),
@@ -21,7 +23,11 @@ const HeadersSchema = z.object({
type ParamsSchema = z.infer<typeof ParamsSchema>;
export async function loader({ request, params }: LoaderFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
@@ -32,51 +38,17 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}
const parsedHeaders = HeadersSchema.safeParse(Object.fromEntries(request.headers));
const branch = parsedHeaders.success ? parsedHeaders.data["x-trigger-branch"] : undefined;
const { projectRef, env } = parsedParams.data;
const project = await prisma.project.findFirst({
where: {
externalRef: projectRef,
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
},
select: {
id: true,
slug: true,
organization: {
select: {
slug: true,
},
},
},
});
const parsedHeaders = HeadersSchema.safeParse(Object.fromEntries(request.headers));
const triggerBranch = parsedHeaders.success ? parsedHeaders.data["x-trigger-branch"] : undefined;
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const envResult = await getEnvironmentFromEnv({
projectId: project.id,
userId: authenticationResult.userId,
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
authenticationResult,
projectRef,
env,
branch,
});
if (!envResult.success) {
return json({ error: envResult.error }, { status: 404 });
}
const runtimeEnv = envResult.environment;
triggerBranch
);
const currentWorker = await findCurrentWorkerFromEnvironment(
{
@@ -110,8 +82,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const urls = {
runs: `${$env.APP_ORIGIN}${v3RunsPath(
{ slug: project.organization.slug },
{ slug: project.slug },
{ slug: runtimeEnv.organization.slug },
{ slug: runtimeEnv.project.slug },
{ slug: runtimeEnv.slug },
{ versions: [currentWorker.version] }
)}`,
@@ -2,7 +2,7 @@ import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import {
authenticateProjectApiKeyOrPersonalAccessToken,
authenticateRequest,
authenticatedEnvironmentForAuthentication,
} from "~/services/apiAuth.server";
import zlib from "node:zlib";
@@ -20,7 +20,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
@@ -82,9 +82,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
});
}
function decompressContent(compressedBuffer: Buffer): string {
// First, we need to decode the base64 Buffer to get the actual compressed data
const decodedBuffer = Buffer.from(compressedBuffer.toString("utf-8"), "base64");
function decompressContent(compressedBuffer: Uint8Array): string {
// Convert Uint8Array to Buffer and decode base64 in one step
const decodedBuffer = Buffer.from(Buffer.from(compressedBuffer).toString("utf-8"), "base64");
// Decompress the data
const decompressedData = zlib.inflateSync(decodedBuffer);
@@ -2,9 +2,9 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateRequest } from "~/services/apiAuth.server";
import { ArchiveBranchService } from "~/services/archiveBranch.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
const ParamsSchema = z.object({
projectRef: z.string(),
@@ -21,7 +21,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
logger.info("Archive branch", { url: request.url, params });
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
@@ -50,13 +55,16 @@ export async function action({ request, params }: ActionFunctionArgs) {
archivedAt: true,
},
where: {
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
organization:
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
},
},
},
project: {
externalRef: projectRef,
},
@@ -74,9 +82,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
const service = new ArchiveBranchService();
const result = await service.call(authenticationResult.userId, {
environmentId: environment.id,
});
const result = await service.call(
authenticationResult.type === "organizationAccessToken"
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
: { type: "userMembership", userId: authenticationResult.result.userId },
{
environmentId: environment.id,
}
);
if (result.success) {
return json(result);
@@ -1,7 +1,8 @@
import { json, LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/server-runtime";
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { UpsertBranchService } from "~/services/upsertBranch.server";
@@ -19,7 +20,11 @@ export async function action({ request, params }: ActionFunctionArgs) {
logger.info("project upsert branch", { url: request.url });
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
@@ -38,13 +43,16 @@ export async function action({ request, params }: ActionFunctionArgs) {
},
where: {
externalRef: projectRef,
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
organization:
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
},
},
},
},
});
if (!project) {
@@ -81,11 +89,16 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { branch, env, git } = parsed.data;
const service = new UpsertBranchService();
const result = await service.call(authenticationResult.userId, {
branchName: branch,
parentEnvironmentId: previewEnvironment.id,
git,
});
const result = await service.call(
authenticationResult.type === "organizationAccessToken"
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
: { type: "userMembership", userId: authenticationResult.result.userId },
{
branchName: branch,
parentEnvironmentId: previewEnvironment.id,
git,
}
);
if (!result.success) {
return json({ error: result.error }, { status: 400 });
@@ -1,16 +1,21 @@
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { devPresence } from "~/presenters/v3/DevPresence.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { getEnvironmentFromEnv } from "./api.v1.projects.$projectRef.$env";
import {
authenticatedEnvironmentForAuthentication,
authenticateRequest,
} from "~/services/apiAuth.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
@@ -24,34 +29,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const { projectRef } = parsedParams.data;
const project = await prisma.project.findFirst({
where: {
externalRef: projectRef,
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
},
});
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const envResult = await getEnvironmentFromEnv({
projectId: project.id,
userId: authenticationResult.userId,
env: "dev",
});
if (!envResult.success) {
return json({ error: envResult.error }, { status: 404 });
}
const runtimeEnv = envResult.environment;
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
authenticationResult,
projectRef,
"dev"
);
const isConnected = await devPresence.isConnected(runtimeEnv.id);
@@ -3,7 +3,7 @@ import { UpdateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import {
authenticateProjectApiKeyOrPersonalAccessToken,
authenticateRequest,
authenticatedEnvironmentForAuthentication,
} from "~/services/apiAuth.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
@@ -21,7 +21,7 @@ export async function action({ params, request }: ActionFunctionArgs) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
@@ -97,7 +97,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
@@ -3,7 +3,7 @@ import { ImportEnvironmentVariablesRequestBody } from "@trigger.dev/core/v3";
import { parse } from "dotenv";
import { z } from "zod";
import {
authenticateProjectApiKeyOrPersonalAccessToken,
authenticateRequest,
authenticatedEnvironmentForAuthentication,
branchNameFromRequest,
} from "~/services/apiAuth.server";
@@ -21,7 +21,7 @@ export async function action({ params, request }: ActionFunctionArgs) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
@@ -2,7 +2,7 @@ import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-
import { CreateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import {
authenticateProjectApiKeyOrPersonalAccessToken,
authenticateRequest,
authenticatedEnvironmentForAuthentication,
} from "~/services/apiAuth.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
@@ -19,7 +19,7 @@ export async function action({ params, request }: ActionFunctionArgs) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
@@ -66,7 +66,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request);
const authenticationResult = await authenticateRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
@@ -1,3 +1,4 @@
import { EngineServiceValidationError } from "@internal/run-engine";
import { json } from "@remix-run/server-runtime";
import {
generateJWT as internal_generateJWT,
@@ -8,7 +9,6 @@ import { TaskRun } from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { EngineServiceValidationError } from "~/runEngine/concerns/errors";
import { ApiAuthenticationResultSuccess, getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+191 -88
View File
@@ -1,97 +1,200 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { WhoAmIResponse } from "@trigger.dev/core/v3";
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type WhoAmIResponse } from "@trigger.dev/core/v3";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { v3ProjectPath } from "~/utils/pathBuilder";
import { authenticateRequest } from "~/services/apiAuth.server";
export async function loader({ request }: LoaderFunctionArgs) {
logger.info("whoami v2", { url: request.url });
try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const url = new URL(request.url);
const projectRef = url.searchParams.get("projectRef") ?? undefined;
switch (authenticationResult.type) {
case "personalAccessToken": {
const result = await getIdentityFromPAT(authenticationResult.result.userId, projectRef);
if (!result.success) {
if (result.error === "user_not_found") {
return json({ error: "User not found" }, { status: 404 });
}
return json({ error: result.error }, { status: 401 });
}
return json(result.result);
}
case "organizationAccessToken": {
const result = await getIdentityFromOAT(
authenticationResult.result.organizationId,
projectRef
);
return json(result.result);
}
default: {
authenticationResult satisfies never;
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const user = await prisma.user.findUnique({
select: {
email: true,
},
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "User not found" }, { status: 404 });
}
const url = new URL(request.url);
const projectRef = url.searchParams.get("projectRef");
let projectDetails: WhoAmIResponse["project"];
if (projectRef) {
const orgs = await prisma.organization.findMany({
select: {
id: true,
},
where: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
});
if (orgs.length > 0) {
const project = await prisma.project.findFirst({
select: {
externalRef: true,
name: true,
slug: true,
organization: {
select: {
slug: true,
title: true,
},
},
},
where: {
externalRef: projectRef,
organizationId: {
in: orgs.map((org) => org.id),
},
},
});
if (project) {
const projectPath = v3ProjectPath(
{ slug: project.organization.slug },
{ slug: project.slug }
);
projectDetails = {
url: new URL(projectPath, env.APP_ORIGIN).href,
name: project.name,
orgTitle: project.organization.title,
};
}
}
}
const result: WhoAmIResponse = {
userId: authenticationResult.userId,
email: user.email,
dashboardUrl: env.APP_ORIGIN,
project: projectDetails,
};
return json(result);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
logger.error("Error in whoami v2", { error: errorMessage });
return json({ error: errorMessage }, { status: 400 });
}
}
async function getIdentityFromPAT(
userId: string,
projectRef: string | undefined
): Promise<
{ success: true; result: WhoAmIResponse } | { success: false; error: "user_not_found" }
> {
const user = await prisma.user.findFirst({
select: {
email: true,
},
where: {
id: userId,
},
});
if (!user) {
return { success: false, error: "user_not_found" };
}
const userDetails = {
userId,
email: user.email,
dashboardUrl: env.APP_ORIGIN,
} satisfies WhoAmIResponse;
if (!projectRef) {
return {
success: true,
result: userDetails,
};
}
const orgs = await prisma.organization.findMany({
select: {
id: true,
},
where: {
members: {
some: {
userId,
},
},
},
});
if (orgs.length === 0) {
return {
success: true,
result: userDetails,
};
}
const project = await prisma.project.findFirst({
select: {
externalRef: true,
name: true,
slug: true,
organization: {
select: {
slug: true,
title: true,
},
},
},
where: {
externalRef: projectRef,
organizationId: {
in: orgs.map((org) => org.id),
},
},
});
if (!project) {
return {
success: true,
result: userDetails,
};
}
const projectPath = v3ProjectPath({ slug: project.organization.slug }, { slug: project.slug });
return {
success: true,
result: {
...userDetails,
project: {
url: new URL(projectPath, env.APP_ORIGIN).href,
name: project.name,
orgTitle: project.organization.title,
},
},
};
}
async function getIdentityFromOAT(
organizationId: string,
projectRef: string | undefined
): Promise<{ success: true; result: WhoAmIResponse }> {
// Organization auth tokens are currently only used internally for the build server.
// We will eventually expose them in the application as well, as they are useful beyond the build server.
// At that point we will need a v3 whoami endpoint that properly handles org auth tokens.
// For now, we just return a dummy user id and email and keep using the existing v2 whoami endpoint.
const orgDetails = {
userId: `org_${organizationId}`,
email: "not_applicable@trigger.dev",
dashboardUrl: env.APP_ORIGIN,
} satisfies WhoAmIResponse;
if (!projectRef) {
return {
success: true,
result: orgDetails,
};
}
const project = await prisma.project.findFirst({
select: {
externalRef: true,
name: true,
slug: true,
organization: {
select: {
slug: true,
title: true,
},
},
},
where: {
externalRef: projectRef,
organizationId,
},
});
if (!project) {
return {
success: true,
result: orgDetails,
};
}
const projectPath = v3ProjectPath(project.organization, project);
return {
success: true,
result: {
...orgDetails,
project: {
url: new URL(projectPath, env.APP_ORIGIN).href,
name: project.name,
orgTitle: project.organization.title,
},
},
};
}
@@ -5,11 +5,12 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { commitSession } from "~/services/sessionStorage.server";
import { redirectCookie } from "./auth.github";
import { sanitizeRedirectPath } from "~/utils";
export let loader: LoaderFunction = async ({ request }) => {
const cookie = request.headers.get("Cookie");
const redirectValue = await redirectCookie.parse(cookie);
const redirectTo = redirectValue ?? "/";
const redirectTo = sanitizeRedirectPath(redirectValue);
const auth = await authenticator.authenticate("github", request, {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
+1 -3
View File
@@ -1,6 +1,4 @@
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { createCookie } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";
export let loader: LoaderFunction = () => redirect("/login");
@@ -9,7 +9,10 @@ export const action = createActionWorkerApiRoute(
{
body: WorkerApiDequeueRequestBody, // Even though we don't use it, we need to keep it for backwards compatibility
},
async ({ authenticatedWorker }): Promise<TypedResponse<WorkerApiDequeueResponseBody>> => {
return json(await authenticatedWorker.dequeue());
async ({
authenticatedWorker,
runnerId,
}): Promise<TypedResponse<WorkerApiDequeueResponseBody>> => {
return json(await authenticatedWorker.dequeue({ runnerId }));
}
);
@@ -18,6 +18,7 @@ export const action = createActionWorkerApiRoute(
authenticatedWorker,
body,
params,
runnerId,
}): Promise<TypedResponse<WorkerApiRunAttemptCompleteResponseBody>> => {
const { completion } = body;
const { runFriendlyId, snapshotFriendlyId } = params;
@@ -26,6 +27,7 @@ export const action = createActionWorkerApiRoute(
runFriendlyId,
snapshotFriendlyId,
completion,
runnerId,
});
return json({ result: completeResult });
@@ -18,6 +18,7 @@ export const action = createActionWorkerApiRoute(
authenticatedWorker,
body,
params,
runnerId,
}): Promise<TypedResponse<WorkerApiRunAttemptStartResponseBody>> => {
const { runFriendlyId, snapshotFriendlyId } = params;
@@ -25,6 +26,7 @@ export const action = createActionWorkerApiRoute(
runFriendlyId,
snapshotFriendlyId,
isWarmStart: body.isWarmStart,
runnerId,
});
return json(runExecutionData);
@@ -14,6 +14,7 @@ export const loader = createLoaderWorkerApiRoute(
async ({
authenticatedWorker,
params,
runnerId,
}): Promise<TypedResponse<WorkerApiContinueRunExecutionRequestBody>> => {
const { runFriendlyId, snapshotFriendlyId } = params;
@@ -23,6 +24,7 @@ export const loader = createLoaderWorkerApiRoute(
const continuationResult = await authenticatedWorker.continueRunExecution({
runFriendlyId,
snapshotFriendlyId,
runnerId,
});
return json(continuationResult);
@@ -13,12 +13,14 @@ export const action = createActionWorkerApiRoute(
async ({
authenticatedWorker,
params,
runnerId,
}): Promise<TypedResponse<WorkloadHeartbeatResponseBody>> => {
const { runFriendlyId, snapshotFriendlyId } = params;
await authenticatedWorker.heartbeatRun({
runFriendlyId,
snapshotFriendlyId,
runnerId,
});
return json({ ok: true });
@@ -19,6 +19,7 @@ export const action = createActionWorkerApiRoute(
authenticatedWorker,
params,
body,
runnerId,
}): Promise<TypedResponse<WorkerApiSuspendRunResponseBody>> => {
const { runFriendlyId, snapshotFriendlyId } = params;
@@ -39,6 +40,7 @@ export const action = createActionWorkerApiRoute(
runFriendlyId,
snapshotFriendlyId,
checkpoint: body.checkpoint,
runnerId,
});
return json({ ok: true });
@@ -37,7 +37,12 @@ export async function action({ request }: ActionFunctionArgs) {
const archiveBranchService = new ArchiveBranchService();
const result = await archiveBranchService.call(userId, submission.value);
const result = await archiveBranchService.call(
{ type: "userMembership", userId },
{
environmentId: submission.value.environmentId,
}
);
if (result.success) {
return redirectWithSuccessMessage(
@@ -1,11 +1,9 @@
import { BulkActionStatus } from "@trigger.dev/database";
import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { devPresence } from "~/presenters/v3/DevPresence.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema, ProjectParamSchema } from "~/utils/pathBuilder";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { createSSELoader, type SendFunction } from "~/utils/sse";
const Params = EnvironmentParamSchema.extend({
@@ -82,7 +80,7 @@ export const loader = createSSELoader({
send({ event: "time", data: new Date().toISOString() });
if (bulkAction?.status !== BulkActionStatus.PENDING) {
if (bulkAction?.status !== "PENDING") {
return false;
}
@@ -91,7 +89,7 @@ export const loader = createSSELoader({
iterator: async ({ send, date }) => {
const bulkAction = await getBulkActionProgress(send);
if (bulkAction?.status !== BulkActionStatus.PENDING) {
if (bulkAction?.status !== "PENDING") {
return false;
}
@@ -1,11 +1,9 @@
import { parse } from "@conform-to/zod";
import { ArrowPathIcon, CheckIcon, InformationCircleIcon } from "@heroicons/react/20/solid";
import { ArrowPathIcon, InformationCircleIcon } from "@heroicons/react/20/solid";
import { XCircleIcon } from "@heroicons/react/24/outline";
import { Form } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router";
import { tryCatch } from "@trigger.dev/core";
import { type TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { useEffect, useState } from "react";
import { typedjson, useTypedFetcher } from "remix-typedjson";
import simplur from "simplur";
@@ -25,7 +23,6 @@ import {
AccordionItem,
AccordionTrigger,
} from "~/components/primitives/Accordion";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import {
@@ -43,19 +40,7 @@ import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import {
filterIcon,
filterTitle,
type TaskRunListSearchFilterKey,
type TaskRunListSearchFilters,
} from "~/components/runs/v3/RunFilters";
import {
appliedSummary,
dateFromString,
timeFilterRenderValues,
} from "~/components/runs/v3/SharedFilters";
import { runStatusTitle } from "~/components/runs/v3/TaskRunStatus";
import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -69,7 +54,6 @@ import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPrese
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { formatNumber } from "~/utils/numberFormatter";
import { EnvironmentParamSchema, v3BulkActionPath, v3RunsPath } from "~/utils/pathBuilder";
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
@@ -339,28 +323,28 @@ export function CreateBulkActionInspector({
replace({ action: value });
}}
>
<RadioGroupItem
id="action-replay"
label={
<span className="inline-flex items-center gap-1">
<ArrowPathIcon className="mb-0.5 size-4 text-blue-400" /> Replay runs
</span>
}
description="Replays all selected runs, regardless of current status."
value={"replay"}
variant="description"
/>
<RadioGroupItem
id="action-cancel"
label={
<span className="inline-flex items-center gap-1">
<XCircleIcon className="mb-0.5 size-4 text-error" /> Cancel runs
<XCircleIcon className="size-4 text-error" /> Cancel runs
</span>
}
description="Cancels all runs still in progress. Any finished runs wont be canceled."
value={"cancel"}
variant="description"
/>
<RadioGroupItem
id="action-replay"
label={
<span className="inline-flex items-center gap-1">
<ArrowPathIcon className="size-4 text-blue-400" /> Replay runs
</span>
}
description="Replays all selected runs, regardless of current status."
value={"replay"}
variant="description"
/>
</RadioGroup>
</InputGroup>
<InputGroup>
@@ -466,8 +450,8 @@ function bulkActionModeFromString(value: string | undefined): BulkActionMode {
}
function bulkActionActionFromString(value: string | undefined): BulkActionAction {
if (!value) return "replay";
if (!value) return "cancel";
const parsed = BulkActionAction.safeParse(value);
if (!parsed.success) return "replay";
if (!parsed.success) return "cancel";
return parsed.data;
}
@@ -171,6 +171,10 @@ const pricingDefinitions = {
title: "Concurrent runs",
content: "The number of runs that can be executed at the same time.",
},
additionalConcurrency: {
title: "Additional concurrency",
content: "Then $50/month per 50",
},
taskRun: {
title: "Task runs",
content: "A single execution of a task.",
@@ -188,6 +192,10 @@ const pricingDefinitions = {
title: "Schedules",
content: "You can attach recurring schedules to tasks using cron syntax.",
},
additionalSchedules: {
title: "Additional schedules",
content: "Then $10/month per 1,000",
},
alerts: {
title: "Alert destination",
content:
@@ -198,9 +206,22 @@ const pricingDefinitions = {
content:
"Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.",
},
additionalRealtimeConnections: {
title: "Additional Realtime connections",
content: "Then $10/month per 100",
},
additionalSeats: {
title: "Additional seats",
content: "Then $20/month per seat",
},
branches: {
title: "Branches",
content: "The number of preview branches that can be active (you can archive old ones).",
content:
"Preview branches allow you to test changes before deploying to production. You can have a limited number active at once (but can archive old ones).",
},
additionalBranches: {
title: "Additional branches",
content: "Then $10/month per branch",
},
};
@@ -338,7 +359,7 @@ export function TierFree({
<div className="my-6">
<Button
type="button"
variant="tertiary/large"
variant="secondary/large"
fullWidth
className="text-md font-medium"
disabled={isLoading}
@@ -384,7 +405,7 @@ export function TierFree({
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="cancel">
<DialogTrigger asChild>
<div className="my-6">
<Button variant="tertiary/large" fullWidth className="text-md font-medium">
<Button variant="secondary/large" fullWidth className="text-md font-medium">
{`Downgrade to ${plan.title}`}
</Button>
</div>
@@ -411,6 +432,7 @@ export function TierFree({
<Header2 className="mb-1">Why are you thinking of downgrading?</Header2>
<ul className="space-y-1">
{[
"The Free plan is all I need",
"Subscription or usage costs too expensive",
"Bugs or technical issues",
"No longer need the service",
@@ -445,7 +467,7 @@ export function TierFree({
</div>
</div>
<DialogFooter className="mt-2">
<Button variant="tertiary/medium" onClick={() => setIsDialogOpen(false)}>
<Button variant="secondary/medium" onClick={() => setIsDialogOpen(false)}>
Dismiss
</Button>
<Button
@@ -465,7 +487,7 @@ export function TierFree({
<input type="hidden" name="type" value="free" />
<input type="hidden" name="callerPath" value={location.pathname} />
<Button
variant="tertiary/large"
variant="secondary/large"
type="submit"
form="subscribe-verified"
fullWidth
@@ -507,7 +529,7 @@ export function TierFree({
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConnecurrency limits={plan.limits} />
<RealtimeConcurrency limits={plan.limits} />
</ul>
</>
)}
@@ -552,7 +574,7 @@ export function TierHobby({
subscription.plan.code !== plan.code ? (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="downgrade">
<DialogTrigger asChild>
<Button variant="tertiary/large" fullWidth className="text-md font-medium">
<Button variant="secondary/large" fullWidth className="text-md font-medium">
{`Downgrade to ${plan.title}`}
</Button>
</DialogTrigger>
@@ -568,11 +590,11 @@ export function TierHobby({
</Paragraph>
</div>
<DialogFooter>
<Button variant="tertiary/medium" onClick={() => setIsDialogOpen(false)}>
<Button variant="secondary/medium" onClick={() => setIsDialogOpen(false)}>
Dismiss
</Button>
<Button
variant="tertiary/medium"
variant="secondary/medium"
disabled={isLoading}
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
form="subscribe-hobby"
@@ -584,7 +606,7 @@ export function TierHobby({
</Dialog>
) : (
<Button
variant={isHighlighted ? "primary/large" : "tertiary/large"}
variant={isHighlighted ? "primary/large" : "secondary/large"}
fullWidth
className="text-md font-medium"
form="subscribe-hobby"
@@ -624,7 +646,7 @@ export function TierHobby({
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConnecurrency limits={plan.limits} />
<RealtimeConcurrency limits={plan.limits} />
</ul>
</TierContainer>
);
@@ -666,7 +688,7 @@ export function TierPro({
subscription.canceledAt === undefined ? (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="upgrade">
<DialogTrigger asChild>
<Button variant="tertiary/large" fullWidth className="text-md font-medium">
<Button variant="secondary/large" fullWidth className="text-md font-medium">
{`Upgrade to ${plan.title}`}
</Button>
</DialogTrigger>
@@ -682,7 +704,7 @@ export function TierPro({
</Paragraph>
</div>
<DialogFooter>
<Button variant="tertiary/medium" onClick={() => setIsDialogOpen(false)}>
<Button variant="secondary/medium" onClick={() => setIsDialogOpen(false)}>
Dismiss
</Button>
<Button
@@ -698,7 +720,7 @@ export function TierPro({
</Dialog>
) : (
<Button
variant="tertiary/large"
variant="secondary/large"
fullWidth
form="subscribe-pro"
className="text-md font-medium"
@@ -724,7 +746,9 @@ export function TierPro({
</div>
</Form>
<ul className="flex flex-col gap-2.5">
<ConcurrentRuns limits={plan.limits} />
<ConcurrentRuns limits={plan.limits}>
{pricingDefinitions.additionalConcurrency.content}
</ConcurrentRuns>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
@@ -734,14 +758,16 @@ export function TierPro({
tasks
</DefinitionTip>
</FeatureItem>
<TeamMembers limits={plan.limits} />
<TeamMembers limits={plan.limits}>{pricingDefinitions.additionalSeats.content}</TeamMembers>
<Environments limits={plan.limits} />
<Branches limits={plan.limits} />
<Schedules limits={plan.limits} />
<Branches limits={plan.limits}>{pricingDefinitions.additionalBranches.content}</Branches>
<Schedules limits={plan.limits}>{pricingDefinitions.additionalSchedules.content}</Schedules>
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConnecurrency limits={plan.limits} />
<RealtimeConcurrency limits={plan.limits}>
{pricingDefinitions.additionalRealtimeConnections.content}
</RealtimeConcurrency>
</ul>
</TierContainer>
);
@@ -788,11 +814,11 @@ export function TierEnterprise() {
<Feedback
defaultValue="enterprise"
button={
<div className="flex h-10 w-full cursor-pointer items-center justify-center rounded bg-tertiary px-8 text-base font-medium transition hover:bg-charcoal-600">
<div className="flex h-10 w-full cursor-pointer items-center justify-center rounded border border-charcoal-600 bg-tertiary px-8 text-base font-medium transition hover:border-charcoal-550 hover:bg-charcoal-600">
<span className="text-center text-text-bright">Contact us</span>
</div>
}
></Feedback>
/>
</div>
</div>
</TierContainer>
@@ -812,7 +838,7 @@ function TierContainer({
<div
className={cn(
"flex w-full min-w-[16rem] flex-col p-6",
isHighlighted ? "border border-primary" : "border border-grid-dimmed",
isHighlighted ? "border border-indigo-500" : "border border-grid-dimmed",
className
)}
>
@@ -843,7 +869,10 @@ function PricingHeader({
return (
<div className="flex flex-col gap-2">
<h2
className={cn("text-xl font-medium", isHighlighted ? "text-primary" : "text-text-dimmed")}
className={cn(
"text-xl font-medium",
isHighlighted ? "text-indigo-500" : "text-text-dimmed"
)}
>
{title}
</h2>
@@ -899,16 +928,16 @@ function FeatureItem({
children: React.ReactNode;
}) {
return (
<li className="flex items-center gap-2">
<li className="flex items-start gap-2">
{checked ? (
<CheckIcon
className={cn(
"size-4 min-w-4",
"mt-0.5 size-4 min-w-4",
checkedColor === "primary" ? "text-primary" : "text-text-bright"
)}
/>
) : (
<XMarkIcon className="size-4 min-w-4 text-charcoal-500" />
<XMarkIcon className="mt-0.5 size-4 min-w-4 text-charcoal-500" />
)}
<div
className={cn(
@@ -922,26 +951,42 @@ function FeatureItem({
);
}
function ConcurrentRuns({ limits }: { limits: Limits }) {
function ConcurrentRuns({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked>
{limits.concurrentRuns.number}
{limits.concurrentRuns.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={pricingDefinitions.concurrentRuns.content}
>
concurrent runs
</DefinitionTip>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.concurrentRuns.canExceed ? (
<>
{limits.concurrentRuns.number}
{"+"}
</>
) : (
<>{limits.concurrentRuns.number} </>
)}{" "}
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={pricingDefinitions.concurrentRuns.content}
>
concurrent runs
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
function TeamMembers({ limits }: { limits: Limits }) {
function TeamMembers({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked>
{limits.teamMembers.number}
{limits.concurrentRuns.canExceed ? "+" : ""} team members
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.teamMembers.number}
{limits.teamMembers.canExceed ? "+" : ""} team members
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
@@ -960,17 +1005,22 @@ function Environments({ limits }: { limits: Limits }) {
);
}
function Schedules({ limits }: { limits: Limits }) {
function Schedules({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked>
{limits.schedules.number}
{limits.schedules.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.schedules.title}
content={pricingDefinitions.schedules.content}
>
schedules
</DefinitionTip>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.schedules.number}
{limits.schedules.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.schedules.title}
content={pricingDefinitions.schedules.content}
>
schedules
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
@@ -1015,32 +1065,52 @@ function Alerts({ limits }: { limits: Limits }) {
);
}
function RealtimeConnecurrency({ limits }: { limits: Limits }) {
function RealtimeConcurrency({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked>
{limits.realtimeConcurrentConnections.number}
{limits.realtimeConcurrentConnections.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.realtime.title}
content={pricingDefinitions.realtime.content}
>
concurrent Realtime connections
</DefinitionTip>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-start gap-1">
{limits.realtimeConcurrentConnections.canExceed ? (
<>
{limits.realtimeConcurrentConnections.number}
{"+"}
</>
) : (
<>{limits.realtimeConcurrentConnections.number} </>
)}{" "}
<DefinitionTip
title={pricingDefinitions.realtime.title}
content={pricingDefinitions.realtime.content}
>
concurrent Realtime connections
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
function Branches({ limits }: { limits: Limits }) {
function Branches({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked={limits.branches.number > 0}>
{limits.branches.number}
{limits.branches.canExceed ? "+ " : " "}
<DefinitionTip
title={pricingDefinitions.branches.title}
content={pricingDefinitions.branches.content}
>
preview branches
</DefinitionTip>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.branches.number > 0 && (
<>
{limits.branches.number}
{limits.branches.canExceed ? "+ " : " "}
</>
)}
<DefinitionTip
title={pricingDefinitions.branches.title}
content={pricingDefinitions.branches.content}
>
{limits.branches.number > 0 ? "preview" : "Preview"} branches
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
@@ -108,6 +108,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const disableVersionSelection = environment.type === "DEVELOPMENT";
const allowArbitraryQueues = backgroundWorkers.at(0)?.engine === "V1";
const payload = await prettyPrintPacket(run.payload, run.payloadType);
return typedjson({
concurrencyKey: run.concurrencyKey,
maxAttempts: run.maxAttempts,
@@ -116,7 +118,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
ttlSeconds: run.ttl ? parseDuration(run.ttl, "s") ?? undefined : undefined,
idempotencyKey: run.idempotencyKey,
runTags: run.runTags,
payload: await prettyPrintPacket(run.payload, run.payloadType),
payload,
payloadType: run.payloadType,
queue: run.queue,
metadata: run.seedMetadata
@@ -1,6 +0,0 @@
export class EngineServiceValidationError extends Error {
constructor(message: string, public status?: number) {
super(message);
this.name = "EngineServiceValidationError";
}
}
@@ -3,7 +3,7 @@ import { PayloadProcessor, TriggerTaskRequest } from "../types";
import { env } from "~/env.server";
import { startActiveSpan } from "~/v3/tracer.server";
import { uploadPacketToObjectStore } from "~/v3/r2.server";
import { EngineServiceValidationError } from "./errors";
import { ServiceValidationError } from "~/v3/services/common.server";
export class DefaultPayloadProcessor implements PayloadProcessor {
async process(request: TriggerTaskRequest): Promise<IOPacket> {
@@ -36,10 +36,7 @@ export class DefaultPayloadProcessor implements PayloadProcessor {
);
if (uploadError) {
throw new EngineServiceValidationError(
"Failed to upload large payload to object store",
500
); // This is retryable
throw new ServiceValidationError("Failed to upload large payload to object store", 500); // This is retryable
}
return {
@@ -13,8 +13,8 @@ import {
import { WorkerGroupService } from "~/v3/services/worker/workerGroupService.server";
import type { RunEngine } from "~/v3/runEngine.server";
import { env } from "~/env.server";
import { EngineServiceValidationError } from "./errors";
import { tryCatch } from "@trigger.dev/core/v3";
import { ServiceValidationError } from "~/v3/services/common.server";
export class DefaultQueueManager implements QueueManager {
constructor(
@@ -45,7 +45,7 @@ export class DefaultQueueManager implements QueueManager {
});
if (!specifiedQueue) {
throw new EngineServiceValidationError(
throw new ServiceValidationError(
`Specified queue '${specifiedQueueName}' not found or not associated with locked version '${
lockedBackgroundWorker.version ?? "<unknown>"
}'.`
@@ -68,7 +68,7 @@ export class DefaultQueueManager implements QueueManager {
});
if (!lockedTask) {
throw new EngineServiceValidationError(
throw new ServiceValidationError(
`Task '${request.taskId}' not found on locked version '${
lockedBackgroundWorker.version ?? "<unknown>"
}'.`
@@ -83,7 +83,7 @@ export class DefaultQueueManager implements QueueManager {
workerId: lockedBackgroundWorker.id,
version: lockedBackgroundWorker.version,
});
throw new EngineServiceValidationError(
throw new ServiceValidationError(
`Default queue configuration for task '${request.taskId}' missing on locked version '${
lockedBackgroundWorker.version ?? "<unknown>"
}'.`
@@ -97,7 +97,7 @@ export class DefaultQueueManager implements QueueManager {
// Task is not locked to a specific version, use regular logic
if (request.body.options?.lockToVersion) {
// This should only happen if the findFirst failed, indicating the version doesn't exist
throw new EngineServiceValidationError(
throw new ServiceValidationError(
`Task locked to version '${request.body.options.lockToVersion}', but no worker found with that version.`
);
}
@@ -221,11 +221,11 @@ export class DefaultQueueManager implements QueueManager {
);
if (error) {
throw new EngineServiceValidationError(error.message);
throw new ServiceValidationError(error.message);
}
if (!workerGroup) {
throw new EngineServiceValidationError("No worker group found");
throw new ServiceValidationError("No worker group found");
}
return workerGroup.masterQueue;
@@ -31,16 +31,24 @@ import type {
} from "../../v3/services/triggerTask.server";
import { getTaskEventStore } from "../../v3/taskEventStore.server";
import { clampMaxDuration } from "../../v3/utils/maxDuration";
import { EngineServiceValidationError } from "../concerns/errors";
import { IdempotencyKeyConcern } from "../concerns/idempotencyKeys.server";
import type {
PayloadProcessor,
QueueManager,
RunNumberIncrementer,
TraceEventConcern,
TriggerRacepoints,
TriggerRacepointSystem,
TriggerTaskRequest,
TriggerTaskValidator,
} from "../types";
import { ServiceValidationError } from "~/v3/services/common.server";
class NoopTriggerRacepointSystem implements TriggerRacepointSystem {
async waitForRacepoint(options: { racepoint: TriggerRacepoints; id: string }): Promise<void> {
return;
}
}
export class RunEngineTriggerTaskService {
private readonly queueConcern: QueueManager;
@@ -52,6 +60,7 @@ export class RunEngineTriggerTaskService {
private readonly engine: RunEngine;
private readonly tracer: Tracer;
private readonly traceEventConcern: TraceEventConcern;
private readonly triggerRacepointSystem: TriggerRacepointSystem;
private readonly metadataMaximumSize: number;
constructor(opts: {
@@ -65,6 +74,7 @@ export class RunEngineTriggerTaskService {
traceEventConcern: TraceEventConcern;
tracer: Tracer;
metadataMaximumSize: number;
triggerRacepointSystem?: TriggerRacepointSystem;
}) {
this.prisma = opts.prisma;
this.engine = opts.engine;
@@ -76,6 +86,7 @@ export class RunEngineTriggerTaskService {
this.tracer = opts.tracer;
this.traceEventConcern = opts.traceEventConcern;
this.metadataMaximumSize = opts.metadataMaximumSize;
this.triggerRacepointSystem = opts.triggerRacepointSystem ?? new NoopTriggerRacepointSystem();
}
public async call({
@@ -157,7 +168,7 @@ export class RunEngineTriggerTaskService {
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(body.options?.delay));
if (parseDelayError) {
throw new EngineServiceValidationError(`Invalid delay ${body.options?.delay}`);
throw new ServiceValidationError(`Invalid delay ${body.options?.delay}`);
}
const ttl =
@@ -196,21 +207,18 @@ export class RunEngineTriggerTaskService {
const { idempotencyKey, idempotencyKeyExpiresAt } = idempotencyKeyConcernResult;
if (idempotencyKey) {
await this.triggerRacepointSystem.waitForRacepoint({
racepoint: "idempotencyKey",
id: idempotencyKey,
});
}
if (!options.skipChecks) {
const queueSizeGuard = await this.queueConcern.validateQueueLimits(environment);
logger.debug("Queue size guard result", {
queueSizeGuard,
environment: {
id: environment.id,
type: environment.type,
organization: environment.organization,
project: environment.project,
},
});
if (!queueSizeGuard.ok) {
throw new EngineServiceValidationError(
throw new ServiceValidationError(
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
);
}
@@ -351,7 +359,7 @@ export class RunEngineTriggerTaskService {
);
if (result?.error) {
throw new EngineServiceValidationError(
throw new ServiceValidationError(
taskRunErrorToString(taskRunErrorEnhancer(result.error))
);
}
@@ -365,7 +373,7 @@ export class RunEngineTriggerTaskService {
}
if (error instanceof RunOneTimeUseTokenError) {
throw new EngineServiceValidationError(
throw new ServiceValidationError(
`Cannot trigger ${taskId} with a one-time use token as it has already been used.`
);
}
+6
View File
@@ -156,3 +156,9 @@ export interface TraceEventConcern {
callback: (span: TracedEventSpan) => Promise<T>
): Promise<T>;
}
export type TriggerRacepoints = "idempotencyKey";
export interface TriggerRacepointSystem {
waitForRacepoint(options: { racepoint: TriggerRacepoints; id: string }): Promise<void>;
}
@@ -3,7 +3,6 @@ import { logger } from "~/services/logger.server";
import { getEntitlement } from "~/services/platform.v3.server";
import { MAX_ATTEMPTS, OutOfEntitlementError } from "~/v3/services/triggerTask.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
import { EngineServiceValidationError } from "../concerns/errors";
import type {
EntitlementValidationParams,
EntitlementValidationResult,
@@ -13,6 +12,7 @@ import type {
TriggerTaskValidator,
ValidationResult,
} from "../types";
import { ServiceValidationError } from "~/v3/services/common.server";
export class DefaultTriggerTaskValidator implements TriggerTaskValidator {
validateTags(params: TagValidationParams): ValidationResult {
@@ -29,7 +29,7 @@ export class DefaultTriggerTaskValidator implements TriggerTaskValidator {
if (tags.length > MAX_TAGS_PER_RUN) {
return {
ok: false,
error: new EngineServiceValidationError(
error: new ServiceValidationError(
`Runs can only have ${MAX_TAGS_PER_RUN} tags, you're trying to set ${tags.length}.`
),
};
@@ -65,7 +65,7 @@ export class DefaultTriggerTaskValidator implements TriggerTaskValidator {
if (attempt > MAX_ATTEMPTS) {
return {
ok: false,
error: new EngineServiceValidationError(
error: new ServiceValidationError(
`Failed to trigger ${taskId} after ${MAX_ATTEMPTS} attempts.`
),
};
@@ -95,7 +95,7 @@ export class DefaultTriggerTaskValidator implements TriggerTaskValidator {
return {
ok: false,
error: new EngineServiceValidationError(
error: new ServiceValidationError(
`Cannot trigger ${taskId} as the parent run has a status of ${parentRun.status}`
),
};
+184 -15
View File
@@ -2,6 +2,7 @@ import { json } from "@remix-run/server-runtime";
import { type Prettify } from "@trigger.dev/core";
import { SignJWT, errors, jwtVerify } from "jose";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { findProjectByRef } from "~/models/project.server";
@@ -16,6 +17,11 @@ import {
authenticateApiRequestWithPersonalAccessToken,
isPersonalAccessToken,
} from "./personalAccessToken.server";
import {
type OrganizationAccessTokenAuthenticationResult,
authenticateApiRequestWithOrganizationAccessToken,
isOrganizationAccessToken,
} from "./organizationAccessToken.server";
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
import { sanitizeBranchName } from "~/v3/gitBranch";
@@ -309,25 +315,81 @@ function getApiKeyResult(apiKey: string): {
return { apiKey, type };
}
export type DualAuthenticationResult =
export type AuthenticationResult =
| {
type: "personalAccessToken";
result: PersonalAccessTokenAuthenticationResult;
}
| {
type: "organizationAccessToken";
result: OrganizationAccessTokenAuthenticationResult;
}
| {
type: "apiKey";
result: ApiAuthenticationResult;
};
export async function authenticateProjectApiKeyOrPersonalAccessToken(
request: Request
): Promise<DualAuthenticationResult | undefined> {
type AuthenticationMethod = "personalAccessToken" | "organizationAccessToken" | "apiKey";
type AllowedAuthenticationMethods = Record<AuthenticationMethod, boolean> &
({ personalAccessToken: true } | { organizationAccessToken: true } | { apiKey: true });
const defaultAllowedAuthenticationMethods: AllowedAuthenticationMethods = {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: true,
};
type FilteredAuthenticationResult<
T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods
> =
| (T["personalAccessToken"] extends true
? Extract<AuthenticationResult, { type: "personalAccessToken" }>
: never)
| (T["organizationAccessToken"] extends true
? Extract<AuthenticationResult, { type: "organizationAccessToken" }>
: never)
| (T["apiKey"] extends true ? Extract<AuthenticationResult, { type: "apiKey" }> : never);
/**
* Authenticates an incoming request by checking for various token types.
*
* Supports personal access tokens, organization access tokens, and API keys.
* Returns the appropriate authentication result based on the token type found.
*
* This method currently only allows private keys for the `apiKey` authentication method.
*
* @template T - The allowed authentication methods configuration type
* @param request - The incoming HTTP request containing authentication headers
* @param allowedAuthenticationMethods - Configuration object specifying which authentication methods are allowed.
* At least one method must be set to `true`. Defaults to allowing all methods.
* @returns Authentication result with only the enabled auth method types, or undefined if no valid token found
*
* @example
* ```typescript
* // Only allow personal access tokens
* const result = await authenticateRequest(request, {
* personalAccessToken: true,
* organizationAccessToken: false,
* apiKey: false,
* });
* // result type: { type: "personalAccessToken"; result: PersonalAccessTokenAuthenticationResult } | undefined
* ```
*/
export async function authenticateRequest<
T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods
>(
request: Request,
allowedAuthenticationMethods?: T
): Promise<FilteredAuthenticationResult<T> | undefined> {
const allowedMethods = allowedAuthenticationMethods ?? defaultAllowedAuthenticationMethods;
const { apiKey, branchName } = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
if (isPersonalAccessToken(apiKey)) {
if (allowedMethods.personalAccessToken && isPersonalAccessToken(apiKey)) {
const result = await authenticateApiRequestWithPersonalAccessToken(request);
if (!result) {
@@ -337,23 +399,49 @@ export async function authenticateProjectApiKeyOrPersonalAccessToken(
return {
type: "personalAccessToken",
result,
};
} satisfies Extract<
AuthenticationResult,
{ type: "personalAccessToken" }
> as FilteredAuthenticationResult<T>;
}
const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName });
if (allowedMethods.organizationAccessToken && isOrganizationAccessToken(apiKey)) {
const result = await authenticateApiRequestWithOrganizationAccessToken(request);
if (!result) {
return;
if (!result) {
return;
}
return {
type: "organizationAccessToken",
result,
} satisfies Extract<
AuthenticationResult,
{ type: "organizationAccessToken" }
> as FilteredAuthenticationResult<T>;
}
return {
type: "apiKey",
result,
};
if (allowedMethods.apiKey) {
const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName });
if (!result) {
return;
}
return {
type: "apiKey",
result,
} satisfies Extract<
AuthenticationResult,
{ type: "apiKey" }
> as FilteredAuthenticationResult<T>;
}
return;
}
export async function authenticatedEnvironmentForAuthentication(
auth: DualAuthenticationResult,
auth: AuthenticationResult,
projectRef: string,
slug: string,
branch?: string
@@ -398,7 +486,7 @@ export async function authenticatedEnvironmentForAuthentication(
});
if (!user) {
throw json({ error: "Invalid or Missing API key" }, { status: 401 });
throw json({ error: "Invalid or missing personal access token" }, { status: 401 });
}
const project = await findProjectByRef(projectRef, user.id);
@@ -407,6 +495,83 @@ export async function authenticatedEnvironmentForAuthentication(
throw json({ error: "Project not found" }, { status: 404 });
}
if (!branch) {
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: slug,
...(slug === "dev"
? {
orgMember: {
userId: user.id,
},
}
: {}),
},
include: {
project: true,
organization: true,
},
});
if (!environment) {
throw json({ error: "Environment not found" }, { status: 404 });
}
return environment;
}
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: slug,
branchName: sanitizeBranchName(branch),
archivedAt: null,
},
include: {
project: true,
organization: true,
parentEnvironment: true,
},
});
if (!environment) {
throw json({ error: "Branch not found" }, { status: 404 });
}
if (!environment.parentEnvironment) {
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
}
return {
...environment,
apiKey: environment.parentEnvironment.apiKey,
organization: environment.organization,
project: environment.project,
};
}
case "organizationAccessToken": {
const organization = await prisma.organization.findUnique({
where: {
id: auth.result.organizationId,
},
});
if (!organization) {
throw json({ error: "Invalid or missing organization access token" }, { status: 401 });
}
const project = await prisma.project.findFirst({
where: {
organizationId: organization.id,
externalRef: projectRef,
},
});
if (!project) {
throw json({ error: "Project not found" }, { status: 404 });
}
if (!branch) {
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
@@ -455,6 +620,10 @@ export async function authenticatedEnvironmentForAuthentication(
project: environment.project,
};
}
default: {
auth satisfies never;
throw json({ error: "Invalid authentication result" }, { status: 401 });
}
}
}
@@ -10,18 +10,34 @@ export class ArchiveBranchService {
this.#prismaClient = prismaClient;
}
public async call(userId: string, { environmentId }: { environmentId: string }) {
public async call(
// The orgFilter approach is not ideal but we need to keep it this way for now because of how the service is used in routes and api endpoints.
// Currently authorization checks are spread across the controller/route layer and the service layer. Often we check in multiple places for org/project membership.
// Ideally we would take care of both the authentication and authorization checks in the controllers and routes.
// That would unify how we handle authorization and org/project membership checks. Also it would make the service layer queries simpler.
orgFilter:
| { type: "userMembership"; userId: string }
| { type: "orgId"; organizationId: string },
{
environmentId,
}: {
environmentId: string;
}
) {
try {
const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({
where: {
id: environmentId,
organization: {
members: {
some: {
userId: userId,
},
},
},
organization:
orgFilter.type === "userMembership"
? {
members: {
some: {
userId: orgFilter.userId,
},
},
}
: { id: orgFilter.organizationId },
},
include: {
organization: {
+190
View File
@@ -0,0 +1,190 @@
import { App, type Octokit } from "octokit";
import { env } from "../env.server";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow";
export const githubApp =
env.GITHUB_APP_ENABLED === "1"
? new App({
appId: env.GITHUB_APP_ID,
privateKey: env.GITHUB_APP_PRIVATE_KEY,
webhooks: {
secret: env.GITHUB_APP_WEBHOOK_SECRET,
},
})
: null;
/**
* Links a GitHub App installation to a Trigger organization
*/
export async function linkGitHubAppInstallation(
installationId: number,
organizationId: string
): Promise<void> {
if (!githubApp) {
throw new Error("GitHub App is not enabled");
}
const octokit = await githubApp.getInstallationOctokit(installationId);
const { data: installation } = await octokit.rest.apps.getInstallation({
installation_id: installationId,
});
const repositories = await fetchInstallationRepositories(octokit, installationId);
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
await prisma.githubAppInstallation.create({
data: {
appInstallationId: installationId,
organizationId,
targetId: installation.target_id,
targetType: installation.target_type,
accountHandle: installation.account
? "login" in installation.account
? installation.account.login
: "slug" in installation.account
? installation.account.slug
: "-"
: "-",
permissions: installation.permissions,
repositorySelection,
repositories: {
create: repositories,
},
},
});
}
/**
* Links a GitHub App installation to a Trigger organization
*/
export async function updateGitHubAppInstallation(installationId: number): Promise<void> {
if (!githubApp) {
throw new Error("GitHub App is not enabled");
}
const octokit = await githubApp.getInstallationOctokit(installationId);
const { data: installation } = await octokit.rest.apps.getInstallation({
installation_id: installationId,
});
const existingInstallation = await prisma.githubAppInstallation.findFirst({
where: { appInstallationId: installationId },
});
if (!existingInstallation) {
throw new Error("GitHub App installation not found");
}
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
// repos are updated asynchronously via webhook events
await prisma.githubAppInstallation.update({
where: { id: existingInstallation?.id },
data: {
appInstallationId: installationId,
targetId: installation.target_id,
targetType: installation.target_type,
accountHandle: installation.account
? "login" in installation.account
? installation.account.login
: "slug" in installation.account
? installation.account.slug
: "-"
: "-",
permissions: installation.permissions,
suspendedAt: existingInstallation?.suspendedAt,
repositorySelection,
},
});
}
async function fetchInstallationRepositories(octokit: Octokit, installationId: number) {
const iterator = octokit.paginate.iterator(octokit.rest.apps.listReposAccessibleToInstallation, {
installation_id: installationId,
per_page: 100,
});
const allRepos = [];
const maxPages = 3;
let pageCount = 0;
for await (const { data } of iterator) {
pageCount++;
allRepos.push(...data);
if (maxPages && pageCount >= maxPages) {
logger.warn("GitHub installation repository fetch truncated", {
installationId,
maxPages,
totalReposFetched: allRepos.length,
});
break;
}
}
return allRepos.map((repo) => ({
githubId: repo.id,
name: repo.name,
fullName: repo.full_name,
htmlUrl: repo.html_url,
private: repo.private,
defaultBranch: repo.default_branch,
}));
}
/**
* Checks if a branch exists in a GitHub repository
*/
export function checkGitHubBranchExists(
installationId: number,
fullRepoName: string,
branch: string
): ResultAsync<boolean, { type: "other" | "github_app_not_enabled"; cause?: unknown }> {
if (!githubApp) {
return errAsync({ type: "github_app_not_enabled" as const });
}
if (!branch || branch.trim() === "") {
return okAsync(false);
}
const [owner, repo] = fullRepoName.split("/");
const getOctokit = () =>
fromPromise(githubApp.getInstallationOctokit(installationId), (error) => ({
type: "other" as const,
cause: error,
}));
const getBranch = (octokit: Octokit) =>
fromPromise(
octokit.rest.repos.getBranch({
owner,
repo,
branch,
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
return getOctokit()
.andThen((octokit) => getBranch(octokit))
.map(() => true)
.orElse((error) => {
if (
error.cause &&
error.cause instanceof Error &&
"status" in error.cause &&
error.cause.status === 404
) {
return okAsync(false);
}
return errAsync(error);
});
}
@@ -0,0 +1,124 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { randomBytes } from "crypto";
import { env } from "../env.server";
import { logger } from "./logger.server";
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__github_app_install",
httpOnly: true,
maxAge: 60 * 60, // 1 hour
path: "/",
sameSite: "lax",
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production",
},
});
/**
* Creates a secure session for GitHub App installation with organization tracking
*/
export async function createGitHubAppInstallSession(
organizationId: string,
redirectTo: string
): Promise<{ url: string; cookieHeader: string }> {
if (env.GITHUB_APP_ENABLED !== "1") {
throw new Error("GitHub App is not enabled");
}
const state = randomBytes(32).toString("hex");
const session = await sessionStorage.getSession();
session.set("organizationId", organizationId);
session.set("redirectTo", redirectTo);
session.set("state", state);
session.set("createdAt", Date.now());
const githubAppSlug = env.GITHUB_APP_SLUG;
// the state query param gets passed through to the installation callback
const url = `https://github.com/apps/${githubAppSlug}/installations/new?state=${state}`;
const cookieHeader = await sessionStorage.commitSession(session);
return { url, cookieHeader };
}
/**
* Validates and retrieves the GitHub App installation session
*/
export async function validateGitHubAppInstallSession(
cookieHeader: string | null,
state: string
): Promise<
{ valid: true; organizationId: string; redirectTo: string } | { valid: false; error?: string }
> {
if (!cookieHeader) {
return {
valid: false,
error: "No installation session cookie found",
};
}
const session = await sessionStorage.getSession(cookieHeader);
const sessionState = session.get("state");
const organizationId = session.get("organizationId");
const redirectTo = session.get("redirectTo");
const createdAt = session.get("createdAt");
if (!sessionState || !organizationId || !createdAt || !redirectTo) {
logger.warn("GitHub App installation session missing required fields", {
hasState: !!sessionState,
hasOrgId: !!organizationId,
hasCreatedAt: !!createdAt,
hasRedirectTo: !!redirectTo,
});
return {
valid: false,
error: "invalid_session_data",
};
}
if (sessionState !== state) {
logger.warn("GitHub App installation state mismatch", {
expectedState: sessionState,
receivedState: state,
});
return {
valid: false,
error: "state_mismatch",
};
}
const expirationTime = createdAt + 60 * 60 * 1000;
if (Date.now() > expirationTime) {
logger.warn("GitHub App installation session expired", {
createdAt: new Date(createdAt),
now: new Date(),
});
return {
valid: false,
error: "session_expired",
};
}
return {
valid: true,
organizationId,
redirectTo,
};
}
/**
* Destroys the GitHub App installation cookie session
*/
export async function destroyGitHubAppInstallSession(cookieHeader: string | null): Promise<string> {
if (!cookieHeader) {
return "";
}
const session = await sessionStorage.getSession(cookieHeader);
return await sessionStorage.destroySession(session);
}
@@ -0,0 +1,159 @@
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { hashToken } from "~/utils/tokens.server";
const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
type CreateOrganizationAccessTokenOptions = {
name: string;
organizationId: string;
expiresAt?: Date;
};
export async function getValidOrganizationAccessTokens(organizationId: string) {
const organizationAccessTokens = await prisma.organizationAccessToken.findMany({
select: {
id: true,
name: true,
createdAt: true,
lastAccessedAt: true,
expiresAt: true,
},
where: {
organizationId,
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }],
},
});
return organizationAccessTokens.map((oat) => ({
id: oat.id,
name: oat.name,
createdAt: oat.createdAt,
lastAccessedAt: oat.lastAccessedAt,
expiresAt: oat.expiresAt,
}));
}
export type ObfuscatedOrganizationAccessToken = Awaited<
ReturnType<typeof getValidOrganizationAccessTokens>
>[number];
export async function revokeOrganizationAccessToken(tokenId: string) {
await prisma.organizationAccessToken.update({
where: {
id: tokenId,
},
data: {
revokedAt: new Date(),
},
});
}
export type OrganizationAccessTokenAuthenticationResult = {
organizationId: string;
};
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export async function authenticateApiRequestWithOrganizationAccessToken(
request: Request
): Promise<OrganizationAccessTokenAuthenticationResult | undefined> {
const token = getOrganizationAccessTokenFromRequest(request);
if (!token) {
return;
}
return authenticateOrganizationAccessToken(token);
}
function getOrganizationAccessTokenFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
return;
}
const organizationAccessToken = authorization.data.replace(/^Bearer /, "");
return organizationAccessToken;
}
export async function authenticateOrganizationAccessToken(
token: string
): Promise<OrganizationAccessTokenAuthenticationResult | undefined> {
if (!token.startsWith(tokenPrefix)) {
logger.warn(`OAT doesn't start with ${tokenPrefix}`);
return;
}
const hashedToken = hashToken(token);
const organizationAccessToken = await prisma.organizationAccessToken.findFirst({
where: {
hashedToken,
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }],
},
});
if (!organizationAccessToken) {
return;
}
await prisma.organizationAccessToken.update({
where: {
id: organizationAccessToken.id,
},
data: {
lastAccessedAt: new Date(),
},
});
return {
organizationId: organizationAccessToken.organizationId,
};
}
export function isOrganizationAccessToken(token: string) {
return token.startsWith(tokenPrefix);
}
export async function createOrganizationAccessToken({
name,
organizationId,
expiresAt,
}: CreateOrganizationAccessTokenOptions) {
const token = createToken();
const organizationAccessToken = await prisma.organizationAccessToken.create({
data: {
name,
organizationId,
hashedToken: hashToken(token),
expiresAt,
},
});
return {
id: organizationAccessToken.id,
name,
organizationId,
token,
expiresAt: organizationAccessToken.expiresAt,
};
}
export type CreatedOrganizationAccessToken = Awaited<
ReturnType<typeof createOrganizationAccessToken>
>;
const tokenPrefix = "tr_oat_";
function createToken() {
return `${tokenPrefix}${tokenGenerator()}`;
}
@@ -1,10 +1,10 @@
import { PersonalAccessToken } from "@trigger.dev/database";
import { type PersonalAccessToken } from "@trigger.dev/database";
import { customAlphabet, nanoid } from "nanoid";
import nodeCrypto from "node:crypto";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
import { env } from "~/env.server";
const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
@@ -266,7 +266,7 @@ export async function createPersonalAccessToken({
userId,
}: CreatePersonalAccessTokenOptions) {
const token = createToken();
const encryptedToken = encryptToken(token);
const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY);
const personalAccessToken = await prisma.personalAccessToken.create({
data: {
@@ -303,22 +303,6 @@ function obfuscateToken(token: string) {
return `${tokenPrefix}${obfuscated}`;
}
function encryptToken(value: string) {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", env.ENCRYPTION_KEY, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
}
function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
const encryptedData = EncryptedSecretValueSchema.safeParse(personalAccessToken.encryptedToken);
if (!encryptedData.success) {
@@ -330,28 +314,8 @@ function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
const decryptedToken = decryptToken(
encryptedData.data.nonce,
encryptedData.data.ciphertext,
encryptedData.data.tag
encryptedData.data.tag,
env.ENCRYPTION_KEY
);
return decryptedToken;
}
function decryptToken(nonce: string, ciphertext: string, tag: string): string {
const decipher = nodeCrypto.createDecipheriv(
"aes-256-gcm",
env.ENCRYPTION_KEY,
Buffer.from(nonce, "hex")
);
decipher.setAuthTag(Buffer.from(tag, "hex"));
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
function hashToken(token: string): string {
const hash = nodeCrypto.createHash("sha256");
hash.update(token);
return hash.digest("hex");
}
@@ -0,0 +1,299 @@
import { type PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { DeleteProjectService } from "~/services/deleteProject.server";
import { BranchTrackingConfigSchema, type BranchTrackingConfig } from "~/v3/github";
import { checkGitHubBranchExists } from "~/services/gitHub.server";
import { errAsync, fromPromise, okAsync, ResultAsync } from "neverthrow";
import { BuildSettings } from "~/v3/buildSettings";
export class ProjectSettingsService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
renameProject(projectId: string, newName: string) {
return fromPromise(
this.#prismaClient.project.update({
where: {
id: projectId,
},
data: {
name: newName,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
}
deleteProject(projectSlug: string, userId: string) {
const deleteProjectService = new DeleteProjectService(this.#prismaClient);
return fromPromise(deleteProjectService.call({ projectSlug, userId }), (error) => ({
type: "other" as const,
cause: error,
}));
}
connectGitHubRepo(
projectId: string,
organizationId: string,
repositoryId: string,
installationId: string
) {
const getRepository = () =>
fromPromise(
this.#prismaClient.githubRepository.findFirst({
where: {
id: repositoryId,
installationId,
installation: {
organizationId: organizationId,
},
},
select: {
id: true,
name: true,
defaultBranch: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).andThen((repository) => {
if (!repository) {
return errAsync({ type: "gh_repository_not_found" as const });
}
return okAsync(repository);
});
const findExistingConnection = () =>
fromPromise(
this.#prismaClient.connectedGithubRepository.findFirst({
where: {
projectId: projectId,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
const createConnectedRepo = (defaultBranch: string) =>
fromPromise(
this.#prismaClient.connectedGithubRepository.create({
data: {
projectId: projectId,
repositoryId: repositoryId,
branchTracking: {
prod: { branch: defaultBranch },
staging: { branch: defaultBranch },
} satisfies BranchTrackingConfig,
previewDeploymentsEnabled: true,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
return ResultAsync.combine([getRepository(), findExistingConnection()]).andThen(
([repository, existingConnection]) => {
if (existingConnection) {
return errAsync({ type: "project_already_has_connected_repository" as const });
}
return createConnectedRepo(repository.defaultBranch);
}
);
}
disconnectGitHubRepo(projectId: string) {
return fromPromise(
this.#prismaClient.connectedGithubRepository.delete({
where: {
projectId: projectId,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
}
updateGitSettings(
projectId: string,
productionBranch?: string,
stagingBranch?: string,
previewDeploymentsEnabled?: boolean
) {
const getExistingConnectedRepo = () =>
fromPromise(
this.#prismaClient.connectedGithubRepository.findFirst({
where: {
projectId: projectId,
},
include: {
repository: {
include: {
installation: true,
},
},
},
}),
(error) => ({ type: "other" as const, cause: error })
)
.andThen((connectedRepo) => {
if (!connectedRepo) {
return errAsync({ type: "connected_gh_repository_not_found" as const });
}
return okAsync(connectedRepo);
})
.map((connectedRepo) => {
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
connectedRepo.branchTracking
);
const branchTracking = branchTrackingOrFailure.success
? branchTrackingOrFailure.data
: undefined;
return {
...connectedRepo,
branchTracking,
};
});
const validateProductionBranch = ({
installationId,
fullRepoName,
oldProductionBranch,
}: {
installationId: number;
fullRepoName: string;
oldProductionBranch?: string;
}) => {
if (productionBranch && oldProductionBranch !== productionBranch) {
return checkGitHubBranchExists(installationId, fullRepoName, productionBranch).andThen(
(exists) => {
if (!exists) {
return errAsync({ type: "production_tracking_branch_not_found" as const });
}
return okAsync(productionBranch);
}
);
}
return okAsync(productionBranch);
};
const validateStagingBranch = ({
installationId,
fullRepoName,
oldStagingBranch,
}: {
installationId: number;
fullRepoName: string;
oldStagingBranch?: string;
}) => {
if (stagingBranch && oldStagingBranch !== stagingBranch) {
return checkGitHubBranchExists(installationId, fullRepoName, stagingBranch).andThen(
(exists) => {
if (!exists) {
return errAsync({ type: "staging_tracking_branch_not_found" as const });
}
return okAsync(stagingBranch);
}
);
}
return okAsync(stagingBranch);
};
const updateConnectedRepo = () =>
fromPromise(
this.#prismaClient.connectedGithubRepository.update({
where: {
projectId: projectId,
},
data: {
branchTracking: {
prod: productionBranch ? { branch: productionBranch } : {},
staging: stagingBranch ? { branch: stagingBranch } : {},
} satisfies BranchTrackingConfig,
previewDeploymentsEnabled: previewDeploymentsEnabled,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
return getExistingConnectedRepo()
.andThen((connectedRepo) => {
const installationId = Number(connectedRepo.repository.installation.appInstallationId);
return ResultAsync.combine([
validateProductionBranch({
installationId,
fullRepoName: connectedRepo.repository.fullName,
oldProductionBranch: connectedRepo.branchTracking?.prod?.branch,
}),
validateStagingBranch({
installationId,
fullRepoName: connectedRepo.repository.fullName,
oldStagingBranch: connectedRepo.branchTracking?.staging?.branch,
}),
]);
})
.andThen(updateConnectedRepo);
}
updateBuildSettings(projectId: string, buildSettings: BuildSettings) {
return fromPromise(
this.#prismaClient.project.update({
where: {
id: projectId,
},
data: {
buildSettings: buildSettings,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
}
verifyProjectMembership(organizationSlug: string, projectSlug: string, userId: string) {
const findProject = () =>
fromPromise(
this.#prismaClient.project.findFirst({
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
organizationId: true,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
return findProject().andThen((project) => {
if (!project) {
return errAsync({ type: "user_not_in_project" as const });
}
return okAsync({
projectId: project.id,
organizationId: project.organizationId,
});
});
}
}
@@ -0,0 +1,166 @@
import { type PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { BranchTrackingConfigSchema } from "~/v3/github";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import { err, fromPromise, ok, okAsync } from "neverthrow";
import { BuildSettingsSchema } from "~/v3/buildSettings";
export class ProjectSettingsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
getProjectSettings(organizationSlug: string, projectSlug: string, userId: string) {
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
const getProject = () =>
fromPromise(findProjectBySlug(organizationSlug, projectSlug, userId), (error) => ({
type: "other" as const,
cause: error,
})).andThen((project) => {
if (!project) {
return err({ type: "project_not_found" as const });
}
return ok(project);
});
if (!githubAppEnabled) {
return getProject().andThen((project) => {
if (!project) {
return err({ type: "project_not_found" as const });
}
const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings);
const buildSettings = buildSettingsOrFailure.success
? buildSettingsOrFailure.data
: undefined;
return ok({
gitHubApp: {
enabled: false,
connectedRepository: undefined,
installations: undefined,
},
buildSettings,
});
});
}
const findConnectedGithubRepository = (projectId: string) =>
fromPromise(
this.#prismaClient.connectedGithubRepository.findFirst({
where: {
projectId,
},
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
createdAt: true,
repository: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
},
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((connectedGithubRepository) => {
if (!connectedGithubRepository) {
return undefined;
}
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
connectedGithubRepository.branchTracking
);
const branchTracking = branchTrackingOrFailure.success
? branchTrackingOrFailure.data
: undefined;
return {
...connectedGithubRepository,
branchTracking,
};
});
const listGithubAppInstallations = (organizationId: string) =>
fromPromise(
this.#prismaClient.githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
// Most installations will only have a couple of repos so loading them here should be fine.
// However, there might be outlier organizations so it's best to expose the installation repos
// via a resource endpoint and filter on user input.
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
return getProject().andThen((project) =>
findConnectedGithubRepository(project.id).andThen((connectedGithubRepository) => {
const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings);
const buildSettings = buildSettingsOrFailure.success
? buildSettingsOrFailure.data
: undefined;
if (connectedGithubRepository) {
return okAsync({
gitHubApp: {
enabled: true,
connectedRepository: connectedGithubRepository,
// skip loading installations if there is a connected repository
// a project can have only a single connected repository
installations: undefined,
},
buildSettings,
});
}
return listGithubAppInstallations(project.organizationId).map((githubAppInstallations) => {
return {
gitHubApp: {
enabled: true,
connectedRepository: undefined,
installations: githubAppInstallations,
},
buildSettings,
};
});
})
);
}
}
@@ -1,5 +1,6 @@
import { AuthenticatedEnvironment } from "../apiAuth.server";
import { logger } from "../logger.server";
import { signalsEmitter } from "../signals.server";
import { StreamIngestor, StreamResponder } from "./types";
import { LineTransformStream } from "./utils.server";
import { v1RealtimeStreams } from "./v1StreamsGlobal.server";
@@ -243,12 +244,17 @@ export class RelayRealtimeStreams implements StreamIngestor, StreamResponder {
}
function initializeRelayRealtimeStreams() {
return new RelayRealtimeStreams({
const service = new RelayRealtimeStreams({
ttl: 1000 * 60 * 5, // 5 minutes
cleanupInterval: 1000 * 60, // 1 minute
fallbackIngestor: v1RealtimeStreams,
fallbackResponder: v1RealtimeStreams,
});
signalsEmitter.on("SIGTERM", service.close.bind(service));
signalsEmitter.on("SIGINT", service.close.bind(service));
return service;
}
export const relayRealtimeStreams = singleton(
@@ -22,6 +22,9 @@ import {
WorkerGroupTokenService,
} from "~/v3/services/worker/workerGroupTokenService.server";
import { API_VERSIONS, getApiVersion } from "~/api/versions";
import { WORKER_HEADERS } from "@trigger.dev/core/v3/runEngineWorker";
import { ServiceValidationError } from "~/v3/services/common.server";
import { EngineServiceValidationError } from "@internal/run-engine";
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
@@ -795,6 +798,7 @@ type WorkerLoaderHandlerFunction<
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
runnerId?: string;
}) => Promise<Response>;
export function createLoaderWorkerApiRoute<
@@ -858,12 +862,15 @@ export function createLoaderWorkerApiRoute<
parsedHeaders = headers.data;
}
const runnerId = request.headers.get(WORKER_HEADERS.RUNNER_ID) ?? undefined;
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
authenticatedWorker: authenticationResult,
request,
headers: parsedHeaders,
runnerId,
});
return result;
} catch (error) {
@@ -924,6 +931,7 @@ type WorkerActionHandlerFunction<
body: TBodySchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TBodySchema>
: undefined;
runnerId?: string;
}) => Promise<Response>;
export function createActionWorkerApiRoute<
@@ -1021,6 +1029,8 @@ export function createActionWorkerApiRoute<
parsedBody = parsed.data;
}
const runnerId = request.headers.get(WORKER_HEADERS.RUNNER_ID) ?? undefined;
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
@@ -1028,14 +1038,22 @@ export function createActionWorkerApiRoute<
request,
body: parsedBody,
headers: parsedHeaders,
runnerId,
});
return result;
} catch (error) {
console.error("Error in API route:", error);
if (error instanceof Response) {
return error;
}
if (error instanceof EngineServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 422 });
}
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 422 });
}
logger.error("Error in action", {
error:
error instanceof Error
@@ -3,8 +3,8 @@ import invariant from "tiny-invariant";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { provider } from "~/v3/tracer.server";
import { logger } from "./logger.server";
import { RunsReplicationService } from "./runsReplicationService.server";
import { signalsEmitter } from "./signals.server";
export const runsReplicationInstance = singleton(
"runsReplicationInstance",
@@ -80,8 +80,8 @@ function initializeRunsReplicationInstance() {
});
});
process.on("SIGTERM", service.shutdown.bind(service));
process.on("SIGINT", service.shutdown.bind(service));
signalsEmitter.on("SIGTERM", service.shutdown.bind(service));
signalsEmitter.on("SIGINT", service.shutdown.bind(service));
}
return service;
@@ -148,6 +148,11 @@ export class RunsReplicationService {
}
for (const item of newBatch) {
if (!item?.run?.id) {
this.logger.warn("Skipping replication event with null run", { event: item });
continue;
}
const key = `${item.event}_${item.run.id}`;
const existingItem = merged.get(key);
@@ -204,6 +209,8 @@ export class RunsReplicationService {
}
public async shutdown() {
if (this._isShuttingDown) return;
this._isShuttingDown = true;
this.logger.info("Initiating shutdown of runs replication service");
@@ -0,0 +1,32 @@
import { EventEmitter } from "events";
import { singleton } from "~/utils/singleton";
export type SignalsEvents = {
SIGTERM: [
{
time: Date;
signal: NodeJS.Signals;
}
];
SIGINT: [
{
time: Date;
signal: NodeJS.Signals;
}
];
};
export type SignalsEventArgs<T extends keyof SignalsEvents> = SignalsEvents[T];
export type SignalsEmitter = EventEmitter<SignalsEvents>;
function initializeSignalsEmitter() {
const emitter = new EventEmitter<SignalsEvents>();
process.on("SIGTERM", () => emitter.emit("SIGTERM", { time: new Date(), signal: "SIGTERM" }));
process.on("SIGINT", () => emitter.emit("SIGINT", { time: new Date(), signal: "SIGINT" }));
return emitter;
}
export const signalsEmitter = singleton("signalsEmitter", initializeSignalsEmitter);
@@ -14,7 +14,16 @@ export class UpsertBranchService {
this.#prismaClient = prismaClient;
}
public async call(userId: string, { parentEnvironmentId, branchName, git }: CreateBranchOptions) {
public async call(
// The orgFilter approach is not ideal but we need to keep it this way for now because of how the service is used in routes and api endpoints.
// Currently authorization checks are spread across the controller/route layer and the service layer. Often we check in multiple places for org/project membership.
// Ideally we would take care of both the authentication and authorization checks in the controllers and routes.
// That would unify how we handle authorization and org/project membership checks. Also it would make the service layer queries simpler.
orgFilter:
| { type: "userMembership"; userId: string }
| { type: "orgId"; organizationId: string },
{ parentEnvironmentId, branchName, git }: CreateBranchOptions
) {
const sanitizedBranchName = sanitizeBranchName(branchName);
if (!sanitizedBranchName) {
return {
@@ -34,13 +43,16 @@ export class UpsertBranchService {
const parentEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
where: {
id: parentEnvironmentId,
organization: {
members: {
some: {
userId: userId,
},
},
},
organization:
orgFilter.type === "userMembership"
? {
members: {
some: {
userId: orgFilter.userId,
},
},
}
: { id: orgFilter.organizationId },
},
include: {
organization: {
+23 -7
View File
@@ -7,22 +7,38 @@ const DEFAULT_REDIRECT = "/";
* This should be used any time the redirect path is user-provided
* (Like the query string on our login/signup pages). This avoids
* open-redirect vulnerabilities.
* @param {string} to The redirect destination
* @param {string} path The redirect destination
* @param {string} defaultRedirect The redirect to use if the to is unsafe.
*/
export function safeRedirect(
to: FormDataEntryValue | string | null | undefined,
export function sanitizeRedirectPath(
path: string | undefined | null,
defaultRedirect: string = DEFAULT_REDIRECT
) {
if (!to || typeof to !== "string") {
): string {
if (!path || typeof path !== "string") {
return defaultRedirect;
}
if (!to.startsWith("/") || to.startsWith("//")) {
if (!path.startsWith("/") || path.startsWith("//")) {
return defaultRedirect;
}
return to;
try {
// should not parse as a full URL
new URL(path);
return defaultRedirect;
} catch {}
try {
// ensure it's a valid relative path
const url = new URL(path, "https://example.com");
if (url.hostname !== "example.com") {
return defaultRedirect;
}
} catch {
return defaultRedirect;
}
return path;
}
/**
+6
View File
@@ -141,6 +141,12 @@ export function v3ProjectPath(organization: OrgForPath, project: ProjectForPath)
return `/orgs/${organizationParam(organization)}/projects/${projectParam(project)}`;
}
export function githubAppInstallPath(organizationSlug: string, redirectTo: string) {
return `/github/install?org_slug=${organizationSlug}&redirect_to=${encodeURIComponent(
redirectTo
)}`;
}
export function v3EnvironmentPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -0,0 +1,64 @@
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
export interface QueryPerformanceConfig {
verySlowQueryThreshold?: number; // ms
maxQueryLogLength: number;
}
export class QueryPerformanceMonitor {
private config: QueryPerformanceConfig;
constructor(config: Partial<QueryPerformanceConfig> = {}) {
this.config = {
maxQueryLogLength: 1000,
...config,
};
}
onQuery(
clientType: "writer" | "replica",
log: {
duration: number;
query: string;
params: string;
target: string;
timestamp: Date;
}
) {
if (this.config.verySlowQueryThreshold === undefined) {
return;
}
const { duration, query, params, target, timestamp } = log;
// Only log very slow queries as errors
if (duration > this.config.verySlowQueryThreshold) {
// Truncate long queries for readability
const truncatedQuery =
query.length > this.config.maxQueryLogLength
? query.substring(0, this.config.maxQueryLogLength) + "..."
: query;
logger.error("Prisma: very slow database query", {
clientType,
durationMs: duration,
query: truncatedQuery,
target,
timestamp,
paramCount: this.countParams(query),
hasParams: params !== "[]" && params !== "",
});
}
}
private countParams(query: string): number {
// Count the number of $1, $2, etc. parameters in the query
const paramMatches = query.match(/\$\d+/g);
return paramMatches ? paramMatches.length : 0;
}
}
export const queryPerformanceMonitor = new QueryPerformanceMonitor({
verySlowQueryThreshold: env.VERY_SLOW_QUERY_THRESHOLD_MS,
});
+34
View File
@@ -0,0 +1,34 @@
import nodeCrypto from "node:crypto";
export function encryptToken(value: string, key: string) {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", key, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
}
export function decryptToken(nonce: string, ciphertext: string, tag: string, key: string): string {
const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "hex"));
decipher.setAuthTag(Buffer.from(tag, "hex"));
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
export function hashToken(token: string): string {
const hash = nodeCrypto.createHash("sha256");
hash.update(token);
return hash.digest("hex");
}
+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod";
export const BuildSettingsSchema = z.object({
triggerConfigFilePath: z.string().optional(),
installDirectory: z.string().optional(),
installCommand: z.string().optional(),
});
export type BuildSettings = z.infer<typeof BuildSettingsSchema>;
@@ -1,6 +1,7 @@
import { Logger } from "@trigger.dev/core/logger";
import { nanoid } from "nanoid";
import pLimit from "p-limit";
import { signalsEmitter } from "~/services/signals.server";
export type DynamicFlushSchedulerConfig<T> = {
batchSize: number;
@@ -22,6 +23,7 @@ export class DynamicFlushScheduler<T> {
private readonly BATCH_SIZE: number;
private readonly FLUSH_INTERVAL: number;
private flushTimer: NodeJS.Timeout | null;
private metricsReporterTimer: NodeJS.Timeout | undefined;
private readonly callback: (flushId: string, batch: T[]) => Promise<void>;
// New properties for dynamic scaling
@@ -41,6 +43,7 @@ export class DynamicFlushScheduler<T> {
droppedEvents: 0,
droppedEventsByKind: new Map<string, number>(),
};
private isShuttingDown: boolean = false;
// New properties for load shedding
private readonly loadSheddingThreshold: number;
@@ -75,6 +78,7 @@ export class DynamicFlushScheduler<T> {
this.startFlushTimer();
this.startMetricsReporter();
this.setupShutdownHandlers();
}
addToBatch(items: T[]): void {
@@ -119,8 +123,8 @@ export class DynamicFlushScheduler<T> {
this.currentBatch.push(...itemsToAdd);
this.totalQueuedItems += itemsToAdd.length;
// Check if we need to create a batch
if (this.currentBatch.length >= this.currentBatchSize) {
// Check if we need to create a batch (if we are shutting down, create a batch immediately because the flush timer is stopped)
if (this.currentBatch.length >= this.currentBatchSize || this.isShuttingDown) {
this.createBatch();
}
@@ -137,6 +141,23 @@ export class DynamicFlushScheduler<T> {
this.resetFlushTimer();
}
private setupShutdownHandlers(): void {
signalsEmitter.on("SIGTERM", () =>
this.shutdown().catch((error) => {
this.logger.error("Error shutting down dynamic flush scheduler", {
error,
});
})
);
signalsEmitter.on("SIGINT", () =>
this.shutdown().catch((error) => {
this.logger.error("Error shutting down dynamic flush scheduler", {
error,
});
})
);
}
private startFlushTimer(): void {
this.flushTimer = setInterval(() => this.checkAndFlush(), this.FLUSH_INTERVAL);
}
@@ -145,6 +166,9 @@ export class DynamicFlushScheduler<T> {
if (this.flushTimer) {
clearInterval(this.flushTimer);
}
if (this.isShuttingDown) return;
this.startFlushTimer();
}
@@ -226,7 +250,7 @@ export class DynamicFlushScheduler<T> {
}
private lastConcurrencyAdjustment: number = Date.now();
private adjustConcurrency(backOff: boolean = false): void {
const currentConcurrency = this.limiter.concurrency;
let newConcurrency = currentConcurrency;
@@ -281,7 +305,7 @@ export class DynamicFlushScheduler<T> {
private startMetricsReporter(): void {
// Report metrics every 30 seconds
setInterval(() => {
this.metricsReporterTimer = setInterval(() => {
const droppedByKind: Record<string, number> = {};
this.metrics.droppedEventsByKind.forEach((count, kind) => {
droppedByKind[kind] = count;
@@ -356,10 +380,18 @@ export class DynamicFlushScheduler<T> {
// Graceful shutdown
async shutdown(): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
if (this.flushTimer) {
clearInterval(this.flushTimer);
}
if (this.metricsReporterTimer) {
clearInterval(this.metricsReporterTimer);
}
// Flush any remaining items
if (this.currentBatch.length > 0) {
this.createBatch();
@@ -11,6 +11,7 @@ import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
import { tryCatch } from "@trigger.dev/core";
import { logger } from "~/services/logger.server";
import { type RegistryConfig } from "./registryConfig.server";
import type { EnvironmentType } from "@trigger.dev/core/v3";
// Optional configuration for cross-account access
export type AssumeRoleConfig = {
@@ -101,19 +102,22 @@ export async function getDeploymentImageRef({
registry,
projectRef,
nextVersion,
environmentSlug,
environmentType,
deploymentShortCode,
}: {
registry: RegistryConfig;
projectRef: string;
nextVersion: string;
environmentSlug: string;
environmentType: EnvironmentType;
deploymentShortCode: string;
}): Promise<{
imageRef: string;
isEcr: boolean;
repoCreated: boolean;
}> {
const repositoryName = `${registry.namespace}/${projectRef}`;
const imageRef = `${registry.host}/${repositoryName}:${nextVersion}.${environmentSlug}`;
const envType = environmentType.toLowerCase();
const imageRef = `${registry.host}/${repositoryName}:${nextVersion}.${envType}.${deploymentShortCode}`;
if (!isEcrRegistry(registry.host)) {
return {
+35
View File
@@ -0,0 +1,35 @@
import { z } from "zod";
export const BranchTrackingConfigSchema = z.object({
prod: z.object({
branch: z.string().optional(),
}),
staging: z.object({
branch: z.string().optional(),
}),
});
export type BranchTrackingConfig = z.infer<typeof BranchTrackingConfigSchema>;
export function getTrackedBranchForEnvironment(
branchTracking: BranchTrackingConfig | undefined,
previewDeploymentsEnabled: boolean,
environment: {
type: "PRODUCTION" | "STAGING" | "DEVELOPMENT" | "PREVIEW";
branchName?: string;
}
): string | undefined {
switch (environment.type) {
case "PRODUCTION":
return branchTracking?.prod?.branch;
case "STAGING":
return branchTracking?.staging?.branch;
case "PREVIEW":
return previewDeploymentsEnabled ? environment.branchName : undefined;
case "DEVELOPMENT":
return undefined;
default:
environment.type satisfies never;
return undefined;
}
}

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