Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a03783d1a0 | |||
| 0178bdbb00 | |||
| ad51168181 | |||
| db87295049 | |||
| a3ef6ea236 | |||
| ae22000409 | |||
| 2bbf8ec6cd | |||
| c8858edf0a | |||
| 9c087646bf | |||
| 691903cf58 | |||
| 885ae5e06f | |||
| 501a383bcd | |||
| 3c199e6d9c | |||
| a55294b7dd | |||
| c9d1aadfc3 | |||
| 08702cd710 | |||
| 04dcb81496 | |||
| 0f9b83db09 | |||
| 7d333e5b3c | |||
| 7c4ce6f76b | |||
| dc42ae7aa4 | |||
| b0b88f1e05 | |||
| 6483a0f1c6 | |||
| 6f1abe058b | |||
| 83bd6f5f9e | |||
| e36d78e4fc | |||
| 3188dc9b28 | |||
| 10e7985fbc | |||
| 2eddda1233 | |||
| 49f2c54031 | |||
| f077d49291 | |||
| 5db583b6cd | |||
| 1227e5463e | |||
| 8e66913e59 | |||
| b9b17e24ac | |||
| 12bef0a938 | |||
| 5567f49846 | |||
| 71060d93b1 | |||
| 89b1d8ba13 | |||
| 97015ba8c8 | |||
| 99660112bd | |||
| e6586d3c1a | |||
| 00d32ed4ee | |||
| a1e9738faa | |||
| 59c17e04e9 | |||
| ed23615aa4 | |||
| 436d951b65 | |||
| cf9398b56e | |||
| 9b1877bef2 | |||
| 0b2b73fc52 | |||
| 0d1eac9406 | |||
| ddbae6b6b4 | |||
| 2b095b1072 | |||
| 847ea866b6 | |||
| fee31f2dc0 | |||
| 10baa396d8 | |||
| 27bc2ad232 | |||
| 3a3e863daf | |||
| f5f14c4206 | |||
| 892adfe81c | |||
| 73e7378459 | |||
| b0b0df69be | |||
| e629810e96 | |||
| e6f6d93e59 | |||
| 6f6ca01584 | |||
| 11cbd1b5a0 | |||
| 82401ec4fe | |||
| 24a915133e | |||
| 688b108ec3 | |||
| 1cc62230ab | |||
| 96243efe16 | |||
| ad26cded99 | |||
| 5e4756f8b7 | |||
| c16cc577b0 |
@@ -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
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
**/dist
|
||||
**/node_modules
|
||||
|
||||
**/generated/prisma
|
||||
|
||||
apps/webapp/build
|
||||
apps/webapp/public/build
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Vendored
+5
-5
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+6
-6
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
+18
-12
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
|
||||
+52
-11
@@ -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" />
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+31
-2
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
+127
-67
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1043
-132
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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] }
|
||||
)}`,
|
||||
|
||||
+5
-5
@@ -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";
|
||||
|
||||
@@ -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,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 }));
|
||||
}
|
||||
);
|
||||
|
||||
+2
@@ -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 });
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+2
@@ -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 });
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+3
-5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+16
-32
@@ -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 won’t 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.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user