Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| caa40ce925 | |||
| ba9b0e17c1 | |||
| 469808cf09 | |||
| 7574c69c2d | |||
| 06cbe6e3ca | |||
| 3875bb292a | |||
| ff80742ab7 | |||
| 11366e658c | |||
| e751f8832e | |||
| a999d9ea3f | |||
| 28a66ac021 | |||
| 7d34817473 | |||
| 6d6ed471d1 | |||
| 7f7f993587 | |||
| d28707826c | |||
| 8b00198f99 | |||
| 74e9246bfa | |||
| 07a31d3732 | |||
| da111e220f | |||
| 2c3cb4a43a | |||
| b71bf89444 | |||
| 28c0c78257 | |||
| c021d1db63 | |||
| f62cdfe00e | |||
| 66c6da7114 | |||
| 7fba9e9f6b | |||
| cf63fc9cd2 | |||
| d1c3bfb9c9 | |||
| d8f5853457 | |||
| a52566d9cc | |||
| 07a1d04d52 | |||
| 185f4ecaf9 | |||
| 0d764d4c46 | |||
| beb52b9800 | |||
| 3401a1d0a9 | |||
| 3f982ed366 | |||
| 4dc956470d | |||
| 7fddadcce9 | |||
| 6e038d4d1e | |||
| 05b53967ea | |||
| 702f3b4bca | |||
| 8fcd93001d | |||
| b01b8740cc | |||
| 357aa99309 | |||
| 7cbf82a4ae | |||
| e7fec4097f | |||
| d279988e38 | |||
| 748ae658f7 | |||
| 249878ed92 | |||
| 117b1d5a53 | |||
| 04173a93b9 | |||
| da4c753b68 | |||
| 6ae3b69745 | |||
| 652d95c7eb | |||
| 341e27d213 | |||
| 9821d02af7 | |||
| 255a73a2fe | |||
| c5f7a8daf7 | |||
| 8b0f51b317 | |||
| 53f21e1330 | |||
| 331882f59c | |||
| 1276491a83 | |||
| 5b7dfe23b5 | |||
| 49b2f683f4 | |||
| af9b3e1c99 | |||
| df4ab97d59 | |||
| 9f27422472 | |||
| 2f1a72b109 | |||
| 3c326a4b4a | |||
| 6ae1317b69 | |||
| 2e1c4f6df6 | |||
| 2bf86dc20e | |||
| 485782cae1 | |||
| 61b338bea7 | |||
| 83ddf721a4 | |||
| a4dd2562d2 | |||
| 5ff21a758c | |||
| 47a64c0335 | |||
| fc351cb6c4 | |||
| c4f2a9d065 | |||
| 2762c542c2 | |||
| 72e286af2f | |||
| f7240a99e7 |
@@ -0,0 +1,11 @@
|
||||
# Remove AI code slop
|
||||
|
||||
Check the diff against main, and remove all AI generated slop introduced in this branch.
|
||||
|
||||
This includes:
|
||||
- Extra comments that a human wouldn't add or is inconsistent with the rest of the file
|
||||
- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths)
|
||||
- Casts to any to get around type issues
|
||||
- Any other style that is inconsistent with the file
|
||||
|
||||
Report at the end with only a 1-3 sentence summary of what you changed
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: how to create and apply database migrations
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations.
|
||||
+9
-3
@@ -34,9 +34,9 @@ DEPLOY_REGISTRY_HOST=localhost:5000
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
|
||||
# WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$"
|
||||
# Accounts with these emails will get global admin rights. This grants access to the admin UI.
|
||||
# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com"
|
||||
# ADMIN_EMAILS="^(admin@example\.com|another-admin@example\.com)$"
|
||||
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
@@ -85,4 +85,10 @@ POSTHOG_PROJECT_KEY=
|
||||
# These control the server-side internal telemetry
|
||||
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
|
||||
# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0
|
||||
|
||||
# Enable local observability stack (requires `pnpm run docker` to start otel-collector)
|
||||
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
|
||||
@@ -0,0 +1,102 @@
|
||||
name: 🦋 Changesets PR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/**"
|
||||
- ".changeset/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-pr:
|
||||
name: Create Release PR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Create release PR
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: release"
|
||||
title: "chore: release"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update PR title with version
|
||||
if: steps.changesets.outputs.published != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number')
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
git fetch origin changeset-release/main
|
||||
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
|
||||
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
|
||||
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
|
||||
fi
|
||||
|
||||
update-lockfile:
|
||||
name: Update lockfile on release PR
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-pr
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: changeset-release/main
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
|
||||
- name: Install and update lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Commit and push lockfile
|
||||
run: |
|
||||
set -e
|
||||
if git diff --quiet pnpm-lock.yaml; then
|
||||
echo "No lockfile changes"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore: update lockfile for release"
|
||||
git push origin changeset-release/main
|
||||
fi
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: 🚀 Publish Trigger.dev Docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_tag:
|
||||
|
||||
+134
-47
@@ -1,98 +1,185 @@
|
||||
name: 🦋 Changesets Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
type:
|
||||
description: "Select release type"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- prerelease
|
||||
default: "prerelease"
|
||||
ref:
|
||||
description: "The ref (branch, tag, or SHA) to checkout and release from"
|
||||
required: true
|
||||
type: string
|
||||
prerelease_tag:
|
||||
description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')"
|
||||
required: false
|
||||
type: string
|
||||
default: "prerelease"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: 🦋 Changesets Release
|
||||
show-release-summary:
|
||||
name: 📋 Release Summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref == 'changeset-release/main'
|
||||
steps:
|
||||
- name: Show release summary
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
release:
|
||||
name: 🚀 Release npm packages
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
id-token: write
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
(
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main')
|
||||
)
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
published_package_version: ${{ steps.get_version.outputs.package_version }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
- name: Verify ref is on main
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
|
||||
echo "Error: ref must be an ancestor of main (i.e., already merged)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
- name: Generate Prisma client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🏗️ Build
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔎 Type check
|
||||
- name: Type check
|
||||
run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔐 Setup npm auth
|
||||
run: |
|
||||
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
||||
|
||||
# This action has two responsibilities. The first time the workflow runs
|
||||
# (initial push to the `main` branch) it will create a new branch and
|
||||
# then open a PR with the related changes for the new version. After the
|
||||
# PR is merged, the workflow will run again and this action will build +
|
||||
# publish to npm.
|
||||
- name: 🚀 PR / Publish
|
||||
if: ${{ !env.ACT }}
|
||||
- name: Publish
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: Update version for release"
|
||||
title: "chore: Update version for release"
|
||||
publish: pnpm run changeset:release
|
||||
createGithubReleases: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# - name: 🚀 PR / Publish (mock)
|
||||
# if: ${{ env.ACT }}
|
||||
# id: changesets
|
||||
# run: |
|
||||
# echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
# echo "publishedPackages=[{\"name\": \"@xx/xx\", \"version\": \"1.2.0\"}, {\"name\": \"@xx/xy\", \"version\": \"0.8.9\"}]" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📦 Get package version
|
||||
- name: Show package version
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
id: get_version
|
||||
run: |
|
||||
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
|
||||
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# this triggers the publish workflow for the docker images
|
||||
- name: Create and push Docker tag
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
run: |
|
||||
set -e
|
||||
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
|
||||
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
|
||||
prerelease:
|
||||
name: 🧪 Prerelease
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease'
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: Snapshot version
|
||||
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Clean
|
||||
run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Publish prerelease
|
||||
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
|
||||
- name: 🔎 Type check
|
||||
run: pnpm run typecheck
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: 🔎 Check exports
|
||||
run: pnpm run check-exports
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
|
||||
+5
-6
@@ -29,12 +29,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.docker
|
||||
.env
|
||||
.env.*
|
||||
.docker/*.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
!.env.example
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
@@ -64,4 +62,5 @@ apps/**/public/build
|
||||
/packages/trigger-sdk/src/package.json
|
||||
/packages/python/src/package.json
|
||||
.claude
|
||||
.mcp.log
|
||||
.mcp.log
|
||||
.cursor/debug.log
|
||||
@@ -1,5 +0,0 @@
|
||||
link-workspace-packages=false
|
||||
public-hoist-pattern[]=*prisma*
|
||||
prefer-workspace-packages=true
|
||||
update-notifier=false
|
||||
side-effects-cache=false
|
||||
@@ -13,7 +13,7 @@ This repository is a pnpm monorepo managed with Turbo. It contains multiple apps
|
||||
See `ai/references/repo.md` for a more complete explanation of the workspaces.
|
||||
|
||||
## Development setup
|
||||
1. Install dependencies with `pnpm i` (pnpm `8.15.5` and Node.js `20.11.1` are required).
|
||||
1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.11.1` are required).
|
||||
2. Copy `.env.example` to `.env` and generate a random 16 byte hex string for `ENCRYPTION_KEY` (`openssl rand -hex 16`). Update other secrets if needed.
|
||||
3. Start the local services with Docker:
|
||||
```bash
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ branch are tagged into a release periodically.
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) version 20.11.1
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 10.23.0
|
||||
- [Docker](https://www.docker.com/get-started/)
|
||||
- [protobuf](https://github.com/protocolbuffers/protobuf)
|
||||
|
||||
@@ -36,7 +36,7 @@ branch are tagged into a release periodically.
|
||||
```
|
||||
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
|
||||
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`10.23.0`) as specified in the root `package.json` file.
|
||||
|
||||
5. Install the required packages using pnpm.
|
||||
```
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
## Creating and applying migrations
|
||||
|
||||
We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file:
|
||||
|
||||
Edit the `schema.prisma` file to add or modify the schema.
|
||||
|
||||
Create a new migration file but don't apply it yet:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:dev:create --name "add_new_column_to_table"
|
||||
```
|
||||
|
||||
The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
All the following lines should be removed:
|
||||
|
||||
```sql
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
Leaving only this:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
```
|
||||
|
||||
After editing the migration file, apply the migration:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:deploy && pnpm run generate
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
## Repo Overview
|
||||
|
||||
This is a pnpm 8.15.5 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
This is a pnpm 10.23.0 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
|
||||
## Apps
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /app
|
||||
FROM node-22-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=supervisor --docker
|
||||
RUN npx -q turbo@2.5.4 prune --scope=supervisor --docker
|
||||
|
||||
FROM node-22-alpine AS base
|
||||
|
||||
@@ -16,7 +16,7 @@ COPY --from=pruner --chown=node:node /app/out/json/ .
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
RUN corepack enable && corepack prepare --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.23.0 --activate
|
||||
|
||||
FROM base AS deps-fetcher
|
||||
RUN apk add --no-cache python3-dev py3-setuptools make g++ gcc linux-headers
|
||||
@@ -37,7 +37,7 @@ COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts
|
||||
|
||||
RUN pnpm run generate && \
|
||||
pnpm run --filter supervisor... build&& \
|
||||
pnpm deploy --filter=supervisor --prod /prod/supervisor
|
||||
pnpm deploy --legacy --filter=supervisor --prod /prod/supervisor
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -244,6 +244,12 @@ class ManagedSupervisor {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!message.deployment.friendlyId) {
|
||||
// mostly a type guard, deployments always exists for deployed environments
|
||||
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
|
||||
throw new Error("Deployment is missing");
|
||||
}
|
||||
|
||||
await this.workloadManager.create({
|
||||
dequeuedAt: message.dequeuedAt,
|
||||
envId: message.environment.id,
|
||||
@@ -252,6 +258,8 @@ class ManagedSupervisor {
|
||||
machine: message.run.machine,
|
||||
orgId: message.organization.id,
|
||||
projectId: message.project.id,
|
||||
deploymentFriendlyId: message.deployment.friendlyId,
|
||||
deploymentVersion: message.backgroundWorker.version,
|
||||
runId: message.run.id,
|
||||
runFriendlyId: message.run.friendlyId,
|
||||
version: message.version,
|
||||
|
||||
@@ -72,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
|
||||
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
|
||||
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
|
||||
`TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
|
||||
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
|
||||
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
|
||||
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
|
||||
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
|
||||
|
||||
@@ -123,6 +123,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
name: "TRIGGER_ENV_ID",
|
||||
value: opts.envId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_ID",
|
||||
value: opts.deploymentFriendlyId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_VERSION",
|
||||
value: opts.deploymentVersion,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_SNAPSHOT_ID",
|
||||
value: opts.snapshotFriendlyId,
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
|
||||
envType: EnvironmentType;
|
||||
orgId: string;
|
||||
projectId: string;
|
||||
deploymentFriendlyId: string;
|
||||
deploymentVersion: string;
|
||||
runId: string;
|
||||
runFriendlyId: string;
|
||||
snapshotId: string;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function GoogleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
|
||||
fill="#FBBC02"
|
||||
/>
|
||||
<path
|
||||
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,13 @@ import {
|
||||
} from "./SetupCommands";
|
||||
import { StepContentContainer } from "./StepContentContainer";
|
||||
import { V4Badge } from "./V4Badge";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "./primitives/ClientTabs";
|
||||
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export function HasNoTasksDev() {
|
||||
return (
|
||||
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
|
||||
}
|
||||
|
||||
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
|
||||
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function SchedulesNoPossibleTaskPanel() {
|
||||
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
|
||||
}
|
||||
|
||||
export function DeploymentsNone() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploy for the first time"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
|
||||
service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function DeploymentsNoneDev() {
|
||||
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploying tasks"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
|
||||
<StepContentContainer className="mb-4 flex flex-col gap-4">
|
||||
<Paragraph>
|
||||
This is the Development environment. When you're ready to deploy your tasks, switch to a
|
||||
different environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous
|
||||
Integration service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
|
||||
/>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentOnboardingSteps() {
|
||||
const environment = useEnvironment();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div className="mb-2 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<ClientTabs defaultValue="github">
|
||||
<ClientTabsList variant="segmented" className="mb-6">
|
||||
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
|
||||
Manual
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub Actions
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"github"}>
|
||||
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Deploy automatically with every push. Read the{" "}
|
||||
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<div className="w-fit">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"cli"}>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
|
||||
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"github-actions"}>
|
||||
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,10 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to adjacent">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
|
||||
@@ -22,6 +22,7 @@ export function UserAvatar({
|
||||
className={cn("aspect-square rounded-full p-[7%]")}
|
||||
src={avatarUrl}
|
||||
alt={name ?? "User"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -331,7 +331,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
type LinkPropsType = Pick<
|
||||
LinkProps,
|
||||
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
|
||||
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
export const LinkButton = ({
|
||||
to,
|
||||
onClick,
|
||||
@@ -340,6 +340,7 @@ export const LinkButton = ({
|
||||
onMouseLeave,
|
||||
download,
|
||||
disabled = false,
|
||||
replace,
|
||||
...props
|
||||
}: LinkPropsType) => {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
@@ -372,7 +373,7 @@ export const LinkButton = ({
|
||||
<ExtLink
|
||||
href={to.toString()}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -387,7 +388,8 @@ export const LinkButton = ({
|
||||
<Link
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
replace={replace}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -408,7 +410,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
|
||||
target={target}
|
||||
>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -1,41 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type Variants } from "./Tabs";
|
||||
|
||||
type ClientTabsContextValue = {
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
|
||||
|
||||
function useClientTabsContext() {
|
||||
return React.useContext(ClientTabsContext);
|
||||
}
|
||||
|
||||
const ClientTabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
|
||||
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
|
||||
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (valueProp !== undefined) {
|
||||
setValue(valueProp);
|
||||
}
|
||||
}, [valueProp]);
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(nextValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValue(nextValue);
|
||||
}
|
||||
onValueChange?.(nextValue);
|
||||
},
|
||||
[onValueChange, valueProp]
|
||||
);
|
||||
|
||||
const controlledProps =
|
||||
valueProp !== undefined
|
||||
? { value: valueProp }
|
||||
: defaultValue !== undefined
|
||||
? { defaultValue }
|
||||
: {};
|
||||
|
||||
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
|
||||
|
||||
return (
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
/>
|
||||
</ClientTabsContext.Provider>
|
||||
);
|
||||
});
|
||||
ClientTabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
const ClientTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center justify-center transition duration-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
||||
variant?: Variants;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", ...props }, ref) => {
|
||||
const variantClassName = (() => {
|
||||
switch (variant) {
|
||||
case "segmented":
|
||||
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
|
||||
case "underline":
|
||||
return "flex gap-x-6 border-b border-grid-bright";
|
||||
default:
|
||||
return "inline-flex items-center justify-center transition duration-100";
|
||||
}
|
||||
})();
|
||||
|
||||
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
|
||||
});
|
||||
ClientTabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const ClientTabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||
variant?: Variants;
|
||||
layoutId?: string;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
|
||||
const context = useClientTabsContext();
|
||||
const activeValue = context?.value;
|
||||
const isActive = activeValue === props.value;
|
||||
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex-1 basis-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{isActive ? (
|
||||
layoutId ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
|
||||
)
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{layoutId ? (
|
||||
isActive ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)
|
||||
) : isActive ? (
|
||||
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const ClientTabsContent = React.forwardRef<
|
||||
@@ -61,39 +205,7 @@ export type TabsProps = {
|
||||
currentValue: string;
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = currentValue === tab.value;
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(`group flex flex-col items-center`, className)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-indigo-500" : "text-charcoal-200"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
|
||||
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
|
||||
|
||||
@@ -3,59 +3,95 @@ import { useState } from "react";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useCopy } from "~/hooks/useCopy";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
export function CopyableText({
|
||||
value,
|
||||
copyValue,
|
||||
className,
|
||||
asChild,
|
||||
variant,
|
||||
}: {
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
className?: string;
|
||||
asChild?: boolean;
|
||||
variant?: "icon-right" | "text-below";
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { copy, copied } = useCopy(copyValue ?? value);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
const resolvedVariant = variant ?? "icon-right";
|
||||
|
||||
if (resolvedVariant === "icon-right") {
|
||||
return (
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedVariant === "text-below") {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
</Button>
|
||||
}
|
||||
content={copied ? "Copied" : "Click to copy"}
|
||||
className="font-sans px-2 py-1"
|
||||
disableHoverableContent
|
||||
open={isHovered || copied}
|
||||
onOpenChange={setIsHovered}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
|
||||
|
||||
const medium =
|
||||
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
medium: cn(medium, "group-hover:border-charcoal-550"),
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NavLink } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
export type Variants = "underline" | "pipe-divider" | "segmented";
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: {
|
||||
label: string;
|
||||
@@ -12,13 +14,14 @@ export type TabsProps = {
|
||||
}[];
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
|
||||
return (
|
||||
<TabContainer className={className}>
|
||||
<TabContainer className={className} variant={variant}>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId}>
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
|
||||
{tab.label}
|
||||
</TabLink>
|
||||
))}
|
||||
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export function TabContainer({
|
||||
children,
|
||||
className,
|
||||
variant = "underline",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn(`flex`, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TabLink({
|
||||
to,
|
||||
children,
|
||||
layoutId,
|
||||
variant = "underline",
|
||||
}: {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group relative flex h-full grow items-center justify-center focus-custom"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pipe-divider") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// underline variant (default)
|
||||
return (
|
||||
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
|
||||
{({ isActive, isPending }) => {
|
||||
@@ -51,13 +138,19 @@ export function TabLink({
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive || isPending ? "text-text-bright" : "text-text-bright"
|
||||
isActive || isPending
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{isActive || isPending ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
@@ -106,17 +199,18 @@ export function TabButton({
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-bright"
|
||||
)}
|
||||
className={"text-sm transition duration-200 text-text-bright"}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
|
||||
</div>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "~/utils/cn";
|
||||
const variantClasses = {
|
||||
basic:
|
||||
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50"
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variantClasses;
|
||||
@@ -64,6 +64,8 @@ function SimpleTooltip({
|
||||
buttonStyle,
|
||||
asChild = false,
|
||||
sideOffset,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
button: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
@@ -76,10 +78,12 @@ function SimpleTooltip({
|
||||
buttonStyle?: React.CSSProperties;
|
||||
asChild?: boolean;
|
||||
sideOffset?: number;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip>
|
||||
<Tooltip open={open} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger
|
||||
tabIndex={-1}
|
||||
className={cn("h-fit", buttonClassName)}
|
||||
|
||||
@@ -423,6 +423,10 @@ export function useTree<TData, TFilterValue>({
|
||||
}
|
||||
case "Left":
|
||||
case "ArrowLeft": {
|
||||
if (e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allBatchStatuses = ["PENDING", "COMPLETED", "ABORTED"] as const satisfies Readonly<
|
||||
Array<BatchTaskRunStatus>
|
||||
>;
|
||||
export const allBatchStatuses = [
|
||||
"PROCESSING",
|
||||
"PENDING",
|
||||
"COMPLETED",
|
||||
"PARTIAL_FAILED",
|
||||
"ABORTED",
|
||||
] as const satisfies Readonly<Array<BatchTaskRunStatus>>;
|
||||
|
||||
const descriptions: Record<BatchTaskRunStatus, string> = {
|
||||
PROCESSING: "The batch is being processed and runs are being created.",
|
||||
PENDING: "The batch has child runs that have not yet completed.",
|
||||
COMPLETED: "All the batch child runs have finished.",
|
||||
ABORTED: "The batch was aborted because some child tasks could not be triggered.",
|
||||
PARTIAL_FAILED: "Some runs failed to be created. Successfully created runs are still executing.",
|
||||
ABORTED: "The batch was aborted because child tasks could not be triggered.",
|
||||
};
|
||||
|
||||
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
|
||||
@@ -47,10 +57,14 @@ export function BatchStatusIcon({
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "PENDING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "PARTIAL_FAILED":
|
||||
return <ExclamationTriangleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "ABORTED":
|
||||
return <XCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
default: {
|
||||
@@ -61,10 +75,14 @@ export function BatchStatusIcon({
|
||||
|
||||
export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "text-blue-500";
|
||||
case "PENDING":
|
||||
return "text-pending";
|
||||
case "COMPLETED":
|
||||
return "text-success";
|
||||
case "PARTIAL_FAILED":
|
||||
return "text-warning";
|
||||
case "ABORTED":
|
||||
return "text-error";
|
||||
default: {
|
||||
@@ -75,10 +93,14 @@ export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
|
||||
export function batchStatusTitle(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "Processing";
|
||||
case "PENDING":
|
||||
return "In progress";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
case "PARTIAL_FAILED":
|
||||
return "Partial failure";
|
||||
case "ABORTED":
|
||||
return "Aborted";
|
||||
default: {
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type NextRunListItem,
|
||||
} from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
|
||||
import { docsPath, v3RunSpanPath, v3TestPath,v3TestTaskPath } from "~/utils/pathBuilder";
|
||||
import { DateTime } from "../../primitives/DateTime";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { Spinner } from "../../primitives/Spinner";
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
filterableTaskRunStatuses,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -62,9 +64,11 @@ type RunsTableProps = {
|
||||
filters: NextRunListAppliedFilters;
|
||||
showJob?: boolean;
|
||||
runs: NextRunListItem[];
|
||||
rootOnlyDefault?: boolean;
|
||||
isLoading?: boolean;
|
||||
allowSelection?: boolean;
|
||||
variant?: TableVariant;
|
||||
disableAdjacentRows?: boolean;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -72,6 +76,8 @@ export function TaskRunsTable({
|
||||
hasFilters,
|
||||
filters,
|
||||
runs,
|
||||
rootOnlyDefault,
|
||||
disableAdjacentRows = false,
|
||||
isLoading = false,
|
||||
allowSelection = false,
|
||||
variant = "dimmed",
|
||||
@@ -81,6 +87,12 @@ export function TaskRunsTable({
|
||||
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const { value } = useSearchParams();
|
||||
const location = useOptimisticLocation();
|
||||
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
|
||||
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
|
||||
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
|
||||
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
|
||||
|
||||
const showCompute = isManagedCloud;
|
||||
|
||||
@@ -293,16 +305,20 @@ export function TaskRunsTable({
|
||||
<BlankState isLoading={isLoading} filters={filters} />
|
||||
) : (
|
||||
runs.map((run, index) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (tableStateParam) {
|
||||
searchParams.set("tableState", tableStateParam);
|
||||
}
|
||||
const path = v3RunSpanPath(organization, project, run.environment, run, {
|
||||
spanId: run.spanId,
|
||||
});
|
||||
}, searchParams);
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
{allowSelection && (
|
||||
<TableCell className="pl-3 pr-0">
|
||||
<Checkbox
|
||||
checked={has(run.friendlyId)}
|
||||
onChange={(element) => {
|
||||
onChange={() => {
|
||||
toggle(run.friendlyId);
|
||||
}}
|
||||
ref={(r) => {
|
||||
@@ -565,6 +581,8 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
if (isLoading) return <TableBlankRow colSpan={15}></TableBlankRow>;
|
||||
|
||||
const { tasks, from, to, ...otherFilters } = filters;
|
||||
const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null;
|
||||
const testPath = singleTaskFromFilters ? v3TestTaskPath(organization, project, environment, {taskIdentifier: singleTaskFromFilters}) : v3TestPath(organization, project, environment);
|
||||
|
||||
if (
|
||||
filters.tasks.length === 1 &&
|
||||
@@ -579,7 +597,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
</Paragraph>
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={testPath}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
className="inline-flex"
|
||||
@@ -620,7 +638,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
<LinkButton
|
||||
LeadingIcon={BeakerIcon}
|
||||
variant="tertiary/medium"
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={testPath}
|
||||
>
|
||||
Run a test
|
||||
</LinkButton>
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import {
|
||||
createReadableStreamFromReadable,
|
||||
type DataFunctionArgs,
|
||||
type EntryContext,
|
||||
} from "@remix-run/node"; // or cloudflare/deno
|
||||
import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; // or cloudflare/deno
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { wrapHandleErrorWithSentry } from "@sentry/remix";
|
||||
import { parseAcceptLanguage } from "intl-parse-accept-language";
|
||||
import isbot from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { PassThrough } from "stream";
|
||||
import * as Worker from "~/services/worker.server";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
|
||||
import {
|
||||
OperatingSystemContextProvider,
|
||||
OperatingSystemPlatform,
|
||||
} from "./components/primitives/OperatingSystemProvider";
|
||||
import { Prisma } from "./db.server";
|
||||
import { env } from "./env.server";
|
||||
import { eventLoopMonitor } from "./eventLoopMonitor.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { wrapHandleErrorWithSentry } from "@sentry/remix";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import {
|
||||
registerRunEngineEventBusHandlers,
|
||||
setupBatchQueueCallbacks,
|
||||
} from "./v3/runEngineHandlers.server";
|
||||
|
||||
const ABORT_DELAY = 30000;
|
||||
|
||||
@@ -228,19 +234,13 @@ process.on("uncaughtException", (error, origin) => {
|
||||
});
|
||||
|
||||
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
|
||||
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
|
||||
|
||||
export { apiRateLimiter } from "./services/apiRateLimit.server";
|
||||
export { engineRateLimiter } from "./services/engineRateLimit.server";
|
||||
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
|
||||
export { socketIo } from "./v3/handleSocketIo.server";
|
||||
export { wss } from "./v3/handleWebsockets.server";
|
||||
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
|
||||
import { eventLoopMonitor } from "./eventLoopMonitor.server";
|
||||
import { env } from "./env.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { Prisma } from "./db.server";
|
||||
import { registerRunEngineEventBusHandlers } from "./v3/runEngineHandlers.server";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
|
||||
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
|
||||
eventLoopMonitor.enable();
|
||||
|
||||
@@ -94,6 +94,8 @@ const EnvironmentSchema = z
|
||||
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||||
FROM_EMAIL: z.string().optional(),
|
||||
REPLY_TO_EMAIL: z.string().optional(),
|
||||
@@ -345,6 +347,12 @@ const EnvironmentSchema = z
|
||||
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
OBJECT_STORE_REGION: z.string().optional(),
|
||||
OBJECT_STORE_SERVICE: z.string().default("s3"),
|
||||
|
||||
ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_REGION: z.string().optional(),
|
||||
EVENTS_BATCH_SIZE: z.coerce.number().int().default(100),
|
||||
EVENTS_BATCH_INTERVAL: z.coerce.number().int().default(1000),
|
||||
EVENTS_DEFAULT_LOG_RETENTION: z.coerce.number().int().default(7),
|
||||
@@ -520,6 +528,7 @@ const EnvironmentSchema = z
|
||||
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
|
||||
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
|
||||
BATCH_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().optional(), // Defaults to TASK_PAYLOAD_OFFLOAD_THRESHOLD if not set
|
||||
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
|
||||
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
|
||||
@@ -529,6 +538,14 @@ const EnvironmentSchema = z
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
// 2-phase batch API settings
|
||||
STREAMING_BATCH_MAX_ITEMS: z.coerce.number().int().default(1_000), // Max items in streaming batch
|
||||
STREAMING_BATCH_ITEM_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728),
|
||||
BATCH_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(100),
|
||||
BATCH_RATE_LIMIT_MAX: z.coerce.number().int().default(1200),
|
||||
BATCH_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"),
|
||||
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(1),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
|
||||
REALTIME_STREAM_TTL: z.coerce
|
||||
@@ -594,6 +611,12 @@ const EnvironmentSchema = z
|
||||
.default(60_000),
|
||||
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
|
||||
|
||||
/** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */
|
||||
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60_000 * 60), // 1 hour
|
||||
|
||||
RUN_ENGINE_WORKER_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -923,6 +946,15 @@ const EnvironmentSchema = z
|
||||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
// BatchQueue DRR settings (Run Engine v2)
|
||||
BATCH_QUEUE_DRR_QUANTUM: z.coerce.number().int().default(25),
|
||||
BATCH_QUEUE_MAX_DEFICIT: z.coerce.number().int().default(100),
|
||||
BATCH_QUEUE_CONSUMER_COUNT: z.coerce.number().int().default(3),
|
||||
BATCH_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(50),
|
||||
// Global rate limit: max items processed per second across all consumers
|
||||
// If not set, no global rate limiting is applied
|
||||
BATCH_QUEUE_GLOBAL_RATE_LIMIT: z.coerce.number().int().positive().optional(),
|
||||
|
||||
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||||
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||||
ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||||
@@ -1149,8 +1181,15 @@ const EnvironmentSchema = z
|
||||
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
|
||||
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
|
||||
EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS: z.coerce.number().int().default(5000),
|
||||
EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60_000 * 5), // 5 minutes
|
||||
EVENT_REPOSITORY_CLICKHOUSE_ROLLOUT_PERCENT: z.coerce.number().optional(),
|
||||
EVENT_REPOSITORY_DEFAULT_STORE: z.enum(["postgres", "clickhouse"]).default("postgres"),
|
||||
EVENT_REPOSITORY_DEFAULT_STORE: z
|
||||
.enum(["postgres", "clickhouse", "clickhouse_v2"])
|
||||
.default("postgres"),
|
||||
EVENT_REPOSITORY_DEBUG_LOGS_DISABLED: BoolEnv.default(false),
|
||||
EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
|
||||
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma, User } from "@trigger.dev/database";
|
||||
import type { GitHubProfile } from "remix-auth-github";
|
||||
import type { GoogleProfile } from "remix-auth-google";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from "~/services/dashboardPreferences.server";
|
||||
export type { User } from "@trigger.dev/database";
|
||||
import { assertEmailAllowed } from "~/utils/email";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type FindOrCreateMagicLink = {
|
||||
authenticationMethod: "MAGIC_LINK";
|
||||
email: string;
|
||||
@@ -20,7 +23,14 @@ type FindOrCreateGithub = {
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
|
||||
type FindOrCreateGoogle = {
|
||||
authenticationMethod: "GOOGLE";
|
||||
email: User["email"];
|
||||
authenticationProfile: GoogleProfile;
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
|
||||
|
||||
type LoggedInUser = {
|
||||
user: User;
|
||||
@@ -35,6 +45,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
|
||||
case "MAGIC_LINK": {
|
||||
return findOrCreateMagicLinkUser(input);
|
||||
}
|
||||
case "GOOGLE": {
|
||||
return findOrCreateGoogleUser(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +175,134 @@ export async function findOrCreateGithubUser({
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOrCreateGoogleUser({
|
||||
email,
|
||||
authenticationProfile,
|
||||
authenticationExtraParams,
|
||||
}: FindOrCreateGoogle): Promise<LoggedInUser> {
|
||||
assertEmailAllowed(email);
|
||||
|
||||
const name = authenticationProfile._json.name;
|
||||
let avatarUrl: string | undefined = undefined;
|
||||
if (authenticationProfile.photos[0]) {
|
||||
avatarUrl = authenticationProfile.photos[0].value;
|
||||
}
|
||||
const displayName = authenticationProfile.displayName;
|
||||
const authProfile = authenticationProfile
|
||||
? (authenticationProfile as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
const authExtraParams = authenticationExtraParams
|
||||
? (authenticationExtraParams as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
|
||||
const authIdentifier = `google:${authenticationProfile.id}`;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
const existingEmailUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEmailUser && !existingUser) {
|
||||
// Link existing email account to Google auth, preserving original authenticationMethod
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
avatarUrl,
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (existingEmailUser && existingUser) {
|
||||
// Check if email user and auth user are the same
|
||||
if (existingEmailUser.id !== existingUser.id) {
|
||||
// Different users: email is taken by one user, Google auth belongs to another
|
||||
logger.error(
|
||||
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
|
||||
{
|
||||
email,
|
||||
existingEmailUserId: existingEmailUser.id,
|
||||
existingAuthUserId: existingUser.id,
|
||||
authIdentifier,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
user: existingUser,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Same user: update all profile fields
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: existingUser.id,
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
|
||||
// It's not possible that the email is already taken by a different user because that would have been handled
|
||||
// by one of the if statements above.
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
update: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
create: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
name,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
authIdentifier,
|
||||
email,
|
||||
authenticationMethod: "GOOGLE",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: !existingUser,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserWithDashboardPreferences = User & {
|
||||
dashboardPreferences: DashboardPreferences;
|
||||
};
|
||||
|
||||
@@ -195,7 +195,7 @@ WHERE
|
||||
throw new Error(`Environment not found for Batch ${batch.id}`);
|
||||
}
|
||||
|
||||
const hasFinished = batch.status !== "PENDING";
|
||||
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { type BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type BatchPresenterOptions = {
|
||||
environmentId: string;
|
||||
batchId: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type BatchPresenterData = Awaited<ReturnType<BatchPresenter["call"]>>;
|
||||
|
||||
export class BatchPresenter extends BasePresenter {
|
||||
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
|
||||
const batch = await this._replica.batchTaskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
runCount: true,
|
||||
batchVersion: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
completedAt: true,
|
||||
processingStartedAt: true,
|
||||
processingCompletedAt: true,
|
||||
successfulRunCount: true,
|
||||
failedRunCount: true,
|
||||
idempotencyKey: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
select: {
|
||||
id: true,
|
||||
index: true,
|
||||
taskIdentifier: true,
|
||||
error: true,
|
||||
errorCode: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
index: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
friendlyId: batchId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new Error("Batch not found");
|
||||
}
|
||||
|
||||
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
|
||||
const isV2 = batch.batchVersion === "runengine:v2";
|
||||
|
||||
// For v2 batches in PROCESSING state, get live progress from Redis
|
||||
// This provides real-time updates without waiting for the batch to complete
|
||||
let liveSuccessCount = batch.successfulRunCount ?? 0;
|
||||
let liveFailureCount = batch.failedRunCount ?? 0;
|
||||
|
||||
if (isV2 && batch.status === "PROCESSING") {
|
||||
const liveProgress = await engine.getBatchQueueProgress(batch.id);
|
||||
if (liveProgress) {
|
||||
liveSuccessCount = liveProgress.successCount;
|
||||
liveFailureCount = liveProgress.failureCount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
status: batch.status as BatchTaskRunStatus,
|
||||
runCount: batch.runCount,
|
||||
batchVersion: batch.batchVersion,
|
||||
isV2,
|
||||
createdAt: batch.createdAt.toISOString(),
|
||||
updatedAt: batch.updatedAt.toISOString(),
|
||||
completedAt: batch.completedAt?.toISOString(),
|
||||
processingStartedAt: batch.processingStartedAt?.toISOString(),
|
||||
processingCompletedAt: batch.processingCompletedAt?.toISOString(),
|
||||
finishedAt: batch.completedAt
|
||||
? batch.completedAt.toISOString()
|
||||
: hasFinished
|
||||
? batch.updatedAt.toISOString()
|
||||
: undefined,
|
||||
hasFinished,
|
||||
successfulRunCount: liveSuccessCount,
|
||||
failedRunCount: liveFailureCount,
|
||||
idempotencyKey: batch.idempotencyKey,
|
||||
environment: displayableEnvironment(batch.runtimeEnvironment, userId),
|
||||
errors: batch.errors.map((error) => ({
|
||||
id: error.id,
|
||||
index: error.index,
|
||||
taskIdentifier: error.taskIdentifier,
|
||||
error: error.error,
|
||||
errorCode: error.errorCode,
|
||||
createdAt: error.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BuildServerMetadata,
|
||||
DeploymentErrorData,
|
||||
ExternalBuildData,
|
||||
prepareDeploymentError,
|
||||
@@ -154,32 +155,40 @@ export class DeploymentPresenter {
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
buildServerMetadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const gitMetadata = processGitMetadata(deployment.git);
|
||||
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
const buildServerMetadata = deployment.buildServerMetadata
|
||||
? BuildServerMetadata.safeParse(deployment.buildServerMetadata)
|
||||
: undefined;
|
||||
|
||||
let s2Logs = undefined;
|
||||
if (env.S2_ENABLED === "1" && gitMetadata?.source === "trigger_github_app") {
|
||||
let eventStream = undefined;
|
||||
if (
|
||||
env.S2_ENABLED === "1" &&
|
||||
(buildServerMetadata || gitMetadata?.source === "trigger_github_app")
|
||||
) {
|
||||
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
|
||||
|
||||
if (error) {
|
||||
logger.error("Failed getting S2 access token", { error });
|
||||
} else {
|
||||
s2Logs = {
|
||||
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
||||
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
|
||||
accessToken,
|
||||
eventStream = {
|
||||
s2: {
|
||||
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
||||
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
|
||||
accessToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
s2Logs,
|
||||
eventStream,
|
||||
deployment: {
|
||||
id: deployment.id,
|
||||
shortCode: deployment.shortCode,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
|
||||
import { env } from "~/env.server";
|
||||
import { BranchTrackingConfigSchema } from "~/v3/github";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type GitHubSettingsOptions = {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export class GitHubSettingsPresenter extends BasePresenter {
|
||||
public call({ projectId, organizationId }: GitHubSettingsOptions) {
|
||||
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
|
||||
|
||||
if (!githubAppEnabled) {
|
||||
return ok({
|
||||
enabled: false,
|
||||
connectedRepository: undefined,
|
||||
installations: undefined,
|
||||
isPreviewEnvironmentEnabled: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const findConnectedGithubRepository = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
repository: {
|
||||
installation: {
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
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 = () =>
|
||||
fromPromise(
|
||||
(this._replica as 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,
|
||||
},
|
||||
take: 200,
|
||||
},
|
||||
},
|
||||
take: 20,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
|
||||
const isPreviewEnvironmentEnabled = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
projectId: projectId,
|
||||
slug: "preview",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((previewEnvironment) => previewEnvironment !== null);
|
||||
|
||||
return ResultAsync.combine([
|
||||
isPreviewEnvironmentEnabled(),
|
||||
findConnectedGithubRepository(),
|
||||
listGithubAppInstallations(),
|
||||
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
|
||||
enabled: true,
|
||||
connectedRepository: connectedGithubRepository,
|
||||
installations: githubAppInstallations,
|
||||
isPreviewEnvironmentEnabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
environmentId: run.runtimeEnvironment.id,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt,
|
||||
debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null,
|
||||
schedule: await this.resolveSchedule(run.scheduleId ?? undefined),
|
||||
queue: {
|
||||
name: run.queue,
|
||||
@@ -273,6 +274,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
spanId: run.spanId,
|
||||
isCached: !!originalRunId,
|
||||
machinePreset: machine?.name,
|
||||
taskEventStore: run.taskEventStore,
|
||||
externalTraceId,
|
||||
};
|
||||
}
|
||||
@@ -356,6 +358,8 @@ export class SpanPresenter extends BasePresenter {
|
||||
//idempotency
|
||||
idempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
//debounce
|
||||
debounce: true,
|
||||
//delayed
|
||||
delayUntil: true,
|
||||
//ttl
|
||||
@@ -497,7 +501,18 @@ export class SpanPresenter extends BasePresenter {
|
||||
duration: span.duration,
|
||||
events: span.events,
|
||||
style: span.style,
|
||||
properties: span.properties ? JSON.stringify(span.properties, null, 2) : undefined,
|
||||
properties:
|
||||
span.properties &&
|
||||
typeof span.properties === "object" &&
|
||||
Object.keys(span.properties).length > 0
|
||||
? JSON.stringify(span.properties, null, 2)
|
||||
: undefined,
|
||||
resourceProperties:
|
||||
span.resourceProperties &&
|
||||
typeof span.resourceProperties === "object" &&
|
||||
Object.keys(span.resourceProperties).length > 0
|
||||
? JSON.stringify(span.resourceProperties, null, 2)
|
||||
: undefined,
|
||||
entity: span.entity,
|
||||
metadata: span.metadata,
|
||||
triggeredRuns,
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import { ArrowRightIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { motion } from "framer-motion";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { BatchPresenter, type BatchPresenterData } from "~/presenters/v3/BatchPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import { EnvironmentParamSchema, v3BatchesPath, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
const BatchParamSchema = EnvironmentParamSchema.extend({
|
||||
batchParam: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const { organizationSlug, projectParam, envParam, batchParam } =
|
||||
BatchParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const presenter = new BatchPresenter();
|
||||
const [error, data] = await tryCatch(
|
||||
presenter.call({
|
||||
environmentId: environment.id,
|
||||
batchId: batchParam,
|
||||
userId,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return typedjson({ batch: data });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { batch } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Auto-reload when batch is still in progress
|
||||
useAutoRevalidate({
|
||||
interval: 1000,
|
||||
onFocus: true,
|
||||
disabled: batch.hasFinished,
|
||||
});
|
||||
|
||||
const showProgressMeter = batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED");
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
{/* Header */}
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
|
||||
<Header2 className={cn("truncate whitespace-nowrap")}>{batch.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{descriptionForBatchStatus(batch.status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="space-y-3">
|
||||
{/* Progress meter for v2 batches */}
|
||||
{showProgressMeter && (
|
||||
<div className="px-3 pt-3">
|
||||
<BatchProgressMeter
|
||||
successCount={batch.successfulRunCount}
|
||||
failureCount={batch.failedRunCount}
|
||||
totalCount={batch.runCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties */}
|
||||
<div className="px-3 py-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.friendlyId} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
{batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Total runs</Property.Label>
|
||||
<Property.Value>{formatNumber(batch.runCount)}</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.isV2 && (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>Successfully created</Property.Label>
|
||||
<Property.Value className="text-success">
|
||||
{formatNumber(batch.successfulRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.failedRunCount > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Failed to create</Property.Label>
|
||||
<Property.Value className="text-error">
|
||||
{formatNumber(batch.failedRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{batch.idempotencyKey && (
|
||||
<Property.Item>
|
||||
<Property.Label>Idempotency key</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.idempotencyKey} className="font-mono text-xs" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.processingStartedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing started</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingStartedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{batch.processingCompletedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing completed</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingCompletedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Finished</Property.Label>
|
||||
<Property.Value>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
{/* Errors section */}
|
||||
{batch.errors.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<Header3 className="mb-2 flex items-center gap-1.5 text-warning">
|
||||
<ExclamationTriangleIcon className="size-4" />
|
||||
Run creation errors ({batch.errors.length})
|
||||
</Header3>
|
||||
<div className="divide-y divide-grid-dimmed rounded-md border border-grid-dimmed bg-charcoal-900">
|
||||
{batch.errors.map((error) => (
|
||||
<div key={error.id} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-text-dimmed">
|
||||
Item #{error.index}
|
||||
</span>
|
||||
<span className="text-sm text-text-bright">{error.taskIdentifier}</span>
|
||||
</div>
|
||||
{error.errorCode && (
|
||||
<span className="rounded bg-charcoal-750 px-1.5 py-0.5 font-mono text-xs text-text-dimmed">
|
||||
{error.errorCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Paragraph variant="small" className="mt-1 text-error">
|
||||
{error.error}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={v3BatchRunsPath(organization, project, environment, batch)}
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BatchProgressMeterProps = {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchProgressMeterProps) {
|
||||
const processedCount = successCount + failureCount;
|
||||
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
|
||||
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Paragraph variant="small/bright">Run creation progress</Paragraph>
|
||||
<Paragraph variant="extra-small">
|
||||
{formatNumber(processedCount)}/{formatNumber(totalCount)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-charcoal-900">
|
||||
<motion.div
|
||||
className="absolute left-0 top-0 h-full bg-success"
|
||||
initial={{ width: `${successPercentage}%` }}
|
||||
animate={{ width: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 h-full bg-error"
|
||||
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-success" />
|
||||
<Paragraph variant="extra-small">{formatNumber(successCount)} created</Paragraph>
|
||||
</div>
|
||||
{failureCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-error" />
|
||||
<Paragraph variant="extra-small">{formatNumber(failureCount)} failed</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+72
-81
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowRightIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { type MetaFunction, Outlet, useNavigation, useParams, useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
@@ -12,12 +8,15 @@ import { BatchesNone } from "~/components/BlankStatePanels";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -44,13 +42,14 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type BatchList,
|
||||
type BatchListItem,
|
||||
BatchListPresenter,
|
||||
} from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { type BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, EnvironmentParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3BatchPath,
|
||||
v3BatchRunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -101,6 +100,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
export default function Page() {
|
||||
const { batches, hasFilters, hasAnyBatches, filters, pagination } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { batchParam } = useParams();
|
||||
const isShowingInspector = batchParam !== undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -123,22 +124,34 @@ export default function Page() {
|
||||
<BatchesNone />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="batches-main" min={"100px"}>
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
hasAnyBatches={hasAnyBatches}
|
||||
/>
|
||||
</div>
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
hasAnyBatches={hasAnyBatches}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{isShowingInspector && (
|
||||
<>
|
||||
<ResizableHandle id="batches-handle" />
|
||||
<ResizablePanel id="batches-inspector" min="100px" default="500px">
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
@@ -147,10 +160,14 @@ export default function Page() {
|
||||
|
||||
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const location = useLocation();
|
||||
const isLoading =
|
||||
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { batchParam } = useParams();
|
||||
|
||||
return (
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
@@ -195,15 +212,19 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
batches.map((batch, index) => {
|
||||
const path = v3BatchRunsPath(organization, project, environment, batch);
|
||||
batches.map((batch) => {
|
||||
const basePath = v3BatchPath(organization, project, environment, batch);
|
||||
const inspectorPath = `${basePath}${location.search}`;
|
||||
const runsPath = v3BatchRunsPath(organization, project, environment, batch);
|
||||
const isSelected = batchParam === batch.friendlyId;
|
||||
|
||||
return (
|
||||
<TableRow key={batch.id}>
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<TableRow key={batch.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
|
||||
<TableCell to={inspectorPath} isTabbableCell>
|
||||
{batch.friendlyId}
|
||||
</TableCell>
|
||||
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.batchVersion === "v1" ? (
|
||||
<SimpleTooltip
|
||||
content="Upgrade to the latest SDK for batch statuses to appear."
|
||||
@@ -223,8 +244,12 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{batch.runCount}</TableCell>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||||
<TableCell to={inspectorPath}>{batch.runCount}</TableCell>
|
||||
<TableCell
|
||||
to={inspectorPath}
|
||||
className="w-[1%]"
|
||||
actionClassName="pr-0 tabular-nums"
|
||||
>
|
||||
{batch.finishedAt ? (
|
||||
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
|
||||
style: "short",
|
||||
@@ -233,13 +258,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
<LiveTimer startTime={new Date(batch.createdAt)} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<BatchActionsCell batch={batch} path={path} />
|
||||
<BatchActionsCell runsPath={runsPath} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
@@ -257,48 +282,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
);
|
||||
}
|
||||
|
||||
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
|
||||
const location = useLocation();
|
||||
|
||||
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
}
|
||||
|
||||
function BatchActionsCell({ runsPath }: { runsPath: string }) {
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="View batch"
|
||||
/>
|
||||
{!batch.hasFinished && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowPathRoundedSquareIcon}
|
||||
leadingIconClassName="text-success"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="w-full px-1.5 py-[0.9rem]"
|
||||
>
|
||||
Try and resume
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CheckBatchCompletionDialog
|
||||
batchId={batch.id}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
hiddenButtons={
|
||||
<LinkButton to={runsPath} variant="minimal/small" LeadingIcon={ArrowRightIcon}>
|
||||
View runs
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+49
-26
@@ -40,6 +40,7 @@ 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";
|
||||
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -48,7 +49,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
try {
|
||||
const presenter = new DeploymentPresenter();
|
||||
const { deployment, s2Logs } = await presenter.call({
|
||||
const { deployment, eventStream } = await presenter.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
@@ -56,7 +57,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
deploymentShortCode: deploymentParam,
|
||||
});
|
||||
|
||||
return typedjson({ deployment, s2Logs });
|
||||
return typedjson({ deployment, eventStream });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
@@ -69,18 +70,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
type LogEntry = {
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
level: "info" | "error" | "warn";
|
||||
level: "info" | "error" | "warn" | "debug";
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { deployment, s2Logs } = useTypedLoaderData<typeof loader>();
|
||||
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const location = useLocation();
|
||||
const page = new URLSearchParams(location.search).get("page");
|
||||
|
||||
const logsDisabled = s2Logs === undefined;
|
||||
const logsDisabled = eventStream === undefined;
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(true);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
@@ -97,9 +98,9 @@ export default function Page() {
|
||||
|
||||
const streamLogs = async () => {
|
||||
try {
|
||||
const s2 = new S2({ accessToken: s2Logs.accessToken });
|
||||
const basin = s2.basin(s2Logs.basin);
|
||||
const stream = basin.stream(s2Logs.stream);
|
||||
const s2 = new S2({ accessToken: eventStream.s2.accessToken });
|
||||
const basin = s2.basin(eventStream.s2.basin);
|
||||
const stream = basin.stream(eventStream.s2.stream);
|
||||
|
||||
const readSession = await stream.readSession(
|
||||
{
|
||||
@@ -113,27 +114,49 @@ export default function Page() {
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
for await (const record of readSession) {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const decoded = decoder.decode(record.body);
|
||||
const result = DeploymentEventFromString.safeParse(decoded);
|
||||
|
||||
if (record.headers) {
|
||||
for (const [nameBytes, valueBytes] of record.headers) {
|
||||
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
|
||||
if (!result.success) {
|
||||
// fallback to the previous format in s2 logs for compatibility
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (record.headers) {
|
||||
for (const [nameBytes, valueBytes] of record.headers) {
|
||||
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
|
||||
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
|
||||
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: decoder.decode(record.body),
|
||||
level,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse log record:", err);
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: decoded,
|
||||
level,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse log record:", err);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = result.data;
|
||||
if (event.type !== "log") {
|
||||
continue;
|
||||
}
|
||||
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: event.data.message,
|
||||
level: event.data.level,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) return;
|
||||
@@ -158,7 +181,7 @@ export default function Page() {
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [s2Logs?.basin, s2Logs?.stream, s2Logs?.accessToken, isPending]);
|
||||
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
|
||||
+2
-2
@@ -359,11 +359,11 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNoneDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNone />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
|
||||
+289
-21
@@ -2,6 +2,7 @@ import {
|
||||
ArrowUturnLeftIcon,
|
||||
BoltSlashIcon,
|
||||
BookOpenIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -68,7 +69,6 @@ import {
|
||||
eventBorderClassName,
|
||||
} from "~/components/runs/v3/SpanTitle";
|
||||
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { env } from "~/env.server";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3RunParamsSchema,
|
||||
v3RunPath,
|
||||
v3RunRedirectPath,
|
||||
v3RunSpanPath,
|
||||
v3RunStreamingPath,
|
||||
@@ -98,6 +99,13 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const resizableSettings = {
|
||||
parent: {
|
||||
@@ -131,6 +139,103 @@ const resizableSettings = {
|
||||
|
||||
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
|
||||
|
||||
type RunsListNavigation = {
|
||||
runs: Array<{ friendlyId: string; spanId: string }>;
|
||||
pagination: { next?: string; previous?: string };
|
||||
prevPageLastRun?: { friendlyId: string; spanId: string; cursor: string };
|
||||
nextPageFirstRun?: { friendlyId: string; spanId: string; cursor: string };
|
||||
};
|
||||
|
||||
async function getRunsListFromTableState({
|
||||
tableStateParam,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
runParam,
|
||||
userId,
|
||||
}: {
|
||||
tableStateParam: string | null;
|
||||
organizationSlug: string;
|
||||
projectParam: string;
|
||||
envParam: string;
|
||||
runParam: string;
|
||||
userId: string;
|
||||
}): Promise<RunsListNavigation | null> {
|
||||
if (!tableStateParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam));
|
||||
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId);
|
||||
|
||||
if (!project || !environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
|
||||
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
pageSize: 25, // Load enough runs to provide navigation context
|
||||
});
|
||||
|
||||
const runsList: RunsListNavigation = {
|
||||
runs: currentPageResult.runs,
|
||||
pagination: currentPageResult.pagination,
|
||||
};
|
||||
|
||||
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
|
||||
|
||||
if (currentRunIndex === 0 && currentPageResult.pagination.previous) {
|
||||
const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
cursor: currentPageResult.pagination.previous,
|
||||
direction: "backward",
|
||||
pageSize: 1, // We only need the last run from the previous page
|
||||
});
|
||||
|
||||
if (prevPageResult.runs.length > 0) {
|
||||
runsList.prevPageLastRun = {
|
||||
friendlyId: prevPageResult.runs[0].friendlyId,
|
||||
spanId: prevPageResult.runs[0].spanId,
|
||||
cursor: currentPageResult.pagination.previous,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) {
|
||||
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
cursor: currentPageResult.pagination.next,
|
||||
direction: "forward",
|
||||
pageSize: 1, // We only need the first run from the next page
|
||||
});
|
||||
|
||||
if (nextPageResult.runs.length > 0) {
|
||||
runsList.nextPageFirstRun = {
|
||||
friendlyId: nextPageResult.runs[0].friendlyId,
|
||||
spanId: nextPageResult.runs[0].spanId,
|
||||
cursor: currentPageResult.pagination.next,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return runsList;
|
||||
} catch (error) {
|
||||
logger.error("Error loading runs list from tableState:", { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
@@ -169,6 +274,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
|
||||
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
|
||||
|
||||
const runsList = await getRunsListFromTableState({
|
||||
tableStateParam: url.searchParams.get("tableState"),
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
runParam,
|
||||
userId,
|
||||
});
|
||||
|
||||
return json({
|
||||
run: result.run,
|
||||
trace: result.trace,
|
||||
@@ -177,13 +291,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
parent,
|
||||
tree,
|
||||
},
|
||||
runsList,
|
||||
});
|
||||
};
|
||||
|
||||
type LoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export default function Page() {
|
||||
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
|
||||
const { run, trace, maximumLiveReloadingSetting, runsList } = useLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -191,16 +306,30 @@ export default function Page() {
|
||||
logCount: trace?.events.length ?? 0,
|
||||
isCompleted: run.completedAt !== null,
|
||||
});
|
||||
const { value } = useSearchParams();
|
||||
const tableState = decodeURIComponent(value("tableState") ?? "");
|
||||
const tableStateSearchParams = new URLSearchParams(tableState);
|
||||
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
|
||||
const tabParam = value("tab") ?? undefined;
|
||||
const spanParam = value("span") ?? undefined;
|
||||
|
||||
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList, tabParam, useSpan: !!spanParam});
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{
|
||||
to: v3RunsPath(organization, project, environment),
|
||||
to: v3RunsPath(organization, project, environment, filters),
|
||||
text: "Runs",
|
||||
}}
|
||||
title={<CopyableText value={run.friendlyId} />}
|
||||
title={<>
|
||||
<CopyableText value={run.friendlyId} variant="text-below" className="font-mono px-0 py-0 pb-[2px]"/>
|
||||
{tableState && (<div className="flex">
|
||||
<PreviousRunButton to={previousRunPath} />
|
||||
<NextRunButton to={nextRunPath} />
|
||||
</div>)}
|
||||
</>}
|
||||
/>
|
||||
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
|
||||
<PageAccessories>
|
||||
@@ -276,14 +405,10 @@ export default function Page() {
|
||||
run={run}
|
||||
trace={trace}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
resizable={resizable}
|
||||
/>
|
||||
) : (
|
||||
<NoLogsView
|
||||
run={run}
|
||||
trace={trace}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
resizable={resizable}
|
||||
/>
|
||||
)}
|
||||
</PageBody>
|
||||
@@ -291,7 +416,7 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
|
||||
function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -385,7 +510,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
);
|
||||
}
|
||||
|
||||
function NoLogsView({ run, resizable }: LoaderData) {
|
||||
function NoLogsView({ run }: Pick<LoaderData, "run">) {
|
||||
const plan = useCurrentPlan();
|
||||
const organization = useOrganization();
|
||||
|
||||
@@ -819,7 +944,6 @@ function TimelineView({
|
||||
scale,
|
||||
rootSpanStatus,
|
||||
rootStartedAt,
|
||||
parentRef,
|
||||
timelineScrollRef,
|
||||
virtualizer,
|
||||
events,
|
||||
@@ -835,6 +959,7 @@ function TimelineView({
|
||||
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
|
||||
const minTimelineWidth = initialTimelineDimensions?.width ?? 300;
|
||||
const maxTimelineWidth = minTimelineWidth * 10;
|
||||
const disableSpansAnimations = rootSpanStatus !== "executing";
|
||||
|
||||
//we want to live-update the duration if the root span is still executing
|
||||
const [duration, setDuration] = useState(queueAdjustedNs(totalDuration, queuedDuration));
|
||||
@@ -1006,7 +1131,8 @@ function TimelineView({
|
||||
"-ml-[0.5px] h-[0.5625rem] w-px rounded-none",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={`${node.id}-${event.name}`}
|
||||
layoutId={disableSpansAnimations ? undefined : `${node.id}-${event.name}`}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1024,7 +1150,8 @@ function TimelineView({
|
||||
"-ml-[0.1562rem] size-[0.3125rem] rounded-full border bg-background-bright",
|
||||
eventBorderClassName(node.data)
|
||||
)}
|
||||
layoutId={`${node.id}-${event.name}`}
|
||||
layoutId={disableSpansAnimations ? undefined : `${node.id}-${event.name}`}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1043,7 +1170,8 @@ function TimelineView({
|
||||
>
|
||||
<motion.div
|
||||
className={cn("h-px w-full", eventBackgroundClassName(node.data))}
|
||||
layoutId={`mark-${node.id}`}
|
||||
layoutId={disableSpansAnimations ? undefined : `mark-${node.id}`}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
</Timeline.Span>
|
||||
) : null}
|
||||
@@ -1066,6 +1194,7 @@ function TimelineView({
|
||||
}
|
||||
node={node}
|
||||
fadeLeft={isTopSpan && queuedDuration !== undefined}
|
||||
disableAnimations={disableSpansAnimations}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -1080,7 +1209,8 @@ function TimelineView({
|
||||
"-ml-0.5 size-3 rounded-full border-2 border-background-bright",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={node.id}
|
||||
layoutId={disableSpansAnimations ? undefined : node.id}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1312,8 +1442,9 @@ function SpanWithDuration({
|
||||
showDuration,
|
||||
node,
|
||||
fadeLeft,
|
||||
disableAnimations,
|
||||
...props
|
||||
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean }) {
|
||||
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean; disableAnimations?: boolean }) {
|
||||
return (
|
||||
<Timeline.Span {...props}>
|
||||
<motion.div
|
||||
@@ -1323,7 +1454,8 @@ function SpanWithDuration({
|
||||
fadeLeft ? "rounded-r-sm bg-gradient-to-r from-black/50 to-transparent" : "rounded-sm"
|
||||
)}
|
||||
style={{ backgroundSize: "20px 100%", backgroundRepeat: "no-repeat" }}
|
||||
layoutId={node.id}
|
||||
layoutId={disableAnimations ? undefined : node.id}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
{node.data.isPartial && (
|
||||
<div
|
||||
@@ -1336,10 +1468,12 @@ function SpanWithDuration({
|
||||
"sticky left-0 z-10 transition-opacity group-hover:opacity-100",
|
||||
!showDuration && "opacity-0"
|
||||
)}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
<motion.div
|
||||
className="whitespace-nowrap rounded-sm px-1 py-0.5 text-xxs text-text-bright text-shadow-custom"
|
||||
layout="position"
|
||||
layout={disableAnimations ? undefined : "position"}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
{formatDurationMilliseconds(props.durationMs, {
|
||||
style: "short",
|
||||
@@ -1422,16 +1556,16 @@ function KeyboardShortcuts({
|
||||
expandAllBelowDepth,
|
||||
collapseAllBelowDepth,
|
||||
toggleExpandLevel,
|
||||
setShowDurations,
|
||||
}: {
|
||||
expandAllBelowDepth: (depth: number) => void;
|
||||
collapseAllBelowDepth: (depth: number) => void;
|
||||
toggleExpandLevel: (depth: number) => void;
|
||||
setShowDurations: (show: (show: boolean) => boolean) => void;
|
||||
setShowDurations?: (show: (show: boolean) => boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ArrowKeyShortcuts />
|
||||
<AdjacentRunsShortcuts />
|
||||
<ShortcutWithAction
|
||||
shortcut={{ key: "e" }}
|
||||
action={() => expandAllBelowDepth(0)}
|
||||
@@ -1448,6 +1582,16 @@ function KeyboardShortcuts({
|
||||
);
|
||||
}
|
||||
|
||||
function AdjacentRunsShortcuts() {
|
||||
return (<div className="flex items-center gap-0.5">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
|
||||
Adjacent runs
|
||||
</Paragraph>
|
||||
</div>);
|
||||
}
|
||||
|
||||
function ArrowKeyShortcuts() {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
@@ -1494,7 +1638,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className={cn(variants.medium, "ml-0 mr-0")}>0</span>
|
||||
<span className="text-[0.75rem] text-text-dimmed">–</span>
|
||||
<span className="text-[0.65rem] text-text-dimmed">–</span>
|
||||
<span className={cn(variants.medium, "ml-0 mr-0")}>9</span>
|
||||
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
|
||||
Toggle level
|
||||
@@ -1526,3 +1670,127 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useAdjacentRunPaths({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
tableState,
|
||||
run,
|
||||
runsList,
|
||||
tabParam,
|
||||
useSpan
|
||||
}: {
|
||||
organization: { slug: string };
|
||||
project: { slug: string };
|
||||
environment: { slug: string };
|
||||
tableState: string;
|
||||
run: { friendlyId: string, spanId: string };
|
||||
runsList: RunsListNavigation | null;
|
||||
tabParam?: string;
|
||||
useSpan?: boolean;
|
||||
}): [string | null, string | null] {
|
||||
if (!runsList || runsList.runs.length === 0) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
// Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list
|
||||
let previousRun: { friendlyId: string; spanId: string } | null = null;
|
||||
const previousRunTableState = new URLSearchParams(tableState);
|
||||
if (currentIndex > 0) {
|
||||
previousRun = runsList.runs[currentIndex - 1];
|
||||
} else if (runsList.prevPageLastRun) {
|
||||
previousRun = runsList.prevPageLastRun;
|
||||
// Update tableState with the new cursor for the previous page
|
||||
previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor);
|
||||
previousRunTableState.set("direction", "backward");
|
||||
}
|
||||
|
||||
// Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list
|
||||
let nextRun: { friendlyId: string; spanId: string } | null = null;
|
||||
const nextRunTableState = new URLSearchParams(tableState);
|
||||
if (currentIndex < runsList.runs.length - 1) {
|
||||
nextRun = runsList.runs[currentIndex + 1];
|
||||
} else if (runsList.nextPageFirstRun) {
|
||||
nextRun = runsList.nextPageFirstRun;
|
||||
// Update tableState with the new cursor for the next page
|
||||
nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor);
|
||||
nextRunTableState.set("direction", "forward");
|
||||
}
|
||||
|
||||
const previousURLSearchParams = new URLSearchParams();
|
||||
previousURLSearchParams.set("tableState", previousRunTableState.toString());
|
||||
if (previousRun && useSpan) {
|
||||
previousURLSearchParams.set("span", previousRun.spanId);
|
||||
}
|
||||
if (tabParam && useSpan) {
|
||||
previousURLSearchParams.set("tab", tabParam);
|
||||
}
|
||||
const previousRunPath = previousRun
|
||||
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
|
||||
: null;
|
||||
|
||||
const nextURLSearchParams = new URLSearchParams();
|
||||
nextURLSearchParams.set("tableState", nextRunTableState.toString());
|
||||
if (nextRun && useSpan) {
|
||||
nextURLSearchParams.set("span", nextRun.spanId);
|
||||
}
|
||||
if (tabParam && useSpan) {
|
||||
nextURLSearchParams.set("tab", tabParam);
|
||||
}
|
||||
const nextRunPath = nextRun
|
||||
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
|
||||
: null;
|
||||
|
||||
return [previousRunPath, nextRunPath];
|
||||
}
|
||||
|
||||
|
||||
function PreviousRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : '#'}
|
||||
variant={"minimal/small"}
|
||||
LeadingIcon={ChevronUpIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
|
||||
!to && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "[" }}
|
||||
tooltip="Previous Run"
|
||||
disabled={!to}
|
||||
replace
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : '#'}
|
||||
variant={"minimal/small"}
|
||||
TrailingIcon={ChevronDownIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
|
||||
!to && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "]" }}
|
||||
tooltip="Next Run"
|
||||
disabled={!to}
|
||||
replace
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -55,6 +55,7 @@ import {
|
||||
v3CreateBulkActionPath,
|
||||
v3ProjectPath,
|
||||
v3TestPath,
|
||||
v3TestTaskPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
|
||||
@@ -235,7 +236,13 @@ function RunsList({
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
<RunTaskInstructions
|
||||
task={
|
||||
list.filters.tasks.length === 1
|
||||
? list.possibleTasks.find((t) => t.slug === list.filters.tasks[0])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className={cn("grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden")}>
|
||||
@@ -291,6 +298,7 @@ function RunsList({
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -339,7 +347,7 @@ function CreateFirstTaskInstructions() {
|
||||
);
|
||||
}
|
||||
|
||||
function RunTaskInstructions() {
|
||||
function RunTaskInstructions({ task }: { task?: { slug: string } }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -352,7 +360,11 @@ function RunTaskInstructions() {
|
||||
Perform a test run with a payload directly from the dashboard.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={
|
||||
task
|
||||
? v3TestTaskPath(organization, project, environment, { taskIdentifier: task.slug })
|
||||
: v3TestPath(organization, project, environment)
|
||||
}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-lime-500"
|
||||
|
||||
+1
@@ -318,6 +318,7 @@ export default function Page() {
|
||||
runs={schedule.runs}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
|
||||
+14
-666
@@ -1,35 +1,18 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
TrashIcon,
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
type MetaFunction,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "@remix-run/react";
|
||||
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
@@ -55,32 +38,12 @@ import {
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
organizationPath,
|
||||
v3ProjectPath,
|
||||
githubAppInstallPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectSettingsPath,
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { type BranchTrackingConfig } from "~/v3/github";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { GitBranchIcon } from "lucide-react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -128,29 +91,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
githubAppInstallations: gitHubApp.installations,
|
||||
connectedGithubRepository: gitHubApp.connectedRepository,
|
||||
isPreviewEnvironmentEnabled: gitHubApp.isPreviewEnvironmentEnabled,
|
||||
buildSettings,
|
||||
});
|
||||
};
|
||||
|
||||
const ConnectGitHubRepoFormSchema = z.object({
|
||||
action: z.literal("connect-repo"),
|
||||
installationId: z.string(),
|
||||
repositoryId: z.string(),
|
||||
});
|
||||
|
||||
const UpdateGitSettingsFormSchema = z.object({
|
||||
action: z.literal("update-git-settings"),
|
||||
productionBranch: z.string().trim().optional(),
|
||||
stagingBranch: z.string().trim().optional(),
|
||||
previewDeploymentsEnabled: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
const UpdateBuildSettingsFormSchema = z.object({
|
||||
action: z.literal("update-build-settings"),
|
||||
triggerConfigFilePath: z
|
||||
@@ -220,12 +164,7 @@ export function createSchema(
|
||||
}
|
||||
}),
|
||||
}),
|
||||
ConnectGitHubRepoFormSchema,
|
||||
UpdateGitSettingsFormSchema,
|
||||
UpdateBuildSettingsFormSchema,
|
||||
z.object({
|
||||
action: z.literal("disconnect-repo"),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -260,7 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId, organizationId } = membershipResultOrFail.value;
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
@@ -316,101 +255,6 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
"Project deleted"
|
||||
);
|
||||
}
|
||||
case "disconnect-repo": {
|
||||
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to disconnect GitHub repository", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully");
|
||||
}
|
||||
case "update-git-settings": {
|
||||
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateGitSettings(
|
||||
projectId,
|
||||
productionBranch,
|
||||
stagingBranch,
|
||||
previewDeploymentsEnabled
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "github_app_not_enabled": {
|
||||
return redirectBackWithErrorMessage(request, "GitHub app is not enabled");
|
||||
}
|
||||
case "connected_gh_repository_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Connected GitHub repository not found");
|
||||
}
|
||||
case "production_tracking_branch_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Production tracking branch not found");
|
||||
}
|
||||
case "staging_tracking_branch_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Staging tracking branch not found");
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to update Git settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to update Git settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
|
||||
}
|
||||
case "connect-repo": {
|
||||
const { repositoryId, installationId } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.connectGitHubRepo(
|
||||
projectId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
installationId
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "gh_repository_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "GitHub repository not found");
|
||||
}
|
||||
case "project_already_has_connected_repository": {
|
||||
return redirectBackWithErrorMessage(
|
||||
request,
|
||||
"Project already has a connected repository"
|
||||
);
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to connect GitHub repository", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
...submission,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
case "update-build-settings": {
|
||||
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
|
||||
submission.value;
|
||||
@@ -446,13 +290,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
githubAppInstallations,
|
||||
connectedGithubRepository,
|
||||
githubAppEnabled,
|
||||
buildSettings,
|
||||
isPreviewEnvironmentEnabled,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
@@ -578,19 +416,12 @@ export default function Page() {
|
||||
<div>
|
||||
<Header2 spacing>Git settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
{connectedGithubRepository ? (
|
||||
<ConnectedGitHubRepoForm
|
||||
connectedGitHubRepo={connectedGithubRepository}
|
||||
previewEnvironmentEnabled={isPreviewEnvironmentEnabled}
|
||||
/>
|
||||
) : (
|
||||
<GitHubConnectionPrompt
|
||||
gitHubAppInstallations={githubAppInstallations ?? []}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
/>
|
||||
)}
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -650,489 +481,6 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
type GitHubRepository = {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
htmlUrl: string;
|
||||
};
|
||||
|
||||
type GitHubAppInstallation = {
|
||||
id: string;
|
||||
appInstallationId: bigint;
|
||||
targetType: string;
|
||||
accountHandle: string;
|
||||
repositories: GitHubRepository[];
|
||||
};
|
||||
|
||||
function ConnectGitHubRepoModal({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
open?: boolean;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<
|
||||
GitHubAppInstallation | undefined
|
||||
>(gitHubAppInstallations.at(0));
|
||||
|
||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isConnectRepositoryLoading =
|
||||
navigation.formData?.get("action") === "connect-repo" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [form, { installationId, repositoryId }] = useForm({
|
||||
id: "connect-repo",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: ConnectGitHubRepoFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (params.get("openGithubRepoModal") === "1") {
|
||||
setIsModalOpen(true);
|
||||
params.delete("openGithubRepoModal");
|
||||
setSearchParams(params);
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [lastSubmission]);
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
|
||||
Connect GitHub repo
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Connect GitHub repository</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<Form method="post" {...form.props} className="w-full">
|
||||
<Paragraph className="mb-3">
|
||||
Choose a GitHub repository to connect to your project.
|
||||
</Paragraph>
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={installationId.id}>Account</Label>
|
||||
<Select
|
||||
name={installationId.name}
|
||||
id={installationId.id}
|
||||
value={selectedInstallation?.id}
|
||||
defaultValue={gitHubAppInstallations.at(0)?.id}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const installation = gitHubAppInstallations.find((i) => i.id === value);
|
||||
setSelectedInstallation(installation);
|
||||
setSelectedRepository(undefined);
|
||||
}}
|
||||
items={gitHubAppInstallations}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select account"
|
||||
dropdownIcon
|
||||
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
|
||||
>
|
||||
{[
|
||||
...gitHubAppInstallations.map((installation) => (
|
||||
<SelectItem
|
||||
key={installation.id}
|
||||
value={installation.id}
|
||||
icon={<OctoKitty className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
{installation.accountHandle}
|
||||
</SelectItem>
|
||||
)),
|
||||
<SelectItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(
|
||||
githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)}?openGithubRepoModal=1`
|
||||
)
|
||||
);
|
||||
}}
|
||||
key="new-account"
|
||||
icon={<PlusIcon className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
Add account
|
||||
</SelectItem>,
|
||||
]}
|
||||
</Select>
|
||||
<FormError id={installationId.errorId}>{installationId.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={repositoryId.id}>Repository</Label>
|
||||
<Select
|
||||
name={repositoryId.name}
|
||||
id={repositoryId.id}
|
||||
value={selectedRepository ? selectedRepository.id : undefined}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const repository = selectedInstallation?.repositories.find(
|
||||
(r) => r.id === value
|
||||
);
|
||||
setSelectedRepository(repository);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select repository"
|
||||
heading="Filter repositories"
|
||||
dropdownIcon
|
||||
items={selectedInstallation?.repositories ?? []}
|
||||
filter={{ keys: ["name"] }}
|
||||
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
|
||||
text={selectedRepository ? selectedRepository.name : null}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<div className="flex items-center gap-1">
|
||||
{repo.name}
|
||||
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<Hint className={cn("invisible", selectedInstallation && "visible")}>
|
||||
Configure repository access in{" "}
|
||||
<TextLink
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
|
||||
>
|
||||
GitHub
|
||||
</TextLink>
|
||||
.
|
||||
</Hint>
|
||||
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="connect-repo"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isConnectRepositoryLoading}
|
||||
>
|
||||
Connect repository
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubConnectionPrompt({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
{gitHubAppInstallations.length === 0 && (
|
||||
<LinkButton
|
||||
to={githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)}?openGithubRepoModal=1`
|
||||
)}
|
||||
variant={"secondary/medium"}
|
||||
LeadingIcon={OctoKitty}
|
||||
>
|
||||
Install GitHub app
|
||||
</LinkButton>
|
||||
)}
|
||||
{gitHubAppInstallations.length !== 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<ConnectGitHubRepoModal
|
||||
gitHubAppInstallations={gitHubAppInstallations}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
/>
|
||||
<span className="flex items-center gap-1 text-xs text-text-dimmed">
|
||||
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
type ConnectedGitHubRepo = {
|
||||
branchTracking: BranchTrackingConfig | undefined;
|
||||
previewDeploymentsEnabled: boolean;
|
||||
createdAt: Date;
|
||||
repository: GitHubRepository;
|
||||
};
|
||||
|
||||
function ConnectedGitHubRepoForm({
|
||||
connectedGitHubRepo,
|
||||
previewEnvironmentEnabled,
|
||||
}: {
|
||||
connectedGitHubRepo: ConnectedGitHubRepo;
|
||||
previewEnvironmentEnabled?: boolean;
|
||||
}) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
const organization = useOrganization();
|
||||
|
||||
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
|
||||
const [gitSettingsValues, setGitSettingsValues] = useState({
|
||||
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
|
||||
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
|
||||
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
gitSettingsValues.productionBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
|
||||
gitSettingsValues.stagingBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
|
||||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
|
||||
setHasGitSettingsChanges(hasChanges);
|
||||
}, [gitSettingsValues, connectedGitHubRepo]);
|
||||
|
||||
const [gitSettingsForm, fields] = useForm({
|
||||
id: "update-git-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateGitSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isGitSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-git-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<OctoKitty className="size-4" />
|
||||
<a
|
||||
href={connectedGitHubRepo.repository.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="max-w-52 truncate text-sm text-text-bright hover:underline"
|
||||
>
|
||||
{connectedGitHubRepo.repository.fullName}
|
||||
</a>
|
||||
{connectedGitHubRepo.repository.private && (
|
||||
<LockClosedIcon className="size-3 text-text-dimmed" />
|
||||
)}
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime
|
||||
date={connectedGitHubRepo.createdAt}
|
||||
includeTime={false}
|
||||
includeSeconds={false}
|
||||
showTimezone={false}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="minimal/small">Disconnect</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Disconnect GitHub repository</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph className="mb-1">
|
||||
Are you sure you want to disconnect{" "}
|
||||
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
|
||||
This will stop automatic deployments from GitHub.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post">
|
||||
<input type="hidden" name="action" value="disconnect-repo" />
|
||||
<Button type="submit" variant="danger/medium">
|
||||
Disconnect repository
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Form method="post" {...gitSettingsForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Hint>
|
||||
Every push to the selected tracking branch creates a deployment in the corresponding
|
||||
environment.
|
||||
</Hint>
|
||||
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
|
||||
{environmentFullTitle({ type: "PRODUCTION" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.productionBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
productionBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
|
||||
{environmentFullTitle({ type: "STAGING" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.stagingBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
stagingBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
|
||||
{environmentFullTitle({ type: "PREVIEW" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
name="previewDeploymentsEnabled"
|
||||
disabled={!previewEnvironmentEnabled}
|
||||
defaultChecked={
|
||||
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
|
||||
}
|
||||
variant="small"
|
||||
label="Create preview deployments for pull requests"
|
||||
labelPosition="right"
|
||||
onCheckedChange={(checked) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
previewDeploymentsEnabled: checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!previewEnvironmentEnabled && (
|
||||
<InfoIconTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
<TextLink to={v3BillingPath(organization)}>Upgrade</TextLink> your plan to
|
||||
enable preview branches
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FormError>{fields.productionBranch?.error}</FormError>
|
||||
<FormError>{fields.stagingBranch?.error}</FormError>
|
||||
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
|
||||
<FormError>{gitSettingsForm.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-git-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
|
||||
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
@@ -1248,7 +596,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
|
||||
/>
|
||||
<Hint>
|
||||
Native build server builds do not rely on external build providers and will become the
|
||||
default in the future. Version 4.1.0 or newer is required.
|
||||
default in the future. Version 4.2.0 or newer is required.
|
||||
</Hint>
|
||||
<FormError id={fields.useNativeBuildServer.errorId}>
|
||||
{fields.useNativeBuildServer.error}
|
||||
|
||||
+1
@@ -126,6 +126,7 @@ export default function Page() {
|
||||
runs={waitpoint.connectedRuns}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
type CreateArtifactResponseBody,
|
||||
CreateArtifactRequestBody,
|
||||
tryCatch,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { authenticateRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ArtifactsService } from "~/v3/services/artifacts.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return json({ error: "Method Not Allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
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 [, rawBody] = await tryCatch(request.json());
|
||||
const body = CreateArtifactRequestBody.safeParse(rawBody ?? {});
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { environment: authenticatedEnv } = authenticationResult.result;
|
||||
|
||||
const service = new ArtifactsService();
|
||||
return await service
|
||||
.createArtifact(body.data.type, authenticatedEnv, body.data.contentLength)
|
||||
.match(
|
||||
(result) => {
|
||||
return json(
|
||||
{
|
||||
artifactKey: result.artifactKey,
|
||||
uploadUrl: result.uploadUrl,
|
||||
uploadFields: result.uploadFields,
|
||||
expiresAt: result.expiresAt.toISOString(),
|
||||
} satisfies CreateArtifactResponseBody,
|
||||
{ status: 201 }
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
switch (error.type) {
|
||||
case "artifact_size_exceeds_limit": {
|
||||
logger.warn("Artifact size exceeds limit", { error });
|
||||
const sizeMB = parseFloat((error.contentLength / (1024 * 1024)).toFixed(1));
|
||||
const limitMB = parseFloat((error.sizeLimit / (1024 * 1024)).toFixed(1));
|
||||
|
||||
let errorMessage;
|
||||
|
||||
switch (body.data.type) {
|
||||
case "deployment_context":
|
||||
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Make sure you are in the correct directory of your Trigger.dev project. Reach out to us if you are seeing this error consistently.`;
|
||||
break;
|
||||
default:
|
||||
body.data.type satisfies never;
|
||||
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB`;
|
||||
}
|
||||
return json(
|
||||
{
|
||||
error: errorMessage,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
case "failed_to_create_presigned_post": {
|
||||
logger.error("Failed to create presigned POST", { error });
|
||||
return json({ error: "Failed to generate artifact upload URL" }, { status: 500 });
|
||||
}
|
||||
case "artifacts_bucket_not_configured": {
|
||||
logger.error("Artifacts bucket not configured", { error });
|
||||
return json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
default: {
|
||||
error satisfies never;
|
||||
logger.error("Failed creating artifact", { error });
|
||||
return json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
errors: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
@@ -35,6 +38,18 @@ export const loader = createLoaderApiRoute(
|
||||
updatedAt: batch.updatedAt,
|
||||
runCount: batch.runCount,
|
||||
runs: batch.runIds,
|
||||
// Include error details for PARTIAL_FAILED batches
|
||||
successfulRunCount: batch.successfulRunCount ?? undefined,
|
||||
failedRunCount: batch.failedRunCount ?? undefined,
|
||||
errors:
|
||||
batch.errors.length > 0
|
||||
? batch.errors.map((err) => ({
|
||||
index: err.index,
|
||||
taskIdentifier: err.taskIdentifier,
|
||||
error: err.error,
|
||||
errorCode: err.errorCode ?? undefined,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const service = new InitializeDeploymentService();
|
||||
|
||||
try {
|
||||
const { deployment, imageRef } = await service.call(authenticatedEnv, body.data);
|
||||
const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data);
|
||||
|
||||
const responseBody: InitializeDeploymentResponseBody = {
|
||||
id: deployment.friendlyId,
|
||||
@@ -48,6 +48,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
|
||||
imageTag: imageRef,
|
||||
imagePlatform: deployment.imagePlatform,
|
||||
eventStream,
|
||||
};
|
||||
|
||||
return json(responseBody, { status: 200 });
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
taskIdentifier: z.string().min(1, "Task identifier is required"),
|
||||
});
|
||||
|
||||
export const { action } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
body: BodySchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "write",
|
||||
resource: () => ({}),
|
||||
superScopes: ["write:runs", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, body, authentication }) => {
|
||||
const service = new ResetIdempotencyKeyService();
|
||||
|
||||
try {
|
||||
const result = await service.call(
|
||||
params.key,
|
||||
body.taskIdentifier,
|
||||
authentication.environment
|
||||
);
|
||||
return json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: error.status ?? 400 });
|
||||
}
|
||||
|
||||
logger.error("Failed to reset idempotency key via API", {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error),
|
||||
});
|
||||
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
errors: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
@@ -33,8 +36,21 @@ export const loader = createLoaderApiRoute(
|
||||
idempotencyKey: batch.idempotencyKey ?? undefined,
|
||||
createdAt: batch.createdAt,
|
||||
updatedAt: batch.updatedAt,
|
||||
processingCompletedAt: batch.processingCompletedAt ?? undefined,
|
||||
runCount: batch.runCount,
|
||||
runs: batch.runIds,
|
||||
processing: {
|
||||
completedAt: batch.processingCompletedAt ?? undefined,
|
||||
errors:
|
||||
batch.errors.length > 0
|
||||
? batch.errors.map((err) => ({
|
||||
index: err.index,
|
||||
taskIdentifier: err.taskIdentifier,
|
||||
error: err.error,
|
||||
errorCode: err.errorCode ?? undefined,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -110,6 +110,8 @@ const { action, loader } = createActionApiRoute(
|
||||
? { traceparent, tracestate }
|
||||
: { external: { traceparent, tracestate } };
|
||||
|
||||
// Note: SDK v4.3+ uses the 2-phase batch API (POST /api/v3/batches + streaming items)
|
||||
// This endpoint is for backwards compatibility with older SDK versions
|
||||
const service = new RunEngineBatchTriggerService(batchProcessingStrategy ?? undefined);
|
||||
|
||||
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
StreamBatchItemsService,
|
||||
createNdjsonParserStream,
|
||||
streamToAsyncIterable,
|
||||
} from "~/runEngine/services/streamBatchItems.server";
|
||||
import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 2 of 2-phase batch API: Stream batch items.
|
||||
*
|
||||
* POST /api/v3/batches/:batchId/items
|
||||
*
|
||||
* Accepts an NDJSON stream of batch items and enqueues them to the BatchQueue.
|
||||
* Each line in the body should be a valid BatchItemNDJSON object.
|
||||
*
|
||||
* The stream is processed with backpressure - items are enqueued as they arrive.
|
||||
* The batch is sealed when the stream completes successfully.
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Validate params
|
||||
const paramsResult = ParamsSchema.safeParse(params);
|
||||
if (!paramsResult.success) {
|
||||
return json({ error: "Invalid batch ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { batchId } = paramsResult.data;
|
||||
|
||||
// Validate content type
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (
|
||||
!contentType.includes("application/x-ndjson") &&
|
||||
!contentType.includes("application/ndjson")
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
error: "Content-Type must be application/x-ndjson or application/ndjson",
|
||||
},
|
||||
{ status: 415 }
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const authResult = await authenticateApiRequestWithFailure(request, {
|
||||
allowPublicKey: true,
|
||||
});
|
||||
|
||||
if (!authResult.ok) {
|
||||
return json({ error: authResult.error }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get the request body stream
|
||||
const body = request.body;
|
||||
if (!body) {
|
||||
return json({ error: "Request body is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("Stream batch items request", {
|
||||
batchId,
|
||||
contentType,
|
||||
envId: authResult.environment.id,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create NDJSON parser transform stream
|
||||
const parser = createNdjsonParserStream(env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE);
|
||||
|
||||
// Pipe the request body through the parser
|
||||
const parsedStream = body.pipeThrough(parser);
|
||||
|
||||
// Convert to async iterable for the service
|
||||
const itemsIterator = streamToAsyncIterable(parsedStream);
|
||||
|
||||
// Process the stream
|
||||
const service = new StreamBatchItemsService();
|
||||
const result = await service.call(authResult.environment, batchId, itemsIterator, {
|
||||
maxItemBytes: env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE,
|
||||
});
|
||||
|
||||
return json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error("Stream batch items error", {
|
||||
batchId,
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
// Check for stream parsing errors
|
||||
if (
|
||||
error.message.includes("Invalid JSON") ||
|
||||
error.message.includes("exceeds maximum size")
|
||||
) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{ status: 500, headers: { "x-should-retry": "false" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Return 405 for GET requests - only POST is allowed
|
||||
return json(
|
||||
{
|
||||
error: "Method not allowed. Use POST to stream batch items.",
|
||||
},
|
||||
{ status: 405 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { CreateBatchRequestBody, CreateBatchResponse, generateJWT } from "@trigger.dev/core/v3";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { BatchRateLimitExceededError } from "~/runEngine/concerns/batchLimits.server";
|
||||
import { CreateBatchService } from "~/runEngine/services/createBatch.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
handleRequestIdempotency,
|
||||
saveRequestIdempotency,
|
||||
} from "~/utils/requestIdempotency.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
/**
|
||||
* Phase 1 of 2-phase batch API: Create a batch.
|
||||
*
|
||||
* POST /api/v3/batches
|
||||
*
|
||||
* Creates a batch record and optionally blocks the parent run for batchTriggerAndWait.
|
||||
* Items are streamed separately via POST /api/v3/batches/:batchId/items
|
||||
*/
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
headers: HeadersSchema,
|
||||
body: CreateBatchRequestBody,
|
||||
allowJWT: true,
|
||||
maxContentLength: 131_072, // 128KB is plenty for the batch metadata
|
||||
authorization: {
|
||||
action: "batchTrigger",
|
||||
resource: () => ({
|
||||
// No specific tasks to authorize at batch creation time
|
||||
// Tasks are validated when items are streamed
|
||||
tasks: [],
|
||||
}),
|
||||
superScopes: ["write:tasks", "admin"],
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, headers, authentication }) => {
|
||||
// Validate runCount
|
||||
if (body.runCount <= 0) {
|
||||
return json({ error: "runCount must be a positive integer" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check runCount against limit
|
||||
if (body.runCount > env.STREAMING_BATCH_MAX_ITEMS) {
|
||||
return json(
|
||||
{
|
||||
error: `Batch runCount of ${body.runCount} exceeds maximum allowed of ${env.STREAMING_BATCH_MAX_ITEMS}.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
"trigger-version": triggerVersion,
|
||||
"x-trigger-span-parent-as-link": spanParentAsLink,
|
||||
"x-trigger-worker": isFromWorker,
|
||||
"x-trigger-client": triggerClient,
|
||||
"x-trigger-realtime-streams-version": realtimeStreamsVersion,
|
||||
traceparent,
|
||||
tracestate,
|
||||
} = headers;
|
||||
|
||||
const oneTimeUseToken = await getOneTimeUseToken(authentication);
|
||||
|
||||
logger.debug("Create batch request", {
|
||||
runCount: body.runCount,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
triggerVersion,
|
||||
isFromWorker,
|
||||
triggerClient,
|
||||
});
|
||||
|
||||
// Handle idempotency for the batch creation
|
||||
const cachedResponse = await handleRequestIdempotency<
|
||||
{ friendlyId: string; runCount: number },
|
||||
CreateBatchResponse
|
||||
>(body.idempotencyKey, {
|
||||
requestType: "create-batch",
|
||||
findCachedEntity: async (cachedRequestId) => {
|
||||
return await prisma.batchTaskRun.findFirst({
|
||||
where: {
|
||||
id: cachedRequestId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
runCount: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
buildResponse: (cachedBatch) => ({
|
||||
id: cachedBatch.friendlyId,
|
||||
runCount: cachedBatch.runCount,
|
||||
isCached: true,
|
||||
}),
|
||||
buildResponseHeaders: async (responseBody) => {
|
||||
return await responseHeaders(responseBody, authentication.environment, triggerClient);
|
||||
},
|
||||
});
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const traceContext = isFromWorker
|
||||
? { traceparent, tracestate }
|
||||
: { external: { traceparent, tracestate } };
|
||||
|
||||
const service = new CreateBatchService();
|
||||
|
||||
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
|
||||
await saveRequestIdempotency(body.idempotencyKey, "create-batch", batch.id);
|
||||
});
|
||||
|
||||
try {
|
||||
const batch = await service.call(authentication.environment, body, {
|
||||
triggerVersion: triggerVersion ?? undefined,
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
oneTimeUseToken,
|
||||
realtimeStreamsVersion: determineRealtimeStreamsVersion(
|
||||
realtimeStreamsVersion ?? undefined
|
||||
),
|
||||
});
|
||||
|
||||
const $responseHeaders = await responseHeaders(
|
||||
batch,
|
||||
authentication.environment,
|
||||
triggerClient
|
||||
);
|
||||
|
||||
return json(batch, {
|
||||
status: 202,
|
||||
headers: $responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BatchRateLimitExceededError) {
|
||||
logger.info("Batch rate limit exceeded", {
|
||||
limit: error.limit,
|
||||
remaining: error.remaining,
|
||||
resetAt: error.resetAt.toISOString(),
|
||||
itemCount: error.itemCount,
|
||||
});
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"X-RateLimit-Limit": error.limit.toString(),
|
||||
"X-RateLimit-Remaining": error.remaining.toString(),
|
||||
"X-RateLimit-Reset": Math.floor(error.resetAt.getTime() / 1000).toString(),
|
||||
"Retry-After": Math.max(
|
||||
1,
|
||||
Math.ceil((error.resetAt.getTime() - Date.now()) / 1000)
|
||||
).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Create batch error", {
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof OutOfEntitlementError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{ status: 500, headers: { "x-should-retry": "false" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function responseHeaders(
|
||||
batch: CreateBatchResponse,
|
||||
environment: AuthenticatedEnvironment,
|
||||
triggerClient?: string | null
|
||||
): Promise<Record<string, string>> {
|
||||
const claimsHeader = JSON.stringify({
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
});
|
||||
|
||||
if (triggerClient === "browser") {
|
||||
const claims = {
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: [`read:batch:${batch.id}`, `write:batch:${batch.id}`],
|
||||
};
|
||||
|
||||
const jwt = await generateJWT({
|
||||
secretKey: extractJwtSigningSecretKey(environment),
|
||||
payload: claims,
|
||||
expirationTime: "1h",
|
||||
});
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
"x-trigger-jwt": jwt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
};
|
||||
}
|
||||
|
||||
export { action, loader };
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "@remix-run/node";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { commitSession } from "~/services/sessionStorage.server";
|
||||
import { redirectCookie } from "./auth.github";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
@@ -41,19 +42,19 @@ export let loader: LoaderFunction = async ({ request }) => {
|
||||
session.set("pending-mfa-user-id", userRecord.id);
|
||||
session.set("pending-mfa-redirect-to", redirectTo);
|
||||
|
||||
return redirect("/login/mfa", {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
|
||||
|
||||
return redirect("/login/mfa", { headers });
|
||||
}
|
||||
|
||||
// and store the user data
|
||||
session.set(authenticator.sessionKey, auth);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
|
||||
|
||||
return redirect(redirectTo, { headers });
|
||||
};
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { env } from "~/env.server";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
export let loader: LoaderFunction = () => redirect("/login");
|
||||
|
||||
export let action: ActionFunction = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = url.searchParams.get("redirectTo");
|
||||
const safeRedirect = sanitizeRedirectPath(redirectTo, "/");
|
||||
|
||||
try {
|
||||
// call authenticate as usual, in successRedirect use returnTo or a fallback
|
||||
return await authenticator.authenticate("github", request, {
|
||||
successRedirect: redirectTo ?? "/",
|
||||
successRedirect: safeRedirect,
|
||||
failureRedirect: "/login",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -19,8 +22,8 @@ export let action: ActionFunction = async ({ request }) => {
|
||||
// if the error is a Response and is a redirect
|
||||
if (error instanceof Response) {
|
||||
// we need to append a Set-Cookie header with a cookie storing the
|
||||
// returnTo value
|
||||
error.headers.append("Set-Cookie", await redirectCookie.serialize(redirectTo));
|
||||
// returnTo value (store the sanitized path)
|
||||
error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -29,4 +32,6 @@ export let action: ActionFunction = async ({ request }) => {
|
||||
export const redirectCookie = createCookie("redirect-to", {
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { commitSession } from "~/services/sessionStorage.server";
|
||||
import { redirectCookie } from "./auth.google";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
export let loader: LoaderFunction = async ({ request }) => {
|
||||
const cookie = request.headers.get("Cookie");
|
||||
const redirectValue = await redirectCookie.parse(cookie);
|
||||
const redirectTo = sanitizeRedirectPath(redirectValue);
|
||||
|
||||
const auth = await authenticator.authenticate("google", request, {
|
||||
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
|
||||
});
|
||||
|
||||
// manually get the session
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
const userRecord = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: auth.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
mfaEnabledAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userRecord) {
|
||||
return redirectWithErrorMessage(
|
||||
"/login",
|
||||
request,
|
||||
"Could not find your account. Please contact support."
|
||||
);
|
||||
}
|
||||
|
||||
if (userRecord.mfaEnabledAt) {
|
||||
session.set("pending-mfa-user-id", userRecord.id);
|
||||
session.set("pending-mfa-redirect-to", redirectTo);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
|
||||
|
||||
return redirect("/login/mfa", { headers });
|
||||
}
|
||||
|
||||
// and store the user data
|
||||
session.set(authenticator.sessionKey, auth);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
|
||||
|
||||
return redirect(redirectTo, { headers });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { env } from "~/env.server";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
export let loader: LoaderFunction = () => redirect("/login");
|
||||
|
||||
export let action: ActionFunction = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = url.searchParams.get("redirectTo");
|
||||
const safeRedirect = sanitizeRedirectPath(redirectTo, "/");
|
||||
|
||||
try {
|
||||
// call authenticate as usual, in successRedirect use returnTo or a fallback
|
||||
return await authenticator.authenticate("google", request, {
|
||||
successRedirect: safeRedirect,
|
||||
failureRedirect: "/login",
|
||||
});
|
||||
} catch (error) {
|
||||
// here we catch anything authenticator.authenticate throw, this will
|
||||
// include redirects
|
||||
// if the error is a Response and is a redirect
|
||||
if (error instanceof Response) {
|
||||
// we need to append a Set-Cookie header with a cookie storing the
|
||||
// returnTo value (store the sanitized path)
|
||||
error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const redirectCookie = createCookie("google-redirect-to", {
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { EnvelopeIcon } from "@heroicons/react/20/solid";
|
||||
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { GitHubLightIcon } from "@trigger.dev/companyicons";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { GoogleLogo } from "~/assets/logos/GoogleLogo";
|
||||
import { LoginPageLayout } from "~/components/LoginPageLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
@@ -10,12 +12,33 @@ import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { isGithubAuthSupported } from "~/services/auth.server";
|
||||
import { isGithubAuthSupported, isGoogleAuthSupported } from "~/services/auth.server";
|
||||
import { getLastAuthMethod } from "~/services/lastAuthMethod.server";
|
||||
import { commitSession, setRedirectTo } from "~/services/redirectTo.server";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
import { getUserSession } from "~/services/sessionStorage.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
|
||||
function LastUsedBadge() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div className="absolute -right-5 top-1 z-10 -translate-y-1/2 shadow-md md:-right-[4.6rem] md:top-1/2">
|
||||
<motion.div
|
||||
className="relative rounded border border-charcoal-700 bg-charcoal-800 px-2 py-1 text-center text-xxs font-medium uppercase text-blue-500"
|
||||
initial={shouldReduceMotion ? undefined : { opacity: 0, x: 4 }}
|
||||
animate={shouldReduceMotion ? undefined : { opacity: 1, x: 0 }}
|
||||
transition={shouldReduceMotion ? undefined : { duration: 0.8, ease: "easeOut" }}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<span className="hidden h-2 w-2 rotate-45 border-b border-l border-charcoal-700 bg-charcoal-800 md:block" />
|
||||
</span>
|
||||
Last used
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: MetaFunction = ({ matches }) => {
|
||||
const parentMeta = matches
|
||||
.flatMap((match) => match.meta ?? [])
|
||||
@@ -45,6 +68,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
const url = requestUrl(request);
|
||||
const redirectTo = url.searchParams.get("redirectTo");
|
||||
const lastAuthMethod = await getLastAuthMethod(request);
|
||||
|
||||
if (redirectTo) {
|
||||
const session = await setRedirectTo(request, redirectTo);
|
||||
@@ -53,6 +77,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
{
|
||||
redirectTo,
|
||||
showGithubAuth: isGithubAuthSupported,
|
||||
showGoogleAuth: isGoogleAuthSupported,
|
||||
lastAuthMethod,
|
||||
authError: null,
|
||||
},
|
||||
{
|
||||
@@ -77,6 +103,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
return typedjson({
|
||||
redirectTo: null,
|
||||
showGithubAuth: isGithubAuthSupported,
|
||||
showGoogleAuth: isGoogleAuthSupported,
|
||||
lastAuthMethod,
|
||||
authError,
|
||||
});
|
||||
}
|
||||
@@ -87,31 +115,57 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<LoginPageLayout>
|
||||
<Form
|
||||
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
method="post"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<Header1 className="pb-4 font-semibold sm:text-2xl md:text-3xl lg:text-4xl">
|
||||
Welcome
|
||||
</Header1>
|
||||
<Paragraph variant="base" className="mb-6">
|
||||
Create an account or login
|
||||
</Paragraph>
|
||||
<Fieldset className="w-full">
|
||||
<div className="flex flex-col items-center gap-y-2">
|
||||
{data.showGithubAuth && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with github"
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<Header1 className="pb-4 font-semibold sm:text-2xl md:text-3xl lg:text-4xl">
|
||||
Welcome
|
||||
</Header1>
|
||||
<Paragraph variant="base" className="mb-6">
|
||||
Create an account or login
|
||||
</Paragraph>
|
||||
<Fieldset className="w-full">
|
||||
<div className="flex flex-col items-center gap-y-3">
|
||||
{data.showGithubAuth && (
|
||||
<div className="relative w-full">
|
||||
{data.lastAuthMethod === "github" && <LastUsedBadge />}
|
||||
<Form
|
||||
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
method="post"
|
||||
className="w-full"
|
||||
>
|
||||
<GitHubLightIcon className={"mr-2 size-5"} />
|
||||
<span className="text-text-bright">Continue with GitHub</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with github"
|
||||
>
|
||||
<GitHubLightIcon className="mr-2 size-5" />
|
||||
<span className="text-text-bright">Continue with GitHub</span>
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
{data.showGoogleAuth && (
|
||||
<div className="relative w-full">
|
||||
{data.lastAuthMethod === "google" && <LastUsedBadge />}
|
||||
<Form
|
||||
action={`/auth/google${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
method="post"
|
||||
className="w-full"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with google"
|
||||
>
|
||||
<GoogleLogo className="mr-2 size-5" />
|
||||
<span className="text-text-bright">Continue with Google</span>
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full">
|
||||
{data.lastAuthMethod === "email" && <LastUsedBadge />}
|
||||
<LinkButton
|
||||
to="/login/magic"
|
||||
variant="secondary/extra-large"
|
||||
@@ -122,22 +176,22 @@ export default function LoginPage() {
|
||||
<EnvelopeIcon className="mr-2 size-5 text-text-bright" />
|
||||
Continue with Email
|
||||
</LinkButton>
|
||||
{data.authError && <FormError>{data.authError}</FormError>}
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="mt-2 text-center">
|
||||
By signing up you agree to our{" "}
|
||||
<TextLink href="https://trigger.dev/legal" target="_blank">
|
||||
terms
|
||||
</TextLink>
|
||||
{" "}and{" "}
|
||||
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
|
||||
privacy
|
||||
</TextLink>
|
||||
{" "}policy.
|
||||
</Paragraph>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</Form>
|
||||
{data.authError && <FormError>{data.authError}</FormError>}
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="mt-2 text-center">
|
||||
By signing up you agree to our{" "}
|
||||
<TextLink href="https://trigger.dev/legal" target="_blank">
|
||||
terms
|
||||
</TextLink>{" "}
|
||||
and{" "}
|
||||
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
|
||||
privacy
|
||||
</TextLink>{" "}
|
||||
policy.
|
||||
</Paragraph>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</LoginPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { getRedirectTo } from "~/services/redirectTo.server";
|
||||
import { commitSession, getSession } from "~/services/sessionStorage.server";
|
||||
|
||||
@@ -38,19 +39,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
session.set("pending-mfa-user-id", userRecord.id);
|
||||
session.set("pending-mfa-redirect-to", redirectTo ?? "/");
|
||||
|
||||
return redirect("/login/mfa", {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
|
||||
|
||||
return redirect("/login/mfa", { headers });
|
||||
}
|
||||
|
||||
// and store the user data
|
||||
session.set(authenticator.sessionKey, auth);
|
||||
|
||||
return redirect(redirectTo ?? "/", {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
|
||||
|
||||
return redirect(redirectTo ?? "/", { headers });
|
||||
}
|
||||
|
||||
+877
@@ -0,0 +1,877 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigation, useNavigate, useSearchParams, useLocation } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedFetcher } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { GitBranchIcon } from "lucide-react";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectBackWithSuccessMessage,
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
githubAppInstallPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectSettingsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type BranchTrackingConfig } from "~/v3/github";
|
||||
import { GitHubSettingsPresenter } from "~/presenters/v3/GitHubSettingsPresenter.server";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type GitHubRepository = {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
htmlUrl: string;
|
||||
};
|
||||
|
||||
export type GitHubAppInstallation = {
|
||||
id: string;
|
||||
appInstallationId: bigint;
|
||||
targetType: string;
|
||||
accountHandle: string;
|
||||
repositories: GitHubRepository[];
|
||||
};
|
||||
|
||||
export type ConnectedGitHubRepo = {
|
||||
branchTracking: BranchTrackingConfig | undefined;
|
||||
previewDeploymentsEnabled: boolean;
|
||||
createdAt: Date;
|
||||
repository: GitHubRepository;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const ConnectGitHubRepoFormSchema = z.object({
|
||||
action: z.literal("connect-repo"),
|
||||
installationId: z.string(),
|
||||
repositoryId: z.string(),
|
||||
redirectUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export const DisconnectGitHubRepoFormSchema = z.object({
|
||||
action: z.literal("disconnect-repo"),
|
||||
redirectUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdateGitSettingsFormSchema = z.object({
|
||||
action: z.literal("update-git-settings"),
|
||||
productionBranch: z.string().trim().optional(),
|
||||
stagingBranch: z.string().trim().optional(),
|
||||
previewDeploymentsEnabled: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
redirectUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
const GitHubActionSchema = z.discriminatedUnion("action", [
|
||||
ConnectGitHubRepoFormSchema,
|
||||
DisconnectGitHubRepoFormSchema,
|
||||
UpdateGitSettingsFormSchema,
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// Loader
|
||||
// ============================================================================
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new GitHubSettingsPresenter();
|
||||
const resultOrFail = await presenter.call({
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
throw new Response("Failed to load GitHub settings", { status: 500 });
|
||||
}
|
||||
|
||||
return typedjson(resultOrFail.value);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action
|
||||
// ============================================================================
|
||||
|
||||
function redirectWithMessage(
|
||||
request: Request,
|
||||
redirectUrl: string | undefined,
|
||||
message: string,
|
||||
type: "success" | "error"
|
||||
) {
|
||||
if (type === "success") {
|
||||
return redirectUrl
|
||||
? redirectWithSuccessMessage(redirectUrl, request, message)
|
||||
: redirectBackWithSuccessMessage(request, message);
|
||||
}
|
||||
return redirectUrl
|
||||
? redirectWithErrorMessage(redirectUrl, request, message)
|
||||
: redirectBackWithErrorMessage(request, message);
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: GitHubActionSchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId, organizationId } = membershipResultOrFail.value;
|
||||
const { action: actionType } = submission.value;
|
||||
|
||||
// Handle connect-repo action
|
||||
if (actionType === "connect-repo") {
|
||||
const { repositoryId, installationId, redirectUrl } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.connectGitHubRepo(
|
||||
projectId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
installationId
|
||||
);
|
||||
|
||||
if (resultOrFail.isOk()) {
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"GitHub repository connected successfully",
|
||||
"success"
|
||||
);
|
||||
}
|
||||
|
||||
const errorType = resultOrFail.error.type;
|
||||
|
||||
if (errorType === "gh_repository_not_found") {
|
||||
return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error");
|
||||
}
|
||||
|
||||
if (errorType === "project_already_has_connected_repository") {
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"Project already has a connected repository",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Failed to connect GitHub repository", { error: resultOrFail.error });
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"Failed to connect GitHub repository",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
||||
// Handle disconnect-repo action
|
||||
if (actionType === "disconnect-repo") {
|
||||
const { redirectUrl } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
|
||||
|
||||
if (resultOrFail.isOk()) {
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"GitHub repository disconnected successfully",
|
||||
"success"
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error });
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"Failed to disconnect GitHub repository",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
||||
// Handle update-git-settings action
|
||||
if (actionType === "update-git-settings") {
|
||||
const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } =
|
||||
submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateGitSettings(
|
||||
projectId,
|
||||
productionBranch,
|
||||
stagingBranch,
|
||||
previewDeploymentsEnabled
|
||||
);
|
||||
|
||||
if (resultOrFail.isOk()) {
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"Git settings updated successfully",
|
||||
"success"
|
||||
);
|
||||
}
|
||||
|
||||
const errorType = resultOrFail.error.type;
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
github_app_not_enabled: "GitHub app is not enabled",
|
||||
connected_gh_repository_not_found: "Connected GitHub repository not found",
|
||||
production_tracking_branch_not_found: "Production tracking branch not found",
|
||||
staging_tracking_branch_not_found: "Staging tracking branch not found",
|
||||
};
|
||||
|
||||
const message = errorMessages[errorType];
|
||||
if (message) {
|
||||
return redirectWithMessage(request, redirectUrl, message, "error");
|
||||
}
|
||||
|
||||
logger.error("Failed to update Git settings", { error: resultOrFail.error });
|
||||
return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error");
|
||||
}
|
||||
|
||||
// Exhaustive check - this should never be reached
|
||||
submission.value satisfies never;
|
||||
return redirectBackWithErrorMessage(request, "Failed to process request");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper: Build resource URL for fetching GitHub data
|
||||
// ============================================================================
|
||||
|
||||
export function gitHubResourcePath(
|
||||
organizationSlug: string,
|
||||
projectSlug: string,
|
||||
environmentSlug: string
|
||||
) {
|
||||
return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/github`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Components
|
||||
// ============================================================================
|
||||
|
||||
export function ConnectGitHubRepoModal({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
redirectUrl,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
redirectUrl?: string;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<
|
||||
GitHubAppInstallation | undefined
|
||||
>(gitHubAppInstallations.at(0));
|
||||
|
||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isConnectRepositoryLoading =
|
||||
navigation.formData?.get("action") === "connect-repo" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [form, { installationId, repositoryId }] = useForm({
|
||||
id: "connect-repo",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: ConnectGitHubRepoFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (params.get("openGithubRepoModal") === "1") {
|
||||
setIsModalOpen(true);
|
||||
params.delete("openGithubRepoModal");
|
||||
setSearchParams(params);
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [lastSubmission]);
|
||||
|
||||
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
|
||||
Connect GitHub repo
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Connect GitHub repository</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<Form method="post" action={actionUrl} {...form.props} className="w-full">
|
||||
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
|
||||
<Paragraph className="mb-3">
|
||||
Choose a GitHub repository to connect to your project.
|
||||
</Paragraph>
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={installationId.id}>Account</Label>
|
||||
<Select
|
||||
name={installationId.name}
|
||||
id={installationId.id}
|
||||
value={selectedInstallation?.id}
|
||||
defaultValue={gitHubAppInstallations.at(0)?.id}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const installation = gitHubAppInstallations.find((i) => i.id === value);
|
||||
setSelectedInstallation(installation);
|
||||
setSelectedRepository(undefined);
|
||||
}}
|
||||
items={gitHubAppInstallations}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select account"
|
||||
dropdownIcon
|
||||
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
|
||||
>
|
||||
{[
|
||||
...gitHubAppInstallations.map((installation) => (
|
||||
<SelectItem
|
||||
key={installation.id}
|
||||
value={installation.id}
|
||||
icon={<OctoKitty className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
{installation.accountHandle}
|
||||
</SelectItem>
|
||||
)),
|
||||
<SelectItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(
|
||||
githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)}?openGithubRepoModal=1`
|
||||
)
|
||||
);
|
||||
}}
|
||||
key="new-account"
|
||||
icon={<PlusIcon className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
Add account
|
||||
</SelectItem>,
|
||||
]}
|
||||
</Select>
|
||||
<FormError id={installationId.errorId}>{installationId.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={repositoryId.id}>Repository</Label>
|
||||
<Select
|
||||
name={repositoryId.name}
|
||||
id={repositoryId.id}
|
||||
value={selectedRepository ? selectedRepository.id : undefined}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const repository = selectedInstallation?.repositories.find(
|
||||
(r) => r.id === value
|
||||
);
|
||||
setSelectedRepository(repository);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select repository"
|
||||
heading="Filter repositories"
|
||||
dropdownIcon
|
||||
items={selectedInstallation?.repositories ?? []}
|
||||
filter={{ keys: ["name"] }}
|
||||
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
|
||||
text={selectedRepository ? selectedRepository.name : null}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<div className="flex items-center gap-1">
|
||||
{repo.name}
|
||||
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<Hint className={cn("invisible", selectedInstallation && "visible")}>
|
||||
Configure repository access in{" "}
|
||||
<TextLink
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
|
||||
>
|
||||
GitHub
|
||||
</TextLink>
|
||||
.
|
||||
</Hint>
|
||||
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="connect-repo"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isConnectRepositoryLoading}
|
||||
>
|
||||
Connect repository
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubConnectionPrompt({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
redirectUrl,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
redirectUrl?: string;
|
||||
}) {
|
||||
|
||||
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
{gitHubAppInstallations.length === 0 && (
|
||||
<LinkButton
|
||||
to={githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${githubInstallationRedirect}?openGithubRepoModal=1`
|
||||
)}
|
||||
variant={"secondary/medium"}
|
||||
LeadingIcon={OctoKitty}
|
||||
>
|
||||
Install GitHub app
|
||||
</LinkButton>
|
||||
)}
|
||||
{gitHubAppInstallations.length !== 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<ConnectGitHubRepoModal
|
||||
gitHubAppInstallations={gitHubAppInstallations}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
<span className="flex items-center gap-1 text-xs text-text-dimmed">
|
||||
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectedGitHubRepoForm({
|
||||
connectedGitHubRepo,
|
||||
previewEnvironmentEnabled,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
billingPath,
|
||||
redirectUrl,
|
||||
}: {
|
||||
connectedGitHubRepo: ConnectedGitHubRepo;
|
||||
previewEnvironmentEnabled?: boolean;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
billingPath: string;
|
||||
redirectUrl?: string;
|
||||
}) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
|
||||
const [gitSettingsValues, setGitSettingsValues] = useState({
|
||||
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
|
||||
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
|
||||
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
gitSettingsValues.productionBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
|
||||
gitSettingsValues.stagingBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
|
||||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
|
||||
setHasGitSettingsChanges(hasChanges);
|
||||
}, [gitSettingsValues, connectedGitHubRepo]);
|
||||
|
||||
const [gitSettingsForm, fields] = useForm({
|
||||
id: "update-git-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateGitSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isGitSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-git-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<OctoKitty className="size-4" />
|
||||
<a
|
||||
href={connectedGitHubRepo.repository.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="max-w-52 truncate text-sm text-text-bright hover:underline"
|
||||
>
|
||||
{connectedGitHubRepo.repository.fullName}
|
||||
</a>
|
||||
{connectedGitHubRepo.repository.private && (
|
||||
<LockClosedIcon className="size-3 text-text-dimmed" />
|
||||
)}
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime
|
||||
date={connectedGitHubRepo.createdAt}
|
||||
includeTime={false}
|
||||
includeSeconds={false}
|
||||
showTimezone={false}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="minimal/small">Disconnect</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Disconnect GitHub repository</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph className="mb-1">
|
||||
Are you sure you want to disconnect{" "}
|
||||
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
|
||||
This will stop automatic deployments from GitHub.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post" action={actionUrl}>
|
||||
<input type="hidden" name="action" value="disconnect-repo" />
|
||||
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
|
||||
<Button type="submit" variant="danger/medium">
|
||||
Disconnect repository
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Form method="post" action={actionUrl} {...gitSettingsForm.props}>
|
||||
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Hint>
|
||||
Every push to the selected tracking branch creates a deployment in the corresponding
|
||||
environment.
|
||||
</Hint>
|
||||
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
|
||||
{environmentFullTitle({ type: "PRODUCTION" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.productionBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
productionBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
|
||||
{environmentFullTitle({ type: "STAGING" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.stagingBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
stagingBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
|
||||
{environmentFullTitle({ type: "PREVIEW" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
name="previewDeploymentsEnabled"
|
||||
disabled={!previewEnvironmentEnabled}
|
||||
defaultChecked={
|
||||
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
|
||||
}
|
||||
variant="small"
|
||||
label="Create preview deployments for pull requests"
|
||||
labelPosition="right"
|
||||
onCheckedChange={(checked) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
previewDeploymentsEnabled: checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!previewEnvironmentEnabled && (
|
||||
<InfoIconTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
<TextLink to={billingPath}>Upgrade</TextLink> your plan to enable preview
|
||||
branches
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FormError>{fields.productionBranch?.error}</FormError>
|
||||
<FormError>{fields.stagingBranch?.error}</FormError>
|
||||
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
|
||||
<FormError>{gitSettingsForm.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-git-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
|
||||
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main GitHub Settings Panel Component
|
||||
// ============================================================================
|
||||
|
||||
export function GitHubSettingsPanel({
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
billingPath,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
billingPath: string;
|
||||
}) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const location = useLocation();
|
||||
|
||||
// Use provided redirectUrl or fall back to current path (without search params)
|
||||
const effectiveRedirectUrl = location.pathname;
|
||||
useEffect(() => {
|
||||
fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug));
|
||||
}, [organizationSlug, projectSlug, environmentSlug]);
|
||||
|
||||
const data = fetcher.data;
|
||||
|
||||
// Loading state
|
||||
if (fetcher.state === "loading" && !data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-text-dimmed">
|
||||
<SpinnerWhite className="size-4" />
|
||||
<span className="text-sm">Loading GitHub settings...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub app not enabled
|
||||
if (!data || !data.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Connected repository exists - show form
|
||||
if (data.connectedRepository) {
|
||||
return (
|
||||
<ConnectedGitHubRepoForm
|
||||
connectedGitHubRepo={data.connectedRepository}
|
||||
previewEnvironmentEnabled={data.isPreviewEnvironmentEnabled}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
billingPath={billingPath}
|
||||
redirectUrl={effectiveRedirectUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// No connected repository - show connection prompt
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<GitHubConnectionPrompt
|
||||
gitHubAppInstallations={data.installations ?? []}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
redirectUrl={effectiveRedirectUrl}
|
||||
/>
|
||||
{!data.connectedRepository && (
|
||||
<Hint>
|
||||
Connect your GitHub repository to automatically deploy your changes.
|
||||
</Hint>
|
||||
)}
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { jsonWithErrorMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
|
||||
import { v3RunParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const resetIdempotencyKeySchema = z.object({
|
||||
taskIdentifier: z.string().min(1, "Task identifier is required"),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, runParam } =
|
||||
v3RunParamsSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: resetIdempotencyKeySchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const { taskIdentifier } = submission.value;
|
||||
|
||||
const taskRun = await prisma.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runParam,
|
||||
project: {
|
||||
slug: projectParam,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runtimeEnvironment: {
|
||||
slug: envParam,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
idempotencyKey: true,
|
||||
taskIdentifier: true,
|
||||
runtimeEnvironmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
submission.error = { runParam: ["Run not found"] };
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!taskRun.idempotencyKey) {
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
"This run does not have an idempotency key"
|
||||
);
|
||||
}
|
||||
|
||||
if (taskRun.taskIdentifier !== taskIdentifier) {
|
||||
submission.error = { taskIdentifier: ["Task identifier does not match this run"] };
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
where: {
|
||||
id: taskRun.runtimeEnvironmentId,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
"Environment not found"
|
||||
);
|
||||
}
|
||||
|
||||
const service = new ResetIdempotencyKeyService();
|
||||
|
||||
await service.call(taskRun.idempotencyKey, taskIdentifier, {
|
||||
...environment,
|
||||
organizationId: environment.project.organizationId,
|
||||
organization: environment.project.organization,
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Failed to reset idempotency key", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
`Failed to reset idempotency key: ${error.message}`
|
||||
);
|
||||
} else {
|
||||
logger.error("Failed to reset idempotency key", { error });
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
`Failed to reset idempotency key: ${JSON.stringify(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
+83
-8
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckIcon,
|
||||
CloudArrowDownIcon,
|
||||
EnvelopeIcon,
|
||||
@@ -29,6 +30,7 @@ import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { ToastUI } from "~/components/primitives/Toast";
|
||||
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
|
||||
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
@@ -69,6 +72,7 @@ import {
|
||||
v3BatchPath,
|
||||
v3DeploymentVersionPath,
|
||||
v3RunDownloadLogsPath,
|
||||
v3RunIdempotencyKeyResetPath,
|
||||
v3RunPath,
|
||||
v3RunRedirectPath,
|
||||
v3RunSpanPath,
|
||||
@@ -81,6 +85,7 @@ import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.proje
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { RealtimeStreamViewer } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
|
||||
import { action as resetIdempotencyKeyAction } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -293,6 +298,28 @@ function RunBody({
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { value, replace } = useSearchParams();
|
||||
const tab = value("tab");
|
||||
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();
|
||||
|
||||
// Handle toast messages from the reset action
|
||||
useEffect(() => {
|
||||
if (resetFetcher.data && resetFetcher.state === "idle") {
|
||||
// Check if the response indicates success
|
||||
if (resetFetcher.data && typeof resetFetcher.data === "object" && "success" in resetFetcher.data && resetFetcher.data.success === true) {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI
|
||||
variant="success"
|
||||
message="Idempotency key reset successfully"
|
||||
t={t as string}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [resetFetcher.data, resetFetcher.state]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
@@ -543,16 +570,49 @@ function RunBody({
|
||||
<Property.Item>
|
||||
<Property.Label>Idempotency</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
|
||||
{run.idempotencyKey && (
|
||||
<div>
|
||||
Expires:{" "}
|
||||
{run.idempotencyKeyExpiresAt ? (
|
||||
<DateTime date={run.idempotencyKeyExpiresAt} />
|
||||
) : (
|
||||
"–"
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
|
||||
{run.idempotencyKey && (
|
||||
<div>
|
||||
Expires:{" "}
|
||||
{run.idempotencyKeyExpiresAt ? (
|
||||
<DateTime date={run.idempotencyKeyExpiresAt} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{run.idempotencyKey && (
|
||||
<resetFetcher.Form
|
||||
method="post"
|
||||
action={v3RunIdempotencyKeyResetPath(organization, project, environment, { friendlyId: runParam })}
|
||||
>
|
||||
<input type="hidden" name="taskIdentifier" value={run.taskIdentifier} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/small"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
disabled={resetFetcher.state === "submitting"}
|
||||
>
|
||||
{resetFetcher.state === "submitting" ? "Resetting..." : "Reset"}
|
||||
</Button>
|
||||
</resetFetcher.Form>
|
||||
)}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Debounce</Property.Label>
|
||||
<Property.Value>
|
||||
{run.debounce ? (
|
||||
<div>
|
||||
<div className="break-all">Key: {run.debounce.key}</div>
|
||||
<div>Delay: {run.debounce.delay}</div>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
@@ -815,6 +875,10 @@ function RunBody({
|
||||
<Property.Label>Span ID</Property.Label>
|
||||
<Property.Value>{run.spanId}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Task event store</Property.Label>
|
||||
<Property.Value>{run.taskEventStore}</Property.Value>
|
||||
</Property.Item>
|
||||
</div>
|
||||
)}
|
||||
</Property.Table>
|
||||
@@ -1071,6 +1135,17 @@ function SpanEntity({ span }: { span: Span }) {
|
||||
showOpenInModal
|
||||
/>
|
||||
) : null}
|
||||
{span.resourceProperties !== undefined ? (
|
||||
<CodeBlock
|
||||
rowTitle="Resource properties"
|
||||
code={span.resourceProperties}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
showTextWrapping
|
||||
showOpenInModal
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams } from "@remix-run/react";
|
||||
export default function Story() {
|
||||
const { tabNumber } = useParams();
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">{tabNumber}</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,188 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "~/components/primitives/ClientTabs";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Tabs } from "~/components/primitives/Tabs";
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="w-96 p-8">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: "My first tab", to: "1" },
|
||||
{ label: "Second tab", to: "2" },
|
||||
{ label: "Third tab", to: "3" },
|
||||
]}
|
||||
layoutId="my-tabs"
|
||||
/>
|
||||
<Outlet />
|
||||
<div className="flex items-start justify-center gap-20 px-16 pt-20">
|
||||
<div className="flex w-full max-w-2xl flex-col gap-4">
|
||||
<div className="flex h-fit flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header1 spacing>{"<Tabs/>"} (updates the URL)</Header1>
|
||||
<Paragraph>Variant="underline"</Paragraph>
|
||||
</div>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: "First tab", to: "1" },
|
||||
{ label: "Second tab", to: "2" },
|
||||
{ label: "Third tab", to: "3" },
|
||||
]}
|
||||
layoutId="my-tabs-1"
|
||||
variant="underline"
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
<div className="flex h-fit flex-col gap-2">
|
||||
<Paragraph>Variant="pipe-divider"</Paragraph>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: "First tab", to: "1" },
|
||||
{ label: "Second tab", to: "2" },
|
||||
{ label: "Third tab", to: "3" },
|
||||
]}
|
||||
layoutId="my-tabs-2"
|
||||
variant="pipe-divider"
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
<div className="flex h-fit flex-col gap-2">
|
||||
<Paragraph>Variant="segmented"</Paragraph>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: "First tab", to: "1" },
|
||||
{ label: "Second tab", to: "2" },
|
||||
{ label: "Third tab", to: "3" },
|
||||
]}
|
||||
layoutId="my-tabs-3"
|
||||
variant="segmented"
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full max-w-2xl flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header1 spacing>{"<ClientTabs/>"}</Header1>
|
||||
<Paragraph>Variant="underline"</Paragraph>
|
||||
</div>
|
||||
<ClientTabs defaultValue="tab-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<ClientTabsList variant="underline">
|
||||
<ClientTabsTrigger
|
||||
value={"tab-1"}
|
||||
variant="underline"
|
||||
layoutId="client-tabs-underline"
|
||||
>
|
||||
First tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value={"tab-2"}
|
||||
variant="underline"
|
||||
layoutId="client-tabs-underline"
|
||||
>
|
||||
Second tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value={"tab-3"}
|
||||
variant="underline"
|
||||
layoutId="client-tabs-underline"
|
||||
>
|
||||
Third tab
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent value={"tab-1"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">1</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-2"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">2</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-3"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">3</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Paragraph spacing>Variant="pipe-divider"</Paragraph>
|
||||
<ClientTabs defaultValue="tab-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<ClientTabsList variant="pipe-divider">
|
||||
<ClientTabsTrigger value={"tab-1"} variant="pipe-divider">
|
||||
First tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"tab-2"} variant="pipe-divider">
|
||||
Second tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"tab-3"} variant="pipe-divider">
|
||||
Third tab
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent value={"tab-1"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">1</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-2"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">2</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-3"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">3</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
<div>
|
||||
<Paragraph spacing>Variant="segmented"</Paragraph>
|
||||
<ClientTabs defaultValue="tab-1">
|
||||
<ClientTabsList variant="segmented">
|
||||
<ClientTabsTrigger
|
||||
value={"tab-1"}
|
||||
variant="segmented"
|
||||
layoutId="client-tabs-segmented"
|
||||
>
|
||||
First tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value={"tab-2"}
|
||||
variant="segmented"
|
||||
layoutId="client-tabs-segmented"
|
||||
>
|
||||
Second tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value={"tab-3"}
|
||||
variant="segmented"
|
||||
layoutId="client-tabs-segmented"
|
||||
>
|
||||
Third tab
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"tab-1"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">1</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-2"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">2</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-3"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">3</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import type { GlobalRateLimiter } from "@trigger.dev/redis-worker";
|
||||
import { RateLimiter } from "~/services/rateLimiter.server";
|
||||
|
||||
/**
|
||||
* Creates a global rate limiter for the batch queue that limits
|
||||
* the maximum number of items processed per second across all consumers.
|
||||
*
|
||||
* Uses a token bucket algorithm where:
|
||||
* - `itemsPerSecond` tokens are available per second
|
||||
* - The bucket can hold up to `itemsPerSecond` tokens (burst capacity)
|
||||
*
|
||||
* @param itemsPerSecond - Maximum items to process per second
|
||||
* @returns A GlobalRateLimiter compatible with FairQueue
|
||||
*/
|
||||
export function createBatchGlobalRateLimiter(itemsPerSecond: number): GlobalRateLimiter {
|
||||
const limiter = new RateLimiter({
|
||||
keyPrefix: "batch-queue-global",
|
||||
// Token bucket: refills `itemsPerSecond` tokens every second
|
||||
// Bucket capacity is also `itemsPerSecond` (allows burst up to limit)
|
||||
limiter: Ratelimit.tokenBucket(itemsPerSecond, "1 s", itemsPerSecond),
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
});
|
||||
|
||||
return {
|
||||
async limit() {
|
||||
const result = await limiter.limit("global");
|
||||
return {
|
||||
allowed: result.success,
|
||||
resetAt: result.reset,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Organization } from "@trigger.dev/database";
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { RateLimiterConfig } from "~/services/authorizationRateLimitMiddleware.server";
|
||||
import { createRedisRateLimitClient, Duration, RateLimiter } from "~/services/rateLimiter.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
const BatchLimitsConfig = z.object({
|
||||
processingConcurrency: z.number().int().default(env.BATCH_CONCURRENCY_LIMIT_DEFAULT),
|
||||
});
|
||||
|
||||
/**
|
||||
* Batch limits configuration for a plan type
|
||||
*/
|
||||
export type BatchLimitsConfig = z.infer<typeof BatchLimitsConfig>;
|
||||
|
||||
const batchLimitsRedisClient = singleton("batchLimitsRedisClient", createBatchLimitsRedisClient);
|
||||
|
||||
function createBatchLimitsRedisClient() {
|
||||
const redisClient = createRedisRateLimitClient({
|
||||
port: env.RATE_LIMIT_REDIS_PORT,
|
||||
host: env.RATE_LIMIT_REDIS_HOST,
|
||||
username: env.RATE_LIMIT_REDIS_USERNAME,
|
||||
password: env.RATE_LIMIT_REDIS_PASSWORD,
|
||||
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
|
||||
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||
});
|
||||
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
function createOrganizationRateLimiter(organization: Organization): RateLimiter {
|
||||
const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig);
|
||||
|
||||
const limiter =
|
||||
limiterConfig.type === "fixedWindow"
|
||||
? Ratelimit.fixedWindow(limiterConfig.tokens, limiterConfig.window)
|
||||
: limiterConfig.type === "tokenBucket"
|
||||
? Ratelimit.tokenBucket(
|
||||
limiterConfig.refillRate,
|
||||
limiterConfig.interval,
|
||||
limiterConfig.maxTokens
|
||||
)
|
||||
: Ratelimit.slidingWindow(limiterConfig.tokens, limiterConfig.window);
|
||||
|
||||
return new RateLimiter({
|
||||
redisClient: batchLimitsRedisClient,
|
||||
keyPrefix: "ratelimit:batch",
|
||||
limiter,
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimiterConfig {
|
||||
const defaultRateLimiterConfig: RateLimiterConfig = {
|
||||
type: "tokenBucket",
|
||||
refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE,
|
||||
interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
maxTokens: env.BATCH_RATE_LIMIT_MAX,
|
||||
};
|
||||
|
||||
if (!batchRateLimitConfig) {
|
||||
return defaultRateLimiterConfig;
|
||||
}
|
||||
|
||||
const parsedBatchRateLimitConfig = RateLimiterConfig.safeParse(batchRateLimitConfig);
|
||||
|
||||
if (!parsedBatchRateLimitConfig.success) {
|
||||
return defaultRateLimiterConfig;
|
||||
}
|
||||
|
||||
return parsedBatchRateLimitConfig.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiter and limits for an organization.
|
||||
* Internally looks up the plan type, but doesn't expose it to callers.
|
||||
*/
|
||||
export async function getBatchLimits(
|
||||
organization: Organization
|
||||
): Promise<{ rateLimiter: RateLimiter; config: BatchLimitsConfig }> {
|
||||
const rateLimiter = createOrganizationRateLimiter(organization);
|
||||
const config = resolveBatchLimitsConfig(organization.batchQueueConcurrencyConfig);
|
||||
return { rateLimiter, config };
|
||||
}
|
||||
|
||||
function resolveBatchLimitsConfig(batchLimitsConfig?: unknown): BatchLimitsConfig {
|
||||
const defaultLimitsConfig: BatchLimitsConfig = {
|
||||
processingConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
|
||||
};
|
||||
|
||||
if (!batchLimitsConfig) {
|
||||
return defaultLimitsConfig;
|
||||
}
|
||||
|
||||
const parsedBatchLimitsConfig = BatchLimitsConfig.safeParse(batchLimitsConfig);
|
||||
|
||||
if (!parsedBatchLimitsConfig.success) {
|
||||
return defaultLimitsConfig;
|
||||
}
|
||||
|
||||
return parsedBatchLimitsConfig.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when batch rate limit is exceeded.
|
||||
* Contains information for constructing a proper 429 response.
|
||||
*/
|
||||
export class BatchRateLimitExceededError extends Error {
|
||||
constructor(
|
||||
public readonly limit: number,
|
||||
public readonly remaining: number,
|
||||
public readonly resetAt: Date,
|
||||
public readonly itemCount: number
|
||||
) {
|
||||
super(
|
||||
`Batch rate limit exceeded. Attempted to submit ${itemCount} items but only ${remaining} remaining. Limit resets at ${resetAt.toISOString()}`
|
||||
);
|
||||
this.name = "BatchRateLimitExceededError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
|
||||
import { env } from "~/env.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
import { uploadPacketToObjectStore, r2 } from "~/v3/r2.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export type BatchPayloadProcessResult = {
|
||||
/** The processed payload - either the original or an R2 path */
|
||||
payload: unknown;
|
||||
/** The payload type - "application/store" if offloaded to R2 */
|
||||
payloadType: string;
|
||||
/** Whether the payload was offloaded to R2 */
|
||||
wasOffloaded: boolean;
|
||||
/** Size of the payload in bytes */
|
||||
size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* BatchPayloadProcessor handles payload offloading for batch items.
|
||||
*
|
||||
* When a batch item's payload exceeds the configured threshold, it's uploaded
|
||||
* to object storage (R2) and the payload is replaced with the storage path.
|
||||
* This aligns with how single task triggers work via DefaultPayloadProcessor.
|
||||
*
|
||||
* Path format: batch_{batchId}/item_{index}/payload.json
|
||||
*/
|
||||
export class BatchPayloadProcessor {
|
||||
/**
|
||||
* Check if object storage is available for payload offloading.
|
||||
* If not available, large payloads will be stored inline (which may fail for very large payloads).
|
||||
*/
|
||||
isObjectStoreAvailable(): boolean {
|
||||
return r2 !== undefined && env.OBJECT_STORE_BASE_URL !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch item payload, offloading to R2 if it exceeds the threshold.
|
||||
*
|
||||
* @param payload - The raw payload from the batch item
|
||||
* @param payloadType - The payload type (e.g., "application/json")
|
||||
* @param batchId - The batch ID (internal format)
|
||||
* @param itemIndex - The item index within the batch
|
||||
* @param environment - The authenticated environment for R2 path construction
|
||||
* @returns The processed result with potentially offloaded payload
|
||||
*/
|
||||
async process(
|
||||
payload: unknown,
|
||||
payloadType: string,
|
||||
batchId: string,
|
||||
itemIndex: number,
|
||||
environment: AuthenticatedEnvironment
|
||||
): Promise<BatchPayloadProcessResult> {
|
||||
return startActiveSpan("BatchPayloadProcessor.process()", async (span) => {
|
||||
span.setAttribute("batchId", batchId);
|
||||
span.setAttribute("itemIndex", itemIndex);
|
||||
span.setAttribute("payloadType", payloadType);
|
||||
|
||||
// Create the packet for size checking
|
||||
const packet = this.#createPayloadPacket(payload, payloadType);
|
||||
|
||||
if (!packet.data) {
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const threshold = env.BATCH_PAYLOAD_OFFLOAD_THRESHOLD ?? env.TASK_PAYLOAD_OFFLOAD_THRESHOLD;
|
||||
const { needsOffloading, size } = packetRequiresOffloading(packet, threshold);
|
||||
|
||||
span.setAttribute("payloadSize", size);
|
||||
span.setAttribute("needsOffloading", needsOffloading);
|
||||
span.setAttribute("threshold", threshold);
|
||||
|
||||
if (!needsOffloading) {
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if object store is available
|
||||
if (!this.isObjectStoreAvailable()) {
|
||||
logger.warn("Payload exceeds threshold but object store is not available", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
size,
|
||||
threshold,
|
||||
});
|
||||
|
||||
// Return without offloading - the payload will be stored inline
|
||||
// This may fail downstream for very large payloads
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload to R2
|
||||
const filename = `batch_${batchId}/item_${itemIndex}/payload.json`;
|
||||
|
||||
const [uploadError] = await tryCatch(
|
||||
uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment)
|
||||
);
|
||||
|
||||
if (uploadError) {
|
||||
logger.error("Failed to upload batch item payload to object store", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
error: uploadError instanceof Error ? uploadError.message : String(uploadError),
|
||||
});
|
||||
|
||||
// Throw to fail this item - SDK can retry
|
||||
throw new Error(
|
||||
`Failed to upload large payload to object store: ${
|
||||
uploadError instanceof Error ? uploadError.message : String(uploadError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Batch item payload offloaded to R2", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
filename,
|
||||
size,
|
||||
});
|
||||
|
||||
span.setAttribute("wasOffloaded", true);
|
||||
span.setAttribute("offloadPath", filename);
|
||||
|
||||
return {
|
||||
payload: filename,
|
||||
payloadType: "application/store",
|
||||
wasOffloaded: true,
|
||||
size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IOPacket from payload for size checking.
|
||||
*/
|
||||
#createPayloadPacket(payload: unknown, payloadType: string): IOPacket {
|
||||
if (payloadType === "application/json") {
|
||||
// Payload from SDK is already serialized as a string - use directly
|
||||
if (typeof payload === "string") {
|
||||
return { data: payload, dataType: "application/json" };
|
||||
}
|
||||
// Non-string payloads (e.g., direct API calls with objects) need serialization
|
||||
return { data: JSON.stringify(payload), dataType: "application/json" };
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return { data: payload, dataType: payloadType };
|
||||
}
|
||||
|
||||
// For other types, try to stringify
|
||||
try {
|
||||
return { data: JSON.stringify(payload), dataType: payloadType };
|
||||
} catch {
|
||||
return { dataType: payloadType };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { autoIncrementCounter } from "~/services/autoIncrementCounter.server";
|
||||
import { RunNumberIncrementer, TriggerTaskRequest } from "../types";
|
||||
|
||||
export class DefaultRunNumberIncrementer implements RunNumberIncrementer {
|
||||
async incrementRunNumber<T>(
|
||||
request: TriggerTaskRequest,
|
||||
callback: (num: number) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
return await autoIncrementCounter.incrementInTransaction(
|
||||
`v3-run:${request.environment.id}:${request.taskId}`,
|
||||
callback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
@@ -116,6 +117,73 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async traceDebouncedRun<T>(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined,
|
||||
options: {
|
||||
existingRun: TaskRun;
|
||||
debounceKey: string;
|
||||
incomplete: boolean;
|
||||
isError: boolean;
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const { existingRun, debounceKey, incomplete, isError } = options;
|
||||
const { repository, store } = await this.#getEventRepository(request, parentStore);
|
||||
|
||||
return await repository.traceEvent(
|
||||
`${request.taskId} (debounced)`,
|
||||
{
|
||||
context: request.options?.traceContext,
|
||||
spanParentAsLink: request.options?.spanParentAsLink,
|
||||
kind: "SERVER",
|
||||
environment: request.environment,
|
||||
taskSlug: request.taskId,
|
||||
attributes: {
|
||||
properties: {
|
||||
[SemanticInternalAttributes.ORIGINAL_RUN_ID]: existingRun.friendlyId,
|
||||
},
|
||||
style: {
|
||||
icon: "task-cached",
|
||||
},
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
incomplete,
|
||||
isError,
|
||||
immediate: true,
|
||||
},
|
||||
async (event, traceContext, traceparent) => {
|
||||
// Log a message about the debounced trigger
|
||||
await repository.recordEvent(
|
||||
`Debounced: using existing run with key "${debounceKey}"`,
|
||||
{
|
||||
taskSlug: request.taskId,
|
||||
environment: request.environment,
|
||||
attributes: {
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
context: request.options?.traceContext,
|
||||
parentId: event.spanId,
|
||||
}
|
||||
);
|
||||
|
||||
return await callback(
|
||||
{
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
traceContext,
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { InitializeBatchOptions } from "@internal/run-engine";
|
||||
import { type CreateBatchRequestBody, type CreateBatchResponse } from "@trigger.dev/core/v3";
|
||||
import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { type BatchTaskRun, Prisma } from "@trigger.dev/database";
|
||||
import { Evt } from "evt";
|
||||
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
||||
import { BatchRateLimitExceededError, getBatchLimits } from "../concerns/batchLimits.server";
|
||||
import { DefaultQueueManager } from "../concerns/queues.server";
|
||||
import { DefaultTriggerTaskValidator } from "../validators/triggerTaskValidator";
|
||||
|
||||
export type CreateBatchServiceOptions = {
|
||||
triggerVersion?: string;
|
||||
traceContext?: Record<string, string | undefined | Record<string, string | undefined>>;
|
||||
spanParentAsLink?: boolean;
|
||||
oneTimeUseToken?: string;
|
||||
realtimeStreamsVersion?: "v1" | "v2";
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Batch Service (Phase 1 of 2-phase batch API).
|
||||
*
|
||||
* This service handles Phase 1 of the streaming batch API:
|
||||
* 1. Validates entitlement and queue limits
|
||||
* 2. Creates BatchTaskRun in Postgres with status=PENDING, expectedCount set
|
||||
* 3. For batchTriggerAndWait: blocks the parent run immediately
|
||||
* 4. Initializes batch metadata in Redis
|
||||
* 5. Returns batch ID - items are streamed separately via Phase 2
|
||||
*
|
||||
* The batch is NOT sealed until Phase 2 completes.
|
||||
*/
|
||||
export class CreateBatchService extends WithRunEngine {
|
||||
public onBatchTaskRunCreated: Evt<BatchTaskRun> = new Evt();
|
||||
private readonly queueConcern: DefaultQueueManager;
|
||||
private readonly validator: DefaultTriggerTaskValidator;
|
||||
|
||||
constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {
|
||||
super({ prisma: _prisma });
|
||||
|
||||
this.queueConcern = new DefaultQueueManager(this._prisma, this._engine);
|
||||
this.validator = new DefaultTriggerTaskValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a batch for 2-phase processing.
|
||||
* Items will be streamed separately via the StreamBatchItemsService.
|
||||
*/
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: CreateBatchRequestBody,
|
||||
options: CreateBatchServiceOptions = {}
|
||||
): Promise<CreateBatchResponse> {
|
||||
try {
|
||||
return await this.traceWithEnv<CreateBatchResponse>(
|
||||
"createBatch()",
|
||||
environment,
|
||||
async (span) => {
|
||||
const { id, friendlyId } = BatchId.generate();
|
||||
|
||||
span.setAttribute("batchId", friendlyId);
|
||||
span.setAttribute("runCount", body.runCount);
|
||||
|
||||
// Validate entitlement
|
||||
const entitlementValidation = await this.validator.validateEntitlement({
|
||||
environment,
|
||||
});
|
||||
|
||||
if (!entitlementValidation.ok) {
|
||||
throw entitlementValidation.error;
|
||||
}
|
||||
|
||||
// Extract plan type from entitlement validation for billing tracking
|
||||
const planType = entitlementValidation.plan?.type;
|
||||
|
||||
// Get batch limits for this organization
|
||||
const { config, rateLimiter } = await getBatchLimits(environment.organization);
|
||||
|
||||
// Check rate limit BEFORE creating the batch
|
||||
// This prevents burst creation of batches that exceed the rate limit
|
||||
const rateResult = await rateLimiter.limit(environment.id, body.runCount);
|
||||
|
||||
if (!rateResult.success) {
|
||||
throw new BatchRateLimitExceededError(
|
||||
rateResult.limit,
|
||||
rateResult.remaining,
|
||||
new Date(rateResult.reset),
|
||||
body.runCount
|
||||
);
|
||||
}
|
||||
|
||||
// Validate queue limits for the expected batch size
|
||||
const queueSizeGuard = await this.queueConcern.validateQueueLimits(
|
||||
environment,
|
||||
body.runCount
|
||||
);
|
||||
|
||||
if (!queueSizeGuard.ok) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot create batch with ${body.runCount} items as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create BatchTaskRun in Postgres with PENDING status
|
||||
// The batch will be sealed (status -> PROCESSING) when items are streamed
|
||||
const batch = await this._prisma.batchTaskRun.create({
|
||||
data: {
|
||||
id,
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
status: "PENDING",
|
||||
runCount: body.runCount,
|
||||
expectedCount: body.runCount,
|
||||
runIds: [],
|
||||
batchVersion: "runengine:v2", // 2-phase streaming batch API
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
// Not sealed yet - will be sealed when items stream completes
|
||||
sealed: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.onBatchTaskRunCreated.post(batch);
|
||||
|
||||
// Block parent run if this is a batchTriggerAndWait
|
||||
if (body.parentRunId && body.resumeParentOnCompletion) {
|
||||
await this._engine.blockRunWithCreatedBatch({
|
||||
runId: RunId.fromFriendlyId(body.parentRunId),
|
||||
batchId: batch.id,
|
||||
environmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
organizationId: environment.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize batch metadata in Redis (without items)
|
||||
const initOptions: InitializeBatchOptions = {
|
||||
batchId: id,
|
||||
friendlyId,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
runCount: body.runCount,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
triggerVersion: options.triggerVersion,
|
||||
traceContext: options.traceContext as Record<string, unknown> | undefined,
|
||||
spanParentAsLink: options.spanParentAsLink,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
processingConcurrency: config.processingConcurrency,
|
||||
planType,
|
||||
};
|
||||
|
||||
await this._engine.initializeBatch(initOptions);
|
||||
|
||||
logger.info("Batch created", {
|
||||
batchId: friendlyId,
|
||||
runCount: body.runCount,
|
||||
envId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
processingConcurrency: config.processingConcurrency,
|
||||
});
|
||||
|
||||
return {
|
||||
id: friendlyId,
|
||||
runCount: body.runCount,
|
||||
isCached: false,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
};
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Handle Prisma unique constraint violations
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.debug("CreateBatchService: Prisma error", {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
meta: error.meta,
|
||||
});
|
||||
|
||||
if (error.code === "P2002") {
|
||||
const target = error.meta?.target;
|
||||
|
||||
if (
|
||||
Array.isArray(target) &&
|
||||
target.length > 0 &&
|
||||
typeof target[0] === "string" &&
|
||||
target[0].includes("oneTimeUseToken")
|
||||
) {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot create batch with a one-time use token as it has already been used."
|
||||
);
|
||||
} else {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot create batch as it has already been created with the same idempotency key."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import {
|
||||
type BatchItemNDJSON,
|
||||
type StreamBatchItemsResponse,
|
||||
BatchItemNDJSON as BatchItemNDJSONSchema,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { BatchItem, RunEngine } from "@internal/run-engine";
|
||||
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
||||
import { BatchPayloadProcessor } from "../concerns/batchPayloads.server";
|
||||
|
||||
export type StreamBatchItemsServiceOptions = {
|
||||
maxItemBytes: number;
|
||||
};
|
||||
|
||||
export type StreamBatchItemsServiceConstructorOptions = {
|
||||
prisma?: PrismaClientOrTransaction;
|
||||
engine?: RunEngine;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream Batch Items Service (Phase 2 of 2-phase batch API).
|
||||
*
|
||||
* This service handles Phase 2 of the streaming batch API:
|
||||
* 1. Validates batch exists and is in PENDING status
|
||||
* 2. Processes NDJSON stream item by item
|
||||
* 3. Calls engine.enqueueBatchItem() for each item
|
||||
* 4. Tracks accepted/deduplicated counts
|
||||
* 5. On completion: validates count, seals the batch
|
||||
*
|
||||
* The service is designed for streaming and processes items as they arrive,
|
||||
* providing backpressure through the async iterator pattern.
|
||||
*/
|
||||
export class StreamBatchItemsService extends WithRunEngine {
|
||||
private readonly payloadProcessor: BatchPayloadProcessor;
|
||||
|
||||
constructor(opts: StreamBatchItemsServiceConstructorOptions = {}) {
|
||||
super({ prisma: opts.prisma ?? prisma, engine: opts.engine });
|
||||
this.payloadProcessor = new BatchPayloadProcessor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a batch friendly ID to its internal ID format.
|
||||
* Throws a ServiceValidationError with 400 status if the ID is malformed.
|
||||
*/
|
||||
private parseBatchFriendlyId(friendlyId: string): string {
|
||||
try {
|
||||
return BatchId.fromFriendlyId(friendlyId);
|
||||
} catch {
|
||||
throw new ServiceValidationError(`Invalid batchFriendlyId: ${friendlyId}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a stream of batch items from an async iterator.
|
||||
* Each item is validated and enqueued to the BatchQueue.
|
||||
* The batch is sealed when the stream completes.
|
||||
*/
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
batchFriendlyId: string,
|
||||
itemsIterator: AsyncIterable<unknown>,
|
||||
options: StreamBatchItemsServiceOptions
|
||||
): Promise<StreamBatchItemsResponse> {
|
||||
return this.traceWithEnv<StreamBatchItemsResponse>(
|
||||
"streamBatchItems()",
|
||||
environment,
|
||||
async (span) => {
|
||||
span.setAttribute("batchId", batchFriendlyId);
|
||||
|
||||
// Convert friendly ID to internal ID
|
||||
const batchId = this.parseBatchFriendlyId(batchFriendlyId);
|
||||
|
||||
// Validate batch exists and belongs to this environment
|
||||
const batch = await this._prisma.batchTaskRun.findFirst({
|
||||
where: {
|
||||
id: batchId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
runCount: true,
|
||||
sealed: true,
|
||||
batchVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new ServiceValidationError(`Batch ${batchFriendlyId} not found`);
|
||||
}
|
||||
|
||||
if (batch.sealed) {
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is already sealed and cannot accept more items`
|
||||
);
|
||||
}
|
||||
|
||||
if (batch.status !== "PENDING") {
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is not in PENDING status (current: ${batch.status})`
|
||||
);
|
||||
}
|
||||
|
||||
let itemsAccepted = 0;
|
||||
let itemsDeduplicated = 0;
|
||||
let lastIndex = -1;
|
||||
|
||||
// Process items from the stream
|
||||
for await (const rawItem of itemsIterator) {
|
||||
// Parse and validate the item
|
||||
const parseResult = BatchItemNDJSONSchema.safeParse(rawItem);
|
||||
if (!parseResult.success) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid item at index ${lastIndex + 1}: ${parseResult.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const item = parseResult.data;
|
||||
lastIndex = item.index;
|
||||
|
||||
// Validate index is within expected range
|
||||
if (item.index >= batch.runCount) {
|
||||
throw new ServiceValidationError(
|
||||
`Item index ${item.index} exceeds batch runCount ${batch.runCount}`
|
||||
);
|
||||
}
|
||||
|
||||
// Get the original payload type
|
||||
const originalPayloadType = (item.options?.payloadType as string) ?? "application/json";
|
||||
|
||||
// Process payload - offload to R2 if it exceeds threshold
|
||||
const processedPayload = await this.payloadProcessor.process(
|
||||
item.payload,
|
||||
originalPayloadType,
|
||||
batchId,
|
||||
item.index,
|
||||
environment
|
||||
);
|
||||
|
||||
// Convert to BatchItem format with potentially offloaded payload
|
||||
const batchItem: BatchItem = {
|
||||
task: item.task,
|
||||
payload: processedPayload.payload,
|
||||
payloadType: processedPayload.payloadType,
|
||||
options: item.options,
|
||||
};
|
||||
|
||||
// Enqueue the item
|
||||
const result = await this._engine.enqueueBatchItem(
|
||||
batchId,
|
||||
environment.id,
|
||||
item.index,
|
||||
batchItem
|
||||
);
|
||||
|
||||
if (result.enqueued) {
|
||||
itemsAccepted++;
|
||||
} else {
|
||||
itemsDeduplicated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the actual enqueued count from Redis
|
||||
const enqueuedCount = await this._engine.getBatchEnqueuedCount(batchId);
|
||||
|
||||
// Validate we received the expected number of items
|
||||
if (enqueuedCount !== batch.runCount) {
|
||||
logger.warn("Batch item count mismatch", {
|
||||
batchId: batchFriendlyId,
|
||||
expected: batch.runCount,
|
||||
received: enqueuedCount,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
});
|
||||
|
||||
// Don't seal the batch if count doesn't match
|
||||
// Return sealed: false so client knows to retry with missing items
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: false,
|
||||
enqueuedCount,
|
||||
expectedCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Seal the batch - use conditional update to prevent TOCTOU race
|
||||
// Another concurrent request may have already sealed this batch
|
||||
const now = new Date();
|
||||
const sealResult = await this._prisma.batchTaskRun.updateMany({
|
||||
where: {
|
||||
id: batchId,
|
||||
sealed: false,
|
||||
status: "PENDING",
|
||||
},
|
||||
data: {
|
||||
sealed: true,
|
||||
sealedAt: now,
|
||||
status: "PROCESSING",
|
||||
processingStartedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if we won the race to seal the batch
|
||||
if (sealResult.count === 0) {
|
||||
// Another request sealed the batch first - re-query to check current state
|
||||
const currentBatch = await this._prisma.batchTaskRun.findUnique({
|
||||
where: { id: batchId },
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
sealed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentBatch?.sealed && currentBatch.status === "PROCESSING") {
|
||||
// The batch was sealed by another request - this is fine, the goal was achieved
|
||||
logger.info("Batch already sealed by concurrent request", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
span.setAttribute("itemsAccepted", itemsAccepted);
|
||||
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
|
||||
span.setAttribute("sealedByConcurrentRequest", true);
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Batch is in an unexpected state - fail with error
|
||||
const actualStatus = currentBatch?.status ?? "unknown";
|
||||
const actualSealed = currentBatch?.sealed ?? "unknown";
|
||||
logger.error("Batch seal race condition: unexpected state", {
|
||||
batchId: batchFriendlyId,
|
||||
expectedStatus: "PENDING",
|
||||
actualStatus,
|
||||
expectedSealed: false,
|
||||
actualSealed,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is in unexpected state (status: ${actualStatus}, sealed: ${actualSealed}). Cannot seal batch.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Batch sealed and ready for processing", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
totalEnqueued: enqueuedCount,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
span.setAttribute("itemsAccepted", itemsAccepted);
|
||||
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NDJSON parser transform stream.
|
||||
*
|
||||
* Converts a stream of Uint8Array chunks into parsed JSON objects.
|
||||
* Each line in the NDJSON is parsed independently.
|
||||
*
|
||||
* Uses byte-buffer accumulation to:
|
||||
* - Prevent OOM from unbounded string buffers
|
||||
* - Properly handle multibyte UTF-8 characters across chunk boundaries
|
||||
* - Check size limits on raw bytes before decoding
|
||||
*
|
||||
* @param maxItemBytes - Maximum allowed bytes per line (item)
|
||||
* @returns TransformStream that outputs parsed JSON objects
|
||||
*/
|
||||
export function createNdjsonParserStream(
|
||||
maxItemBytes: number
|
||||
): TransformStream<Uint8Array, unknown> {
|
||||
// Single decoder instance, reused for all lines
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
// Byte buffer: array of chunks with tracked total length
|
||||
let chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
const NEWLINE_BYTE = 0x0a; // '\n'
|
||||
|
||||
/**
|
||||
* Concatenate all chunks into a single Uint8Array
|
||||
*/
|
||||
function concatenateChunks(): Uint8Array {
|
||||
if (chunks.length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (chunks.length === 1) {
|
||||
return chunks[0];
|
||||
}
|
||||
const result = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first newline byte in the buffer.
|
||||
* Returns -1 if not found.
|
||||
*/
|
||||
function findNewlineIndex(): number {
|
||||
let globalIndex = 0;
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunk.byteLength; i++) {
|
||||
if (chunk[i] === NEWLINE_BYTE) {
|
||||
return globalIndex + i;
|
||||
}
|
||||
}
|
||||
globalIndex += chunk.byteLength;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bytes from the buffer up to (but not including) the given index,
|
||||
* and remove those bytes plus the delimiter from the buffer.
|
||||
*/
|
||||
function extractLine(newlineIndex: number): Uint8Array {
|
||||
const fullBuffer = concatenateChunks();
|
||||
const lineBytes = fullBuffer.slice(0, newlineIndex);
|
||||
const remaining = fullBuffer.slice(newlineIndex + 1); // Skip the newline
|
||||
|
||||
// Reset buffer with remaining bytes
|
||||
if (remaining.byteLength > 0) {
|
||||
chunks = [remaining];
|
||||
totalBytes = remaining.byteLength;
|
||||
} else {
|
||||
chunks = [];
|
||||
totalBytes = 0;
|
||||
}
|
||||
|
||||
return lineBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a line from bytes, handling whitespace trimming.
|
||||
* Returns the parsed object or null for empty lines.
|
||||
*/
|
||||
function parseLine(
|
||||
lineBytes: Uint8Array,
|
||||
controller: TransformStreamDefaultController<unknown>
|
||||
): void {
|
||||
lineNumber++;
|
||||
|
||||
// Decode the line bytes (stream: false since this is a complete line)
|
||||
let lineText: string;
|
||||
try {
|
||||
lineText = decoder.decode(lineBytes, { stream: false });
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid UTF-8 at line ${lineNumber}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const trimmed = lineText.trim();
|
||||
if (!trimmed) {
|
||||
return; // Skip empty lines
|
||||
}
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
controller.enqueue(obj);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid JSON at line ${lineNumber}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new TransformStream<Uint8Array, unknown>({
|
||||
transform(chunk, controller) {
|
||||
// Append chunk to buffer
|
||||
chunks.push(chunk);
|
||||
totalBytes += chunk.byteLength;
|
||||
|
||||
// Process all complete lines in the buffer
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = findNewlineIndex()) !== -1) {
|
||||
// Check size limit BEFORE extracting/decoding (bytes up to newline)
|
||||
if (newlineIndex > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${newlineIndex})`
|
||||
);
|
||||
}
|
||||
|
||||
const lineBytes = extractLine(newlineIndex);
|
||||
parseLine(lineBytes, controller);
|
||||
}
|
||||
|
||||
// Check if the remaining buffer (incomplete line) exceeds the limit
|
||||
// This prevents OOM from a single huge line without newlines
|
||||
if (totalBytes > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (buffered: ${totalBytes}, no newline found)`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
// Flush any remaining bytes from the decoder's internal state
|
||||
// This handles multibyte characters that may have been split across chunks
|
||||
decoder.decode(new Uint8Array(0), { stream: false });
|
||||
|
||||
// Process any remaining buffered data (no trailing newline case)
|
||||
if (totalBytes === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check size limit before processing final line
|
||||
if (totalBytes > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${totalBytes})`
|
||||
);
|
||||
}
|
||||
|
||||
const finalBytes = concatenateChunks();
|
||||
parseLine(finalBytes, controller);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ReadableStream into an AsyncIterable.
|
||||
* Useful for processing streams with for-await-of loops.
|
||||
*/
|
||||
export async function* streamToAsyncIterable<T>(stream: ReadableStream<T>): AsyncIterable<T> {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ import { IdempotencyKeyConcern } from "../concerns/idempotencyKeys.server";
|
||||
import type {
|
||||
PayloadProcessor,
|
||||
QueueManager,
|
||||
RunNumberIncrementer,
|
||||
TraceEventConcern,
|
||||
TriggerRacepoints,
|
||||
TriggerRacepointSystem,
|
||||
@@ -54,7 +53,6 @@ export class RunEngineTriggerTaskService {
|
||||
private readonly validator: TriggerTaskValidator;
|
||||
private readonly payloadProcessor: PayloadProcessor;
|
||||
private readonly idempotencyKeyConcern: IdempotencyKeyConcern;
|
||||
private readonly runNumberIncrementer: RunNumberIncrementer;
|
||||
private readonly prisma: PrismaClientOrTransaction;
|
||||
private readonly engine: RunEngine;
|
||||
private readonly tracer: Tracer;
|
||||
@@ -69,7 +67,6 @@ export class RunEngineTriggerTaskService {
|
||||
validator: TriggerTaskValidator;
|
||||
payloadProcessor: PayloadProcessor;
|
||||
idempotencyKeyConcern: IdempotencyKeyConcern;
|
||||
runNumberIncrementer: RunNumberIncrementer;
|
||||
traceEventConcern: TraceEventConcern;
|
||||
tracer: Tracer;
|
||||
metadataMaximumSize: number;
|
||||
@@ -81,7 +78,6 @@ export class RunEngineTriggerTaskService {
|
||||
this.validator = opts.validator;
|
||||
this.payloadProcessor = opts.payloadProcessor;
|
||||
this.idempotencyKeyConcern = opts.idempotencyKeyConcern;
|
||||
this.runNumberIncrementer = opts.runNumberIncrementer;
|
||||
this.tracer = opts.tracer;
|
||||
this.traceEventConcern = opts.traceEventConcern;
|
||||
this.metadataMaximumSize = opts.metadataMaximumSize;
|
||||
@@ -164,10 +160,34 @@ export class RunEngineTriggerTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(body.options?.delay));
|
||||
// Parse delay from either explicit delay option or debounce.delay
|
||||
const delaySource = body.options?.delay ?? body.options?.debounce?.delay;
|
||||
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(delaySource));
|
||||
|
||||
if (parseDelayError) {
|
||||
throw new ServiceValidationError(`Invalid delay ${body.options?.delay}`);
|
||||
throw new ServiceValidationError(`Invalid delay ${delaySource}`);
|
||||
}
|
||||
|
||||
// Validate debounce options
|
||||
if (body.options?.debounce) {
|
||||
if (!delayUntil) {
|
||||
throw new ServiceValidationError(
|
||||
`Debounce requires a valid delay duration. Provided: ${body.options.debounce.delay}`
|
||||
);
|
||||
}
|
||||
|
||||
// Always validate debounce.delay separately since it's used for rescheduling
|
||||
// This catches the case where options.delay is valid but debounce.delay is invalid
|
||||
const [debounceDelayError, debounceDelayUntil] = await tryCatch(
|
||||
parseDelay(body.options.debounce.delay)
|
||||
);
|
||||
|
||||
if (debounceDelayError || !debounceDelayUntil) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid debounce delay: ${body.options.debounce.delay}. ` +
|
||||
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ttl =
|
||||
@@ -271,97 +291,129 @@ export class RunEngineTriggerTaskService {
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
async (event, store) => {
|
||||
const result = await this.runNumberIncrementer.incrementRunNumber(
|
||||
triggerRequest,
|
||||
async (num) => {
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
event.setAttribute("runId", runFriendlyId);
|
||||
span.setAttribute("runId", runFriendlyId);
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
event.setAttribute("runId", runFriendlyId);
|
||||
span.setAttribute("runId", runFriendlyId);
|
||||
|
||||
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
|
||||
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
|
||||
|
||||
const taskRun = await this.engine.trigger(
|
||||
{
|
||||
number: num,
|
||||
friendlyId: runFriendlyId,
|
||||
environment: environment,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
|
||||
taskIdentifier: taskId,
|
||||
payload: payloadPacket.data ?? "",
|
||||
payloadType: payloadPacket.dataType,
|
||||
context: body.context,
|
||||
traceContext: this.#propagateExternalTraceContext(
|
||||
event.traceContext,
|
||||
parentRun?.traceContext,
|
||||
event.traceparent?.spanId
|
||||
),
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
|
||||
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
|
||||
lockedToVersionId: lockedToBackgroundWorker?.id,
|
||||
taskVersion: lockedToBackgroundWorker?.version,
|
||||
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
|
||||
cliVersion: lockedToBackgroundWorker?.cliVersion,
|
||||
concurrencyKey: body.options?.concurrencyKey,
|
||||
queue: queueName,
|
||||
lockedQueueId,
|
||||
workerQueue,
|
||||
isTest: body.options?.test ?? false,
|
||||
delayUntil,
|
||||
queuedAt: delayUntil ? undefined : new Date(),
|
||||
maxAttempts: body.options?.maxAttempts,
|
||||
taskEventStore: store,
|
||||
ttl,
|
||||
tags,
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
parentTaskRunId: parentRun?.id,
|
||||
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
|
||||
batch: options?.batchId
|
||||
? {
|
||||
id: options.batchId,
|
||||
index: options.batchIndex ?? 0,
|
||||
}
|
||||
: undefined,
|
||||
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
||||
depth,
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
seedMetadata: metadataPacket?.data,
|
||||
seedMetadataType: metadataPacket?.dataType,
|
||||
maxDurationInSeconds: body.options?.maxDuration
|
||||
? clampMaxDuration(body.options.maxDuration)
|
||||
: undefined,
|
||||
machine: body.options?.machine,
|
||||
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
|
||||
queueTimestamp:
|
||||
options.queueTimestamp ??
|
||||
(parentRun && body.options?.resumeParentOnCompletion
|
||||
? parentRun.queueTimestamp ?? undefined
|
||||
: undefined),
|
||||
scheduleId: options.scheduleId,
|
||||
scheduleInstanceId: options.scheduleInstanceId,
|
||||
createdAt: options.overrideCreatedAt,
|
||||
bulkActionId: body.options?.bulkActionId,
|
||||
planType,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
|
||||
|
||||
if (error) {
|
||||
event.failWithError(error);
|
||||
}
|
||||
|
||||
return { run: taskRun, error, isCached: false };
|
||||
}
|
||||
const taskRun = await this.engine.trigger(
|
||||
{
|
||||
friendlyId: runFriendlyId,
|
||||
environment: environment,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
|
||||
taskIdentifier: taskId,
|
||||
payload: payloadPacket.data ?? "",
|
||||
payloadType: payloadPacket.dataType,
|
||||
context: body.context,
|
||||
traceContext: this.#propagateExternalTraceContext(
|
||||
event.traceContext,
|
||||
parentRun?.traceContext,
|
||||
event.traceparent?.spanId
|
||||
),
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
|
||||
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
|
||||
lockedToVersionId: lockedToBackgroundWorker?.id,
|
||||
taskVersion: lockedToBackgroundWorker?.version,
|
||||
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
|
||||
cliVersion: lockedToBackgroundWorker?.cliVersion,
|
||||
concurrencyKey: body.options?.concurrencyKey,
|
||||
queue: queueName,
|
||||
lockedQueueId,
|
||||
workerQueue,
|
||||
isTest: body.options?.test ?? false,
|
||||
delayUntil,
|
||||
queuedAt: delayUntil ? undefined : new Date(),
|
||||
maxAttempts: body.options?.maxAttempts,
|
||||
taskEventStore: store,
|
||||
ttl,
|
||||
tags,
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
parentTaskRunId: parentRun?.id,
|
||||
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
|
||||
batch: options?.batchId
|
||||
? {
|
||||
id: options.batchId,
|
||||
index: options.batchIndex ?? 0,
|
||||
}
|
||||
: undefined,
|
||||
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
||||
depth,
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
seedMetadata: metadataPacket?.data,
|
||||
seedMetadataType: metadataPacket?.dataType,
|
||||
maxDurationInSeconds: body.options?.maxDuration
|
||||
? clampMaxDuration(body.options.maxDuration)
|
||||
: undefined,
|
||||
machine: body.options?.machine,
|
||||
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
|
||||
queueTimestamp:
|
||||
options.queueTimestamp ??
|
||||
(parentRun && body.options?.resumeParentOnCompletion
|
||||
? parentRun.queueTimestamp ?? undefined
|
||||
: undefined),
|
||||
scheduleId: options.scheduleId,
|
||||
scheduleInstanceId: options.scheduleInstanceId,
|
||||
createdAt: options.overrideCreatedAt,
|
||||
bulkActionId: body.options?.bulkActionId,
|
||||
planType,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
debounce: body.options?.debounce,
|
||||
// When debouncing with triggerAndWait, create a span for the debounced trigger
|
||||
onDebounced:
|
||||
body.options?.debounce && body.options?.resumeParentOnCompletion
|
||||
? async ({ existingRun, waitpoint, debounceKey }) => {
|
||||
return await this.traceEventConcern.traceDebouncedRun(
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
{
|
||||
existingRun,
|
||||
debounceKey,
|
||||
incomplete: waitpoint.status === "PENDING",
|
||||
isError: waitpoint.outputIsError,
|
||||
},
|
||||
async (spanEvent) => {
|
||||
const spanId =
|
||||
options?.parentAsLinkType === "replay"
|
||||
? spanEvent.spanId
|
||||
: spanEvent.traceparent?.spanId
|
||||
? `${spanEvent.traceparent.spanId}:${spanEvent.spanId}`
|
||||
: spanEvent.spanId;
|
||||
return spanId;
|
||||
}
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
// If the returned run has a different friendlyId, it was debounced.
|
||||
// For triggerAndWait: stop the outer span since a replacement debounced span was created via onDebounced.
|
||||
// For regular trigger: let the span complete normally - no replacement span needed since the
|
||||
// original run already has its span from when it was first created.
|
||||
if (
|
||||
taskRun.friendlyId !== runFriendlyId &&
|
||||
body.options?.debounce &&
|
||||
body.options?.resumeParentOnCompletion
|
||||
) {
|
||||
event.stop();
|
||||
}
|
||||
|
||||
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
|
||||
|
||||
if (error) {
|
||||
event.failWithError(error);
|
||||
}
|
||||
|
||||
const result = { run: taskRun, error, isCached: false };
|
||||
|
||||
if (result?.error) {
|
||||
throw new ServiceValidationError(
|
||||
taskRunErrorToString(taskRunErrorEnhancer(result.error))
|
||||
@@ -374,7 +426,13 @@ export class RunEngineTriggerTaskService {
|
||||
} catch (error) {
|
||||
if (error instanceof RunDuplicateIdempotencyKeyError) {
|
||||
//retry calling this function, because this time it will return the idempotent run
|
||||
return await this.call({ taskId, environment, body, options, attempt: attempt + 1 });
|
||||
return await this.call({
|
||||
taskId,
|
||||
environment,
|
||||
body,
|
||||
options: { ...options, runFriendlyId },
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof RunOneTimeUseTokenError) {
|
||||
|
||||
@@ -76,13 +76,6 @@ export interface PayloadProcessor {
|
||||
process(request: TriggerTaskRequest): Promise<IOPacket>;
|
||||
}
|
||||
|
||||
export interface RunNumberIncrementer {
|
||||
incrementRunNumber<T>(
|
||||
request: TriggerTaskRequest,
|
||||
callback: (num: number) => Promise<T>
|
||||
): Promise<T | undefined>;
|
||||
}
|
||||
|
||||
export interface TagValidationParams {
|
||||
tags?: string[] | string;
|
||||
}
|
||||
@@ -138,6 +131,12 @@ export type TracedEventSpan = {
|
||||
};
|
||||
setAttribute: (key: string, value: string) => void;
|
||||
failWithError: (error: TaskRunError) => void;
|
||||
/**
|
||||
* Stop the span without writing any event.
|
||||
* Used when a debounced run is returned - the span for the debounced
|
||||
* trigger is created separately via traceDebouncedRun.
|
||||
*/
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export interface TraceEventConcern {
|
||||
@@ -157,6 +156,17 @@ export interface TraceEventConcern {
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T>;
|
||||
traceDebouncedRun<T>(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined,
|
||||
options: {
|
||||
existingRun: TaskRun;
|
||||
debounceKey: string;
|
||||
incomplete: boolean;
|
||||
isError: boolean;
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export type TriggerRacepoints = "idempotencyKey";
|
||||
|
||||
@@ -61,6 +61,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
"/api/v1/auth/jwt/claims",
|
||||
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
|
||||
/^\/api\/v1\/waitpoints\/tokens\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash
|
||||
/^\/api\/v1\/deployments/, // /api/v1/deployments/*
|
||||
],
|
||||
log: {
|
||||
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Authenticator } from "remix-auth";
|
||||
import type { AuthUser } from "./authUser";
|
||||
import { addEmailLinkStrategy } from "./emailAuth.server";
|
||||
import { addGitHubStrategy } from "./gitHubAuth.server";
|
||||
import { addGoogleStrategy } from "./googleAuth.server";
|
||||
import { sessionStorage } from "./sessionStorage.server";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
@@ -13,10 +14,18 @@ const isGithubAuthSupported =
|
||||
typeof env.AUTH_GITHUB_CLIENT_ID === "string" &&
|
||||
typeof env.AUTH_GITHUB_CLIENT_SECRET === "string";
|
||||
|
||||
const isGoogleAuthSupported =
|
||||
typeof env.AUTH_GOOGLE_CLIENT_ID === "string" &&
|
||||
typeof env.AUTH_GOOGLE_CLIENT_SECRET === "string";
|
||||
|
||||
if (env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET) {
|
||||
addGitHubStrategy(authenticator, env.AUTH_GITHUB_CLIENT_ID, env.AUTH_GITHUB_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
|
||||
addGoogleStrategy(authenticator, env.AUTH_GOOGLE_CLIENT_ID, env.AUTH_GOOGLE_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
addEmailLinkStrategy(authenticator);
|
||||
|
||||
export { authenticator, isGithubAuthSupported };
|
||||
export { authenticator, isGithubAuthSupported, isGoogleAuthSupported };
|
||||
|
||||
@@ -20,7 +20,7 @@ export function addGitHubStrategy(
|
||||
async ({ extraParams, profile }) => {
|
||||
const emails = profile.emails;
|
||||
|
||||
if (!emails) {
|
||||
if (!emails?.length) {
|
||||
throw new Error("GitHub login requires an email address");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Authenticator } from "remix-auth";
|
||||
import { GoogleStrategy } from "remix-auth-google";
|
||||
import { env } from "~/env.server";
|
||||
import { findOrCreateUser } from "~/models/user.server";
|
||||
import type { AuthUser } from "./authUser";
|
||||
import { logger } from "./logger.server";
|
||||
import { postAuthentication } from "./postAuth.server";
|
||||
|
||||
export function addGoogleStrategy(
|
||||
authenticator: Authenticator<AuthUser>,
|
||||
clientID: string,
|
||||
clientSecret: string
|
||||
) {
|
||||
const googleStrategy = new GoogleStrategy(
|
||||
{
|
||||
clientID,
|
||||
clientSecret,
|
||||
callbackURL: `${env.LOGIN_ORIGIN}/auth/google/callback`,
|
||||
},
|
||||
async ({ extraParams, profile }) => {
|
||||
const emails = profile.emails;
|
||||
|
||||
if (!emails?.length) {
|
||||
throw new Error("Google login requires an email address");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Google login", {
|
||||
emails,
|
||||
profile,
|
||||
extraParams,
|
||||
});
|
||||
|
||||
const { user, isNewUser } = await findOrCreateUser({
|
||||
email: emails[0].value,
|
||||
authenticationMethod: "GOOGLE",
|
||||
authenticationProfile: profile,
|
||||
authenticationExtraParams: extraParams,
|
||||
});
|
||||
|
||||
await postAuthentication({ user, isNewUser, loginMethod: "GOOGLE" });
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Google login failed", { error: JSON.stringify(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
authenticator.use(googleStrategy);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createCookie } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export type LastAuthMethod = "github" | "google" | "email";
|
||||
|
||||
// Cookie that persists for 1 year to remember the user's last login method
|
||||
export const lastAuthMethodCookie = createCookie("last-auth-method", {
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
});
|
||||
|
||||
export async function getLastAuthMethod(request: Request): Promise<LastAuthMethod | null> {
|
||||
const cookie = request.headers.get("Cookie");
|
||||
const value = await lastAuthMethodCookie.parse(cookie);
|
||||
if (value === "github" || value === "google" || value === "email") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function setLastAuthMethodHeader(method: LastAuthMethod): Promise<string> {
|
||||
return lastAuthMethodCookie.serialize(method);
|
||||
}
|
||||
@@ -591,6 +591,31 @@ export async function generateRegistryCredentials(
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function enqueueBuild(
|
||||
projectId: string,
|
||||
deploymentId: string,
|
||||
artifactKey: string,
|
||||
options: {
|
||||
skipPromotion?: boolean;
|
||||
configFilePath?: string;
|
||||
}
|
||||
) {
|
||||
if (!client) return undefined;
|
||||
const result = await client.enqueueBuild(projectId, { deploymentId, artifactKey, options });
|
||||
if (!result.success) {
|
||||
logger.error("Error enqueuing build", {
|
||||
error: result.error,
|
||||
projectId,
|
||||
deploymentId,
|
||||
artifactKey,
|
||||
options,
|
||||
});
|
||||
throw new Error("Failed to enqueue build");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function isCloud(): boolean {
|
||||
const acceptableHosts = [
|
||||
"https://cloud.trigger.dev",
|
||||
|
||||
@@ -17,6 +17,6 @@ function createRequestIdempotencyInstance() {
|
||||
},
|
||||
logLevel: env.REQUEST_IDEMPOTENCY_LOG_LEVEL,
|
||||
ttlInMs: env.REQUEST_IDEMPOTENCY_TTL_IN_MS,
|
||||
types: ["batch-trigger", "trigger"],
|
||||
types: ["batch-trigger", "trigger", "create-batch"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,15 +288,17 @@ export function v3RunPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath
|
||||
run: v3RunForPath,
|
||||
searchParams?: URLSearchParams
|
||||
) {
|
||||
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`;
|
||||
const query = searchParams ? `?${searchParams.toString()}` : "";
|
||||
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`;
|
||||
}
|
||||
|
||||
export function v3RunRedirectPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
run: v3RunForPath
|
||||
run: v3RunForPath,
|
||||
) {
|
||||
return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`;
|
||||
}
|
||||
@@ -310,9 +312,12 @@ export function v3RunSpanPath(
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath,
|
||||
span: v3SpanForPath
|
||||
span: v3SpanForPath,
|
||||
searchParams?: URLSearchParams
|
||||
) {
|
||||
return `${v3RunPath(organization, project, environment, run)}?span=${span.spanId}`;
|
||||
searchParams = searchParams ?? new URLSearchParams();
|
||||
searchParams.set("span", span.spanId);
|
||||
return `${v3RunPath(organization, project, environment, run, searchParams)}`;
|
||||
}
|
||||
|
||||
export function v3RunStreamingPath(
|
||||
@@ -324,6 +329,17 @@ export function v3RunStreamingPath(
|
||||
return `${v3RunPath(organization, project, environment, run)}/stream`;
|
||||
}
|
||||
|
||||
export function v3RunIdempotencyKeyResetPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath
|
||||
) {
|
||||
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
|
||||
project
|
||||
)}/env/${environmentParam(environment)}/runs/${run.friendlyId}/idempotencyKey/reset`;
|
||||
}
|
||||
|
||||
export function v3SchedulesPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
@@ -407,7 +423,7 @@ export function v3BatchPath(
|
||||
environment: EnvironmentForPath,
|
||||
batch: { friendlyId: string }
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/batches?id=${batch.friendlyId}`;
|
||||
return `${v3BatchesPath(organization, project, environment)}/${batch.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3BatchRunsPath(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { logger } from "~/services/logger.server";
|
||||
import { requestIdempotency } from "~/services/requestIdempotencyInstance.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
|
||||
type RequestIdempotencyType = "batch-trigger" | "trigger";
|
||||
type RequestIdempotencyType = "batch-trigger" | "trigger" | "create-batch";
|
||||
|
||||
export type IdempotencyConfig<T, R> = {
|
||||
requestType: RequestIdempotencyType;
|
||||
|
||||
@@ -6,8 +6,23 @@ import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { BatchTriggerV3Service } from "./services/batchTriggerV3.server";
|
||||
// Import engine to ensure it's initialized (which initializes BatchQueue for v2 batches)
|
||||
import { engine } from "./runEngine.server";
|
||||
|
||||
/**
|
||||
* Legacy batch trigger worker for processing v3 and run engine v1 batches.
|
||||
*
|
||||
* NOTE: Run Engine v2 batches (batchVersion: "runengine:v2") use the new BatchQueue
|
||||
* system with Deficit Round Robin scheduling, which is encapsulated within the RunEngine.
|
||||
* See runEngine.server.ts for the configuration.
|
||||
*
|
||||
* This worker is kept for backwards compatibility with:
|
||||
* - v3 batches (batchVersion: "v3") - handled by BatchTriggerV3Service
|
||||
* - Run Engine v1 batches (batchVersion: "runengine:v1") - handled by RunEngineBatchTriggerService
|
||||
*/
|
||||
function initializeWorker() {
|
||||
// Ensure the engine (and its BatchQueue) is initialized
|
||||
void engine;
|
||||
const redisOptions = {
|
||||
keyPrefix: "batch-trigger:worker:",
|
||||
host: env.BATCH_TRIGGER_WORKER_REDIS_HOST,
|
||||
|
||||
@@ -825,12 +825,16 @@ export async function resolveVariablesForEnvironment(
|
||||
runtimeEnvironment: RuntimeEnvironmentForEnvRepo,
|
||||
parentEnvironment?: RuntimeEnvironmentForEnvRepo
|
||||
) {
|
||||
const projectSecrets = await environmentVariablesRepository.getEnvironmentVariables(
|
||||
let projectSecrets = await environmentVariablesRepository.getEnvironmentVariables(
|
||||
runtimeEnvironment.projectId,
|
||||
runtimeEnvironment.id,
|
||||
parentEnvironment?.id
|
||||
);
|
||||
|
||||
projectSecrets = renameVariables(projectSecrets, {
|
||||
OTEL_RESOURCE_ATTRIBUTES: "CUSTOM_OTEL_RESOURCE_ATTRIBUTES",
|
||||
});
|
||||
|
||||
const overridableTriggerVariables = await resolveOverridableTriggerVariables(runtimeEnvironment);
|
||||
|
||||
const builtInVariables =
|
||||
@@ -853,6 +857,15 @@ export async function resolveVariablesForEnvironment(
|
||||
return result;
|
||||
}
|
||||
|
||||
function renameVariables(variables: EnvironmentVariable[], renameMap: Record<string, string>) {
|
||||
return variables.map((variable) => {
|
||||
return {
|
||||
...variable,
|
||||
key: renameMap[variable.key] ?? variable.key,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveOverridableTriggerVariables(
|
||||
runtimeEnvironment: RuntimeEnvironmentForEnvRepo
|
||||
) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
TaskEventDetailsV1Result,
|
||||
TaskEventSummaryV1Result,
|
||||
TaskEventV1Input,
|
||||
TaskEventV2Input,
|
||||
} from "@internal/clickhouse";
|
||||
import { Attributes, startSpan, trace, Tracer } from "@internal/tracing";
|
||||
import { createJsonErrorObject } from "@trigger.dev/core/v3/errors";
|
||||
@@ -72,6 +73,18 @@ export type ClickhouseEventRepositoryConfig = {
|
||||
maximumTraceSummaryViewCount?: number;
|
||||
maximumTraceDetailedSummaryViewCount?: number;
|
||||
maximumLiveReloadingSetting?: number;
|
||||
/**
|
||||
* Maximum age in milliseconds for start_time. If start_time is older than this threshold,
|
||||
* it will be clamped to the current time when creating events.
|
||||
* If not provided, no clamping will be done.
|
||||
*/
|
||||
startTimeMaxAgeMs?: number;
|
||||
/**
|
||||
* The version of the ClickHouse task_events table to use.
|
||||
* - "v1": Uses task_events_v1 (partitioned by start_time)
|
||||
* - "v2": Uses task_events_v2 (partitioned by inserted_at to avoid "too many parts" errors)
|
||||
*/
|
||||
version?: "v1" | "v2";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -81,13 +94,15 @@ export type ClickhouseEventRepositoryConfig = {
|
||||
export class ClickhouseEventRepository implements IEventRepository {
|
||||
private _clickhouse: ClickHouse;
|
||||
private _config: ClickhouseEventRepositoryConfig;
|
||||
private readonly _flushScheduler: DynamicFlushScheduler<TaskEventV1Input>;
|
||||
private readonly _flushScheduler: DynamicFlushScheduler<TaskEventV1Input | TaskEventV2Input>;
|
||||
private _tracer: Tracer;
|
||||
private _version: "v1" | "v2";
|
||||
|
||||
constructor(config: ClickhouseEventRepositoryConfig) {
|
||||
this._clickhouse = config.clickhouse;
|
||||
this._config = config;
|
||||
this._tracer = config.tracer ?? trace.getTracer("clickhouseEventRepo", "0.0.1");
|
||||
this._version = config.version ?? "v1";
|
||||
|
||||
this._flushScheduler = new DynamicFlushScheduler({
|
||||
batchSize: config.batchSize ?? 1000,
|
||||
@@ -99,31 +114,90 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
memoryPressureThreshold: 10000,
|
||||
loadSheddingThreshold: 10000,
|
||||
loadSheddingEnabled: false,
|
||||
isDroppableEvent: (event: TaskEventV1Input) => {
|
||||
isDroppableEvent: (event: TaskEventV1Input | TaskEventV2Input) => {
|
||||
// Only drop LOG events during load shedding
|
||||
return event.kind === "DEBUG_EVENT";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get version() {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
get maximumLiveReloadingSetting() {
|
||||
return this._config.maximumLiveReloadingSetting ?? 1000;
|
||||
}
|
||||
|
||||
async #flushBatch(flushId: string, events: TaskEventV1Input[]) {
|
||||
/**
|
||||
* Clamps a start time (in nanoseconds) to now if it's too far in the past.
|
||||
* Returns the clamped value as a bigint.
|
||||
*/
|
||||
#clampStartTimeNanoseconds(startTimeNs: bigint): bigint {
|
||||
if (!this._config.startTimeMaxAgeMs) {
|
||||
return startTimeNs;
|
||||
}
|
||||
|
||||
const nowNs = getNowInNanoseconds();
|
||||
const maxAgeNs = BigInt(this._config.startTimeMaxAgeMs) * 1_000_000n; // ms to ns
|
||||
const minAllowedStartTime = nowNs - maxAgeNs;
|
||||
|
||||
if (startTimeNs < minAllowedStartTime) {
|
||||
return nowNs;
|
||||
}
|
||||
|
||||
return startTimeNs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a start time string (nanoseconds as string) to now if it's too far in the past.
|
||||
* Returns the formatted string for ClickHouse.
|
||||
*/
|
||||
#clampAndFormatStartTime(startTimeNsString: string): string {
|
||||
const startTimeNs = BigInt(startTimeNsString);
|
||||
const clampedNs = this.#clampStartTimeNanoseconds(startTimeNs);
|
||||
return formatClickhouseDate64NanosecondsEpochString(clampedNs.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a Date start time to now if it's too far in the past.
|
||||
*/
|
||||
#clampStartTimeDate(startTime: Date): Date {
|
||||
if (!this._config.startTimeMaxAgeMs) {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const minAllowedStartTime = new Date(now.getTime() - this._config.startTimeMaxAgeMs);
|
||||
|
||||
if (startTime < minAllowedStartTime) {
|
||||
return now;
|
||||
}
|
||||
|
||||
return startTime;
|
||||
}
|
||||
|
||||
async #flushBatch(flushId: string, events: (TaskEventV1Input | TaskEventV2Input)[]) {
|
||||
await startSpan(this._tracer, "flushBatch", async (span) => {
|
||||
span.setAttribute("flush_id", flushId);
|
||||
span.setAttribute("event_count", events.length);
|
||||
span.setAttribute("version", this._version);
|
||||
|
||||
const firstEvent = events[0];
|
||||
|
||||
if (firstEvent) {
|
||||
logger.debug("ClickhouseEventRepository.flushBatch first event", {
|
||||
event: firstEvent,
|
||||
version: this._version,
|
||||
});
|
||||
}
|
||||
|
||||
const [insertError, insertResult] = await this._clickhouse.taskEvents.insert(events, {
|
||||
const insertFn =
|
||||
this._version === "v2"
|
||||
? this._clickhouse.taskEventsV2.insert
|
||||
: this._clickhouse.taskEvents.insert;
|
||||
|
||||
const [insertError, insertResult] = await insertFn(events, {
|
||||
params: {
|
||||
clickhouse_settings: this.#getClickhouseInsertSettings(),
|
||||
},
|
||||
@@ -136,6 +210,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
logger.info("ClickhouseEventRepository.flushBatch Inserted batch into clickhouse", {
|
||||
events: events.length,
|
||||
insertResult,
|
||||
version: this._version,
|
||||
});
|
||||
|
||||
this.#publishToRedis(events);
|
||||
@@ -155,7 +230,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async #publishToRedis(events: TaskEventV1Input[]) {
|
||||
async #publishToRedis(events: (TaskEventV1Input | TaskEventV2Input)[]) {
|
||||
if (events.length === 0) return;
|
||||
await tracePubSub.publish(events.map((e) => e.trace_id));
|
||||
}
|
||||
@@ -176,7 +251,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
project_id: event.projectId,
|
||||
task_identifier: event.taskSlug,
|
||||
run_id: event.runId,
|
||||
start_time: formatClickhouseDate64NanosecondsEpochString(event.startTime.toString()),
|
||||
start_time: this.#clampAndFormatStartTime(event.startTime.toString()),
|
||||
duration: formatClickhouseUnsignedIntegerString(event.duration ?? 0),
|
||||
trace_id: event.traceId,
|
||||
span_id: event.spanId,
|
||||
@@ -184,7 +259,10 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
message: event.message,
|
||||
kind: this.createEventToTaskEventV1InputKind(event),
|
||||
status: this.createEventToTaskEventV1InputStatus(event),
|
||||
attributes: this.createEventToTaskEventV1InputAttributes(event.properties),
|
||||
attributes: this.createEventToTaskEventV1InputAttributes(
|
||||
event.properties,
|
||||
event.resourceProperties
|
||||
),
|
||||
metadata: this.createEventToTaskEventV1InputMetadata(event),
|
||||
expires_at: convertDateToClickhouseDateTime(
|
||||
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
|
||||
@@ -242,7 +320,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
project_id: event.projectId,
|
||||
task_identifier: event.taskSlug,
|
||||
run_id: event.runId,
|
||||
start_time: formatClickhouseDate64NanosecondsEpochString(
|
||||
start_time: this.#clampAndFormatStartTime(
|
||||
convertDateToNanoseconds(spanEvent.time).toString()
|
||||
),
|
||||
duration: "0", // Events have no duration
|
||||
@@ -278,7 +356,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
project_id: event.projectId,
|
||||
task_identifier: event.taskSlug,
|
||||
run_id: event.runId,
|
||||
start_time: formatClickhouseDate64NanosecondsEpochString(
|
||||
start_time: this.#clampAndFormatStartTime(
|
||||
convertDateToNanoseconds(spanEvent.time).toString()
|
||||
),
|
||||
duration: "0", // Events have no duration
|
||||
@@ -308,7 +386,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
project_id: event.projectId,
|
||||
task_identifier: event.taskSlug,
|
||||
run_id: event.runId,
|
||||
start_time: formatClickhouseDate64NanosecondsEpochString(
|
||||
start_time: this.#clampAndFormatStartTime(
|
||||
convertDateToNanoseconds(spanEvent.time).toString()
|
||||
),
|
||||
duration: "0", // Events have no duration
|
||||
@@ -342,7 +420,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
project_id: event.projectId,
|
||||
task_identifier: event.taskSlug,
|
||||
run_id: event.runId,
|
||||
start_time: formatClickhouseDate64NanosecondsEpochString(
|
||||
start_time: this.#clampAndFormatStartTime(
|
||||
convertDateToNanoseconds(spanEvent.time).toString()
|
||||
),
|
||||
duration: "0", // Events have no duration
|
||||
@@ -392,7 +470,24 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
return "OK";
|
||||
}
|
||||
|
||||
private createEventToTaskEventV1InputAttributes(attributes: Attributes): Record<string, unknown> {
|
||||
private createEventToTaskEventV1InputAttributes(
|
||||
attributes: Attributes,
|
||||
resourceAttributes?: Attributes
|
||||
): Record<string, unknown> {
|
||||
if (!attributes && !resourceAttributes) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
...this.createAttributesToInputAttributes(attributes),
|
||||
...this.createAttributesToInputAttributes(resourceAttributes, "$resource"),
|
||||
};
|
||||
}
|
||||
|
||||
private createAttributesToInputAttributes(
|
||||
attributes: Attributes | undefined,
|
||||
key?: string
|
||||
): Record<string, unknown> {
|
||||
if (!attributes) {
|
||||
return {};
|
||||
}
|
||||
@@ -406,6 +501,12 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
const unflattenedAttributes = unflattenAttributes(publicAttributes);
|
||||
|
||||
if (unflattenedAttributes && typeof unflattenedAttributes === "object") {
|
||||
if (key) {
|
||||
return {
|
||||
[key]: unflattenedAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...unflattenedAttributes,
|
||||
};
|
||||
@@ -487,7 +588,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
project_id: options.environment.projectId,
|
||||
task_identifier: options.taskSlug,
|
||||
run_id: options.attributes.runId,
|
||||
start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
|
||||
start_time: this.#clampAndFormatStartTime(startTime.toString()),
|
||||
duration: formatClickhouseUnsignedIntegerString(duration),
|
||||
trace_id: traceId,
|
||||
span_id: spanId,
|
||||
@@ -588,7 +689,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
project_id: options.environment.projectId,
|
||||
task_identifier: options.taskSlug,
|
||||
run_id: options.attributes.runId,
|
||||
start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
|
||||
start_time: this.#clampAndFormatStartTime(startTime.toString()),
|
||||
duration: formatClickhouseUnsignedIntegerString(options.incomplete ? 0 : duration),
|
||||
trace_id: traceId,
|
||||
span_id: spanId,
|
||||
@@ -622,7 +723,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
project_id: options.environment.projectId,
|
||||
task_identifier: options.taskSlug,
|
||||
run_id: options.attributes.runId,
|
||||
start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
|
||||
start_time: this.#clampAndFormatStartTime(startTime.toString()),
|
||||
duration: formatClickhouseUnsignedIntegerString(options.incomplete ? 0 : duration),
|
||||
trace_id: traceId,
|
||||
span_id: spanId,
|
||||
@@ -660,7 +761,8 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = convertDateToNanoseconds(run.createdAt);
|
||||
const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
|
||||
const startTime = convertDateToNanoseconds(clampedCreatedAt);
|
||||
const expiresAt = convertDateToClickhouseDateTime(
|
||||
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
);
|
||||
@@ -710,7 +812,8 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = convertDateToNanoseconds(spanCreatedAt);
|
||||
const clampedSpanCreatedAt = this.#clampStartTimeDate(spanCreatedAt);
|
||||
const startTime = convertDateToNanoseconds(clampedSpanCreatedAt);
|
||||
const expiresAt = convertDateToClickhouseDateTime(
|
||||
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
);
|
||||
@@ -752,7 +855,8 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = convertDateToNanoseconds(run.createdAt);
|
||||
const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
|
||||
const startTime = convertDateToNanoseconds(clampedCreatedAt);
|
||||
const expiresAt = convertDateToClickhouseDateTime(
|
||||
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
);
|
||||
@@ -800,7 +904,8 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = convertDateToNanoseconds(run.createdAt);
|
||||
const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
|
||||
const startTime = convertDateToNanoseconds(clampedCreatedAt);
|
||||
const expiresAt = convertDateToClickhouseDateTime(
|
||||
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
);
|
||||
@@ -848,7 +953,8 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = convertDateToNanoseconds(endTime ?? new Date());
|
||||
const clampedEndTime = this.#clampStartTimeDate(endTime ?? new Date());
|
||||
const startTime = convertDateToNanoseconds(clampedEndTime);
|
||||
const expiresAt = convertDateToClickhouseDateTime(
|
||||
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
);
|
||||
@@ -892,7 +998,8 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = convertDateToNanoseconds(run.createdAt);
|
||||
const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
|
||||
const startTime = convertDateToNanoseconds(clampedCreatedAt);
|
||||
const expiresAt = convertDateToClickhouseDateTime(
|
||||
new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
);
|
||||
@@ -932,9 +1039,15 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
endCreatedAt?: Date,
|
||||
options?: { includeDebugLogs?: boolean }
|
||||
): Promise<TraceSummary | undefined> {
|
||||
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);
|
||||
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 60_000);
|
||||
const endCreatedAtWithBuffer = endCreatedAt
|
||||
? new Date(endCreatedAt.getTime() + 60_000)
|
||||
: undefined;
|
||||
|
||||
const queryBuilder = this._clickhouse.taskEvents.traceSummaryQueryBuilder();
|
||||
const queryBuilder =
|
||||
this._version === "v2"
|
||||
? this._clickhouse.taskEventsV2.traceSummaryQueryBuilder()
|
||||
: this._clickhouse.taskEvents.traceSummaryQueryBuilder();
|
||||
|
||||
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
|
||||
queryBuilder.where("trace_id = {traceId: String}", { traceId });
|
||||
@@ -942,12 +1055,20 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
|
||||
});
|
||||
|
||||
if (endCreatedAt) {
|
||||
if (endCreatedAtWithBuffer) {
|
||||
queryBuilder.where("start_time <= {endCreatedAt: String}", {
|
||||
endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
|
||||
endCreatedAt: convertDateToNanoseconds(endCreatedAtWithBuffer).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
// For v2, add inserted_at filtering for partition pruning
|
||||
if (this._version === "v2") {
|
||||
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
|
||||
insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
|
||||
});
|
||||
// No upper bound on inserted_at - we want all events inserted up to now
|
||||
}
|
||||
|
||||
if (options?.includeDebugLogs === false) {
|
||||
queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" });
|
||||
}
|
||||
@@ -1032,7 +1153,10 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
): Promise<SpanDetail | undefined> {
|
||||
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);
|
||||
|
||||
const queryBuilder = this._clickhouse.taskEvents.spanDetailsQueryBuilder();
|
||||
const queryBuilder =
|
||||
this._version === "v2"
|
||||
? this._clickhouse.taskEventsV2.spanDetailsQueryBuilder()
|
||||
: this._clickhouse.taskEvents.spanDetailsQueryBuilder();
|
||||
|
||||
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
|
||||
queryBuilder.where("trace_id = {traceId: String}", { traceId });
|
||||
@@ -1047,6 +1171,13 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
});
|
||||
}
|
||||
|
||||
// For v2, add inserted_at filtering for partition pruning
|
||||
if (this._version === "v2") {
|
||||
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
|
||||
insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
|
||||
});
|
||||
}
|
||||
|
||||
queryBuilder.orderBy("start_time ASC");
|
||||
|
||||
const [queryError, records] = await queryBuilder.execute();
|
||||
@@ -1087,8 +1218,20 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
}
|
||||
|
||||
let span: SpanDetail | undefined;
|
||||
let earliestStartTime: Date | undefined;
|
||||
|
||||
for (const record of records) {
|
||||
const recordStartTime = convertClickhouseDateTime64ToJsDate(record.start_time);
|
||||
|
||||
// Track the earliest start time across all records
|
||||
if (
|
||||
record.kind !== "ANCESTOR_OVERRIDE" &&
|
||||
record.kind !== "SPAN_EVENT" &&
|
||||
(!earliestStartTime || recordStartTime < earliestStartTime)
|
||||
) {
|
||||
earliestStartTime = recordStartTime;
|
||||
}
|
||||
|
||||
if (!span) {
|
||||
span = {
|
||||
spanId: spanId,
|
||||
@@ -1098,11 +1241,12 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
isPartial: true, // Partial by default, can only be set to false
|
||||
isCancelled: false,
|
||||
level: kindToLevel(record.kind),
|
||||
startTime: convertClickhouseDateTime64ToJsDate(record.start_time),
|
||||
startTime: recordStartTime,
|
||||
duration: typeof record.duration === "number" ? record.duration : Number(record.duration),
|
||||
events: [],
|
||||
style: {},
|
||||
properties: undefined,
|
||||
resourceProperties: undefined,
|
||||
entity: {
|
||||
type: undefined,
|
||||
id: undefined,
|
||||
@@ -1124,7 +1268,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
// We need to add an event to the span
|
||||
span.events.push({
|
||||
name: record.message,
|
||||
time: convertClickhouseDateTime64ToJsDate(record.start_time),
|
||||
time: recordStartTime,
|
||||
properties: parsedMetadata ?? {},
|
||||
});
|
||||
}
|
||||
@@ -1172,16 +1316,31 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
span.duration =
|
||||
typeof record.duration === "number" ? record.duration : Number(record.duration);
|
||||
} else {
|
||||
span.startTime = convertClickhouseDateTime64ToJsDate(record.start_time);
|
||||
span.message = record.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (!span.properties && typeof record.attributes_text === "string") {
|
||||
span.properties = this.#parseAttributes(record.attributes_text);
|
||||
if (
|
||||
(span.properties == null ||
|
||||
(typeof span.properties === "object" && Object.keys(span.properties).length === 0)) &&
|
||||
typeof record.attributes_text === "string"
|
||||
) {
|
||||
const parsedAttributes = this.#parseAttributes(record.attributes_text);
|
||||
const resourceAttributes = parsedAttributes["$resource"];
|
||||
|
||||
// Remove the $resource key from the attributes
|
||||
delete parsedAttributes["$resource"];
|
||||
|
||||
span.properties = parsedAttributes;
|
||||
span.resourceProperties = resourceAttributes as Record<string, unknown> | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Always use the earliest start time found across all records
|
||||
if (span && earliestStartTime) {
|
||||
span.startTime = earliestStartTime;
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
@@ -1323,8 +1482,20 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
}
|
||||
|
||||
let span: SpanSummary | undefined;
|
||||
let earliestStartTime: Date | undefined;
|
||||
|
||||
for (const record of records) {
|
||||
const recordStartTime = convertClickhouseDateTime64ToJsDate(record.start_time);
|
||||
|
||||
// Track the earliest start time across all records, except for ancestor overrides and span events
|
||||
if (
|
||||
record.kind !== "ANCESTOR_OVERRIDE" &&
|
||||
record.kind !== "SPAN_EVENT" &&
|
||||
(!earliestStartTime || recordStartTime < earliestStartTime)
|
||||
) {
|
||||
earliestStartTime = recordStartTime;
|
||||
}
|
||||
|
||||
if (!span) {
|
||||
span = {
|
||||
id: spanId,
|
||||
@@ -1339,7 +1510,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
isPartial: true, // Partial by default, can only be set to false
|
||||
isCancelled: false,
|
||||
isDebug: record.kind === "DEBUG_EVENT",
|
||||
startTime: convertClickhouseDateTime64ToJsDate(record.start_time),
|
||||
startTime: recordStartTime,
|
||||
level: kindToLevel(record.kind),
|
||||
events: [],
|
||||
},
|
||||
@@ -1366,7 +1537,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
// We need to add an event to the span
|
||||
span.data.events.push({
|
||||
name: record.message,
|
||||
time: convertClickhouseDateTime64ToJsDate(record.start_time),
|
||||
time: recordStartTime,
|
||||
properties: parsedMetadata ?? {},
|
||||
});
|
||||
}
|
||||
@@ -1392,12 +1563,16 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
span.data.duration =
|
||||
typeof record.duration === "number" ? record.duration : Number(record.duration);
|
||||
} else {
|
||||
span.data.startTime = convertClickhouseDateTime64ToJsDate(record.start_time);
|
||||
span.data.message = record.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always use the earliest start time found across all records
|
||||
if (span && earliestStartTime) {
|
||||
span.data.startTime = earliestStartTime;
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
@@ -1439,7 +1614,10 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
): Promise<TraceDetailedSummary | undefined> {
|
||||
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);
|
||||
|
||||
const queryBuilder = this._clickhouse.taskEvents.traceDetailedSummaryQueryBuilder();
|
||||
const queryBuilder =
|
||||
this._version === "v2"
|
||||
? this._clickhouse.taskEventsV2.traceDetailedSummaryQueryBuilder()
|
||||
: this._clickhouse.taskEvents.traceDetailedSummaryQueryBuilder();
|
||||
|
||||
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
|
||||
queryBuilder.where("trace_id = {traceId: String}", { traceId });
|
||||
@@ -1453,6 +1631,13 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
});
|
||||
}
|
||||
|
||||
// For v2, add inserted_at filtering for partition pruning
|
||||
if (this._version === "v2") {
|
||||
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
|
||||
insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.includeDebugLogs === false) {
|
||||
queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" });
|
||||
}
|
||||
@@ -1553,8 +1738,20 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
}
|
||||
|
||||
let span: SpanDetailedSummary | undefined;
|
||||
let earliestStartTime: Date | undefined;
|
||||
|
||||
for (const record of records) {
|
||||
const recordStartTime = convertClickhouseDateTime64ToJsDate(record.start_time);
|
||||
|
||||
// Track the earliest start time across all records
|
||||
if (
|
||||
record.kind !== "ANCESTOR_OVERRIDE" &&
|
||||
record.kind !== "SPAN_EVENT" &&
|
||||
(!earliestStartTime || recordStartTime < earliestStartTime)
|
||||
) {
|
||||
earliestStartTime = recordStartTime;
|
||||
}
|
||||
|
||||
if (!span) {
|
||||
span = {
|
||||
id: spanId,
|
||||
@@ -1568,7 +1765,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
isError: false,
|
||||
isPartial: true, // Partial by default, can only be set to false
|
||||
isCancelled: false,
|
||||
startTime: convertClickhouseDateTime64ToJsDate(record.start_time),
|
||||
startTime: recordStartTime,
|
||||
level: kindToLevel(record.kind),
|
||||
events: [],
|
||||
},
|
||||
@@ -1596,7 +1793,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
// We need to add an event to the span
|
||||
span.data.events.push({
|
||||
name: record.message,
|
||||
time: convertClickhouseDateTime64ToJsDate(record.start_time),
|
||||
time: recordStartTime,
|
||||
properties: parsedMetadata ?? {},
|
||||
});
|
||||
}
|
||||
@@ -1618,12 +1815,16 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
span.data.duration =
|
||||
typeof record.duration === "number" ? record.duration : Number(record.duration);
|
||||
} else {
|
||||
span.data.startTime = convertClickhouseDateTime64ToJsDate(record.start_time);
|
||||
span.data.message = record.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always use the earliest start time found across all records
|
||||
if (span && earliestStartTime) {
|
||||
span.data.startTime = earliestStartTime;
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
@@ -1637,7 +1838,10 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
): Promise<RunPreparedEvent[]> {
|
||||
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);
|
||||
|
||||
const queryBuilder = this._clickhouse.taskEvents.traceSummaryQueryBuilder();
|
||||
const queryBuilder =
|
||||
this._version === "v2"
|
||||
? this._clickhouse.taskEventsV2.traceSummaryQueryBuilder()
|
||||
: this._clickhouse.taskEvents.traceSummaryQueryBuilder();
|
||||
|
||||
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
|
||||
queryBuilder.where("trace_id = {traceId: String}", { traceId });
|
||||
@@ -1652,6 +1856,13 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
});
|
||||
}
|
||||
|
||||
// For v2, add inserted_at filtering for partition pruning
|
||||
if (this._version === "v2") {
|
||||
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
|
||||
insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
|
||||
});
|
||||
}
|
||||
|
||||
queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" });
|
||||
queryBuilder.orderBy("start_time ASC");
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ export const clickhouseEventRepository = singleton(
|
||||
initializeClickhouseRepository
|
||||
);
|
||||
|
||||
function initializeClickhouseRepository() {
|
||||
export const clickhouseEventRepositoryV2 = singleton(
|
||||
"clickhouseEventRepositoryV2",
|
||||
initializeClickhouseRepositoryV2
|
||||
);
|
||||
|
||||
function getClickhouseClient() {
|
||||
if (!env.EVENTS_CLICKHOUSE_URL) {
|
||||
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
|
||||
}
|
||||
@@ -16,12 +21,7 @@ function initializeClickhouseRepository() {
|
||||
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
|
||||
url.searchParams.delete("secure");
|
||||
|
||||
const safeUrl = new URL(url.toString());
|
||||
safeUrl.password = "redacted";
|
||||
|
||||
console.log("🗃️ Initializing Clickhouse event repository", { url: safeUrl.toString() });
|
||||
|
||||
const clickhouse = new ClickHouse({
|
||||
return new ClickHouse({
|
||||
url: url.toString(),
|
||||
name: "task-events",
|
||||
keepAlive: {
|
||||
@@ -34,6 +34,22 @@ function initializeClickhouseRepository() {
|
||||
},
|
||||
maxOpenConnections: env.EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
function initializeClickhouseRepository() {
|
||||
if (!env.EVENTS_CLICKHOUSE_URL) {
|
||||
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
|
||||
}
|
||||
|
||||
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
|
||||
url.searchParams.delete("secure");
|
||||
|
||||
const safeUrl = new URL(url.toString());
|
||||
safeUrl.password = "redacted";
|
||||
|
||||
console.log("🗃️ Initializing Clickhouse event repository (v1)", { url: safeUrl.toString() });
|
||||
|
||||
const clickhouse = getClickhouseClient();
|
||||
|
||||
const repository = new ClickhouseEventRepository({
|
||||
clickhouse: clickhouse,
|
||||
@@ -47,6 +63,41 @@ function initializeClickhouseRepository() {
|
||||
waitForAsyncInsert: env.EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT === "1",
|
||||
asyncInsertMaxDataSize: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE,
|
||||
asyncInsertBusyTimeoutMs: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS,
|
||||
startTimeMaxAgeMs: env.EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS,
|
||||
version: "v1",
|
||||
});
|
||||
|
||||
return repository;
|
||||
}
|
||||
|
||||
function initializeClickhouseRepositoryV2() {
|
||||
if (!env.EVENTS_CLICKHOUSE_URL) {
|
||||
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
|
||||
}
|
||||
|
||||
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
|
||||
url.searchParams.delete("secure");
|
||||
|
||||
const safeUrl = new URL(url.toString());
|
||||
safeUrl.password = "redacted";
|
||||
|
||||
console.log("🗃️ Initializing Clickhouse event repository (v2)", { url: safeUrl.toString() });
|
||||
|
||||
const clickhouse = getClickhouseClient();
|
||||
|
||||
const repository = new ClickhouseEventRepository({
|
||||
clickhouse: clickhouse,
|
||||
batchSize: env.EVENTS_CLICKHOUSE_BATCH_SIZE,
|
||||
flushInterval: env.EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS,
|
||||
maximumTraceSummaryViewCount: env.EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT,
|
||||
maximumTraceDetailedSummaryViewCount:
|
||||
env.EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT,
|
||||
maximumLiveReloadingSetting: env.EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING,
|
||||
insertStrategy: env.EVENTS_CLICKHOUSE_INSERT_STRATEGY,
|
||||
waitForAsyncInsert: env.EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT === "1",
|
||||
asyncInsertMaxDataSize: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE,
|
||||
asyncInsertBusyTimeoutMs: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS,
|
||||
version: "v2",
|
||||
});
|
||||
|
||||
return repository;
|
||||
|
||||
@@ -53,6 +53,7 @@ export type CreateEventInput = Omit<
|
||||
| "links"
|
||||
> & {
|
||||
properties: Attributes;
|
||||
resourceProperties?: Attributes;
|
||||
metadata: Attributes | undefined;
|
||||
style: Attributes | undefined;
|
||||
};
|
||||
@@ -209,6 +210,7 @@ export type SpanDetail = {
|
||||
events: SpanEvents; // Timeline events, SpanEvents component
|
||||
style: TaskEventStyle; // Icons, variants, accessories (RunIcon, SpanTitle)
|
||||
properties: Record<string, unknown> | string | number | boolean | null | undefined; // Displayed as JSON in span properties (CodeBlock)
|
||||
resourceProperties?: Record<string, unknown> | string | number | boolean | null | undefined; // Displayed as JSON in span resource properties (CodeBlock)
|
||||
|
||||
// ============================================================================
|
||||
// Entity & Relationships
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { env } from "~/env.server";
|
||||
import { eventRepository } from "./eventRepository.server";
|
||||
import { clickhouseEventRepository } from "./clickhouseEventRepositoryInstance.server";
|
||||
import {
|
||||
clickhouseEventRepository,
|
||||
clickhouseEventRepositoryV2,
|
||||
} from "./clickhouseEventRepositoryInstance.server";
|
||||
import { IEventRepository, TraceEventOptions } from "./eventRepository.types";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
@@ -10,6 +13,10 @@ import { getTaskEventStore } from "../taskEventStore.server";
|
||||
export function resolveEventRepositoryForStore(store: string | undefined): IEventRepository {
|
||||
const taskEventStore = store ?? env.EVENT_REPOSITORY_DEFAULT_STORE;
|
||||
|
||||
if (taskEventStore === "clickhouse_v2") {
|
||||
return clickhouseEventRepositoryV2;
|
||||
}
|
||||
|
||||
if (taskEventStore === "clickhouse") {
|
||||
return clickhouseEventRepository;
|
||||
}
|
||||
@@ -22,6 +29,9 @@ export async function getEventRepository(
|
||||
parentStore: string | undefined
|
||||
): Promise<{ repository: IEventRepository; store: string }> {
|
||||
if (typeof parentStore === "string") {
|
||||
if (parentStore === "clickhouse_v2") {
|
||||
return { repository: clickhouseEventRepositoryV2, store: "clickhouse_v2" };
|
||||
}
|
||||
if (parentStore === "clickhouse") {
|
||||
return { repository: clickhouseEventRepository, store: "clickhouse" };
|
||||
} else {
|
||||
@@ -31,6 +41,10 @@ export async function getEventRepository(
|
||||
|
||||
const taskEventRepository = await resolveTaskEventRepositoryFlag(featureFlags);
|
||||
|
||||
if (taskEventRepository === "clickhouse_v2") {
|
||||
return { repository: clickhouseEventRepositoryV2, store: "clickhouse_v2" };
|
||||
}
|
||||
|
||||
if (taskEventRepository === "clickhouse") {
|
||||
return { repository: clickhouseEventRepository, store: "clickhouse" };
|
||||
}
|
||||
@@ -42,6 +56,9 @@ export async function getV3EventRepository(
|
||||
parentStore: string | undefined
|
||||
): Promise<{ repository: IEventRepository; store: string }> {
|
||||
if (typeof parentStore === "string") {
|
||||
if (parentStore === "clickhouse_v2") {
|
||||
return { repository: clickhouseEventRepositoryV2, store: "clickhouse_v2" };
|
||||
}
|
||||
if (parentStore === "clickhouse") {
|
||||
return { repository: clickhouseEventRepository, store: "clickhouse" };
|
||||
} else {
|
||||
@@ -49,7 +66,9 @@ export async function getV3EventRepository(
|
||||
}
|
||||
}
|
||||
|
||||
if (env.EVENT_REPOSITORY_DEFAULT_STORE === "clickhouse") {
|
||||
if (env.EVENT_REPOSITORY_DEFAULT_STORE === "clickhouse_v2") {
|
||||
return { repository: clickhouseEventRepositoryV2, store: "clickhouse_v2" };
|
||||
} else if (env.EVENT_REPOSITORY_DEFAULT_STORE === "clickhouse") {
|
||||
return { repository: clickhouseEventRepository, store: "clickhouse" };
|
||||
} else {
|
||||
return { repository: eventRepository, store: getTaskEventStore() };
|
||||
@@ -58,13 +77,17 @@ export async function getV3EventRepository(
|
||||
|
||||
async function resolveTaskEventRepositoryFlag(
|
||||
featureFlags: Record<string, unknown> | undefined
|
||||
): Promise<"clickhouse" | "postgres"> {
|
||||
): Promise<"clickhouse" | "clickhouse_v2" | "postgres"> {
|
||||
const flag = await flags({
|
||||
key: FEATURE_FLAG.taskEventRepository,
|
||||
defaultValue: env.EVENT_REPOSITORY_DEFAULT_STORE,
|
||||
overrides: featureFlags,
|
||||
});
|
||||
|
||||
if (flag === "clickhouse_v2") {
|
||||
return "clickhouse_v2";
|
||||
}
|
||||
|
||||
if (flag === "clickhouse") {
|
||||
return "clickhouse";
|
||||
}
|
||||
@@ -75,6 +98,10 @@ async function resolveTaskEventRepositoryFlag(
|
||||
const randomNumber = Math.random();
|
||||
|
||||
if (randomNumber < rolloutPercent) {
|
||||
// Use the default store when rolling out (could be clickhouse or clickhouse_v2)
|
||||
if (env.EVENT_REPOSITORY_DEFAULT_STORE === "clickhouse_v2") {
|
||||
return "clickhouse_v2";
|
||||
}
|
||||
return "clickhouse";
|
||||
}
|
||||
}
|
||||
@@ -100,6 +127,13 @@ export async function recordRunDebugLog(
|
||||
error?: unknown;
|
||||
}
|
||||
> {
|
||||
if (env.EVENT_REPOSITORY_DEBUG_LOGS_DISABLED) {
|
||||
// drop debug events silently
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
return recordRunEvent(runId, message, {
|
||||
...options,
|
||||
attributes: {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const FEATURE_FLAG = {
|
||||
const FeatureFlagCatalog = {
|
||||
[FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(),
|
||||
[FEATURE_FLAG.runsListRepository]: z.enum(["clickhouse", "postgres"]),
|
||||
[FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "postgres"]),
|
||||
[FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]),
|
||||
};
|
||||
|
||||
type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RepositoryNotFoundException,
|
||||
GetAuthorizationTokenCommand,
|
||||
PutLifecyclePolicyCommand,
|
||||
PutImageTagMutabilityCommand,
|
||||
} from "@aws-sdk/client-ecr";
|
||||
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
@@ -196,6 +197,22 @@ export function parseRegistryTags(tags: string): Tag[] {
|
||||
.filter((tag): tag is Tag => tag !== null);
|
||||
}
|
||||
|
||||
const untaggedImageExpirationPolicy = JSON.stringify({
|
||||
rules: [
|
||||
{
|
||||
rulePriority: 1,
|
||||
description: "Expire untagged images older than 3 days",
|
||||
selection: {
|
||||
tagStatus: "untagged",
|
||||
countType: "sinceImagePushed",
|
||||
countUnit: "days",
|
||||
countNumber: 3,
|
||||
},
|
||||
action: { type: "expire" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
async function createEcrRepository({
|
||||
repositoryName,
|
||||
region,
|
||||
@@ -241,27 +258,62 @@ async function createEcrRepository({
|
||||
new PutLifecyclePolicyCommand({
|
||||
repositoryName: result.repository.repositoryName,
|
||||
registryId: result.repository.registryId,
|
||||
lifecyclePolicyText: JSON.stringify({
|
||||
rules: [
|
||||
{
|
||||
rulePriority: 1,
|
||||
description: "Expire untagged images older than 3 days",
|
||||
selection: {
|
||||
tagStatus: "untagged",
|
||||
countType: "sinceImagePushed",
|
||||
countUnit: "days",
|
||||
countNumber: 3,
|
||||
},
|
||||
action: { type: "expire" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
lifecyclePolicyText: untaggedImageExpirationPolicy,
|
||||
})
|
||||
);
|
||||
|
||||
return result.repository;
|
||||
}
|
||||
|
||||
async function updateEcrRepositoryCacheSettings({
|
||||
repositoryName,
|
||||
region,
|
||||
accountId,
|
||||
assumeRole,
|
||||
}: {
|
||||
repositoryName: string;
|
||||
region: string;
|
||||
accountId?: string;
|
||||
assumeRole?: AssumeRoleConfig;
|
||||
}): Promise<void> {
|
||||
logger.debug("Updating ECR repository tag mutability to IMMUTABLE_WITH_EXCLUSION", {
|
||||
repositoryName,
|
||||
region,
|
||||
});
|
||||
|
||||
const ecr = await createEcrClient({ region, assumeRole });
|
||||
|
||||
await ecr.send(
|
||||
new PutImageTagMutabilityCommand({
|
||||
repositoryName,
|
||||
registryId: accountId,
|
||||
imageTagMutability: "IMMUTABLE_WITH_EXCLUSION",
|
||||
imageTagMutabilityExclusionFilters: [
|
||||
{
|
||||
// only the `cache` tag will be mutable, all other tags will be immutable
|
||||
filter: "cache",
|
||||
filterType: "WILDCARD",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// When the `cache` tag is mutated, the old cache images are untagged.
|
||||
// This policy matches those images and expires them to avoid bloating the repository.
|
||||
await ecr.send(
|
||||
new PutLifecyclePolicyCommand({
|
||||
repositoryName,
|
||||
registryId: accountId,
|
||||
lifecyclePolicyText: untaggedImageExpirationPolicy,
|
||||
})
|
||||
);
|
||||
|
||||
logger.debug("Successfully updated ECR repository to IMMUTABLE_WITH_EXCLUSION", {
|
||||
repositoryName,
|
||||
region,
|
||||
});
|
||||
}
|
||||
|
||||
async function getEcrRepository({
|
||||
repositoryName,
|
||||
region,
|
||||
@@ -290,7 +342,10 @@ async function getEcrRepository({
|
||||
|
||||
return result.repositories[0];
|
||||
} catch (error) {
|
||||
if (error instanceof RepositoryNotFoundException) {
|
||||
if (
|
||||
error instanceof RepositoryNotFoundException ||
|
||||
(error instanceof Error && error.message?.includes("does not exist"))
|
||||
) {
|
||||
logger.debug("ECR repository not found: RepositoryNotFoundException", {
|
||||
repositoryName,
|
||||
region,
|
||||
@@ -350,6 +405,22 @@ async function ensureEcrRepositoryExists({
|
||||
|
||||
if (existingRepo) {
|
||||
logger.debug("ECR repository already exists", { repositoryName, region, existingRepo });
|
||||
|
||||
// check if the repository is missing the cache settings
|
||||
if (existingRepo.imageTagMutability === "IMMUTABLE") {
|
||||
const [updateError] = await tryCatch(
|
||||
updateEcrRepositoryCacheSettings({ repositoryName, region, accountId, assumeRole })
|
||||
);
|
||||
|
||||
if (updateError) {
|
||||
logger.error("Failed to update ECR repository cache settings", {
|
||||
repositoryName,
|
||||
region,
|
||||
updateError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repo: existingRepo,
|
||||
repoCreated: false,
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
} from "@trigger.dev/otlp-importer";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ClickhouseEventRepository } from "./eventRepository/clickhouseEventRepository.server";
|
||||
import { clickhouseEventRepository } from "./eventRepository/clickhouseEventRepositoryInstance.server";
|
||||
import {
|
||||
clickhouseEventRepository,
|
||||
clickhouseEventRepositoryV2,
|
||||
} from "./eventRepository/clickhouseEventRepositoryInstance.server";
|
||||
import { generateSpanId } from "./eventRepository/common.server";
|
||||
import { EventRepository, eventRepository } from "./eventRepository/eventRepository.server";
|
||||
import type {
|
||||
@@ -38,6 +41,7 @@ class OTLPExporter {
|
||||
constructor(
|
||||
private readonly _eventRepository: EventRepository,
|
||||
private readonly _clickhouseEventRepository: ClickhouseEventRepository,
|
||||
private readonly _clickhouseEventRepositoryV2: ClickhouseEventRepository,
|
||||
private readonly _verbose: boolean,
|
||||
private readonly _spanAttributeValueLengthLimit: number
|
||||
) {
|
||||
@@ -111,6 +115,10 @@ class OTLPExporter {
|
||||
return this._clickhouseEventRepository;
|
||||
}
|
||||
|
||||
if (store === "clickhouse_v2") {
|
||||
return this._clickhouseEventRepositoryV2;
|
||||
}
|
||||
|
||||
return this._eventRepository;
|
||||
}
|
||||
|
||||
@@ -204,6 +212,24 @@ function convertLogsToCreateableEvents(
|
||||
|
||||
const resourceProperties = extractEventProperties(resourceAttributes);
|
||||
|
||||
const userDefinedResourceAttributes = truncateAttributes(
|
||||
convertKeyValueItemsToMap(resourceAttributes ?? [], [], undefined, [
|
||||
SemanticInternalAttributes.USAGE,
|
||||
SemanticInternalAttributes.SPAN,
|
||||
SemanticInternalAttributes.METADATA,
|
||||
SemanticInternalAttributes.STYLE,
|
||||
SemanticInternalAttributes.METRIC_EVENTS,
|
||||
SemanticInternalAttributes.TRIGGER,
|
||||
"process",
|
||||
"sdk",
|
||||
"service",
|
||||
"ctx",
|
||||
"cli",
|
||||
"cloud",
|
||||
]),
|
||||
spanAttributeValueLengthLimit
|
||||
);
|
||||
|
||||
const taskEventStore =
|
||||
extractStringAttribute(resourceAttributes, [SemanticInternalAttributes.TASK_EVENT_STORE]) ??
|
||||
env.EVENT_REPOSITORY_DEFAULT_STORE;
|
||||
@@ -249,6 +275,7 @@ function convertLogsToCreateableEvents(
|
||||
status: logLevelToEventStatus(log.severityNumber),
|
||||
startTime: log.timeUnixNano,
|
||||
properties,
|
||||
resourceProperties: userDefinedResourceAttributes,
|
||||
style: convertKeyValueItemsToMap(
|
||||
pickAttributes(log.attributes ?? [], SemanticInternalAttributes.STYLE),
|
||||
[]
|
||||
@@ -285,6 +312,24 @@ function convertSpansToCreateableEvents(
|
||||
|
||||
const resourceProperties = extractEventProperties(resourceAttributes);
|
||||
|
||||
const userDefinedResourceAttributes = truncateAttributes(
|
||||
convertKeyValueItemsToMap(resourceAttributes ?? [], [], undefined, [
|
||||
SemanticInternalAttributes.USAGE,
|
||||
SemanticInternalAttributes.SPAN,
|
||||
SemanticInternalAttributes.METADATA,
|
||||
SemanticInternalAttributes.STYLE,
|
||||
SemanticInternalAttributes.METRIC_EVENTS,
|
||||
SemanticInternalAttributes.TRIGGER,
|
||||
"process",
|
||||
"sdk",
|
||||
"service",
|
||||
"ctx",
|
||||
"cli",
|
||||
"cloud",
|
||||
]),
|
||||
spanAttributeValueLengthLimit
|
||||
);
|
||||
|
||||
const taskEventStore =
|
||||
extractStringAttribute(resourceAttributes, [SemanticInternalAttributes.TASK_EVENT_STORE]) ??
|
||||
env.EVENT_REPOSITORY_DEFAULT_STORE;
|
||||
@@ -336,6 +381,7 @@ function convertSpansToCreateableEvents(
|
||||
events: spanEventsToEventEvents(span.events ?? []),
|
||||
duration: span.endTimeUnixNano - span.startTimeUnixNano,
|
||||
properties,
|
||||
resourceProperties: userDefinedResourceAttributes,
|
||||
style: convertKeyValueItemsToMap(
|
||||
pickAttributes(span.attributes ?? [], SemanticInternalAttributes.STYLE),
|
||||
[]
|
||||
@@ -848,6 +894,7 @@ function initializeOTLPExporter() {
|
||||
return new OTLPExporter(
|
||||
eventRepository,
|
||||
clickhouseEventRepository,
|
||||
clickhouseEventRepositoryV2,
|
||||
process.env.OTLP_EXPORTER_VERBOSE === "1",
|
||||
process.env.SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT
|
||||
? parseInt(process.env.SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT, 10)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { RunEngine } from "@internal/run-engine";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { createBatchGlobalRateLimiter } from "~/runEngine/concerns/batchGlobalRateLimiter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { defaultMachine, getCurrentPlan } from "~/services/platform.v3.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { allMachines } from "./machinePresets.server";
|
||||
import { meter, tracer } from "./tracer.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export const engine = singleton("RunEngine", createRunEngine);
|
||||
|
||||
@@ -155,6 +156,36 @@ function createRunEngine() {
|
||||
};
|
||||
},
|
||||
},
|
||||
// BatchQueue with DRR scheduling for fair batch processing
|
||||
// Consumers are controlled by options.worker.disabled (same as main worker)
|
||||
batchQueue: {
|
||||
redis: {
|
||||
keyPrefix: "engine:",
|
||||
port: env.BATCH_TRIGGER_WORKER_REDIS_PORT ?? undefined,
|
||||
host: env.BATCH_TRIGGER_WORKER_REDIS_HOST ?? undefined,
|
||||
username: env.BATCH_TRIGGER_WORKER_REDIS_USERNAME ?? undefined,
|
||||
password: env.BATCH_TRIGGER_WORKER_REDIS_PASSWORD ?? undefined,
|
||||
enableAutoPipelining: true,
|
||||
...(env.BATCH_TRIGGER_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
},
|
||||
drr: {
|
||||
quantum: env.BATCH_QUEUE_DRR_QUANTUM,
|
||||
maxDeficit: env.BATCH_QUEUE_MAX_DEFICIT,
|
||||
},
|
||||
consumerCount: env.BATCH_QUEUE_CONSUMER_COUNT,
|
||||
consumerIntervalMs: env.BATCH_QUEUE_CONSUMER_INTERVAL_MS,
|
||||
// Default processing concurrency when no specific limit is set
|
||||
// This is overridden per-batch based on the plan type at batch creation
|
||||
defaultConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
|
||||
// Optional global rate limiter - limits max items/sec processed across all consumers
|
||||
globalRateLimiter: env.BATCH_QUEUE_GLOBAL_RATE_LIMIT
|
||||
? createBatchGlobalRateLimiter(env.BATCH_QUEUE_GLOBAL_RATE_LIMIT)
|
||||
: undefined,
|
||||
},
|
||||
// Debounce configuration
|
||||
debounce: {
|
||||
maxDebounceDurationMs: env.RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
|
||||
return engine;
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { CompleteBatchResult } from "@internal/run-engine";
|
||||
import { SpanKind } from "@internal/tracing";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { createJsonErrorObject, sanitizeError } from "@trigger.dev/core/v3";
|
||||
import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { $replica } from "~/db.server";
|
||||
import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { findEnvironmentFromRun } from "~/models/runtimeEnvironment.server";
|
||||
import { findEnvironmentById, findEnvironmentFromRun } from "~/models/runtimeEnvironment.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { updateMetadataService } from "~/services/metadata/updateMetadataInstance.server";
|
||||
import { reportInvocationUsage } from "~/services/platform.v3.server";
|
||||
import { MetadataTooLargeError } from "~/utils/packets";
|
||||
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
|
||||
import { tracer } from "~/v3/tracer.server";
|
||||
import { createExceptionPropertiesFromError } from "./eventRepository/common.server";
|
||||
import { recordRunDebugLog, resolveEventRepositoryForStore } from "./eventRepository/index.server";
|
||||
import { roomFromFriendlyRunId, socketIo } from "./handleSocketIo.server";
|
||||
import { engine } from "./runEngine.server";
|
||||
import { PerformTaskRunAlertsService } from "./services/alerts/performTaskRunAlerts.server";
|
||||
import { resolveEventRepositoryForStore, recordRunDebugLog } from "./eventRepository/index.server";
|
||||
import { createExceptionPropertiesFromError } from "./eventRepository/common.server";
|
||||
|
||||
export function registerRunEngineEventBusHandlers() {
|
||||
engine.eventBus.on("runSucceeded", async ({ time, run }) => {
|
||||
@@ -626,3 +632,206 @@ export function registerRunEngineEventBusHandlers() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the BatchQueue processing callbacks.
|
||||
* These handle creating runs from batch items and completing batches.
|
||||
*
|
||||
* Payload handling:
|
||||
* - If payloadType is "application/store", the payload is an R2 path (already offloaded)
|
||||
* - DefaultPayloadProcessor in TriggerTaskService will pass it through without re-offloading
|
||||
* - The run engine will download from R2 when the task executes
|
||||
*/
|
||||
export function setupBatchQueueCallbacks() {
|
||||
// Item processing callback - creates a run for each batch item
|
||||
engine.setBatchProcessItemCallback(async ({ batchId, friendlyId, itemIndex, item, meta }) => {
|
||||
return tracer.startActiveSpan(
|
||||
"batch.processItem",
|
||||
{
|
||||
kind: SpanKind.INTERNAL,
|
||||
attributes: {
|
||||
"batch.id": friendlyId,
|
||||
"batch.item_index": itemIndex,
|
||||
"batch.task": item.task,
|
||||
"batch.environment_id": meta.environmentId,
|
||||
"batch.parent_run_id": meta.parentRunId ?? "",
|
||||
},
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const environment = await findEnvironmentById(meta.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
span.setAttribute("batch.result.error", "Environment not found");
|
||||
span.end();
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Environment not found",
|
||||
errorCode: "ENVIRONMENT_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
const triggerTaskService = new TriggerTaskService();
|
||||
|
||||
// Normalize payload - for application/store (R2 paths), this passes through as-is
|
||||
const payload = normalizePayload(item.payload, item.payloadType);
|
||||
|
||||
const result = await triggerTaskService.call(
|
||||
item.task,
|
||||
environment,
|
||||
{
|
||||
payload,
|
||||
options: {
|
||||
...(item.options as Record<string, unknown>),
|
||||
payloadType: item.payloadType,
|
||||
parentRunId: meta.parentRunId,
|
||||
resumeParentOnCompletion: meta.resumeParentOnCompletion,
|
||||
parentBatch: batchId,
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerVersion: meta.triggerVersion,
|
||||
traceContext: meta.traceContext as Record<string, unknown> | undefined,
|
||||
spanParentAsLink: meta.spanParentAsLink,
|
||||
batchId,
|
||||
batchIndex: itemIndex,
|
||||
skipChecks: true, // Already validated at batch level
|
||||
realtimeStreamsVersion: meta.realtimeStreamsVersion,
|
||||
planType: meta.planType,
|
||||
},
|
||||
"V2"
|
||||
);
|
||||
|
||||
if (result) {
|
||||
span.setAttribute("batch.result.run_id", result.run.friendlyId);
|
||||
span.end();
|
||||
return { success: true as const, runId: result.run.friendlyId };
|
||||
} else {
|
||||
span.setAttribute("batch.result.error", "TriggerTaskService returned undefined");
|
||||
span.end();
|
||||
return {
|
||||
success: false as const,
|
||||
error: "TriggerTaskService returned undefined",
|
||||
errorCode: "TRIGGER_FAILED",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
span.setAttribute(
|
||||
"batch.result.error",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
||||
span.end();
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorCode: "TRIGGER_ERROR",
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Batch completion callback - updates Postgres with results
|
||||
engine.setBatchCompletionCallback(async (result: CompleteBatchResult) => {
|
||||
const { batchId, runIds, successfulRunCount, failedRunCount, failures } = result;
|
||||
|
||||
// Determine final status
|
||||
let status: BatchTaskRunStatus;
|
||||
if (failedRunCount > 0 && successfulRunCount === 0) {
|
||||
status = "ABORTED";
|
||||
} else if (failedRunCount > 0) {
|
||||
status = "PARTIAL_FAILED";
|
||||
} else {
|
||||
status = "PENDING"; // All runs created, waiting for completion
|
||||
}
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure atomicity of batch update and error record creation
|
||||
// skipDuplicates handles idempotency when callback is retried (relies on unique constraint)
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Update BatchTaskRun
|
||||
await tx.batchTaskRun.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status,
|
||||
runIds,
|
||||
successfulRunCount,
|
||||
failedRunCount,
|
||||
completedAt: status === "ABORTED" ? new Date() : undefined,
|
||||
processingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create error records if there were failures
|
||||
if (failures.length > 0) {
|
||||
await tx.batchTaskRunError.createMany({
|
||||
data: failures.map((failure) => ({
|
||||
batchTaskRunId: batchId,
|
||||
index: failure.index,
|
||||
taskIdentifier: failure.taskIdentifier,
|
||||
payload: failure.payload,
|
||||
options: failure.options as Prisma.InputJsonValue | undefined,
|
||||
error: failure.error,
|
||||
errorCode: failure.errorCode,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Try to complete the batch (handles waitpoint completion if all runs are done)
|
||||
if (status !== "ABORTED") {
|
||||
await engine.tryCompleteBatch({ batchId });
|
||||
}
|
||||
|
||||
logger.info("Batch completion handled", {
|
||||
batchId,
|
||||
status,
|
||||
successfulRunCount,
|
||||
failedRunCount,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle batch completion", {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Re-throw to preserve Redis data for retry (BatchQueue expects errors to propagate)
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
logger.info("BatchQueue callbacks configured");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the payload from BatchQueue.
|
||||
*
|
||||
* Handles different payload types:
|
||||
* - "application/store": Already offloaded to R2, payload is the path - pass through as-is
|
||||
* - "application/json": May be a pre-serialized JSON string - parse to avoid double-stringification
|
||||
* - Other types: Pass through as-is
|
||||
*
|
||||
* @param payload - The raw payload from the batch item
|
||||
* @param payloadType - The payload type (e.g., "application/json", "application/store")
|
||||
*/
|
||||
function normalizePayload(payload: unknown, payloadType?: string): unknown {
|
||||
// Only process "application/json" payloads
|
||||
// For all other types (including undefined), return as-is
|
||||
if (payloadType !== "application/json") {
|
||||
return payload;
|
||||
}
|
||||
|
||||
// For JSON payloads, if payload is a string, try to parse it
|
||||
// This handles pre-serialized JSON from the SDK
|
||||
if (typeof payload === "string") {
|
||||
try {
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
// If it's not valid JSON, return as-is
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user