From 3d418a94829153d9b160a194aa443100401aeb19 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 5 May 2026 09:20:08 +0100 Subject: [PATCH 001/491] ci: add zizmor workflow security scanner (#3506) Adds zizmor alongside the actionlint job from #3503. Both now run as parallel jobs in a single `.github/workflows/workflow-checks.yml`, triggered on `.github/workflows/**` and `.github/actions/**` changes. Zizmor is configured with `unpinned-uses: hash-pin` policy via `.github/zizmor.yml`, so any future unpinned action will fail CI. Findings upload SARIF to the Security tab alongside CodeQL. Bulk of the diff is cleanup of the findings zizmor surfaced on first run. `zizmor --fix=all` handled most of them mechanically; the rest were judgment calls. --- .github/actions/get-image-tag/action.yml | 30 ++++++------ .github/workflows/changesets-pr.yml | 2 +- .github/workflows/claude-md-audit.yml | 1 + .github/workflows/claude.yml | 1 + .github/workflows/docs.yml | 2 + .github/workflows/e2e-webapp.yml | 6 +++ .github/workflows/e2e.yml | 1 + .github/workflows/helm-prerelease.yml | 24 +++++++--- .github/workflows/pr_checks.yml | 8 ++-- .github/workflows/publish-webapp.yml | 24 +++++++--- .github/workflows/publish-worker-v4.yml | 12 +++-- .github/workflows/publish-worker.yml | 9 ++++ .github/workflows/publish.yml | 33 ++++++++++--- .github/workflows/release-helm.yml | 20 ++++++-- .github/workflows/release.yml | 47 ++++++++++++++----- .github/workflows/sdk-compat.yml | 4 ++ .github/workflows/typecheck.yml | 1 + .github/workflows/unit-tests-internal.yml | 7 +++ .github/workflows/unit-tests-packages.yml | 7 +++ .github/workflows/unit-tests-webapp.yml | 7 +++ .github/workflows/unit-tests.yml | 21 +++++++-- .github/workflows/vouch-check-pr.yml | 13 +++-- .../{actionlint.yml => workflow-checks.yml} | 20 +++++++- .github/zizmor.yml | 5 ++ 24 files changed, 233 insertions(+), 72 deletions(-) rename .github/workflows/{actionlint.yml => workflow-checks.yml} (54%) create mode 100644 .github/zizmor.yml diff --git a/.github/actions/get-image-tag/action.yml b/.github/actions/get-image-tag/action.yml index e0646230463..7f1505a0c11 100644 --- a/.github/actions/get-image-tag/action.yml +++ b/.github/actions/get-image-tag/action.yml @@ -23,35 +23,37 @@ runs: id: get_tag shell: bash run: | - if [[ -n "${{ inputs.tag }}" ]]; then - tag="${{ inputs.tag }}" - elif [[ "${{ github.ref_type }}" == "tag" ]]; then - if [[ "${{ github.ref_name }}" == infra-*-* ]]; then - env=$(echo ${{ github.ref_name }} | cut -d- -f2) - sha=$(echo ${{ github.sha }} | head -c7) + if [[ -n "${INPUTS_TAG}" ]]; then + tag="${INPUTS_TAG}" + elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + if [[ "${GITHUB_REF_NAME}" == infra-*-* ]]; then + env=$(echo ${GITHUB_REF_NAME} | cut -d- -f2) + sha=$(echo "${GITHUB_SHA}" | head -c7) ts=$(date +%s) tag=${env}-${sha}-${ts} - elif [[ "${{ github.ref_name }}" == re2-*-* ]]; then - env=$(echo ${{ github.ref_name }} | cut -d- -f2) - sha=$(echo ${{ github.sha }} | head -c7) + elif [[ "${GITHUB_REF_NAME}" == re2-*-* ]]; then + env=$(echo ${GITHUB_REF_NAME} | cut -d- -f2) + sha=$(echo "${GITHUB_SHA}" | head -c7) ts=$(date +%s) tag=${env}-${sha}-${ts} - elif [[ "${{ github.ref_name }}" == v.docker.* ]]; then + elif [[ "${GITHUB_REF_NAME}" == v.docker.* ]]; then version="${GITHUB_REF_NAME#v.docker.}" tag="v${version}" - elif [[ "${{ github.ref_name }}" == build-* ]]; then + elif [[ "${GITHUB_REF_NAME}" == build-* ]]; then tag="${GITHUB_REF_NAME#build-}" else - echo "Invalid git tag: ${{ github.ref_name }}" + echo "Invalid git tag: ${GITHUB_REF_NAME}" exit 1 fi - elif [[ "${{ github.ref_name }}" == "main" ]]; then + elif [[ "${GITHUB_REF_NAME}" == "main" ]]; then tag="main" else - echo "Invalid git ref: ${{ github.ref }}" + echo "Invalid git ref: ${GITHUB_REF}" exit 1 fi echo "tag=${tag}" >> "$GITHUB_OUTPUT" + env: + INPUTS_TAG: ${{ inputs.tag }} - name: πŸ” Check for validity id: check_validity diff --git a/.github/workflows/changesets-pr.yml b/.github/workflows/changesets-pr.yml index 4b4d241257c..01c303a95ca 100644 --- a/.github/workflows/changesets-pr.yml +++ b/.github/workflows/changesets-pr.yml @@ -25,7 +25,7 @@ jobs: if: github.repository == 'triggerdotdev/trigger.dev' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # zizmor: ignore[artipacked] changesets/action pushes the release branch; no artifact upload here so no leak path with: fetch-depth: 0 diff --git a/.github/workflows/claude-md-audit.yml b/.github/workflows/claude-md-audit.yml index 4b320d05e16..e8716b1d6a9 100644 --- a/.github/workflows/claude-md-audit.yml +++ b/.github/workflows/claude-md-audit.yml @@ -30,6 +30,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: Run Claude Code id: claude diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index aa807583240..a3c60b928e6 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -29,6 +29,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 090cfe9ab43..0cac7c8595f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,6 +27,8 @@ jobs: steps: - name: πŸ“₯ Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: πŸ“¦ Cache npm uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 diff --git a/.github/workflows/e2e-webapp.yml b/.github/workflows/e2e-webapp.yml index 08402d4857a..307898facd4 100644 --- a/.github/workflows/e2e-webapp.yml +++ b/.github/workflows/e2e-webapp.yml @@ -5,6 +5,11 @@ permissions: on: workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false jobs: e2eTests: @@ -44,6 +49,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9ee1a415cc9..b9d1e19c6be 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,6 +27,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/helm-prerelease.yml b/.github/workflows/helm-prerelease.yml index ec998978d5b..dd58fbb3551 100644 --- a/.github/workflows/helm-prerelease.yml +++ b/.github/workflows/helm-prerelease.yml @@ -34,6 +34,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 @@ -78,6 +80,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 @@ -108,11 +112,11 @@ jobs: SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) PRERELEASE_VERSION="${BASE_VERSION}-pr${PR_NUMBER}.${SHORT_SHA}" elif [[ "${{ github.event_name }}" == "push" ]]; then - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7) PRERELEASE_VERSION="${BASE_VERSION}-main.${SHORT_SHA}" else - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - REF_SLUG=$(echo "${{ github.ref_name }}" | tr '/' '-' | tr -cd 'a-zA-Z0-9-') + SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7) + REF_SLUG=$(echo "${GITHUB_REF_NAME}" | tr '/' '-' | tr -cd 'a-zA-Z0-9-') if [[ -z "$REF_SLUG" ]]; then REF_SLUG="manual" fi @@ -123,7 +127,9 @@ jobs: - name: Update Chart.yaml with prerelease version run: | - sed -i "s/^version:.*/version: ${{ steps.version.outputs.version }}/" ./hosting/k8s/helm/Chart.yaml + sed -i "s/^version:.*/version: ${STEPS_VERSION_OUTPUTS_VERSION}/" ./hosting/k8s/helm/Chart.yaml + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} - name: Override appVersion if: github.event_name == 'workflow_dispatch' && inputs.app_version != '' @@ -138,26 +144,30 @@ jobs: - name: Push Helm Chart to GHCR run: | - VERSION="${{ steps.version.outputs.version }}" + VERSION="${STEPS_VERSION_OUTPUTS_VERSION}" CHART_PACKAGE="/tmp/${{ env.CHART_NAME }}-${VERSION}.tgz" # Push to GHCR OCI registry helm push "$CHART_PACKAGE" "oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts" + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} - name: Write run summary run: | { echo "### 🧭 Helm Chart Prerelease Published" echo "" - echo "**Version:** \`${{ steps.version.outputs.version }}\`" + echo "**Version:** \`${STEPS_VERSION_OUTPUTS_VERSION}\`" echo "" echo "**Install:**" echo '```bash' echo "helm upgrade --install trigger \\" echo " oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts/${{ env.CHART_NAME }} \\" - echo " --version \"${{ steps.version.outputs.version }}\"" + echo " --version \"${STEPS_VERSION_OUTPUTS_VERSION}\"" echo '```' } >> "$GITHUB_STEP_SUMMARY" + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} - name: Find existing comment if: github.event_name == 'pull_request' diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index be9009ae96a..27aa6a61a5b 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -15,23 +15,21 @@ concurrency: permissions: contents: read - id-token: write jobs: typecheck: uses: ./.github/workflows/typecheck.yml - secrets: inherit units: uses: ./.github/workflows/unit-tests.yml - secrets: inherit + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} e2e: uses: ./.github/workflows/e2e.yml with: package: cli-v3 - secrets: inherit sdk-compat: uses: ./.github/workflows/sdk-compat.yml - secrets: inherit diff --git a/.github/workflows/publish-webapp.yml b/.github/workflows/publish-webapp.yml index 76b57335acb..b4ac9defb6f 100644 --- a/.github/workflows/publish-webapp.yml +++ b/.github/workflows/publish-webapp.yml @@ -13,6 +13,9 @@ on: type: string required: false default: "" + secrets: + SENTRY_AUTH_TOKEN: + required: false jobs: publish: @@ -30,6 +33,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive + persist-credentials: false - name: "#️⃣ Get the image tag" id: get_tag @@ -40,34 +44,40 @@ jobs: - name: πŸ”’ Get the commit hash id: get_commit run: | - echo "sha_short=$(echo ${{ github.sha }} | cut -c1-7)" >> "$GITHUB_OUTPUT" + echo "sha_short=$(echo "${GITHUB_SHA}" | cut -c1-7)" >> "$GITHUB_OUTPUT" - name: πŸ“› Set the tags id: set_tags run: | ref_without_tag=ghcr.io/triggerdotdev/trigger.dev - image_tags=$ref_without_tag:${{ steps.get_tag.outputs.tag }} + image_tags=$ref_without_tag:${STEPS_GET_TAG_OUTPUTS_TAG} # if tag is a semver, also tag it as v4 - if [[ "${{ steps.get_tag.outputs.is_semver }}" == true ]]; then + if [[ "${STEPS_GET_TAG_OUTPUTS_IS_SEMVER}" == true ]]; then # TODO: switch to v4 tag on GA image_tags=$image_tags,$ref_without_tag:v4-beta fi echo "image_tags=${image_tags}" >> "$GITHUB_OUTPUT" + env: + STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} + STEPS_GET_TAG_OUTPUTS_IS_SEMVER: ${{ steps.get_tag.outputs.is_semver }} - name: πŸ“ Set the build info id: set_build_info run: | { - tag="${{ steps.get_tag.outputs.tag }}" - if [[ "${{ steps.get_tag.outputs.is_semver }}" == true ]]; then + tag="${STEPS_GET_TAG_OUTPUTS_TAG}" + if [[ "${STEPS_GET_TAG_OUTPUTS_IS_SEMVER}" == true ]]; then echo "BUILD_APP_VERSION=${tag}" fi - echo "BUILD_GIT_SHA=${{ github.sha }}" - echo "BUILD_GIT_REF_NAME=${{ github.ref_name }}" + echo "BUILD_GIT_SHA=${GITHUB_SHA}" + echo "BUILD_GIT_REF_NAME=${GITHUB_REF_NAME}" echo "BUILD_TIMESTAMP_SECONDS=$(date +%s)" } >> "$GITHUB_OUTPUT" + env: + STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} + STEPS_GET_TAG_OUTPUTS_IS_SEMVER: ${{ steps.get_tag.outputs.is_semver }} - name: πŸ™ Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 diff --git a/.github/workflows/publish-worker-v4.yml b/.github/workflows/publish-worker-v4.yml index ed3bfd923e1..c3b72c6b7d9 100644 --- a/.github/workflows/publish-worker-v4.yml +++ b/.github/workflows/publish-worker-v4.yml @@ -41,6 +41,8 @@ jobs: - name: ⬇️ Checkout git repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: πŸ“¦ Get image repo id: get_repository @@ -63,16 +65,20 @@ jobs: - name: πŸ“› Set tags to push id: set_tags run: | - ref_without_tag=ghcr.io/triggerdotdev/${{ steps.get_repository.outputs.repo }} - image_tags=$ref_without_tag:${{ steps.get_tag.outputs.tag }} + ref_without_tag=ghcr.io/triggerdotdev/${STEPS_GET_REPOSITORY_OUTPUTS_REPO} + image_tags=$ref_without_tag:${STEPS_GET_TAG_OUTPUTS_TAG} # if tag is a semver, also tag it as v4 - if [[ "${{ steps.get_tag.outputs.is_semver }}" == true ]]; then + if [[ "${STEPS_GET_TAG_OUTPUTS_IS_SEMVER}" == true ]]; then # TODO: switch to v4 tag on GA image_tags=$image_tags,$ref_without_tag:v4-beta fi echo "image_tags=${image_tags}" >> "$GITHUB_OUTPUT" + env: + STEPS_GET_REPOSITORY_OUTPUTS_REPO: ${{ steps.get_repository.outputs.repo }} + STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} + STEPS_GET_TAG_OUTPUTS_IS_SEMVER: ${{ steps.get_tag.outputs.is_semver }} - name: πŸ™ Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 diff --git a/.github/workflows/publish-worker.yml b/.github/workflows/publish-worker.yml index bd11dfc6253..d7e0c79ddd2 100644 --- a/.github/workflows/publish-worker.yml +++ b/.github/workflows/publish-worker.yml @@ -8,6 +8,11 @@ on: type: string required: false default: "" + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false push: tags: - "infra-dev-*" @@ -26,9 +31,12 @@ jobs: runs-on: ubuntu-latest env: DOCKER_BUILDKIT: "1" + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} steps: - name: ⬇️ Checkout git repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: πŸ“¦ Get image repo id: get_repository @@ -52,6 +60,7 @@ jobs: # ..to avoid rate limits when pulling images - name: 🐳 Login to DockerHub + if: ${{ env.DOCKERHUB_USERNAME }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6213499c5ad..0bc873d80d4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,13 @@ on: description: The image tag to publish required: true type: string + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false + SENTRY_AUTH_TOKEN: + required: false push: branches: - main @@ -37,8 +44,6 @@ on: - "tests/**" permissions: - id-token: write - packages: write contents: read concurrency: @@ -50,29 +55,43 @@ env: jobs: typecheck: uses: ./.github/workflows/typecheck.yml - secrets: inherit units: uses: ./.github/workflows/unit-tests.yml - secrets: inherit + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} publish-webapp: needs: [typecheck] + permissions: + contents: read + packages: write + id-token: write uses: ./.github/workflows/publish-webapp.yml - secrets: inherit + secrets: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} with: image_tag: ${{ inputs.image_tag }} publish-worker: needs: [typecheck] + permissions: + contents: read + packages: write uses: ./.github/workflows/publish-worker.yml - secrets: inherit + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} with: image_tag: ${{ inputs.image_tag }} publish-worker-v4: needs: [typecheck] + permissions: + contents: read + packages: write + id-token: write uses: ./.github/workflows/publish-worker-v4.yml - secrets: inherit with: image_tag: ${{ inputs.image_tag }} diff --git a/.github/workflows/release-helm.yml b/.github/workflows/release-helm.yml index 51b51df2297..65e846d0d39 100644 --- a/.github/workflows/release-helm.yml +++ b/.github/workflows/release-helm.yml @@ -29,6 +29,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 @@ -68,6 +70,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 @@ -92,18 +96,20 @@ jobs: - name: Extract version from tag or input id: version run: | - if [ -n "${{ inputs.chart_version }}" ]; then - VERSION="${{ inputs.chart_version }}" + if [ -n "${INPUTS_CHART_VERSION}" ]; then + VERSION="${INPUTS_CHART_VERSION}" else - VERSION="${{ github.ref_name }}" + VERSION="${GITHUB_REF_NAME}" VERSION="${VERSION#helm-v}" fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Releasing version: $VERSION" + env: + INPUTS_CHART_VERSION: ${{ inputs.chart_version }} - name: Check Chart.yaml version matches release version run: | - VERSION="${{ steps.version.outputs.version }}" + VERSION="${STEPS_VERSION_OUTPUTS_VERSION}" CHART_VERSION=$(grep '^version:' ./hosting/k8s/helm/Chart.yaml | awk '{print $2}') echo "Chart.yaml version: $CHART_VERSION" echo "Release version: $VERSION" @@ -112,6 +118,8 @@ jobs: exit 1 fi echo "βœ… Chart.yaml version matches release version." + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} - name: Package Helm Chart run: | @@ -119,11 +127,13 @@ jobs: - name: Push Helm Chart to GHCR run: | - VERSION="${{ steps.version.outputs.version }}" + VERSION="${STEPS_VERSION_OUTPUTS_VERSION}" CHART_PACKAGE="/tmp/${{ env.CHART_NAME }}-${VERSION}.tgz" # Push to GHCR OCI registry helm push "$CHART_PACKAGE" "oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts" + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} - name: Create GitHub Release id: release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6178b056ff4..0f0c8cae302 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ jobs: show-release-summary: name: πŸ“‹ Release Summary runs-on: ubuntu-latest + permissions: {} if: | github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'pull_request' && @@ -65,7 +66,7 @@ jobs: published_package_version: ${{ steps.get_version.outputs.package_version }} steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # zizmor: ignore[artipacked] needs persisted git creds for tag push; no artifact upload here so no leak path with: fetch-depth: 0 ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }} @@ -73,10 +74,12 @@ jobs: - 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 + 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 + env: + GITHUB_EVENT_INPUTS_REF: ${{ github.event.inputs.ref }} - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -119,16 +122,19 @@ jobs: if: steps.changesets.outputs.published == 'true' id: get_version run: | - package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version') + package_version=$(echo "${STEPS_CHANGESETS_OUTPUTS_PUBLISHEDPACKAGES}" | jq -r '.[0].version') echo "package_version=${package_version}" >> "$GITHUB_OUTPUT" + env: + STEPS_CHANGESETS_OUTPUTS_PUBLISHEDPACKAGES: ${{ steps.changesets.outputs.publishedPackages }} - name: Create unified GitHub release if: steps.changesets.outputs.published == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_PR_BODY: ${{ github.event.pull_request.body }} + STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION: ${{ steps.get_version.outputs.package_version }} run: | - VERSION="${{ steps.get_version.outputs.package_version }}" + VERSION="${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" node scripts/generate-github-release.mjs "$VERSION" > /tmp/release-body.md gh release create "v${VERSION}" \ --title "trigger.dev v${VERSION}" \ @@ -139,15 +145,19 @@ jobs: 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 }}" + git tag "v.docker.${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" + git push origin "v.docker.${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" + env: + STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION: ${{ steps.get_version.outputs.package_version }} - name: Create and push Helm chart tag if: steps.changesets.outputs.published == 'true' run: | set -e - git tag "helm-v${{ steps.get_version.outputs.package_version }}" - git push origin "helm-v${{ steps.get_version.outputs.package_version }}" + git tag "helm-v${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" + git push origin "helm-v${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" + env: + STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION: ${{ steps.get_version.outputs.package_version }} # Trigger Docker builds directly via workflow_call since tags pushed with # GITHUB_TOKEN don't trigger other workflows (GitHub Actions limitation). @@ -155,8 +165,15 @@ jobs: name: 🐳 Publish Docker images needs: release if: needs.release.outputs.published == 'true' + permissions: + contents: read + packages: write + id-token: write uses: ./.github/workflows/publish.yml - secrets: inherit + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} with: image_tag: v${{ needs.release.outputs.published_package_version }} @@ -171,7 +188,6 @@ jobs: contents: write packages: write uses: ./.github/workflows/release-helm.yml - secrets: inherit with: chart_version: ${{ needs.release.outputs.published_package_version }} @@ -189,9 +205,10 @@ jobs: - name: Update GitHub release with Docker image link env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEEDS_RELEASE_OUTPUTS_PUBLISHED_PACKAGE_VERSION: ${{ needs.release.outputs.published_package_version }} run: | set -e - VERSION="${{ needs.release.outputs.published_package_version }}" + VERSION="${NEEDS_RELEASE_OUTPUTS_PUBLISHED_PACKAGE_VERSION}" TAG="v${VERSION}" # Query GHCR for the version ID matching this tag @@ -223,6 +240,7 @@ jobs: needs: [release, update-release] if: needs.release.outputs.published == 'true' runs-on: ubuntu-latest + permissions: {} steps: - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: @@ -246,6 +264,7 @@ jobs: with: fetch-depth: 0 ref: ${{ github.event.inputs.ref }} + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -270,9 +289,10 @@ jobs: run: pnpm run generate - name: Snapshot version - run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }} + run: pnpm exec changeset version --snapshot "${GITHUB_EVENT_INPUTS_PRERELEASE_TAG}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_EVENT_INPUTS_PRERELEASE_TAG: ${{ github.event.inputs.prerelease_tag }} - name: Clean run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev" @@ -281,6 +301,7 @@ jobs: 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 }} + run: pnpm exec changeset publish --no-git-tag --snapshot --tag "${GITHUB_EVENT_INPUTS_PRERELEASE_TAG}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_EVENT_INPUTS_PRERELEASE_TAG: ${{ github.event.inputs.prerelease_tag }} diff --git a/.github/workflows/sdk-compat.yml b/.github/workflows/sdk-compat.yml index 798f747dfa1..1940504e3f8 100644 --- a/.github/workflows/sdk-compat.yml +++ b/.github/workflows/sdk-compat.yml @@ -21,6 +21,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -59,6 +60,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -100,6 +102,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -145,6 +148,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 96ef7ac5028..199af9f741a 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -15,6 +15,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/unit-tests-internal.yml b/.github/workflows/unit-tests-internal.yml index 129a7a33640..97ba202fcb3 100644 --- a/.github/workflows/unit-tests-internal.yml +++ b/.github/workflows/unit-tests-internal.yml @@ -5,6 +5,11 @@ permissions: on: workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false jobs: unitTests: @@ -49,6 +54,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -118,6 +124,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/unit-tests-packages.yml b/.github/workflows/unit-tests-packages.yml index 1d4f56b110a..fb3d513aecb 100644 --- a/.github/workflows/unit-tests-packages.yml +++ b/.github/workflows/unit-tests-packages.yml @@ -5,6 +5,11 @@ permissions: on: workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false jobs: unitTests: @@ -49,6 +54,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -118,6 +124,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/unit-tests-webapp.yml b/.github/workflows/unit-tests-webapp.yml index f119c4aef38..79445503669 100644 --- a/.github/workflows/unit-tests-webapp.yml +++ b/.github/workflows/unit-tests-webapp.yml @@ -5,6 +5,11 @@ permissions: on: workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false jobs: unitTests: @@ -49,6 +54,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -126,6 +132,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: βŽ” Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 2c4276a5aa0..96e76279c82 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -5,17 +5,30 @@ permissions: on: workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false jobs: webapp: uses: ./.github/workflows/unit-tests-webapp.yml - secrets: inherit + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} e2e-webapp: uses: ./.github/workflows/e2e-webapp.yml - secrets: inherit + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} packages: uses: ./.github/workflows/unit-tests-packages.yml - secrets: inherit + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} internal: uses: ./.github/workflows/unit-tests-internal.yml - secrets: inherit + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index ab28275553a..29090296bb0 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -1,17 +1,18 @@ name: Vouch - Check PR on: - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] needed to comment/close fork PRs; safe because we never check out PR HEAD ref so no fork-controlled code runs types: [opened, reopened] -permissions: - contents: read - pull-requests: write - issues: read +permissions: {} jobs: check-vouch: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # auto-close unvouched PRs + issues: read steps: - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: @@ -23,6 +24,8 @@ jobs: require-draft: needs: check-vouch + permissions: + pull-requests: write # close non-draft PRs with a comment if: > github.event.pull_request.draft == false && github.event.pull_request.author_association != 'MEMBER' && diff --git a/.github/workflows/actionlint.yml b/.github/workflows/workflow-checks.yml similarity index 54% rename from .github/workflows/actionlint.yml rename to .github/workflows/workflow-checks.yml index 3ed4d99e57b..2e4d50cd9ed 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/workflow-checks.yml @@ -1,4 +1,4 @@ -name: Actionlint +name: Workflow Checks on: push: @@ -6,10 +6,12 @@ on: paths: - '.github/workflows/**' - '.github/actions/**' + - '.github/zizmor.yml' pull_request: paths: - '.github/workflows/**' - '.github/actions/**' + - '.github/zizmor.yml' permissions: {} @@ -31,3 +33,19 @@ jobs: - name: Run actionlint uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 + + zizmor: + name: Zizmor + runs-on: ubuntu-latest + permissions: + security-events: write # Upload SARIF to GitHub Security tab + contents: read # Read workflow files for analysis + actions: read # Read workflow run metadata + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000000..2fcbb540127 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,5 @@ +rules: + unpinned-uses: + config: + policies: + '*': hash-pin From 386b4f65fff2b3653d73360a802137f8c438669c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 5 May 2026 10:06:58 +0100 Subject: [PATCH 002/491] feat(webapp): per-org S2 basin migration (#3516) ## Summary Move from a single shared S2 basin to **per-org basins** with retention tied to the org's billing plan. Stops S2 from deleting streams out from under live chat sessions when basin retention fires before the chat ends, and unlocks per-org cost attribution. OSS / s2-lite installs are unaffected: provisioning is gated by `REALTIME_STREAMS_PER_ORG_BASINS_ENABLED` (default `false`), and the read precedence falls back to the global basin env var when an entity has no stamped basin. ``` basin = run.streamBasinName ?? session.streamBasinName ?? env.REALTIME_STREAMS_S2_BASIN ``` ## Design Three nullable `streamBasinName` columns (`Organization`, `TaskRun`, `Session`) plus a provisioner that idempotently creates the basin and reconfigures retention on plan changes. The trigger and session-create paths stamp the org's basin onto new rows; the realtime read path picks the basin from the entity context. Admin routes back-fill existing orgs and force-reconfigure a single org. ## Test plan - [x] `pnpm run typecheck --filter webapp --filter @internal/run-engine` - [x] Backfill admin route end-to-end (provision + DB stamp + S2 basin config). - [x] Reconfigure on plan change (all retention tiers). - [x] chat.agent multi-turn drives streams into the per-org basin. - [x] Legacy fallback when entity has no stamped basin. - [x] Provisioner is a no-op when the flag is off. --- .server-changes/per-org-stream-basins.md | 6 + apps/webapp/app/env.server.ts | 19 ++ ...pi.v1.orgs.$organizationId.stream-basin.ts | 47 ++++ ....runs.$runFriendlyId.input-streams.wait.ts | 4 +- ...uns.$runFriendlyId.session-streams.wait.ts | 14 +- apps/webapp/app/routes/api.v1.sessions.ts | 2 + ...ealtime.v1.sessions.$session.$io.append.ts | 4 +- .../realtime.v1.sessions.$session.$io.ts | 14 +- .../realtime.v1.streams.$runId.$streamId.ts | 8 +- ...streams.$runId.$target.$streamId.append.ts | 4 +- ...ime.v1.streams.$runId.$target.$streamId.ts | 46 +++- ...ltime.v1.streams.$runId.input.$streamId.ts | 7 +- .../route.tsx | 3 +- .../runEngine/services/triggerTask.server.ts | 1 + .../webapp/app/services/platform.v3.server.ts | 11 + .../app/services/realtime/duration.server.ts | 49 ++++ .../realtime/s2realtimeStreams.server.ts | 5 +- .../realtime/streamBasinProvisioner.server.ts | 248 ++++++++++++++++++ .../realtime/v1StreamsGlobal.server.ts | 84 ++++-- .../migration.sql | 8 + .../database/prisma/schema.prisma | 18 ++ .../run-engine/src/engine/index.ts | 2 + .../run-engine/src/engine/types.ts | 1 + 23 files changed, 547 insertions(+), 58 deletions(-) create mode 100644 .server-changes/per-org-stream-basins.md create mode 100644 apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.stream-basin.ts create mode 100644 apps/webapp/app/services/realtime/duration.server.ts create mode 100644 apps/webapp/app/services/realtime/streamBasinProvisioner.server.ts create mode 100644 internal-packages/database/prisma/migrations/20260504071227_add_stream_basin_name/migration.sql diff --git a/.server-changes/per-org-stream-basins.md b/.server-changes/per-org-stream-basins.md new file mode 100644 index 00000000000..4e45129849c --- /dev/null +++ b/.server-changes/per-org-stream-basins.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Per-org S2 stream basins with retention tied to the org's billing plan, gated by `REALTIME_STREAMS_PER_ORG_BASINS_ENABLED`. Stops basin retention from deleting streams out from under live chat sessions and unlocks per-org cost attribution. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index ff27168445a..13e9e5dacbd 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -3,6 +3,15 @@ import { MachinePresetName } from "@trigger.dev/core/v3"; import { BoolEnv } from "./utils/boolEnv"; import { isValidDatabaseUrl } from "./utils/db"; import { isValidRegex } from "./utils/regex"; +import { isValidDuration } from "./services/realtime/duration.server"; + +// `z.string()` constrained to a `parseDuration`-parseable string (e.g. +// `7d`, `1h`). Validated at boot so a typo'd duration fails fast. +function durationString() { + return z + .string() + .refine(isValidDuration, "must be a duration like 7d, 30d, 365d, 1h, 1y"); +} // Parses a CSV of machine preset names (e.g. "small-1x,small-2x") into a // non-empty array of MachinePresetName. Used by COMPUTE_TEMPLATE_MACHINE_PRESETS @@ -1506,6 +1515,16 @@ const EnvironmentSchema = z REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS: z.coerce.number().int().default(100), REALTIME_STREAMS_S2_MAX_RETRIES: z.coerce.number().int().default(10), REALTIME_STREAMS_S2_WAIT_SECONDS: z.coerce.number().int().default(60), + // When "true", provision a dedicated S2 basin per org and stamp + // `streamBasinName` on new rows. Off keeps everything on the single + // basin defined by `REALTIME_STREAMS_S2_BASIN`. + REALTIME_STREAMS_PER_ORG_BASINS_ENABLED: z.enum(["true", "false"]).default("false"), + // Per-org basin name = `{prefix}-{env}-org-{orgId}`. + REALTIME_STREAMS_BASIN_NAME_PREFIX: z.string().default("triggerdotdev"), + REALTIME_STREAMS_BASIN_NAME_ENV: z.string().default("dev"), + REALTIME_STREAMS_BASIN_DEFAULT_RETENTION: durationString().default("30d"), + REALTIME_STREAMS_BASIN_STORAGE_CLASS: z.enum(["express", "standard"]).default("express"), + REALTIME_STREAMS_BASIN_DELETE_ON_EMPTY_MIN_AGE: durationString().default("1h"), REALTIME_STREAMS_DEFAULT_VERSION: z.enum(["v1", "v2"]).default("v1"), WAIT_UNTIL_TIMEOUT_MS: z.coerce.number().int().default(600_000), diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.stream-basin.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.stream-basin.ts new file mode 100644 index 00000000000..67fd457c6b3 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.stream-basin.ts @@ -0,0 +1,47 @@ +import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; +import { isValidDuration } from "~/services/realtime/duration.server"; +import { + deprovisionBasinForOrg, + ensureBasinForOrg, +} from "~/services/realtime/streamBasinProvisioner.server"; + +const ParamsSchema = z.object({ organizationId: z.string() }); + +const BodySchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("ensure"), + retention: z + .string() + .refine(isValidDuration, "retention must be a duration like 7d, 30d, 365d, 1h, 1y"), + }), + z.object({ action: z.literal("deprovision") }), +]); + +export async function action({ request, params }: ActionFunctionArgs) { + await requireAdminApiRequest(request); + + const { organizationId } = ParamsSchema.parse(params); + + let parsed: z.infer; + try { + const text = await request.text(); + const raw = text.length > 0 ? JSON.parse(text) : {}; + const result = BodySchema.safeParse(raw); + if (!result.success) { + return json({ ok: false, error: result.error.flatten() }, { status: 400 }); + } + parsed = result.data; + } catch { + return json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); + } + + if (parsed.action === "ensure") { + const result = await ensureBasinForOrg(organizationId, parsed.retention); + return json({ ok: true, ...result }); + } + + const result = await deprovisionBasinForOrg(organizationId); + return json({ ok: true, ...result }); +} diff --git a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts index 8e41e9fe4c8..a0f24f9abd8 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts @@ -40,6 +40,7 @@ const { action, loader } = createActionApiRoute( id: true, friendlyId: true, realtimeStreamsVersion: true, + streamBasinName: true, }, }); @@ -98,7 +99,8 @@ const { action, loader } = createActionApiRoute( try { const realtimeStream = getRealtimeStreamInstance( authentication.environment, - run.realtimeStreamsVersion + run.realtimeStreamsVersion, + { run } ); const records = await realtimeStream.readRecords( diff --git a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts index 18034caab47..4fbdb454d92 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts @@ -123,12 +123,14 @@ const { action, loader } = createActionApiRoute( // and remove the pending registration. if (!result.isCached) { try { - // Session streams are always v2 (S2) β€” the writer in - // `appendPartToSessionStream` and the SSE subscribe both - // hardcode "v2", so the race-check reader has to match. - // Don't fall through to the run's own `realtimeStreamsVersion`, - // which only describes the run's run-scoped streams. - const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2"); + // Match the writer's basin resolution exactly: session if the + // row exists, otherwise the org so we look at the same basin a + // fresh row would be stamped with. Mirrors the PUT/GET sister + // routes in `realtime.v1.sessions.$session.$io.ts`. + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { + session: maybeSession, + organization: maybeSession ? null : authentication.environment.organization, + }); if (realtimeStream instanceof S2RealtimeStreams) { const records = await realtimeStream.readSessionStreamRecords( diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index 38270fdfc77..eafb0f7a20c 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -167,6 +167,7 @@ const { action } = createActionApiRoute( runtimeEnvironmentId: authentication.environment.id, environmentType: authentication.environment.type, organizationId: authentication.environment.organizationId, + streamBasinName: authentication.environment.organization.streamBasinName, }, update: { triggerConfig: triggerConfigJson }, }); @@ -186,6 +187,7 @@ const { action } = createActionApiRoute( runtimeEnvironmentId: authentication.environment.id, environmentType: authentication.environment.type, organizationId: authentication.environment.organizationId, + streamBasinName: authentication.environment.organization.streamBasinName, }, }); } diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts index 4251baae91e..45fbde5924b 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts @@ -81,7 +81,9 @@ const { action, loader } = createActionApiRoute( ); } - const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2"); + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { + session, + }); if (!(realtimeStream instanceof S2RealtimeStreams)) { return json( diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts index c04992f7f14..37ec58c51ae 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts @@ -59,7 +59,13 @@ const { action } = createActionApiRoute( }); } - const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2"); + // No-row form: resolve via the org so the stream initialised here + // matches what later appends/subscribes will land on once the row + // is created. + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { + session: maybeSession, + organization: maybeSession ? null : authentication.environment.organization, + }); if (!(realtimeStream instanceof S2RealtimeStreams)) { return new Response("Session channels require the S2 realtime backend", { @@ -122,7 +128,11 @@ const loader = createLoaderApiRoute( }, }, async ({ params, request, authentication, resource }) => { - const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2"); + // Same no-row fallback as PUT above. + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { + session: resource.row, + organization: resource.row ? null : authentication.environment.organization, + }); if (!(realtimeStream instanceof S2RealtimeStreams)) { return new Response("Session channels require the S2 realtime backend", { diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts index aabd83bc9bb..477ce781a20 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts @@ -29,6 +29,7 @@ export async function action({ request, params }: ActionFunctionArgs) { select: { id: true, friendlyId: true, + streamBasinName: true, runtimeEnvironment: { include: { project: true, @@ -64,7 +65,9 @@ export async function action({ request, params }: ActionFunctionArgs) { } // The runtimeEnvironment from the run is already in the correct shape for AuthenticatedEnvironment - const realtimeStream = getRealtimeStreamInstance(run.runtimeEnvironment, streamVersion); + const realtimeStream = getRealtimeStreamInstance(run.runtimeEnvironment, streamVersion, { + run, + }); return realtimeStream.ingestData( request.body, @@ -127,7 +130,8 @@ export const loader = createLoaderApiRoute( const realtimeStream = getRealtimeStreamInstance( authentication.environment, - run.realtimeStreamsVersion + run.realtimeStreamsVersion, + { run } ); return realtimeStream.streamResponse(request, run.friendlyId, params.streamId, getRequestAbortSignal(), { diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.append.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.append.ts index deefbc20773..ec5800c1f9f 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.append.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.append.ts @@ -72,6 +72,7 @@ const { action } = createActionApiRoute( realtimeStreamsVersion: true, completedAt: true, id: true, + streamBasinName: true, }, }); @@ -102,7 +103,8 @@ const { action } = createActionApiRoute( const realtimeStream = getRealtimeStreamInstance( authentication.environment, - targetRun.realtimeStreamsVersion + targetRun.realtimeStreamsVersion, + { run: targetRun } ); const partId = request.headers.get("X-Part-Id") ?? nanoid(7); diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.ts index 2a8d07053d9..9ca8e36f4ef 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$target.$streamId.ts @@ -26,14 +26,17 @@ const { action } = createActionApiRoute( select: { id: true, friendlyId: true, + streamBasinName: true, parentTaskRun: { select: { friendlyId: true, + streamBasinName: true, }, }, rootTaskRun: { select: { friendlyId: true, + streamBasinName: true, }, }, }, @@ -43,17 +46,20 @@ const { action } = createActionApiRoute( return new Response("Run not found", { status: 404 }); } - const targetId = + const targetRun = params.target === "self" - ? run.friendlyId + ? run : params.target === "parent" - ? run.parentTaskRun?.friendlyId - : run.rootTaskRun?.friendlyId; + ? run.parentTaskRun + : run.rootTaskRun; - if (!targetId) { + if (!targetRun?.friendlyId) { return new Response("Target not found", { status: 404 }); } + const targetId = targetRun.friendlyId; + const basinContext = { run: { streamBasinName: targetRun.streamBasinName ?? null } }; + if (request.method === "PUT") { // This is the "create" endpoint const updatedRun = await prisma.taskRun.update({ @@ -80,7 +86,8 @@ const { action } = createActionApiRoute( const realtimeStream = getRealtimeStreamInstance( authentication.environment, - updatedRun.realtimeStreamsVersion + updatedRun.realtimeStreamsVersion, + basinContext ); const { responseHeaders } = await realtimeStream.initializeStream(targetId, params.streamId); @@ -112,7 +119,11 @@ const { action } = createActionApiRoute( resumeFromChunkNumber = parsed; } - const realtimeStream = getRealtimeStreamInstance(authentication.environment, streamVersion); + const realtimeStream = getRealtimeStreamInstance( + authentication.environment, + streamVersion, + basinContext + ); return realtimeStream.ingestData( request.body, @@ -139,14 +150,17 @@ const loader = createLoaderApiRoute( select: { id: true, friendlyId: true, + streamBasinName: true, parentTaskRun: { select: { friendlyId: true, + streamBasinName: true, }, }, rootTaskRun: { select: { friendlyId: true, + streamBasinName: true, }, }, }, @@ -158,17 +172,19 @@ const loader = createLoaderApiRoute( return new Response("Run not found", { status: 404 }); } - const targetId = + const targetRun = params.target === "self" - ? run.friendlyId + ? run : params.target === "parent" - ? run.parentTaskRun?.friendlyId - : run.rootTaskRun?.friendlyId; + ? run.parentTaskRun + : run.rootTaskRun; - if (!targetId) { + if (!targetRun?.friendlyId) { return new Response("Target not found", { status: 404 }); } + const targetId = targetRun.friendlyId; + // Handle HEAD request to get last chunk index if (request.method !== "HEAD") { return new Response("Only HEAD requests are allowed for this endpoint", { status: 405 }); @@ -178,7 +194,11 @@ const loader = createLoaderApiRoute( const clientId = request.headers.get("X-Client-Id") || "default"; const streamVersion = request.headers.get("X-Stream-Version") || "v1"; - const realtimeStream = getRealtimeStreamInstance(authentication.environment, streamVersion); + const realtimeStream = getRealtimeStreamInstance( + authentication.environment, + streamVersion, + { run: { streamBasinName: targetRun.streamBasinName ?? null } } + ); const lastChunkIndex = await realtimeStream.getLastChunkIndex( targetId, diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts index b16b1ca7922..089f2dc55e3 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts @@ -46,6 +46,7 @@ const { action } = createActionApiRoute( friendlyId: true, completedAt: true, realtimeStreamsVersion: true, + streamBasinName: true, }, }); @@ -68,7 +69,8 @@ const { action } = createActionApiRoute( const realtimeStream = getRealtimeStreamInstance( authentication.environment, - run.realtimeStreamsVersion + run.realtimeStreamsVersion, + { run } ); // Build the input stream record (raw user data, no wrapper) @@ -155,7 +157,8 @@ const loader = createLoaderApiRoute( const realtimeStream = getRealtimeStreamInstance( authentication.environment, - run.realtimeStreamsVersion + run.realtimeStreamsVersion, + { run } ); // Read from the internal S2 stream name (prefixed to avoid user stream collisions) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx index 1295adb7842..b6a72d3aa09 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx @@ -87,7 +87,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const realtimeStream = getRealtimeStreamInstance( run.runtimeEnvironment, - run.realtimeStreamsVersion + run.realtimeStreamsVersion, + { run } ); return realtimeStream.streamResponse(request, run.friendlyId, streamKey, getRequestAbortSignal(), { diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index 610484e67ca..445e0eb155a 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -395,6 +395,7 @@ export class RunEngineTriggerTaskService { bulkActionId: body.options?.bulkActionId, planType, realtimeStreamsVersion: options.realtimeStreamsVersion, + streamBasinName: environment.organization.streamBasinName, debounce: body.options?.debounce, annotations, // When debouncing with triggerAndWait, create a span for the debounced trigger diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 51075c1b87d..6df93c9c0e9 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -44,6 +44,17 @@ function initializeClient() { } const client = singleton("billingClient", initializeClient); + +/** + * `true` when the billing client was instantiated β€” i.e. we're running + * in a cloud-style install with `BILLING_API_URL` + `BILLING_API_KEY` + * configured. OSS / self-hosted installs return `false` here, which + * lets callers distinguish "no billing wired up, fall back to + * defaults" from "billing wired up but the call failed, retry." + */ +export function isBillingConfigured(): boolean { + return client !== undefined; +} // Failures from @trigger.dev/platform billing client calls are tracked via // this metric (with low-cardinality {function, kind} labels) rather than // logged. Every task invocation hits these paths, so per-call logs were too diff --git a/apps/webapp/app/services/realtime/duration.server.ts b/apps/webapp/app/services/realtime/duration.server.ts new file mode 100644 index 00000000000..c6aab9eb9df --- /dev/null +++ b/apps/webapp/app/services/realtime/duration.server.ts @@ -0,0 +1,49 @@ +/** + * Duration string parsing for stream-basin retention / delete-on-empty + * configuration. Used by `streamBasinProvisioner` (to convert to S2's + * integer-seconds wire format) and by `env.server.ts` (to validate + * duration-shaped env vars at boot rather than at first use). + * + * Accepts the short forms (`7d`, `30d`, `365d`, `1h`, `90m`, `45s`, + * `2w`, `1y`) and the human forms (`7days`, `1week`, `1year`). + */ + +const PATTERN = + /^(\d+)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hour|hours?|d|day|days?|w|week|weeks?|y|year|years?)$/; + +export function isValidDuration(input: string): boolean { + return PATTERN.test(input.trim().toLowerCase()); +} + +/** + * Parse a duration string into seconds. Throws on garbage so a + * misconfigured env var fails loudly. Use {@link isValidDuration} + * for non-throwing validation (e.g. inside a Zod `.refine()`). + */ +export function parseDuration(input: string): number { + const trimmed = input.trim().toLowerCase(); + const match = trimmed.match(PATTERN); + if (!match) { + throw new Error(`Invalid duration string: ${input}`); + } + const value = parseInt(match[1]!, 10); + const unit = match[2]!; + const multiplier = + /^s/.test(unit) + ? 1 + : /^m(?:in|ins|inute|inutes)?$/.test(unit) + ? 60 + : /^h/.test(unit) + ? 3600 + : /^d/.test(unit) + ? 86400 + : /^w/.test(unit) + ? 604800 + : /^y/.test(unit) + ? 31_536_000 + : NaN; + if (!Number.isFinite(multiplier)) { + throw new Error(`Invalid duration unit: ${unit}`); + } + return value * multiplier; +} diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts index 46c7f3854a1..0295d5a58b6 100644 --- a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts @@ -464,7 +464,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { return this.s2IssueAccessToken(id); } - const result = await this.cache.accessToken.swr(this.streamPrefix, async () => { + // Cache key includes basin so per-org basins never collide on + // cached tokens. `${basin}:${prefix}` is unique per (org-basin, env). + const cacheKey = `${this.basin}:${this.streamPrefix}`; + const result = await this.cache.accessToken.swr(cacheKey, async () => { return this.s2IssueAccessToken(id); }); diff --git a/apps/webapp/app/services/realtime/streamBasinProvisioner.server.ts b/apps/webapp/app/services/realtime/streamBasinProvisioner.server.ts new file mode 100644 index 00000000000..e29aeb168fb --- /dev/null +++ b/apps/webapp/app/services/realtime/streamBasinProvisioner.server.ts @@ -0,0 +1,248 @@ +/** + * Per-org S2 basin provisioning. Gated by + * `REALTIME_STREAMS_PER_ORG_BASINS_ENABLED`: when off, all orgs share + * `REALTIME_STREAMS_S2_BASIN` and this module no-ops. + * + * Pure retention-string in / S2-call out. Plan vocabulary lives in the + * cloud billing app, which calls into the admin sync route to drive + * provisioning + reconfiguration. + */ +import type { PrismaClientOrTransaction } from "~/db.server"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { parseDuration } from "./duration.server"; + +export function isPerOrgBasinsEnabled(): boolean { + return env.REALTIME_STREAMS_PER_ORG_BASINS_ENABLED === "true"; +} + +export function defaultRetention(): string { + return env.REALTIME_STREAMS_BASIN_DEFAULT_RETENTION; +} + +// Org id is a cuid β€” fixed-length and stable, so the basin name is +// collision-free without truncation. Slugs are user-editable and would +// drift. +export function basinNameForOrg(org: { id: string }): string { + const prefix = env.REALTIME_STREAMS_BASIN_NAME_PREFIX; + const envName = env.REALTIME_STREAMS_BASIN_NAME_ENV; + return `${prefix}-${envName}-org-${org.id}`; +} + +type ProvisionInput = { + id: string; + retention?: string; + streamBasinName: string | null | undefined; +}; + +type ProvisionResult = + | { kind: "skipped"; reason: "feature-disabled" | "already-provisioned"; basin: string | null } + | { kind: "provisioned"; basin: string; retention: string }; + +// Idempotent. Treats S2 409 as success (race with another caller, or +// previous run that crashed after S2 ack but before the column write). +export async function provisionBasinForOrg( + org: ProvisionInput, + prismaClient: PrismaClientOrTransaction = prisma +): Promise { + if (!isPerOrgBasinsEnabled()) { + return { kind: "skipped", reason: "feature-disabled", basin: null }; + } + + if (org.streamBasinName) { + return { kind: "skipped", reason: "already-provisioned", basin: org.streamBasinName }; + } + + const accessToken = env.REALTIME_STREAMS_S2_ACCESS_TOKEN; + if (!accessToken) { + throw new Error( + "REALTIME_STREAMS_S2_ACCESS_TOKEN must be set when REALTIME_STREAMS_PER_ORG_BASINS_ENABLED=true" + ); + } + + const basin = basinNameForOrg(org); + const retention = org.retention ?? defaultRetention(); + + await s2CreateBasin(basin, { + accessToken, + retentionPolicy: retention, + storageClass: env.REALTIME_STREAMS_BASIN_STORAGE_CLASS, + deleteOnEmptyMinAge: env.REALTIME_STREAMS_BASIN_DELETE_ON_EMPTY_MIN_AGE, + }); + + await prismaClient.organization.update({ + where: { id: org.id }, + data: { streamBasinName: basin }, + }); + + logger.info("[streamBasinProvisioner] provisioned basin for org", { + orgId: org.id, + basin, + retention, + }); + + return { kind: "provisioned", basin, retention }; +} + +export async function reconfigureBasinForOrg( + orgId: string, + retention: string +): Promise { + if (!isPerOrgBasinsEnabled()) return; + + const accessToken = env.REALTIME_STREAMS_S2_ACCESS_TOKEN; + if (!accessToken) { + throw new Error( + "REALTIME_STREAMS_S2_ACCESS_TOKEN must be set when REALTIME_STREAMS_PER_ORG_BASINS_ENABLED=true" + ); + } + + const org = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { id: true, streamBasinName: true }, + }); + if (!org?.streamBasinName) return; + + await s2ReconfigureBasin(org.streamBasinName, { accessToken, retentionPolicy: retention }); + + logger.info("[streamBasinProvisioner] reconfigured basin retention", { + orgId, + basin: org.streamBasinName, + retention, + }); +} + +type EnsureResult = + | { kind: "skipped"; reason: "feature-disabled" | "org-not-found" } + | { kind: "provisioned"; basin: string; retention: string } + | { kind: "reconfigured"; basin: string; retention: string }; + +// Idempotent: provisions if the org has no basin, PATCHes retention if +// it does. The single entrypoint the cloud billing app drives β€” both +// for the live plan-change path and the bulk backfill. +export async function ensureBasinForOrg( + orgId: string, + retention: string +): Promise { + if (!isPerOrgBasinsEnabled()) { + return { kind: "skipped", reason: "feature-disabled" }; + } + + const org = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { id: true, streamBasinName: true }, + }); + if (!org) return { kind: "skipped", reason: "org-not-found" }; + + if (!org.streamBasinName) { + const result = await provisionBasinForOrg( + { id: org.id, streamBasinName: null, retention } + ); + if (result.kind === "provisioned") { + return { kind: "provisioned", basin: result.basin, retention: result.retention }; + } + return { kind: "skipped", reason: "feature-disabled" }; + } + + await reconfigureBasinForOrg(org.id, retention); + return { kind: "reconfigured", basin: org.streamBasinName, retention }; +} + +// Inverse of ensureBasinForOrg: nulls the column so future runs/sessions +// land in the shared global basin. The S2 basin lingers; existing streams +// age out on their original retention. +export async function deprovisionBasinForOrg( + orgId: string +): Promise<{ kind: "deprovisioned" } | { kind: "skipped"; reason: "no-basin" }> { + const org = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { id: true, streamBasinName: true }, + }); + if (!org?.streamBasinName) return { kind: "skipped", reason: "no-basin" }; + + await prisma.organization.update({ + where: { id: org.id }, + data: { streamBasinName: null }, + }); + + logger.info("[streamBasinProvisioner] deprovisioned basin for org", { + orgId, + previousBasin: org.streamBasinName, + }); + + return { kind: "deprovisioned" }; +} + +// S2 REST: POST /v1/basins to create, PATCH /v1/basins/{name} to +// reconfigure. Wire shape takes integer seconds; we accept human strings +// like "7d" / "1y" as env-var ergonomics and parse them here. + +type CreateBasinOptions = { + accessToken: string; + retentionPolicy: string; + storageClass: "express" | "standard"; + deleteOnEmptyMinAge: string; +}; + +async function s2CreateBasin(name: string, opts: CreateBasinOptions): Promise { + const url = `https://aws.s2.dev/v1/basins`; + const body = { + basin: name, + config: { + create_stream_on_append: true, + create_stream_on_read: true, + default_stream_config: { + storage_class: opts.storageClass, + retention_policy: { age: parseDuration(opts.retentionPolicy) }, + delete_on_empty: { min_age_secs: parseDuration(opts.deleteOnEmptyMinAge) }, + }, + }, + }; + + const res = await fetch(url, { + signal: AbortSignal.timeout(10_000), + method: "POST", + headers: { + Authorization: `Bearer ${opts.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + // 409 = basin already exists; treat as success (idempotent). + if (res.ok || res.status === 409) return; + + const text = await res.text().catch(() => ""); + throw new Error(`S2 createBasin failed: ${res.status} ${res.statusText} ${text}`); +} + +type ReconfigureBasinOptions = { + accessToken: string; + retentionPolicy: string; +}; + +async function s2ReconfigureBasin(name: string, opts: ReconfigureBasinOptions): Promise { + const url = `https://aws.s2.dev/v1/basins/${encodeURIComponent(name)}`; + const body = { + default_stream_config: { + retention_policy: { age: parseDuration(opts.retentionPolicy) }, + }, + }; + + const res = await fetch(url, { + signal: AbortSignal.timeout(10_000), + method: "PATCH", + headers: { + Authorization: `Bearer ${opts.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (res.ok) return; + + const text = await res.text().catch(() => ""); + throw new Error(`S2 reconfigureBasin failed: ${res.status} ${res.statusText} ${text}`); +} + diff --git a/apps/webapp/app/services/realtime/v1StreamsGlobal.server.ts b/apps/webapp/app/services/realtime/v1StreamsGlobal.server.ts index b1bf15b9fed..868294abfde 100644 --- a/apps/webapp/app/services/realtime/v1StreamsGlobal.server.ts +++ b/apps/webapp/app/services/realtime/v1StreamsGlobal.server.ts @@ -29,41 +29,69 @@ function initializeRedisRealtimeStreams() { export const v1RealtimeStreams = singleton("realtimeStreams", initializeRedisRealtimeStreams); +/** + * Resolve a stream's basin. Precedence: run β†’ session β†’ org β†’ global env. + * Pre-migration rows have `streamBasinName: null` and fall through to + * the global basin (where their streams actually live), so only pass + * `organization` when no run/session row exists at all β€” otherwise a + * null column would short-circuit to the org's *current* basin. + */ +export type StreamBasinContext = { + run?: { streamBasinName: string | null } | null; + session?: { streamBasinName: string | null } | null; + organization?: { streamBasinName: string | null } | null; +}; + +export function resolveStreamBasin(ctx: StreamBasinContext): string | undefined { + return ( + ctx.run?.streamBasinName ?? + ctx.session?.streamBasinName ?? + ctx.organization?.streamBasinName ?? + env.REALTIME_STREAMS_S2_BASIN ?? + undefined + ); +} + export function getRealtimeStreamInstance( environment: AuthenticatedEnvironment, - streamVersion: string + streamVersion: string, + basinContext?: StreamBasinContext ): StreamIngestor & StreamResponder { if (streamVersion === "v1") { return v1RealtimeStreams; - } else { - if ( - env.REALTIME_STREAMS_S2_BASIN && - (env.REALTIME_STREAMS_S2_ACCESS_TOKEN || - env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true") - ) { - return new S2RealtimeStreams({ - basin: env.REALTIME_STREAMS_S2_BASIN, - accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN ?? "", - endpoint: env.REALTIME_STREAMS_S2_ENDPOINT, - skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true", - streamPrefix: [ - "org", - environment.organization.id, - "env", - environment.slug, - environment.id, - ].join("/"), - logLevel: env.REALTIME_STREAMS_S2_LOG_LEVEL, - flushIntervalMs: env.REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS, - maxRetries: env.REALTIME_STREAMS_S2_MAX_RETRIES, - s2WaitSeconds: env.REALTIME_STREAMS_S2_WAIT_SECONDS, - accessTokenExpirationInMs: env.REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS, - cache: s2RealtimeStreamsCache, - }); - } + } - throw new Error("Realtime streams v2 is required for this run but S2 configuration is missing"); + const resolvedBasin = resolveStreamBasin(basinContext ?? {}); + if ( + resolvedBasin && + (env.REALTIME_STREAMS_S2_ACCESS_TOKEN || env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true") + ) { + return new S2RealtimeStreams({ + basin: resolvedBasin, + accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN ?? "", + endpoint: env.REALTIME_STREAMS_S2_ENDPOINT, + skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true", + streamPrefix: streamPrefixFor(environment, resolvedBasin), + logLevel: env.REALTIME_STREAMS_S2_LOG_LEVEL, + flushIntervalMs: env.REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS, + maxRetries: env.REALTIME_STREAMS_S2_MAX_RETRIES, + s2WaitSeconds: env.REALTIME_STREAMS_S2_WAIT_SECONDS, + accessTokenExpirationInMs: env.REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS, + cache: s2RealtimeStreamsCache, + }); } + + throw new Error("Realtime streams v2 is required for this run but S2 configuration is missing"); +} + +// Shared basin needs `org/{orgId}` to namespace; per-org basin already +// isolates so the segment drops. +function streamPrefixFor(environment: AuthenticatedEnvironment, basin: string): string { + const isPerOrgBasin = basin !== env.REALTIME_STREAMS_S2_BASIN; + const segments = isPerOrgBasin + ? ["env", environment.slug, environment.id] + : ["org", environment.organization.id, "env", environment.slug, environment.id]; + return segments.join("/"); } export function determineRealtimeStreamsVersion(streamVersion?: string): "v1" | "v2" { diff --git a/internal-packages/database/prisma/migrations/20260504071227_add_stream_basin_name/migration.sql b/internal-packages/database/prisma/migrations/20260504071227_add_stream_basin_name/migration.sql new file mode 100644 index 00000000000..c346d499e76 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260504071227_add_stream_basin_name/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "public"."Organization" ADD COLUMN IF NOT EXISTS "streamBasinName" TEXT; + +-- AlterTable +ALTER TABLE "public"."Session" ADD COLUMN IF NOT EXISTS "streamBasinName" TEXT; + +-- AlterTable +ALTER TABLE "public"."TaskRun" ADD COLUMN IF NOT EXISTS "streamBasinName" TEXT; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7ace893e4dd..dcce2727683 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -251,6 +251,13 @@ model Organization { platformNotifications PlatformNotification[] errorGroupStates ErrorGroupState[] + + /// S2 basin that holds this org's realtime streams. Null until the + /// per-org basin has been provisioned (OSS / s2-lite installs leave + /// it null forever; reads fall back to the global basin env var). + /// Set once at provisioning time; retention is reconfigured in-place + /// when the org's plan changes. + streamBasinName String? } model OrgMember { @@ -758,6 +765,12 @@ model Session { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// S2 basin where this session's stream pair lives. Stamped at create + /// time from `Organization.streamBasinName` so reads can resolve the + /// basin without joining org. Null when the org has no per-org basin + /// (OSS, or pre-backfill); reads fall back to the global basin. + streamBasinName String? + runs SessionRun[] /// Idempotency: `(env, externalId)` uniquely identifies a session. @@ -992,6 +1005,11 @@ model TaskRun { realtimeStreamsVersion String @default("v1") /// Store the stream keys that are being used by the run realtimeStreams String[] @default([]) + /// S2 basin where this run's realtime streams live. Stamped at create + /// time from `Organization.streamBasinName` so reads can resolve the + /// basin without joining org. Null when the org has no per-org basin + /// (OSS, or pre-backfill); reads fall back to the global basin. + streamBasinName String? @@unique([oneTimeUseToken]) @@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey]) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 0da98c3c835..1725587df45 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -498,6 +498,7 @@ export class RunEngine { bulkActionId, planType, realtimeStreamsVersion, + streamBasinName, debounce, annotations, onDebounced, @@ -660,6 +661,7 @@ export class RunEngine { bulkActionGroupIds: bulkActionId ? [bulkActionId] : undefined, planType, realtimeStreamsVersion, + streamBasinName, debounce: debounce ? { key: debounce.key, diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index 15e63368d2e..0b17262ba1c 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -259,6 +259,7 @@ export type TriggerParams = { bulkActionId?: string; planType?: string; realtimeStreamsVersion?: string; + streamBasinName?: string | null; debounce?: { key: string; delay: string; From a8966a40aba3bd0e9b4d4513cee65845493e4aa3 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 5 May 2026 10:49:54 +0100 Subject: [PATCH 003/491] fix(helm): bump clickhouse subchart 9.3.7 -> 9.4.4 (clickhouse 25.7.5) (#3524) Fixes #3520. The bundled bitnami clickhouse subchart was pinned at `9.3.7` (clickhouse `25.6.1-debian-12-r0`), which hits a memory-tracker accounting bug under sustained ingest - the global counter overflows to ~7 EiB and every query gets rejected by OvercommitTracker until the pod is restarted. Self-hosters running 4.0.5 through 4.4.5 are exposed regardless of chart version since the subchart pin hadn't moved. Bumping to `9.4.4` (clickhouse `25.7.5-debian-12-r0`) pulls in the 25.7.x memory-tracker fixes. This is also the latest publicly packaged release at `oci://registry-1.docker.io/bitnamicharts` - that registry has been frozen since 2025-08-28 (Bitnami catalog changes), but the chart source remains under Apache 2 on `bitnami/charts`. The image continues to resolve via `bitnamilegacy/clickhouse` per the existing `values.yaml` override, since `bitnami/clickhouse` itself moved to paid-only. Verified locally: `helm dependency update` + `helm lint` + `helm template` + kubeconform across all 57 rendered manifests. Rendered statefulset image is `docker.io/bitnamilegacy/clickhouse:25.7.5-debian-12-r0`. --- hosting/k8s/helm/Chart.lock | 6 +++--- hosting/k8s/helm/Chart.yaml | 2 +- hosting/k8s/helm/values.yaml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/hosting/k8s/helm/Chart.lock b/hosting/k8s/helm/Chart.lock index ac445fac172..5a7882cfa0e 100644 --- a/hosting/k8s/helm/Chart.lock +++ b/hosting/k8s/helm/Chart.lock @@ -7,9 +7,9 @@ dependencies: version: 21.2.6 - name: clickhouse repository: oci://registry-1.docker.io/bitnamicharts - version: 9.3.7 + version: 9.4.4 - name: minio repository: oci://registry-1.docker.io/bitnamicharts version: 17.0.9 -digest: sha256:b6cef61abc0b8bcdf4e6d7d86bd8dd7999dd07543f5532f3d94797ffdf0ad30b -generated: "2025-06-27T19:27:24.075488134+01:00" +digest: sha256:e1b572ab8eca0cc376311398c27b1734d8a598095fccc81dd9c32b2c8b9c1149 +generated: "2026-05-05T10:31:58.493590751+01:00" diff --git a/hosting/k8s/helm/Chart.yaml b/hosting/k8s/helm/Chart.yaml index 855453bb422..83734b4ddb1 100644 --- a/hosting/k8s/helm/Chart.yaml +++ b/hosting/k8s/helm/Chart.yaml @@ -27,7 +27,7 @@ dependencies: repository: "oci://registry-1.docker.io/bitnamicharts" condition: redis.deploy - name: clickhouse - version: "9.3.7" + version: "9.4.4" repository: "oci://registry-1.docker.io/bitnamicharts" condition: clickhouse.deploy - name: minio diff --git a/hosting/k8s/helm/values.yaml b/hosting/k8s/helm/values.yaml index 3ed254397e7..062bebf9c7f 100644 --- a/hosting/k8s/helm/values.yaml +++ b/hosting/k8s/helm/values.yaml @@ -542,7 +542,7 @@ clickhouse: image: # Use bitnami legacy repo repository: bitnamilegacy/clickhouse - # image: docker.io/bitnamilegacy/clickhouse:25.6.1-debian-12-r0 + # image: docker.io/bitnamilegacy/clickhouse:25.7.5-debian-12-r0 # TLS/Secure connection configuration secure: false # Set to true to use HTTPS and secure connections From 14920ce2c412608e634b37d2e5e9c12350a2ac26 Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Tue, 5 May 2026 11:31:39 +0100 Subject: [PATCH 004/491] fix(webapp): downgrade expected user-input error logs to warn (#3523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dac9c83bd` added `ignoreErrors: /^ServiceValidationError(?::|$)/` in `apps/webapp/sentry.server.ts` to drop SVEs before they reach Sentry. The filter only matches when the captured event's *type* is `ServiceValidationError`, but nine call sites in the webapp catch SVE (and analogous user-input error types β€” `OutOfEntitlementError`, `CreateDeclarativeScheduleError`, `QueryError`) and call `logger.error("wrapper message", { error: e })` *before* the type check. The captured event is then titled with the wrapper message, with the inner error buried in `extra.error` β€” invisible to the SDK filter. Result: a steady stream of expected user-input failures escalating as `error`-level events when they should be `warn`. Each catch block now type-discriminates first, logs expected types at `warn`, and keeps unknown-error fall-throughs at `error`. For service sites that wrap into SVE (`createBackgroundWorker`, `createDeploymentBackgroundWorkerV4`), the inner error is logged at `error` before wrapping β€” mirrors the `waitpointCompletionPacket.server.ts` pattern from `dac9c83bd`. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .server-changes/sentry-wrapper-bypass-fix.md | 10 ++++++++ ...yments.$deploymentId.background-workers.ts | 12 ++++++--- ...projects.$projectRef.background-workers.ts | 12 ++++++--- apps/webapp/app/routes/api.v1.query.ts | 18 ++++++++----- apps/webapp/app/routes/api.v1.tasks.batch.ts | 18 +++++++++---- apps/webapp/app/routes/api.v2.tasks.batch.ts | 18 +++++++++---- .../routes/api.v3.batches.$batchId.items.ts | 25 +++++++++++++------ apps/webapp/app/routes/api.v3.batches.ts | 18 +++++++++---- .../services/createBackgroundWorker.server.ts | 19 +++++++++++--- ...eateDeploymentBackgroundWorkerV4.server.ts | 20 ++++++++++++--- 10 files changed, 127 insertions(+), 43 deletions(-) create mode 100644 .server-changes/sentry-wrapper-bypass-fix.md diff --git a/.server-changes/sentry-wrapper-bypass-fix.md b/.server-changes/sentry-wrapper-bypass-fix.md new file mode 100644 index 00000000000..b19d90d84c5 --- /dev/null +++ b/.server-changes/sentry-wrapper-bypass-fix.md @@ -0,0 +1,10 @@ +--- +area: webapp +type: fix +--- + +Stop nine catch sites in the webapp from escalating expected user-input +failures (`ServiceValidationError`, `OutOfEntitlementError`, +`CreateDeclarativeScheduleError`, `QueryError`) as `error`-level events. +Type-discriminate before logging; downgrade the user-facing branches to +`warn` while keeping unknown-error fall-throughs at `error`. diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts index edaa1b257e5..c22399ef60e 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts @@ -60,14 +60,20 @@ export async function action({ request, params }: ActionFunctionArgs) { { status: 200 } ); } catch (e) { - logger.error("Failed to create background worker", { error: e }); - + // Customer-facing validation failures (invalid task config, customer cron + // expression, etc.). The handler returns 4xx with the message; system + // handles it gracefully, no alert needed. if (e instanceof ServiceValidationError) { + logger.warn("Failed to create background worker", { error: e.message }); return json({ error: e.message }, { status: e.status ?? 400 }); - } else if (e instanceof CreateDeclarativeScheduleError) { + } + if (e instanceof CreateDeclarativeScheduleError) { + logger.warn("Failed to create background worker", { error: e.message }); return json({ error: e.message }, { status: 400 }); } + logger.error("Failed to create background worker", { error: e }); + return json({ error: "Failed to create background worker" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.background-workers.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.background-workers.ts index 872ca6f2f53..bc9842f0afa 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.background-workers.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.background-workers.ts @@ -58,14 +58,20 @@ export async function action({ request, params }: ActionFunctionArgs) { { status: 200 } ); } catch (e) { - logger.error("Failed to create background worker", { error: JSON.stringify(e) }); - + // Customer-facing validation failures (invalid task config, customer cron + // expression, etc.). The handler returns 4xx with the message; system + // handles it gracefully, no alert needed. if (e instanceof ServiceValidationError) { + logger.warn("Failed to create background worker", { error: e.message }); return json({ error: e.message }, { status: 400 }); - } else if (e instanceof CreateDeclarativeScheduleError) { + } + if (e instanceof CreateDeclarativeScheduleError) { + logger.warn("Failed to create background worker", { error: e.message }); return json({ error: e.message }, { status: 400 }); } + logger.error("Failed to create background worker", { error: e }); + return json({ error: "Failed to create background worker" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts index 22500011671..05d92e9726a 100644 --- a/apps/webapp/app/routes/api.v1.query.ts +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -61,10 +61,16 @@ const { action, loader } = createActionApiRoute( }); if (!queryResult.success) { - const message = - queryResult.error instanceof QueryError - ? queryResult.error.message - : "An unexpected error occurred while executing the query."; + // QueryError surfaces customer SQL problems (invalid syntax, + // unsupported construct). Returned to the caller as 400; system + // handles it gracefully, no alert needed. + if (queryResult.error instanceof QueryError) { + logger.warn("Query API error", { + error: queryResult.error.message, + query, + }); + return json({ error: queryResult.error.message }, { status: 400 }); + } logger.error("Query API error", { error: queryResult.error, @@ -72,8 +78,8 @@ const { action, loader } = createActionApiRoute( }); return json( - { error: message }, - { status: queryResult.error instanceof QueryError ? 400 : 500 } + { error: "An unexpected error occurred while executing the query." }, + { status: 500 } ); } diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index e6ada1a739c..50760b79a6d 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -127,6 +127,18 @@ const { action, loader } = createActionApiRoute( return json(batch, { status: 202, headers: $responseHeaders }); } catch (error) { + // Customer-facing validation/quota failures (invalid batch shape, + // entitlements exhausted). The handler returns 422 with the message; + // system handles it gracefully, no alert needed. + if (error instanceof ServiceValidationError) { + logger.warn("Batch trigger error", { error: error.message }); + return json({ error: error.message }, { status: 422 }); + } + if (error instanceof OutOfEntitlementError) { + logger.warn("Batch trigger error", { error: error.message }); + return json({ error: error.message }, { status: 422 }); + } + logger.error("Batch trigger error", { error: { message: (error as Error).message, @@ -134,11 +146,7 @@ const { action, loader } = createActionApiRoute( }, }); - 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) { + if (error instanceof Error) { return json( { error: "Something went wrong" }, { status: 500, headers: { "x-should-retry": "false" } } diff --git a/apps/webapp/app/routes/api.v2.tasks.batch.ts b/apps/webapp/app/routes/api.v2.tasks.batch.ts index 8db98b4d343..e45f7508b90 100644 --- a/apps/webapp/app/routes/api.v2.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v2.tasks.batch.ts @@ -144,6 +144,18 @@ const { action, loader } = createActionApiRoute( headers: $responseHeaders, }); } catch (error) { + // Customer-facing validation/quota failures (invalid batch shape, + // entitlements exhausted). The handler returns 422 with the message; + // system handles it gracefully, no alert needed. + if (error instanceof ServiceValidationError) { + logger.warn("Batch trigger error", { error: error.message }); + return json({ error: error.message }, { status: 422 }); + } + if (error instanceof OutOfEntitlementError) { + logger.warn("Batch trigger error", { error: error.message }); + return json({ error: error.message }, { status: 422 }); + } + logger.error("Batch trigger error", { error: { message: (error as Error).message, @@ -151,11 +163,7 @@ const { action, loader } = createActionApiRoute( }, }); - 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) { + if (error instanceof Error) { return json( { error: error.message }, { status: 500, headers: { "x-should-retry": "false" } } diff --git a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts index 2d732d1555a..0e26bae94e3 100644 --- a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts +++ b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts @@ -88,6 +88,22 @@ export async function action({ request, params }: ActionFunctionArgs) { return json(result, { status: 200 }); } catch (error) { + // Customer-facing validation failures (invalid item shape, invalid JSON + // in the streamed body). The handler returns 4xx with the message; + // system handles it gracefully, no alert needed. + if (error instanceof ServiceValidationError) { + logger.warn("Stream batch items error", { batchId, error: error.message }); + return json({ error: error.message }, { status: 422 }); + } + + if (error instanceof Error && error.message.includes("Invalid JSON")) { + logger.warn("Stream batch items error: invalid JSON", { + batchId, + error: error.message, + }); + return json({ error: error.message }, { status: 400 }); + } + logger.error("Stream batch items error", { batchId, error: { @@ -96,14 +112,7 @@ export async function action({ request, params }: ActionFunctionArgs) { }, }); - if (error instanceof ServiceValidationError) { - return json({ error: error.message }, { status: 422 }); - } else if (error instanceof Error) { - // Check for stream parsing errors (e.g. invalid JSON) - if (error.message.includes("Invalid JSON")) { - return json({ error: error.message }, { status: 400 }); - } - + if (error instanceof Error) { return json({ error: error.message }, { status: 500 }); } diff --git a/apps/webapp/app/routes/api.v3.batches.ts b/apps/webapp/app/routes/api.v3.batches.ts index 5067eaef06e..b671a8efbd6 100644 --- a/apps/webapp/app/routes/api.v3.batches.ts +++ b/apps/webapp/app/routes/api.v3.batches.ts @@ -172,6 +172,18 @@ const { action, loader } = createActionApiRoute( ); } + // Customer-facing validation/quota failures (invalid batch shape, + // entitlements exhausted). The handler returns 422 with the message; + // system handles it gracefully, no alert needed. + if (error instanceof ServiceValidationError) { + logger.warn("Create batch error", { error: error.message }); + return json({ error: error.message }, { status: error.status ?? 422 }); + } + if (error instanceof OutOfEntitlementError) { + logger.warn("Create batch error", { error: error.message }); + return json({ error: error.message }, { status: 422 }); + } + logger.error("Create batch error", { error: { message: (error as Error).message, @@ -179,11 +191,7 @@ const { action, loader } = createActionApiRoute( }, }); - 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) { + if (error instanceof Error) { return json( { error: error.message }, { status: 500, headers: { "x-should-retry": "false" } } diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index c8381327249..8f49dc34aad 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -146,16 +146,27 @@ export class CreateBackgroundWorkerService extends BaseService { ); if (schedulesError) { + if (schedulesError instanceof ServiceValidationError) { + // Customer schedule config (typically invalid cron). Surface to + // client via the rethrow; system returns gracefully. + logger.warn("Error syncing declarative schedules", { + error: schedulesError.message, + backgroundWorker, + environment, + }); + throw schedulesError; + } + + // Wrapping the underlying error into a ServiceValidationError below + // would otherwise hide it once the SDK-level filter drops SVEs; log at + // error so the underlying cause stays visible. Mirrors the + // waitpointCompletionPacket.server.ts pattern from dac9c83bd. logger.error("Error syncing declarative schedules", { error: schedulesError, backgroundWorker, environment, }); - if (schedulesError instanceof ServiceValidationError) { - throw schedulesError; - } - throw new ServiceValidationError("Error syncing declarative schedules"); } diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index cc73a8569d9..16e36b57dff 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts @@ -139,14 +139,26 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { ); if (schedulesError) { + if (schedulesError instanceof ServiceValidationError) { + // Customer schedule config (typically invalid cron). Surface to + // client via the rethrow; system returns gracefully. + logger.warn("Error syncing declarative schedules", { + error: schedulesError.message, + }); + + await this.#failBackgroundWorkerDeployment(deployment, schedulesError); + throw schedulesError; + } + + // Wrapping the underlying error into a ServiceValidationError below + // would otherwise hide it once the SDK-level filter drops SVEs; log at + // error so the underlying cause stays visible. Mirrors the + // waitpointCompletionPacket.server.ts pattern from dac9c83bd. logger.error("Error syncing declarative schedules", { error: schedulesError, }); - const serviceError = - schedulesError instanceof ServiceValidationError - ? schedulesError - : new ServiceValidationError("Error syncing declarative schedules"); + const serviceError = new ServiceValidationError("Error syncing declarative schedules"); await this.#failBackgroundWorkerDeployment(deployment, serviceError); From 31999afcaab5d867a8458a4bbc7c8f459c4484a6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 5 May 2026 14:52:36 +0100 Subject: [PATCH 005/491] perf(webapp): trim BackgroundWorker.metadata to the schedule slice on create (#3525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large deploys (projects with many tasks or source files) blocked the webapp event loop for several seconds inside Prisma's client-side serializer on `BackgroundWorker.create`, tail-latencying every other in-flight request on the same Node process. The `metadata` JSON column was being written with the full deploy manifest β€” every task's config, every queue and prompt, and the full source of every file β€” all of which already live on dedicated columns or in dedicated tables. Fix: project the manifest to `{ packageVersion, contentHash, tasks: [{ id, filePath, schedule }] }` on insert. The only post-write read site is `changeCurrentDeployment`, which feeds `tasks[].schedule` into `syncDeclarativeSchedules` at deploy promotion. The retained top-level keys and per-task `filePath` are kept solely so `BackgroundWorkerMetadata.safeParse` still succeeds on read. ## Test plan - [ ] Deploy a project with declarative schedules; verify schedules are created on first deploy - [ ] Modify / remove schedules across subsequent deploys; verify sync - [ ] Roll back to a previous deploy; verify `changeCurrentDeployment` re-syncs schedules - [ ] Inspect `BackgroundWorker.metadata` on a fresh deploy β€” should be a small object, not the full manifest --- .../strip-background-worker-metadata.md | 6 ++++ .../services/createBackgroundWorker.server.ts | 29 +++++++++++++++++-- ...eateDeploymentBackgroundWorkerV3.server.ts | 11 ++++--- ...eateDeploymentBackgroundWorkerV4.server.ts | 6 ++-- 4 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 .server-changes/strip-background-worker-metadata.md diff --git a/.server-changes/strip-background-worker-metadata.md b/.server-changes/strip-background-worker-metadata.md new file mode 100644 index 00000000000..c92ee62b1d6 --- /dev/null +++ b/.server-changes/strip-background-worker-metadata.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Strip BackgroundWorker.metadata to the schedule slice read at deploy promotion, removing a 5+ second event-loop block in Prisma's client serializer when creating workers for projects with many tasks or source files. diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index 8f49dc34aad..f1bcb8e3699 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -29,6 +29,32 @@ import { tryCatch } from "@trigger.dev/core/v3"; import { engine } from "../runEngine.server"; import { scheduleEngine } from "../scheduleEngine.server"; +/** + * Strip BackgroundWorkerMetadata down to the slice that's actually read after + * storage. Everything else is duplicated to dedicated columns/tables + * (BackgroundWorker.{contentHash,cliVersion,sdkVersion,runtime,runtimeVersion}, + * BackgroundWorkerTask, BackgroundWorkerFile, TaskQueue, Prompt). Today the + * only post-write reader is changeCurrentDeployment.server.ts, which feeds + * tasks[].schedule into syncDeclarativeSchedules. packageVersion, contentHash, + * and tasks[].filePath are kept solely to satisfy BackgroundWorkerMetadata's + * required fields when the column is parsed back. + */ +export function stripBackgroundWorkerMetadataForStorage( + metadata: BackgroundWorkerMetadata +): Prisma.InputJsonValue { + return { + packageVersion: metadata.packageVersion, + contentHash: metadata.contentHash, + tasks: metadata.tasks + .filter((t) => t.schedule) + .map((t) => ({ + id: t.id, + filePath: t.filePath, + schedule: t.schedule, + })), + }; +} + export class CreateBackgroundWorkerService extends BaseService { public async call( projectRef: string, @@ -79,8 +105,7 @@ export class CreateBackgroundWorkerService extends BaseService { version: nextVersion, runtimeEnvironmentId: environment.id, projectId: project.id, - // body.metadata has an index signature that Prisma doesn't like (from the JSONSchema type) so we are safe to just cast it - metadata: body.metadata as Prisma.InputJsonValue, + metadata: stripBackgroundWorkerMetadataForStorage(body.metadata), contentHash: body.metadata.contentHash, cliVersion: body.metadata.cliPackageVersion, sdkVersion: body.metadata.packageVersion, diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts index 9743cffcdbe..f20a024d2d6 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts @@ -1,5 +1,5 @@ import { CreateBackgroundWorkerRequestBody, tryCatch } from "@trigger.dev/core/v3"; -import type { BackgroundWorker, Prisma } from "@trigger.dev/database"; +import type { BackgroundWorker } from "@trigger.dev/database"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server"; @@ -7,7 +7,11 @@ import { socketIo } from "../handleSocketIo.server"; import { updateEnvConcurrencyLimits } from "../runQueue.server"; import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server"; import { BaseService } from "./baseService.server"; -import { createWorkerResources, syncDeclarativeSchedules } from "./createBackgroundWorker.server"; +import { + createWorkerResources, + stripBackgroundWorkerMetadataForStorage, + syncDeclarativeSchedules, +} from "./createBackgroundWorker.server"; import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy"; import { projectPubSub } from "./projectPubSub.server"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; @@ -49,8 +53,7 @@ export class CreateDeploymentBackgroundWorkerServiceV3 extends BaseService { version: deployment.version, runtimeEnvironmentId: environment.id, projectId: environment.projectId, - // body.metadata has an index signature that Prisma doesn't like (from the JSONSchema type) so we are safe to just cast it - metadata: body.metadata as Prisma.InputJsonValue, + metadata: stripBackgroundWorkerMetadataForStorage(body.metadata), contentHash: body.metadata.contentHash, cliVersion: body.metadata.cliPackageVersion, sdkVersion: body.metadata.packageVersion, diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 16e36b57dff..f764d39dc7b 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts @@ -1,11 +1,12 @@ import { CreateBackgroundWorkerRequestBody, logger, tryCatch } from "@trigger.dev/core/v3"; import { BackgroundWorkerId } from "@trigger.dev/core/v3/isomorphic"; -import type { BackgroundWorker, Prisma, WorkerDeployment } from "@trigger.dev/database"; +import type { BackgroundWorker, WorkerDeployment } from "@trigger.dev/database"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { createBackgroundFiles, createWorkerResources, + stripBackgroundWorkerMetadataForStorage, syncDeclarativeSchedules, } from "./createBackgroundWorker.server"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; @@ -65,8 +66,7 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { version: deployment.version, runtimeEnvironmentId: environment.id, projectId: environment.projectId, - // body.metadata has an index signature that Prisma doesn't like (from the JSONSchema type) so we are safe to just cast it - metadata: body.metadata as Prisma.InputJsonValue, + metadata: stripBackgroundWorkerMetadataForStorage(body.metadata), contentHash: body.metadata.contentHash, cliVersion: body.metadata.cliPackageVersion, sdkVersion: body.metadata.packageVersion, From 6e8b039a4ef70a9e00ac69790d7e8842b3726576 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 6 May 2026 09:40:15 +0200 Subject: [PATCH 006/491] ci: GHCR commit-SHA tag, OCI labels, and build provenance (#3528) - Tags webapp images by full commit SHA on `main` pushes (`ghcr.io/triggerdotdev/trigger.dev:`) so any commit can be resolved to a digest easily. - Adds OCI labels (`source`, `revision`, `version`, `created`) so `docker inspect`, vulnerability scanners, and registry browsers see source/commit/version directly. - Signs each pushed digest with SLSA build provenance via `actions/attest-build-provenance@v4.1.0` (pinned by SHA), enabling `gh attestation verify oci://...` against the source commit and workflow. --- .github/workflows/publish-webapp.yml | 21 +++++++++++++++++++++ .github/workflows/publish.yml | 1 + .github/workflows/release.yml | 1 + docker/Dockerfile | 6 ++++++ 4 files changed, 29 insertions(+) diff --git a/.github/workflows/publish-webapp.yml b/.github/workflows/publish-webapp.yml index b4ac9defb6f..036d65728ed 100644 --- a/.github/workflows/publish-webapp.yml +++ b/.github/workflows/publish-webapp.yml @@ -4,6 +4,7 @@ permissions: contents: read packages: write id-token: write + attestations: write on: workflow_call: @@ -58,6 +59,12 @@ jobs: image_tags=$image_tags,$ref_without_tag:v4-beta fi + # when pushing the mutable main tag, also push an immutable-by-convention + # full-commit-sha tag so a commit can be resolved to a specific digest + if [[ "${STEPS_GET_TAG_OUTPUTS_TAG}" == "main" ]]; then + image_tags=$image_tags,$ref_without_tag:${GITHUB_SHA} + fi + echo "image_tags=${image_tags}" >> "$GITHUB_OUTPUT" env: STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} @@ -74,6 +81,7 @@ jobs: echo "BUILD_GIT_SHA=${GITHUB_SHA}" echo "BUILD_GIT_REF_NAME=${GITHUB_REF_NAME}" echo "BUILD_TIMESTAMP_SECONDS=$(date +%s)" + echo "BUILD_TIMESTAMP_RFC3339=$(date -u +%Y-%m-%dT%H:%M:%SZ)" } >> "$GITHUB_OUTPUT" env: STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} @@ -87,6 +95,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: 🐳 Build image and push to GitHub Container Registry + id: build_push uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0 with: file: ./docker/Dockerfile @@ -98,8 +107,20 @@ jobs: BUILD_GIT_SHA=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }} BUILD_GIT_REF_NAME=${{ steps.set_build_info.outputs.BUILD_GIT_REF_NAME }} BUILD_TIMESTAMP_SECONDS=${{ steps.set_build_info.outputs.BUILD_TIMESTAMP_SECONDS }} + BUILD_TIMESTAMP_RFC3339=${{ steps.set_build_info.outputs.BUILD_TIMESTAMP_RFC3339 }} SENTRY_RELEASE=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }} SENTRY_ORG=triggerdev SENTRY_PROJECT=trigger-cloud secrets: | sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} + + - name: πŸͺͺ Attest build provenance + # Image is already pushed by this point β€” don't fail releases (and the + # downstream publish-helm job) on a Sigstore/GHCR-referrer hiccup. Real + # config errors still surface as a step warning in the workflow run. + continue-on-error: true + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ghcr.io/triggerdotdev/trigger.dev + subject-digest: ${{ steps.build_push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0bc873d80d4..a238395c8c0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -68,6 +68,7 @@ jobs: contents: read packages: write id-token: write + attestations: write uses: ./.github/workflows/publish-webapp.yml secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f0c8cae302..07af45a8a40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,6 +169,7 @@ jobs: contents: read packages: write id-token: write + attestations: write uses: ./.github/workflows/publish.yml secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/docker/Dockerfile b/docker/Dockerfile index bd280879419..5906b63e194 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -98,11 +98,17 @@ ARG BUILD_APP_VERSION ARG BUILD_GIT_SHA ARG BUILD_GIT_REF_NAME ARG BUILD_TIMESTAMP_SECONDS +ARG BUILD_TIMESTAMP_RFC3339 ENV BUILD_APP_VERSION=${BUILD_APP_VERSION} \ BUILD_GIT_SHA=${BUILD_GIT_SHA} \ BUILD_GIT_REF_NAME=${BUILD_GIT_REF_NAME} \ BUILD_TIMESTAMP_SECONDS=${BUILD_TIMESTAMP_SECONDS} +LABEL org.opencontainers.image.source="https://github.com/triggerdotdev/trigger.dev" \ + org.opencontainers.image.revision="${BUILD_GIT_SHA}" \ + org.opencontainers.image.version="${BUILD_APP_VERSION}" \ + org.opencontainers.image.created="${BUILD_TIMESTAMP_RFC3339}" + EXPOSE 3000 # Add global pnpm shims and install pnpm during build (root user) From 62e006617e8f74f0e7e7fc81a0a25dc250327271 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 6 May 2026 19:35:43 +0100 Subject: [PATCH 007/491] fix(cli): fail attempt on uncaught exception instead of hanging to maxDuration (TRI-9117) (#3529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Node EventEmitter (e.g. node-redis) emits an "error" event with no listener attached, Node escalates it to process.on("uncaughtException") in the task worker. The worker reported the error via the UNCAUGHT_EXCEPTION IPC event but did not exit, and the supervisor-side handler in taskRunProcess only logged the message at debug level β€” leaving the run() promise orphaned until maxDuration fired and producing empty attempts (durationMs=0, costInCents=0). The supervisor now rejects the in-flight attempt with an UncaughtExceptionError and gracefully terminates the worker (preserving the OTEL flush window) on UNCAUGHT_EXCEPTION. The attempt fails fast with TASK_EXECUTION_FAILED, surfacing the original error name, message, and stack trace, and falls under the normal retry policy. This mirrors the existing indexing-side behavior in indexWorkerManifest. Apply the same handling to unhandled promise rejections, which Node already routes through uncaughtException by default. --- .changeset/uncaught-exception-fail-attempt.md | 6 +++ .../uncaught-exception-status-mapping.md | 12 +++++ docs/troubleshooting.mdx | 49 +++++++++++++++++++ .../run-engine/src/engine/errors.ts | 1 + .../src/executions/taskRunProcess.test.ts | 36 +++++++++++++- .../cli-v3/src/executions/taskRunProcess.ts | 45 +++++++++++++++++ packages/core/src/v3/errors.ts | 14 ++++++ packages/core/src/v3/links.ts | 1 + packages/core/src/v3/schemas/common.ts | 1 + 9 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 .changeset/uncaught-exception-fail-attempt.md create mode 100644 .server-changes/uncaught-exception-status-mapping.md diff --git a/.changeset/uncaught-exception-fail-attempt.md b/.changeset/uncaught-exception-fail-attempt.md new file mode 100644 index 00000000000..d80c09c825e --- /dev/null +++ b/.changeset/uncaught-exception-fail-attempt.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Fail attempts on uncaught exceptions instead of hanging to `MAX_DURATION_EXCEEDED`. A Node `EventEmitter` (e.g. `node-redis`) emitting `"error"` with no `.on("error", ...)` listener escalates to `uncaughtException`, which the worker previously reported but did not act on β€” runs drifted to maxDuration with empty attempts. They now fail fast with the original error and status `FAILED`, and respect the task's normal retry policy. You should still attach `.on("error", ...)` listeners to long-lived clients to handle errors gracefully. diff --git a/.server-changes/uncaught-exception-status-mapping.md b/.server-changes/uncaught-exception-status-mapping.md new file mode 100644 index 00000000000..941342359fb --- /dev/null +++ b/.server-changes/uncaught-exception-status-mapping.md @@ -0,0 +1,12 @@ +--- +area: run-engine +type: fix +--- + +Map the new `TASK_RUN_UNCAUGHT_EXCEPTION` internal-error code to +`COMPLETED_WITH_ERRORS` (Failed) status in `runStatusFromError`. cli-v3 +now emits this code when the worker process surfaces an uncaught +exception (e.g. a Node EventEmitter emitting `"error"` with no listener), +so the run renders as a regular task failure in the dashboard rather +than a system failure, while still routing through the engine's +`lockedRetryConfig` lookup so the user's retry policy is honoured. diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 4bac1ba5b5c..71784c30edc 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -278,6 +278,55 @@ You could also offload the CPU-heavy work to a Node.js worker thread, but this i If the above doesn't work, then we recommend you try increasing the machine size of your task. See our [machines guide](/machines) for more information. +### Uncaught exceptions + +If you see a `TASK_RUN_UNCAUGHT_EXCEPTION` error, an exception escaped your task's `run()` function without being thrown through your `await` chain β€” the runtime caught it via Node's `process.on("uncaughtException")` handler. The dashboard surfaces this as a regular task failure (status `Failed`) and the run will retry according to your task's retry policy, but the exception still indicates a bug worth fixing. + +The most common cause is a Node `EventEmitter` emitting an `"error"` event with no listener attached. When this happens, Node escalates the event into an `uncaughtException`. Long-lived clients like `node-redis`, `pg`, `kafkajs`, and `mongodb` all surface socket-level errors this way. + +For example, a `node-redis` client with no error listener will fail your run with an `Error: read ECONNRESET` (or similar TCP error) the next time the socket is reset: + +```ts +import { task } from "@trigger.dev/sdk"; +import { createClient } from "redis"; + +export const myTask = task({ + id: "my-task", + run: async () => { + const client = createClient({ url: process.env.REDIS_URL }); + + // BAD: no .on("error", ...) listener β€” a socket reset will crash the run + // with an uncaught exception, even if the next .get() would have worked. + await client.connect(); + return await client.get("foo"); + }, +}); +``` + +Fix it by attaching an `error` listener so the event has somewhere to go: + +```ts +const client = createClient({ url: process.env.REDIS_URL }); + +// GOOD: the listener catches socket-level errors. The awaited command +// (e.g. .get) will still reject if the connection is broken, and that +// rejection propagates through your run() and fails the attempt cleanly. +client.on("error", (err) => { + logger.warn("Redis client error", { err }); +}); + +await client.connect(); +return await client.get("foo"); +``` + +The same fix applies to any library that emits `"error"` events. As a rule, attach an `.on("error", ...)` listener to every long-lived client you create inside a task. + + + +Unhandled promise rejections (e.g. `Promise.reject(...)` with no `.catch`) take the same path β€” Node routes them through `uncaughtException` by default, and the runtime treats them as `TASK_RUN_UNCAUGHT_EXCEPTION` for the same reasons. Make sure every promise either gets `await`ed or has a `.catch(...)` handler. + + + ### Realtime stream error (`sendBatchNonBlocking` / `S2AppendSession`) Errors mentioning `sendBatchNonBlocking`, `@s2-dev/streamstore`, or `S2AppendSession` (often with `code: undefined`) can occur when you close a stream and then await `waitUntilComplete()`, or when a stream runs for a long time (e.g. 20+ minutes). Wrap `waitUntilComplete()` in try/catch so Transport/closed-stream errors don't fail your task: diff --git a/internal-packages/run-engine/src/engine/errors.ts b/internal-packages/run-engine/src/engine/errors.ts index 820f0ec4ce6..9a41cba11ee 100644 --- a/internal-packages/run-engine/src/engine/errors.ts +++ b/internal-packages/run-engine/src/engine/errors.ts @@ -19,6 +19,7 @@ export function runStatusFromError( case "TASK_INPUT_ERROR": case "TASK_OUTPUT_ERROR": case "TASK_MIDDLEWARE_ERROR": + case "TASK_RUN_UNCAUGHT_EXCEPTION": return "COMPLETED_WITH_ERRORS"; case "TASK_RUN_CANCELLED": return "CANCELED"; diff --git a/packages/cli-v3/src/executions/taskRunProcess.test.ts b/packages/cli-v3/src/executions/taskRunProcess.test.ts index 82ab19639b2..9f36ac13b34 100644 --- a/packages/cli-v3/src/executions/taskRunProcess.test.ts +++ b/packages/cli-v3/src/executions/taskRunProcess.test.ts @@ -1,6 +1,6 @@ import { TaskRunProcess, type TaskRunProcessOptions } from "./taskRunProcess.js"; import { describe, it, expect, vi } from "vitest"; -import { UnexpectedExitError } from "@trigger.dev/core/v3/errors"; +import { UncaughtExceptionError, UnexpectedExitError } from "@trigger.dev/core/v3/errors"; import type { TaskRunExecution, TaskRunExecutionPayload, @@ -118,4 +118,38 @@ describe("TaskRunProcess", () => { } }); }); + + describe("parseExecuteError(UncaughtExceptionError)", () => { + it("returns INTERNAL_ERROR with TASK_RUN_UNCAUGHT_EXCEPTION + original message and stack", () => { + const error = new UncaughtExceptionError( + { + name: "Error", + message: "read ECONNRESET", + stack: + "Error: read ECONNRESET\n at TCP.onStreamRead (node:internal/stream_base_commons:216:20)", + }, + "uncaughtException" + ); + + const result = TaskRunProcess.parseExecuteError(error); + + expect(result.type).toBe("INTERNAL_ERROR"); + expect(result.code).toBe("TASK_RUN_UNCAUGHT_EXCEPTION"); + expect(result.message).toBe("read ECONNRESET"); + expect(result.stackTrace).toContain("TCP.onStreamRead"); + }); + + it("uses the same code for unhandledRejection origin", () => { + const error = new UncaughtExceptionError( + { name: "TypeError", message: "boom" }, + "unhandledRejection" + ); + + const result = TaskRunProcess.parseExecuteError(error); + + expect(result.type).toBe("INTERNAL_ERROR"); + expect(result.code).toBe("TASK_RUN_UNCAUGHT_EXCEPTION"); + expect(result.message).toBe("boom"); + }); + }); }); diff --git a/packages/cli-v3/src/executions/taskRunProcess.ts b/packages/cli-v3/src/executions/taskRunProcess.ts index a329956c0d2..6816b2e24f2 100644 --- a/packages/cli-v3/src/executions/taskRunProcess.ts +++ b/packages/cli-v3/src/executions/taskRunProcess.ts @@ -33,6 +33,7 @@ import { MaxDurationExceededError, UnexpectedExitError, SuspendedProcessError, + UncaughtExceptionError, } from "@trigger.dev/core/v3/errors"; export type OnSendDebugLogMessage = InferSocketMessageSchema< @@ -205,6 +206,18 @@ export class TaskRunProcess { }, UNCAUGHT_EXCEPTION: async (message) => { logger.debug("uncaught exception in task run process", { ...message }); + + // The worker process reports uncaught exceptions and unhandled rejections via this + // event, but does not exit on its own. If we don't terminate the attempt here, run() + // hangs (the awaited promise that triggered the throw is orphaned) until maxDuration + // expires β€” surfacing as TIMED_OUT/MAX_DURATION_EXCEEDED with empty attempts. Reject + // any pending attempts now and gracefully terminate the worker so OTEL gets a flush + // window before SIGKILL. + this.#rejectPendingAttempts( + new UncaughtExceptionError(message.error, message.origin) + ); + + await this.#gracefullyTerminate(this.options.gracefulTerminationTimeoutInMs); }, SEND_DEBUG_LOG: async (message) => { this.onSendDebugLog.post(message); @@ -339,6 +352,23 @@ export class TaskRunProcess { logger.debug("child process error", { error, pid: this.pid }); } + #rejectPendingAttempts(error: Error) { + for (const [id, status] of this._attemptStatuses.entries()) { + if (status !== "PENDING") { + continue; + } + + this._attemptStatuses.set(id, "REJECTED"); + + const attemptPromise = this._attemptPromises.get(id); + if (!attemptPromise) { + continue; + } + + attemptPromise.rejecter(error); + } + } + async #handleExit(code: number | null, signal: NodeJS.Signals | null) { logger.debug("handling child exit", { code, signal, pid: this.pid }); @@ -559,6 +589,21 @@ export class TaskRunProcess { }; } + if (error instanceof UncaughtExceptionError) { + // Dedicated INTERNAL_ERROR code so the engine handles retry via the + // existing crash-style lookup of run.lockedRetryConfig (same pathway as + // TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE etc.) and so the dashboard + // renders this as "Failed" rather than "System failure" β€” the exception + // was raised by user code (or a dependency the user controls, e.g. an + // EventEmitter "error" event with no listener), not a platform fault. + return { + type: "INTERNAL_ERROR", + code: TaskRunErrorCodes.TASK_RUN_UNCAUGHT_EXCEPTION, + message: error.originalError.message, + stackTrace: error.originalError.stack, + }; + } + return { type: "INTERNAL_ERROR", code: TaskRunErrorCodes.TASK_EXECUTION_FAILED, diff --git a/packages/core/src/v3/errors.ts b/packages/core/src/v3/errors.ts index 802f53c5441..a538ca9357b 100644 --- a/packages/core/src/v3/errors.ts +++ b/packages/core/src/v3/errors.ts @@ -395,6 +395,7 @@ export function shouldRetryError(error: TaskRunError): boolean { case "TASK_EXECUTION_ABORTED": case "TASK_EXECUTION_FAILED": case "TASK_RUN_CRASHED": + case "TASK_RUN_UNCAUGHT_EXCEPTION": case "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE": case "TASK_PROCESS_SIGTERM": return true; @@ -425,6 +426,7 @@ export function shouldLookupRetrySettings(error: TaskRunError): boolean { case "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE": case "TASK_PROCESS_SIGTERM": case "TASK_PROCESS_SIGSEGV": + case "TASK_RUN_UNCAUGHT_EXCEPTION": return true; default: @@ -722,6 +724,18 @@ const prettyInternalErrors: Partial< href: links.docs.troubleshooting.stalledExecution, }, }, + // Link only β€” we deliberately do NOT set `message`, so the original + // error message (e.g. "read ECONNRESET") is preserved in the dashboard. + // Common cause: an EventEmitter (node-redis, pg, etc.) emitted "error" + // with no listener attached, which Node escalates to uncaughtException. + // The docs page explains how to attach .on("error") listeners and how + // unhandled rejections route through the same path. + TASK_RUN_UNCAUGHT_EXCEPTION: { + link: { + name: "Read our troubleshooting guide", + href: links.docs.troubleshooting.uncaughtException, + }, + }, }; const getPrettyTaskRunError = (code: TaskRunInternalError["code"]): TaskRunInternalError => { diff --git a/packages/core/src/v3/links.ts b/packages/core/src/v3/links.ts index d04284e73fe..739f9dd28f7 100644 --- a/packages/core/src/v3/links.ts +++ b/packages/core/src/v3/links.ts @@ -15,6 +15,7 @@ export const links = { troubleshooting: { concurrentWaits: "https://trigger.dev/docs/troubleshooting#parallel-waits-are-not-supported", stalledExecution: "https://trigger.dev/docs/troubleshooting#task-run-stalled-executing", + uncaughtException: "https://trigger.dev/docs/troubleshooting#uncaught-exceptions", }, concurrency: { recursiveDeadlock: diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index 8bd22dd4bbb..4de2ddb5802 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -174,6 +174,7 @@ export const TaskRunInternalError = z.object({ "GRACEFUL_EXIT_TIMEOUT", "TASK_RUN_HEARTBEAT_TIMEOUT", "TASK_RUN_CRASHED", + "TASK_RUN_UNCAUGHT_EXCEPTION", "MAX_DURATION_EXCEEDED", "DISK_SPACE_EXCEEDED", "POD_EVICTED", From 3e6458f9b5fae3332de5070c7198bdef18287187 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 7 May 2026 11:52:58 +0100 Subject: [PATCH 008/491] ci(claude): switch Claude Code actions to ANTHROPIC_API_KEY (#3532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Both Claude Code workflows (`claude.yml` and `claude-md-audit.yml`) authenticated via `CLAUDE_CODE_OAUTH_TOKEN`, which broke when the org disabled Claude subscription access for Claude Code: > Your organization has disabled Claude subscription access for Claude Code Β· Use an Anthropic API key instead, or ask your admin to enable access This switches both workflows to `anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}` (secret already added to the repo). ## Test plan - [ ] Confirm `πŸ“ CLAUDE.md Audit` runs to completion on this PR - [ ] Confirm `@claude` mention in a PR comment still triggers the `Claude Code` workflow successfully --- .github/workflows/claude-md-audit.yml | 2 +- .github/workflows/claude.yml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-md-audit.yml b/.github/workflows/claude-md-audit.yml index e8716b1d6a9..01b1185cf16 100644 --- a/.github/workflows/claude-md-audit.yml +++ b/.github/workflows/claude-md-audit.yml @@ -36,7 +36,7 @@ jobs: id: claude uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} use_sticky_comment: true allowed_bots: "devin-ai-integration[bot]" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index a3c60b928e6..96a3ec96385 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -19,9 +19,9 @@ jobs: (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write id-token: write actions: read # Required for Claude to read CI results on PRs steps: @@ -52,7 +52,7 @@ jobs: id: claude uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | From 749dc467f1cfaa00aac8601a7e76b95f87ecebbf Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Fri, 8 May 2026 11:47:24 +0100 Subject: [PATCH 009/491] feat(webapp): link Sentry events to OTel traces via trace_id (#3531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Stamps the active OpenTelemetry `trace_id` and `span_id` onto every Sentry event captured from the webapp, so engineers can copy a `trace_id` from a Sentry issue and search for the corresponding trace in any OTel-aware backend. Also adds an `otel_sampled` tag to indicate whether the trace was head-sampled β€” a cheap signal for whether the link will resolve to span data or hit a missing trace. ## Why Sentry and OTel were OTel-disconnected: `apps/webapp/sentry.server.ts` initialised Sentry with `skipOpenTelemetrySetup: true`, and no error-capture site (`logger.server.ts`, the Remix-wrapped `handleError`, the root `ErrorBoundary`) attached OTel context to the event. With many spans/sec across services, getting from a Sentry issue to its trace was guesswork. ## Approach Single global Sentry event processor, registered immediately after `Sentry.init`. On each event it reads `trace.getActiveSpan()?.spanContext()` via `@opentelemetry/api`, then writes: - `event.contexts.trace.trace_id` and `event.contexts.trace.span_id` (Sentry's native trace context fields) - `event.tags.otel_sampled` = `"true"` | `"false"` (derived from `traceFlags`) If no active span (module-load errors, scheduled timers without a context, primary cluster process), the processor returns the event unmodified β€” Sentry's default propagation context fills in. Implementation is co-located in `apps/webapp/sentry.server.ts` (no separate helper module β€” `sentry.server.ts` is built standalone by esbuild and a separate import would have required a new bundling step). Helper functions are exported so the unit tests can reach them without re-running `Sentry.init`. ## Non-goals (deliberate) - No sample rate change. ~95% of Sentry events will carry a `trace_id` that returns no spans in the tracing backend (head-sampled out). The `otel_sampled` tag makes that obvious at a glance. Raising find-rate is a separate conversation with cost trade-offs. - No user/org tags or `Sentry.setUser` (would need auth-helper + per-request scope wiring across multiple worker entrypoints β€” separate ticket). - Webapp image only. No changes to supervisor or CLI workers. ## Test plan - [x] Unit tests in `apps/webapp/test/sentryTraceContext.server.test.ts` β€” 9 tests covering: helper returns \`undefined\` with no active span; returns \`traceId\`/\`spanId\`/\`sampled=true\` for a recording span; returns \`sampled=false\` for a non-recording span; processor leaves the event unchanged with no active span; processor stamps \`trace_id\`/\`span_id\` onto \`contexts.trace\`; preserves existing \`contexts.trace\` fields; tags \`otel_sampled\` correctly for both sampled and non-sampled cases; never throws if \`@opentelemetry/api\` access throws. - [x] \`pnpm run typecheck --filter webapp\` passes. - [x] Manually verified end-to-end against a sandboxed Sentry project: confirmed both sampled and non-sampled traces correctly populate \`contexts.trace.trace_id\` matching the OTel ids logged from the loader, and the \`otel_sampled\` tag appears with the expected value. Co-authored-by: Claude Opus 4.7 (1M context) --- .server-changes/sentry-trace-id-context.md | 6 + .../app/utils/sentryTraceContext.server.ts | 52 +++++++ apps/webapp/package.json | 2 +- apps/webapp/sentry.server.ts | 3 + .../test/sentryTraceContext.server.test.ts | 127 ++++++++++++++++++ 5 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 .server-changes/sentry-trace-id-context.md create mode 100644 apps/webapp/app/utils/sentryTraceContext.server.ts create mode 100644 apps/webapp/test/sentryTraceContext.server.test.ts diff --git a/.server-changes/sentry-trace-id-context.md b/.server-changes/sentry-trace-id-context.md new file mode 100644 index 00000000000..eaf2c333aa3 --- /dev/null +++ b/.server-changes/sentry-trace-id-context.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Stamp the active OpenTelemetry trace_id and span_id onto every Sentry event so issues can be cross-referenced with traces in any OTel backend. diff --git a/apps/webapp/app/utils/sentryTraceContext.server.ts b/apps/webapp/app/utils/sentryTraceContext.server.ts new file mode 100644 index 00000000000..bad678c34d8 --- /dev/null +++ b/apps/webapp/app/utils/sentryTraceContext.server.ts @@ -0,0 +1,52 @@ +import { type Span, TraceFlags, trace } from "@opentelemetry/api"; +import type { Event, EventHint } from "@sentry/remix"; + +export type GetActiveSpan = () => Span | undefined; + +const defaultGetActiveSpan: GetActiveSpan = () => trace.getActiveSpan(); + +export function getActiveTraceIds( + getActiveSpan: GetActiveSpan = defaultGetActiveSpan +): { traceId: string; spanId: string; sampled: boolean } | undefined { + try { + const span = getActiveSpan(); + if (!span) return undefined; + const ctx = span.spanContext(); + return { + traceId: ctx.traceId, + spanId: ctx.spanId, + sampled: (ctx.traceFlags & TraceFlags.SAMPLED) !== 0, + }; + } catch { + return undefined; + } +} + +export function addOtelTraceContextToEvent( + event: Event, + _hint: EventHint, + getActiveSpan: GetActiveSpan = defaultGetActiveSpan +): Event { + const ids = getActiveTraceIds(getActiveSpan); + if (!ids) return event; + // We intentionally overwrite Sentry's own trace_id/span_id on contexts.trace. + // With skipOpenTelemetrySetup: true, Sentry generates an internal trace_id + // unrelated to OTel; replacing it with the active OTel ids is the whole + // point of this processor β€” it makes Sentry issues navigable to the + // corresponding OTel trace in any backend. + return { + ...event, + contexts: { + ...event.contexts, + trace: { + ...event.contexts?.trace, + trace_id: ids.traceId, + span_id: ids.spanId, + }, + }, + tags: { + ...event.tags, + otel_sampled: ids.sampled ? "true" : "false", + }, + }; +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 0880eb71037..5b2725288dd 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -7,7 +7,7 @@ "build": "run-s build:** && pnpm run upload:sourcemaps", "build:remix": "remix build --sourcemap", "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap", - "build:sentry": "esbuild --platform=node --format=cjs ./sentry.server.ts --outdir=build --sourcemap", + "build:sentry": "esbuild --platform=node --format=cjs --outbase=. ./sentry.server.ts ./app/utils/sentryTraceContext.server.ts --outdir=build --sourcemap", "dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"", "dev:worker": "cross-env NODE_PATH=../../node_modules/.pnpm/node_modules node ./build/server.js", "format": "prettier --write .", diff --git a/apps/webapp/sentry.server.ts b/apps/webapp/sentry.server.ts index d34e676dca5..ee84c1d0e32 100644 --- a/apps/webapp/sentry.server.ts +++ b/apps/webapp/sentry.server.ts @@ -1,4 +1,5 @@ import * as Sentry from "@sentry/remix"; +import { addOtelTraceContextToEvent } from "./app/utils/sentryTraceContext.server"; if (process.env.SENTRY_DSN) { console.log("πŸ”­ Initializing Sentry"); @@ -29,4 +30,6 @@ if (process.env.SENTRY_DSN) { ignoreErrors: ["queryRoute() call aborted", /^ServiceValidationError(?::|$)/], includeLocalVariables: false, }); + + Sentry.addEventProcessor(addOtelTraceContextToEvent); } diff --git a/apps/webapp/test/sentryTraceContext.server.test.ts b/apps/webapp/test/sentryTraceContext.server.test.ts new file mode 100644 index 00000000000..231d9387e10 --- /dev/null +++ b/apps/webapp/test/sentryTraceContext.server.test.ts @@ -0,0 +1,127 @@ +import { ROOT_CONTEXT, TraceFlags, context, trace } from "@opentelemetry/api"; +import { describe, expect, it } from "vitest"; +import { + addOtelTraceContextToEvent, + getActiveTraceIds, +} from "../app/utils/sentryTraceContext.server"; +import { createInMemoryTracing } from "./utils/tracing"; + +describe("getActiveTraceIds", () => { + it("returns undefined when no OTel span is active", () => { + expect(getActiveTraceIds()).toBeUndefined(); + }); + + it("returns the trace_id, span_id, and sampled=true for an active recording span", () => { + const { tracer } = createInMemoryTracing(); + + tracer.startActiveSpan("test-span", (span) => { + const ids = getActiveTraceIds(); + expect(ids).toEqual({ + traceId: span.spanContext().traceId, + spanId: span.spanContext().spanId, + sampled: true, + }); + span.end(); + }); + }); + + it("returns sampled=false when the active span is non-recording", () => { + // Initialise the global context manager (createInMemoryTracing does this + // as a side effect of NodeTracerProvider.register()). + createInMemoryTracing(); + + const nonSampledSpan = trace.wrapSpanContext({ + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + traceFlags: TraceFlags.NONE, + }); + + context.with(trace.setSpan(ROOT_CONTEXT, nonSampledSpan), () => { + expect(getActiveTraceIds()).toEqual({ + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + sampled: false, + }); + }); + }); +}); + +describe("addOtelTraceContextToEvent", () => { + it("returns the event unchanged when no OTel span is active", () => { + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result).toBe(event); + expect(result).toEqual({ message: "boom" }); + }); + + it("stamps trace_id and span_id from the active span onto event.contexts.trace", () => { + const { tracer } = createInMemoryTracing(); + + tracer.startActiveSpan("test-span", (span) => { + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result.contexts?.trace?.trace_id).toBe(span.spanContext().traceId); + expect(result.contexts?.trace?.span_id).toBe(span.spanContext().spanId); + span.end(); + }); + }); + + it("tags the event with otel_sampled=true when the active span is recording", () => { + const { tracer } = createInMemoryTracing(); + + tracer.startActiveSpan("test-span", (span) => { + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result.tags?.otel_sampled).toBe("true"); + span.end(); + }); + }); + + it("tags the event with otel_sampled=false when the active span is non-recording", () => { + createInMemoryTracing(); + + const nonSampledSpan = trace.wrapSpanContext({ + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + traceFlags: TraceFlags.NONE, + }); + + context.with(trace.setSpan(ROOT_CONTEXT, nonSampledSpan), () => { + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result.tags?.otel_sampled).toBe("false"); + }); + }); + + it("preserves existing event.contexts.trace fields", () => { + const { tracer } = createInMemoryTracing(); + + tracer.startActiveSpan("test-span", (span) => { + const event = { + message: "boom", + contexts: { + trace: { op: "http.server", description: "GET /things" }, + runtime: { name: "node" }, + }, + }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result.contexts?.trace).toMatchObject({ + op: "http.server", + description: "GET /things", + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + }); + expect(result.contexts?.runtime).toEqual({ name: "node" }); + span.end(); + }); + }); + + it("returns the event unchanged if reading the OTel context throws", () => { + const throwingAccessor = () => { + throw new Error("otel api blew up"); + }; + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}, throwingAccessor); + expect(result).toBe(event); + }); +}); From 61ae67cc021deb5a368cc143e52157a13dcb12b5 Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Fri, 8 May 2026 16:25:53 +0100 Subject: [PATCH 010/491] fix(webapp): stop leaking exception messages on 5xx API responses (#3536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a webapp API route's catch-all 500 branch handles a non-typed exception, it returns the raw `error.message` to the caller. If the exception originates from an internal subsystem (the ORM client, an infra dependency, etc.) the server-side error string is surfaced verbatim in the response body β€” exposing implementation details the API surface shouldn't carry. The leak shows up in three shapes across the routes: - `return json({ error: error.message }, { status: 500 })` - `return json({ error: error instanceof Error ? error.message : "Internal Server Error" }, { status: 500 })` - ``return json({ error: `Internal server error: ${error.message}` }, { status: 500 })`` (plus a couple of analogous neverthrow-Result variants on admin routes.) ## Fix Across 19 webapp routes, replace each leaking branch with a generic body (`"Something went wrong"` / `"Internal Server Error"` to match the file's existing fallback) and add `logger.error(...)` so full visibility is preserved server-side. Catch blocks that branch on typed user-input errors (`ServiceValidationError`, `EngineServiceValidationError`, `OutOfEntitlementError`, `PrismaClientKnownRequestError`) are left intact β€” those messages are constructed deliberately and intended to be customer-facing. ## Test plan - [x] `pnpm run typecheck --filter webapp` - [x] Per-route manual probe: inject a synthetic `Error` at the top of the catch'd `try` block (or fake the wrapped call's rejection / Result error), curl the route with the dev API key, confirm the response body changed from the synthetic message verbatim β†’ generic body. 21/21 leak sites verified end-to-end. - [x] 4xx-typed-error paths spot-checked: throwing `ServiceValidationError` from inside the catch'd try still surfaces its message at 422 as intended. --- .server-changes/sanitize-api-500-errors.md | 6 ++++ .../admin.api.v1.platform-notifications.ts | 4 ++- .../webapp/app/routes/admin.notifications.tsx | 34 ++++++++++++++----- .../api.v1.batches.$batchParam.results.ts | 8 ++--- ...i.v1.deployments.$deploymentId.finalize.ts | 9 ++--- apps/webapp/app/routes/api.v1.deployments.ts | 9 ++--- ...i.v1.projects.$projectRef.alertChannels.ts | 7 ++-- apps/webapp/app/routes/api.v1.queues.ts | 7 ++-- ....runs.$runFriendlyId.input-streams.wait.ts | 4 +-- .../app/routes/api.v1.runs.$runId.tags.ts | 7 ++-- .../routes/api.v1.runs.$runParam.attempts.ts | 7 ++-- .../api.v1.runs.$runParam.reschedule.ts | 8 ++--- .../routes/api.v1.runs.$runParam.result.ts | 8 ++--- .../api.v1.schedules.$scheduleId.activate.ts | 7 ++-- ...api.v1.schedules.$scheduleId.deactivate.ts | 7 ++-- .../routes/api.v1.schedules.$scheduleId.ts | 13 +++---- apps/webapp/app/routes/api.v1.schedules.ts | 7 ++-- .../routes/api.v1.tasks.$taskId.trigger.ts | 3 +- .../app/routes/api.v1.waitpoints.tokens.ts | 4 +-- ...i.v2.deployments.$deploymentId.finalize.ts | 9 ++--- .../routes/api.v3.batches.$batchId.items.ts | 4 --- ...i.v3.deployments.$deploymentId.finalize.ts | 14 +++----- ...ealtime.v1.sessions.$session.$io.append.ts | 7 +++- 23 files changed, 95 insertions(+), 98 deletions(-) create mode 100644 .server-changes/sanitize-api-500-errors.md diff --git a/.server-changes/sanitize-api-500-errors.md b/.server-changes/sanitize-api-500-errors.md new file mode 100644 index 00000000000..1621e15a16f --- /dev/null +++ b/.server-changes/sanitize-api-500-errors.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Stop leaking raw exception messages on 500 responses across webapp API routes; return a generic error string and log the full error server-side instead. diff --git a/apps/webapp/app/routes/admin.api.v1.platform-notifications.ts b/apps/webapp/app/routes/admin.api.v1.platform-notifications.ts index 3798d9fa734..c0b59631864 100644 --- a/apps/webapp/app/routes/admin.api.v1.platform-notifications.ts +++ b/apps/webapp/app/routes/admin.api.v1.platform-notifications.ts @@ -1,5 +1,6 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { err, ok, type Result } from "neverthrow"; +import { logger } from "~/services/logger.server"; import { authenticateAdminRequest } from "~/services/personalAccessToken.server"; import { createPlatformNotification, @@ -42,7 +43,8 @@ export async function action({ request }: ActionFunctionArgs) { return json({ error: "Validation failed", details: error.issues }, { status: 400 }); } - return json({ error: error.message }, { status: 500 }); + logger.error("Failed to create platform notification", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } return json(result.value, { status: 201 }); diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx index 179ab23c3ee..f05397d3c20 100644 --- a/apps/webapp/app/routes/admin.notifications.tsx +++ b/apps/webapp/app/routes/admin.notifications.tsx @@ -37,6 +37,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { archivePlatformNotification, @@ -234,7 +235,8 @@ async function handleCreateAction(formData: FormData, userId: string, isPreview: { status: 400 } ); } - return typedjson({ error: err.message }, { status: 500 }); + logger.error("Failed to create platform notification", { error: err }); + return typedjson({ error: "Something went wrong, please try again." }, { status: 500 }); } if (isPreview) { @@ -249,8 +251,13 @@ async function handleArchiveAction(formData: FormData) { return typedjson({ error: "Missing notificationId" }, { status: 400 }); } - await archivePlatformNotification(notificationId); - return typedjson({ success: true }); + try { + await archivePlatformNotification(notificationId); + return typedjson({ success: true }); + } catch (error) { + logger.error("Failed to archive platform notification", { error, notificationId }); + return typedjson({ error: "Failed to archive notification, please try again." }, { status: 500 }); + } } async function handleDeleteAction(formData: FormData) { @@ -259,8 +266,13 @@ async function handleDeleteAction(formData: FormData) { return typedjson({ error: "Missing notificationId" }, { status: 400 }); } - await deletePlatformNotification(notificationId); - return typedjson({ success: true }); + try { + await deletePlatformNotification(notificationId); + return typedjson({ success: true }); + } catch (error) { + logger.error("Failed to delete platform notification", { error, notificationId }); + return typedjson({ error: "Failed to delete notification, please try again." }, { status: 500 }); + } } async function handlePublishNowAction(formData: FormData) { @@ -269,8 +281,13 @@ async function handlePublishNowAction(formData: FormData) { return typedjson({ error: "Missing notificationId" }, { status: 400 }); } - await publishNowPlatformNotification(notificationId); - return typedjson({ success: true }); + try { + await publishNowPlatformNotification(notificationId); + return typedjson({ success: true }); + } catch (error) { + logger.error("Failed to publish platform notification", { error, notificationId }); + return typedjson({ error: "Failed to publish notification, please try again." }, { status: 500 }); + } } async function handleEditAction(formData: FormData) { @@ -310,7 +327,8 @@ async function handleEditAction(formData: FormData) { { status: 400 } ); } - return typedjson({ error: err.message }, { status: 500 }); + logger.error("Failed to update platform notification", { error: err }); + return typedjson({ error: "Something went wrong, please try again." }, { status: 500 }); } return typedjson({ success: true, id: result.value.id }); diff --git a/apps/webapp/app/routes/api.v1.batches.$batchParam.results.ts b/apps/webapp/app/routes/api.v1.batches.$batchParam.results.ts index 7eb2fd4207b..1a5889fab1d 100644 --- a/apps/webapp/app/routes/api.v1.batches.$batchParam.results.ts +++ b/apps/webapp/app/routes/api.v1.batches.$batchParam.results.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server"; import { ApiRunResultPresenter } from "~/presenters/v3/ApiRunResultPresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ /* This is the batch friendly ID */ @@ -36,10 +37,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json(result); } catch (error) { - if (error instanceof Error) { - return json({ error: error.message }, { status: 500 }); - } else { - return json({ error: JSON.stringify(error) }, { status: 500 }); - } + logger.error("Failed to load batch results", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts index 9bd12e4bd3f..9bafd8644af 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts @@ -54,12 +54,9 @@ export async function action({ request, params }: ActionFunctionArgs) { } catch (error) { if (error instanceof ServiceValidationError) { return json({ error: error.message }, { status: 400 }); - } else if (error instanceof Error) { - logger.error("Error finalizing deployment", { error: error.message }); - return json({ error: `Internal server error: ${error.message}` }, { status: 500 }); - } else { - logger.error("Error finalizing deployment", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); } + + logger.error("Error finalizing deployment", { error }); + return json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 0190ba123d5..8fa5b432950 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -55,13 +55,10 @@ export async function action({ request, params }: ActionFunctionArgs) { } catch (error) { if (error instanceof ServiceValidationError) { return json({ error: error.message }, { status: 400 }); - } else if (error instanceof Error) { - logger.error("Error initializing deployment", { error: error.message }); - return json({ error: `Internal server error: ${error.message}` }, { status: 500 }); - } else { - logger.error("Error initializing deployment", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); } + + logger.error("Error initializing deployment", { error }); + return json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts index ebc5b176478..a2f2dcf417f 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts @@ -5,6 +5,7 @@ import { ApiAlertChannelPresenter, ApiCreateAlertChannel, } from "~/presenters/v3/ApiAlertChannelPresenter.server"; +import { logger } from "~/services/logger.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; import { CreateAlertChannelService } from "~/v3/services/alerts/createAlertChannel.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -88,9 +89,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: error.message }, { status: 422 }); } - return json( - { error: error instanceof Error ? error.message : "Internal Server Error" }, - { status: 500 } - ); + logger.error("Failed to create alert channel", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.queues.ts b/apps/webapp/app/routes/api.v1.queues.ts index 551b3c2f34f..18c0f688370 100644 --- a/apps/webapp/app/routes/api.v1.queues.ts +++ b/apps/webapp/app/routes/api.v1.queues.ts @@ -2,6 +2,7 @@ import { json } from "@remix-run/server-runtime"; import { type QueueItem } from "@trigger.dev/core/v3"; import { z } from "zod"; import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; +import { logger } from "~/services/logger.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -35,10 +36,8 @@ export const loader = createLoaderApiRoute( return json({ error: error.message }, { status: 422 }); } - return json( - { error: error instanceof Error ? error.message : "Internal Server Error" }, - { status: 500 } - ); + logger.error("Failed to list queues", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } ); diff --git a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts index a0f24f9abd8..0924bf3fc91 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts @@ -11,6 +11,7 @@ import { deleteInputStreamWaitpoint, setInputStreamWaitpoint, } from "~/services/inputStreamWaitpointCache.server"; +import { logger } from "~/services/logger.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { parseDelay } from "~/utils/delays"; @@ -138,10 +139,9 @@ const { action, loader } = createActionApiRoute( } catch (error) { if (error instanceof ServiceValidationError) { return json({ error: error.message }, { status: 422 }); - } else if (error instanceof Error) { - return json({ error: error.message }, { status: 500 }); } + logger.error("Failed to create input-stream waitpoint", { error }); return json({ error: "Something went wrong" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.tags.ts b/apps/webapp/app/routes/api.v1.runs.$runId.tags.ts index 2a48ded529e..eae94375b9f 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.tags.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.tags.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ runId: z.string(), @@ -85,9 +86,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ message: `Successfully set ${newTags.length} new tags.` }, { status: 200 }); } catch (error) { - return json( - { error: error instanceof Error ? error.message : "Internal Server Error" }, - { status: 500 } - ); + logger.error("Failed to add run tags", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.runs.$runParam.attempts.ts b/apps/webapp/app/routes/api.v1.runs.$runParam.attempts.ts index 33894f8493c..790e52bee4e 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runParam.attempts.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runParam.attempts.ts @@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { CreateTaskRunAttemptService } from "~/v3/services/createTaskRunAttempt.server"; @@ -40,9 +41,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: error.message }, { status: error.status ?? 422 }); } - return json( - { error: error instanceof Error ? error.message : "Internal Server Error" }, - { status: 500 } - ); + logger.error("Failed to create run attempt", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.runs.$runParam.reschedule.ts b/apps/webapp/app/routes/api.v1.runs.$runParam.reschedule.ts index f4e08831f4f..0ac8aec8351 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runParam.reschedule.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runParam.reschedule.ts @@ -6,6 +6,7 @@ import { getApiVersion } from "~/api/versions"; import { prisma } from "~/db.server"; import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { RescheduleTaskRunService } from "~/v3/services/rescheduleTaskRun.server"; @@ -84,10 +85,9 @@ export async function action({ request, params }: ActionFunctionArgs) { } catch (error) { if (error instanceof ServiceValidationError) { return json({ error: error.message }, { status: 400 }); - } else if (error instanceof Error) { - return json({ error: error.message }, { status: 500 }); - } else { - return json({ error: "An unknown error occurred" }, { status: 500 }); } + + logger.error("Failed to reschedule run", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.runs.$runParam.result.ts b/apps/webapp/app/routes/api.v1.runs.$runParam.result.ts index 16343a91434..4cbf27d3275 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runParam.result.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runParam.result.ts @@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { ApiRunResultPresenter } from "~/presenters/v3/ApiRunResultPresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ /* This is the run friendly ID */ @@ -35,10 +36,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json(result); } catch (error) { - if (error instanceof Error) { - return json({ error: error.message }, { status: 500 }); - } else { - return json({ error: JSON.stringify(error) }, { status: 500 }); - } + logger.error("Failed to load run result", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.activate.ts b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.activate.ts index 7eb281c0520..9cc8b7173c7 100644 --- a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.activate.ts +++ b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.activate.ts @@ -5,6 +5,7 @@ import { prisma } from "~/db.server"; import { scheduleUniqWhereClause, scheduleWhereClause } from "~/models/schedules.server"; import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ scheduleId: z.string(), @@ -68,9 +69,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json(presenter.toJSONResponse(result), { status: 200 }); } catch (error) { - return json( - { error: error instanceof Error ? error.message : "Internal Server Error" }, - { status: 500 } - ); + logger.error("Failed to activate schedule", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.deactivate.ts b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.deactivate.ts index e9b2997116f..eb985bea728 100644 --- a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.deactivate.ts +++ b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.deactivate.ts @@ -5,6 +5,7 @@ import { prisma } from "~/db.server"; import { scheduleUniqWhereClause, scheduleWhereClause } from "~/models/schedules.server"; import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ scheduleId: z.string(), @@ -68,9 +69,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json(presenter.toJSONResponse(result), { status: 200 }); } catch (error) { - return json( - { error: error instanceof Error ? error.message : "Internal Server Error" }, - { status: 500 } - ); + logger.error("Failed to deactivate schedule", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts index b9fc8e2caff..e76f65e6e8a 100644 --- a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts +++ b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts @@ -6,6 +6,7 @@ import { Prisma, prisma } from "~/db.server"; import { scheduleUniqWhereClause } from "~/models/schedules.server"; import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { UpsertSchedule } from "~/v3/schedules"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server"; @@ -57,10 +58,8 @@ export async function action({ request, params }: ActionFunctionArgs) { { status: error.code === "P2025" ? 404 : 422 } ); } else { - return json( - { error: error instanceof Error ? error.message : "Internal Server Error" }, - { status: 500 } - ); + logger.error("Failed to delete schedule", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } } @@ -110,10 +109,8 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: error.message }, { status: 422 }); } - return json( - { error: error instanceof Error ? error.message : "Internal Server Error" }, - { status: 500 } - ); + logger.error("Failed to upsert schedule", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } } diff --git a/apps/webapp/app/routes/api.v1.schedules.ts b/apps/webapp/app/routes/api.v1.schedules.ts index beb232c9757..56250eaac55 100644 --- a/apps/webapp/app/routes/api.v1.schedules.ts +++ b/apps/webapp/app/routes/api.v1.schedules.ts @@ -4,6 +4,7 @@ import { CreateScheduleOptions, ScheduleObject } from "@trigger.dev/core/v3"; import { z } from "zod"; import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { UpsertSchedule } from "~/v3/schedules"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server"; @@ -71,10 +72,8 @@ export async function action({ request }: ActionFunctionArgs) { return json({ error: error.message }, { status: 422 }); } - return json( - { error: error instanceof Error ? error.message : "Internal Server Error" }, - { status: 500 } - ); + logger.error("Failed to create schedule", { error }); + return json({ error: "Something went wrong, please try again." }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index 5811fc67709..e39f4b3cc8f 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -153,10 +153,9 @@ const { action, loader } = createActionApiRoute( return json({ error: error.message }, { status: error.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 }); } + logger.error("Trigger task failed", { error }); return json({ error: "Something went wrong" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts index 4542236d488..b7ef988b728 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts @@ -10,6 +10,7 @@ import { ApiWaitpointListSearchParams, } from "~/presenters/v3/ApiWaitpointListPresenter.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; import { createActionApiRoute, @@ -92,10 +93,9 @@ const { action } = createActionApiRoute( } catch (error) { if (error instanceof ServiceValidationError) { return json({ error: error.message }, { status: 422 }); - } else if (error instanceof Error) { - return json({ error: error.message }, { status: 500 }); } + logger.error("Failed to create waitpoint token", { error }); return json({ error: "Something went wrong" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts b/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts index 768212ee8dd..380f2c2e600 100644 --- a/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts +++ b/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts @@ -54,12 +54,9 @@ export async function action({ request, params }: ActionFunctionArgs) { } catch (error) { if (error instanceof ServiceValidationError) { return json({ error: error.message }, { status: 400 }); - } else if (error instanceof Error) { - logger.error("Error finalizing deployment", { error: error.message }); - return json({ error: `Internal server error: ${error.message}` }, { status: 500 }); - } else { - logger.error("Error finalizing deployment", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); } + + logger.error("Error finalizing deployment", { error }); + return json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts index 0e26bae94e3..49e0d9053cc 100644 --- a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts +++ b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts @@ -112,10 +112,6 @@ export async function action({ request, params }: ActionFunctionArgs) { }, }); - if (error instanceof Error) { - return json({ error: error.message }, { status: 500 }); - } - return json({ error: "Something went wrong" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts b/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts index d6594c2520d..f08b3ed0525 100644 --- a/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts +++ b/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts @@ -75,11 +75,8 @@ export async function action({ request, params }: ActionFunctionArgs) { if (error instanceof ServiceValidationError) { errorMessage = { error: error.message }; - } else if (error instanceof Error) { - logger.error("Error finalizing deployment", { error: error.message }); - errorMessage = { error: `Internal server error: ${error.message}` }; } else { - logger.error("Error finalizing deployment", { error: String(error) }); + logger.error("Error finalizing deployment", { error }); errorMessage = { error: "Internal server error" }; } @@ -93,12 +90,9 @@ export async function action({ request, params }: ActionFunctionArgs) { } catch (error) { if (error instanceof ServiceValidationError) { return json({ error: error.message }, { status: 400 }); - } else if (error instanceof Error) { - logger.error("Error finalizing deployment", { error: error.message }); - return json({ error: `Internal server error: ${error.message}` }, { status: 500 }); - } else { - logger.error("Error finalizing deployment", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); } + + logger.error("Error finalizing deployment", { error }); + return json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts index 45fbde5924b..a21b5202317 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts @@ -135,7 +135,12 @@ const { action, loader } = createActionApiRoute( { status: appendError.status ?? 422 } ); } - return json({ ok: false, error: appendError.message }, { status: 500 }); + logger.error("Failed to append to session stream", { + sessionId: session.id, + io: params.io, + error: appendError, + }); + return json({ ok: false, error: "Something went wrong, please try again." }, { status: 500 }); } // Fire any run-scoped waitpoints registered against this channel. Best From f8ddb766facc0aacead8dd69eedd4a75cee0e2f8 Mon Sep 17 00:00:00 2001 From: Iss <74388823+isshaddad@users.noreply.github.com> Date: Fri, 8 May 2026 11:51:57 -0400 Subject: [PATCH 011/491] feat: Plain customer cards (#2933) --- apps/webapp/app/env.server.ts | 3 + apps/webapp/app/models/admin.server.ts | 9 +- apps/webapp/app/routes/admin._index.tsx | 26 +- apps/webapp/app/routes/admin.impersonate.tsx | 57 +++ .../app/routes/api.v1.plain.customer-cards.ts | 444 ++++++++++++++++++ .../app/services/apiRateLimit.server.ts | 1 + .../app/services/impersonation.server.ts | 109 +++++ 7 files changed, 625 insertions(+), 24 deletions(-) create mode 100644 apps/webapp/app/routes/admin.impersonate.tsx create mode 100644 apps/webapp/app/routes/api.v1.plain.customer-cards.ts diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 13e9e5dacbd..7588ebb2b38 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -151,6 +151,9 @@ const EnvironmentSchema = z SMTP_PASSWORD: z.string().optional(), PLAIN_API_KEY: z.string().optional(), + PLAIN_CUSTOMER_CARDS_SECRET: z.string().optional(), + PLAIN_CUSTOMER_CARDS_KEY: z.string().optional(), + PLAIN_CUSTOMER_CARDS_HEADERS: z.string().optional(), WORKER_SCHEMA: z.string().default("graphile_worker"), WORKER_CONCURRENCY: z.coerce.number().int().default(10), WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index bd3adc8cbf2..8620ea6a244 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -210,8 +210,13 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se }; } -export async function redirectWithImpersonation(request: Request, userId: string, path: string) { - const user = await requireUser(request); +export async function redirectWithImpersonation( + request: Request, + userId: string, + path: string, + currentUser?: { id: string; admin: boolean } +) { + const user = currentUser ?? (await requireUser(request)); if (!user.admin) { throw new Error("Unauthorized"); } diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index aafb8180026..e847fc759ad 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -1,12 +1,10 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CopyableText } from "~/components/primitives/CopyableText"; -import { Header1 } from "~/components/primitives/Headers"; import { Input } from "~/components/primitives/Input"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -19,9 +17,7 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { useUser } from "~/hooks/useUser"; -import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; -import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server"; +import { adminGetUsers } from "~/models/admin.server"; import { requireUserId } from "~/services/session.server"; import { createSearchParams } from "~/utils/searchParams"; @@ -32,7 +28,7 @@ export const SearchParams = z.object({ export type SearchParams = z.infer; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { +export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const searchParams = createSearchParams(request.url, SearchParams); @@ -44,21 +40,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson(result); }; -const FormSchema = z.object({ id: z.string() }); - -export async function action({ request }: ActionFunctionArgs) { - if (request.method.toLowerCase() !== "post") { - return new Response("Method not allowed", { status: 405 }); - } - - const payload = Object.fromEntries(await request.formData()); - const { id } = FormSchema.parse(payload); - - return redirectWithImpersonation(request, id, "/"); -} - export default function AdminDashboardRoute() { - const user = useUser(); const { users, filters, page, pageCount } = useTypedLoaderData(); return ( @@ -136,7 +118,7 @@ export default function AdminDashboardRoute() { {user.admin ? "βœ…" : ""} -
+ @@ -162,7 +165,6 @@ function CreateDashboardUpgradeDialog({ isFreePlan: boolean; organization: { slug: string }; }) { - if (isFreePlan) { return ( diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index b9065dfdc2d..bee2ccb3d1e 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -489,7 +489,7 @@ export function SideMenu({ /> )} {buttonContent} - + {tooltip} {shortcut && renderShortcutKey()} @@ -353,7 +353,11 @@ export const Button = forwardRef( form={props.form} autoFocus={autoFocus} > - + ); @@ -362,13 +366,13 @@ export const Button = forwardRef( - + {buttonElement} - + {props.tooltip} {props.shortcut && !props.hideShortcutKey && ( - + )} diff --git a/apps/webapp/app/components/primitives/Input.tsx b/apps/webapp/app/components/primitives/Input.tsx index 3364e48bed2..15a7592c32f 100644 --- a/apps/webapp/app/components/primitives/Input.tsx +++ b/apps/webapp/app/components/primitives/Input.tsx @@ -67,6 +67,7 @@ const variants = { export type InputProps = React.InputHTMLAttributes & { variant?: keyof typeof variants; icon?: RenderIcon; + iconClassName?: string; accessory?: React.ReactNode; fullWidth?: boolean; containerClassName?: string; @@ -81,6 +82,7 @@ const Input = React.forwardRef( fullWidth = true, variant = "medium", icon, + iconClassName, containerClassName, ...props }, @@ -91,7 +93,7 @@ const Input = React.forwardRef( const variantContainerClassName = variants[variant].container; const inputClassName = variants[variant].input; - const iconClassName = variants[variant].iconSize; + const variantIconClassName = variants[variant].iconSize; return (
( > {icon && (
- +
)} { - const urlSearch = value("search") ?? ""; + const urlSearch = value(paramName) ?? ""; if (urlSearch !== text && !isFocused) { setText(urlSearch); } - }, [value, text, isFocused]); + }, [value, text, isFocused, paramName]); - const handleSubmit = useCallback(() => { + const handleSubmit = () => { const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined])); if (text.trim()) { - replace({ search: text.trim(), ...resetValues }); + replace({ [paramName]: text.trim(), ...resetValues }); } else { - del(["search", ...resetParams]); + del([paramName, ...resetParams]); } - }, [text, replace, del, resetParams]); + }; - const handleClear = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setText(""); - del(["search", ...resetParams]); - }, - [del, resetParams] - ); + const handleClear = () => { + setText(""); + del([paramName, ...resetParams]); + }; return (
@@ -81,24 +80,42 @@ export function SearchInput({ handleSubmit(); } if (e.key === "Escape") { - e.currentTarget.blur(); + if (text.length > 0) { + e.stopPropagation(); + handleClear(); + } else { + e.currentTarget.blur(); + } } }} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} - icon={} + icon={} accessory={ text.length > 0 ? (
- + e.preventDefault()} + onClick={() => handleClear()} + className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright" + > + + + } + content={ +
+ Clear field + +
+ } + className="px-2 py-1.5 text-xs" + disableHoverableContent + />
) : undefined } diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index d3e4c866891..d0142b363e4 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -104,6 +104,7 @@ export interface SelectProps open?: boolean; setOpen?: (open: boolean) => void; shortcut?: ShortcutDefinition; + tooltipTitle?: string; allowItemShortcuts?: boolean; clearSearchOnSelection?: boolean; dropdownIcon?: boolean | React.ReactNode; @@ -127,6 +128,7 @@ export function Select({ open, setOpen, shortcut, + tooltipTitle, allowItemShortcuts = true, disabled, clearSearchOnSelection = true, @@ -206,6 +208,7 @@ export function Select({ text={text} placeholder={placeholder} shortcut={shortcut} + tooltipTitle={tooltipTitle} disabled={disabled} dropdownIcon={dropdownIcon} {...props} @@ -354,7 +357,7 @@ export function SelectTrigger({ {showTooltip && (
diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index 1a30bc82b8a..d69e1201035 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -65,6 +65,7 @@ type TableProps = { children: ReactNode; fullWidth?: boolean; showTopBorder?: boolean; + stickyHeader?: boolean; }; // Add TableContext @@ -79,6 +80,7 @@ export const Table = forwardRef { @@ -86,7 +88,8 @@ export const Table = forwardRef
-
+
{isAdmin && ( + } + content={ +
+ Clear field + +
+ } + className="px-2 py-1.5 text-xs" + disableHoverableContent + /> +
) : undefined } /> diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index e0d417ddec4..2c2f15b4584 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -8,25 +8,18 @@ import { } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; import type { BatchTaskRunStatus, RuntimeEnvironment } from "@trigger.dev/database"; -import { ListFilterIcon } from "lucide-react"; -import type { ReactNode } from "react"; -import { useCallback, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useRef, useState } from "react"; import { z } from "zod"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; -import { FormError } from "~/components/primitives/FormError"; -import { Input } from "~/components/primitives/Input"; -import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { - ComboBox, - SelectButtonItem, SelectItem, SelectList, SelectPopover, SelectProvider, - SelectTrigger, shortcutFromIndex, } from "~/components/primitives/Select"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { Tooltip, TooltipContent, @@ -35,6 +28,7 @@ import { } from "~/components/primitives/Tooltip"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { Button } from "../../primitives/Buttons"; import { allBatchStatuses, @@ -42,7 +36,13 @@ import { batchStatusTitle, descriptionForBatchStatus, } from "./BatchStatus"; -import { TimeFilter, appliedSummary, FilterMenuProvider } from "./SharedFilters"; +import { + TimeFilter, + appliedSummary, + FilterMenuProvider, + IdFilterDropdown, + type IdFilterDropdownProps, +} from "./SharedFilters"; import { StatusIcon } from "~/assets/icons/StatusIcon"; export const BatchStatus = z.enum(allBatchStatuses); @@ -69,133 +69,33 @@ type BatchFiltersProps = { export function BatchFilters(props: BatchFiltersProps) { const location = useOptimisticLocation(); const searchParams = new URLSearchParams(location.search); - const hasFilters = searchParams.has("statuses") || searchParams.has("id"); + const hasFilters = + searchParams.has("statuses") || + searchParams.has("id") || + searchParams.has("period") || + searchParams.has("from") || + searchParams.has("to"); return ( -
- - - +
+ + + {hasFilters && ( - -
); } -const filterTypes = [ - { - name: "statuses", - title: "Status", - icon: ( -
-
-
- ), - }, - { name: "batch", title: "Batch ID", icon: }, -] as const; - -type FilterType = (typeof filterTypes)[number]["name"]; - -const shortcut = { key: "f" }; - -function FilterMenu(props: BatchFiltersProps) { - const [filterType, setFilterType] = useState(); - - const filterTrigger = ( - - -
- } - variant={"secondary/small"} - shortcut={shortcut} - tooltipTitle={"Filter batches"} - > - Filter - - ); - - return ( - setFilterType(undefined)}> - {(search, setSearch) => ( - setSearch("")} - trigger={filterTrigger} - filterType={filterType} - setFilterType={setFilterType} - {...props} - /> - )} - - ); -} - -function AppliedFilters() { - return ( - <> - - - - ); -} - -type MenuProps = { - searchValue: string; - clearSearchValue: () => void; - trigger: React.ReactNode; - filterType: FilterType | undefined; - setFilterType: (filterType: FilterType | undefined) => void; -} & BatchFiltersProps; - -function Menu(props: MenuProps) { - switch (props.filterType) { - case undefined: - return ; - case "statuses": - return props.setFilterType(undefined)} {...props} />; - case "batch": - return props.setFilterType(undefined)} {...props} />; - } -} - -function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { - const filtered = useMemo(() => { - return filterTypes.filter((item) => { - return item.title.toLowerCase().includes(searchValue.toLowerCase()); - }); - }, [searchValue]); - - return ( - - {trigger} - - - - {filtered.map((type, index) => ( - { - clearSearchValue(); - setFilterType(type.name); - }} - icon={type.icon} - shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} - > - {type.title} - - ))} - - - - ); -} - const statuses = allBatchStatuses.map((status) => ({ title: batchStatusTitle(status), value: status, @@ -219,10 +119,6 @@ function StatusDropdown({ replace({ statuses: values, cursor: undefined, direction: undefined }); }; - const filtered = useMemo(() => { - return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase())); - }, [searchValue]); - return ( {trigger} @@ -237,9 +133,8 @@ function StatusDropdown({ return true; }} > - - {filtered.map((item, index) => ( + {statuses.map((item, index) => ( 0; + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: statusShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={appliedSummary( - statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus)) + + } + /> + } + > + {hasStatuses ? ( + } + value={appliedSummary( + statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus)) + )} + onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+
+
+
+ Status +
)} - onRemove={() => del(["statuses", "cursor", "direction"])} - variant="secondary/small" - /> - + + +
+ Filter by status + +
+
+ } searchValue={search} clearSearchValue={() => setSearch("")} @@ -298,117 +231,83 @@ function AppliedStatusFilter() { ); } -function BatchIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const batchIdValue = value("id"); - - const [batchId, setBatchId] = useState(batchIdValue); - - const apply = useCallback(() => { - clearSearchValue(); - replace({ - cursor: undefined, - direction: undefined, - id: batchId === "" ? undefined : batchId?.toString(), - }); - - setOpen(false); - }, [batchId, replace]); - - let error: string | undefined = undefined; - if (batchId) { - if (!batchId.startsWith("batch_")) { - error = "Batch IDs start with 'batch_'"; - } else if (batchId.length !== 27 && batchId.length !== 31) { - error = "Batch IDs are 27/32 characters long"; - } - } +function validateBatchId(value: string): string | undefined { + if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'"; + if (value.length !== 27 && value.length !== 31) return "Batch IDs are 27 or 31 characters long"; +} +function BatchIdDropdown( + props: Omit +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setBatchId(e.target.value)} - variant="small" - className="w-[29ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } -function AppliedBatchIdFilter() { - const { value, del } = useSearchParams(); - - if (value("id") === undefined) { - return null; - } +const batchIdShortcut = { key: "b" }; +function PermanentBatchIdFilter() { + const { value, del } = useSearchParams(); const batchId = value("id"); + const hasBatchId = batchId !== undefined; + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: batchIdShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={batchId} - onRemove={() => del(["id", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasBatchId ? ( + } + value={batchId} + onRemove={() => del(["id", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Batch ID +
+ )} +
+ +
+ Filter by batch ID + +
+
+
} searchValue={search} clearSearchValue={() => setSearch("")} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 83ebaa0d51b..c02e93a5c6e 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -3,6 +3,7 @@ import { CalendarIcon, ClockIcon, FingerPrintIcon, + PlusIcon, RectangleStackIcon, Squares2X2Icon, TagIcon, @@ -12,9 +13,8 @@ import { Form, useFetcher } from "@remix-run/react"; import { IconBugFilled, IconRotateClockwise2, IconToggleLeft } from "@tabler/icons-react"; import { MachinePresetName } from "@trigger.dev/core/v3"; import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database"; -import { ListFilterIcon } from "lucide-react"; import { matchSorter } from "match-sorter"; -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { MachineDefaultIcon } from "~/assets/icons/MachineIcon"; @@ -28,9 +28,6 @@ import { import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { Badge } from "~/components/primitives/Badge"; import { DateTime } from "~/components/primitives/DateTime"; -import { FormError } from "~/components/primitives/FormError"; -import { Input } from "~/components/primitives/Input"; -import { Label } from "~/components/primitives/Label"; import { MiddleTruncate } from "~/components/primitives/MiddleTruncate"; import { Paragraph } from "~/components/primitives/Paragraph"; import { @@ -59,13 +56,22 @@ import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; +import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags"; import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues"; import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions"; -import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags"; import { Button } from "../../primitives/Buttons"; -import { BulkActionTypeCombo } from "./BulkAction"; -import { appliedSummary, FilterMenuProvider, TimeFilter, timeFilters } from "./SharedFilters"; import { AIFilterInput } from "./AIFilterInput"; +import { BulkActionTypeCombo } from "./BulkAction"; +import { + IdFilterDropdown, + type IdFilterDropdownProps, + appliedSummary, + FilterMenuProvider, + TimeFilter, + timeFilters, +} from "./SharedFilters"; import { allTaskRunStatuses, descriptionForTaskRunStatus, @@ -356,18 +362,23 @@ export function RunsFilters(props: RunFiltersProps) { searchParams.has("errorId"); return ( -
- +
{!props.hideSearch && } + + + - + {hasFilters && ( -
- {searchParams.has("rootOnly") && ( - - )} -
@@ -375,12 +386,6 @@ export function RunsFilters(props: RunFiltersProps) { } const filterTypes = [ - { - name: "statuses", - title: "Status", - icon: , - }, - { name: "tasks", title: "Tasks", icon: }, { name: "tags", title: "Tags", icon: }, { name: "versions", title: "Versions", icon: }, { name: "queues", title: "Queues", icon: }, @@ -403,15 +408,15 @@ function FilterMenu(props: RunFiltersProps) { - +
} variant={"secondary/small"} shortcut={shortcut} - tooltipTitle={"Filter runs"} - className="pr-0.5" + tooltipTitle={"More filters"} + className="pl-1 pr-2" > - <> + More filters ); @@ -431,11 +436,9 @@ function FilterMenu(props: RunFiltersProps) { ); } -function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) { +function AppliedFilters({ bulkActions }: RunFiltersProps) { return ( <> - - @@ -461,10 +464,6 @@ function Menu(props: MenuProps) { switch (props.filterType) { case undefined: return ; - case "statuses": - return props.setFilterType(undefined)} {...props} />; - case "tasks": - return props.setFilterType(undefined)} {...props} />; case "bulk": return props.setFilterType(undefined)} {...props} />; case "tags": @@ -509,7 +508,7 @@ function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: Men icon={type.icon} shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} > - {type.title} + {type.title} ))} @@ -587,28 +586,67 @@ function StatusDropdown({ ); } -function AppliedStatusFilter() { +const statusShortcut = { key: "s" }; + +function PermanentStatusFilter() { const { values, del } = useSearchParams(); const statuses = values("statuses"); - - if (statuses.length === 0 || statuses.every((v) => v === "")) { - return null; - } + const hasStatuses = statuses.length > 0 && !statuses.every((v) => v === ""); + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: statusShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - runStatusTitle(v as TaskRunStatus)))} - onRemove={() => del(["statuses", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasStatuses ? ( + runStatusTitle(v as TaskRunStatus)))} + onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+
+
+
+ Status +
+ )} + + +
+ Filter by status + +
+
+ } searchValue={search} clearSearchValue={() => setSearch("")} @@ -633,9 +671,23 @@ function TasksDropdown({ }) { const { values, replace } = useSearchParams(); - const handleChange = (values: string[]) => { + const handleChange = (newValues: string[]) => { clearSearchValue(); - replace({ tasks: values, cursor: undefined, direction: undefined }); + const previousTasks = values("tasks"); + const wasEmpty = previousTasks.length === 0 || previousTasks.every((v) => v === ""); + const isEmpty = newValues.length === 0 || newValues.every((v) => v === ""); + // empty -> tasks: temporarily force rootOnly off so child runs of the selected + // task are visible. tasks -> empty: drop rootOnly so the toggle reverts to the + // user's saved session preference. Neither writes to the cookie (see loader). + const transitioningToTasks = wasEmpty && !isEmpty; + const transitioningToNoTasks = !wasEmpty && isEmpty; + replace({ + tasks: newValues, + cursor: undefined, + direction: undefined, + ...(transitioningToTasks ? { rootOnly: "false" } : {}), + ...(transitioningToNoTasks ? { rootOnly: undefined } : {}), + }); }; const filtered = useMemo(() => { @@ -664,11 +716,12 @@ function TasksDropdown({ .filter((item) => item.isInLatestDeployment) .map((item) => ( } + className="text-text-bright" > @@ -680,7 +733,7 @@ function TasksDropdown({ .filter((item) => !item.isInLatestDeployment) .map((item) => ( @@ -690,6 +743,7 @@ function TasksDropdown({ /> } + className="text-text-bright" > @@ -702,32 +756,70 @@ function TasksDropdown({ ); } -function AppliedTaskFilter({ possibleTasks }: Pick) { - const { values, del } = useSearchParams(); +const tasksShortcut = { key: "t" }; - if (values("tasks").length === 0 || values("tasks").every((v) => v === "")) { - return null; - } +function PermanentTasksFilter({ possibleTasks }: Pick) { + const { values, del } = useSearchParams(); + const tasks = values("tasks"); + const hasTasks = tasks.length > 0 && !tasks.every((v) => v === ""); + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: tasksShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - { - const task = possibleTasks.find((task) => task.slug === v); - return task ? task.slug : v; - }) + + } + /> + } + > + {hasTasks ? ( + { + const task = possibleTasks.find((task) => task.slug === v); + return task ? task.slug : v; + }) + )} + onRemove={() => del(["tasks", "cursor", "direction", "rootOnly"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ {filterIcon("tasks")} + Tasks +
)} - onRemove={() => del(["tasks", "cursor", "direction"])} - variant="secondary/small" - /> - +
+ +
+ Filter by task + +
+
+
} searchValue={search} clearSearchValue={() => setSearch("")} @@ -922,15 +1014,17 @@ function TagsDropdown({ return true; }} > - ( -
- - {fetcher.state === "loading" && } -
- )} - /> + {(filtered.length > 0 || fetcher.state === "loading" || searchValue.length > 0) && ( + ( +
+ + {fetcher.state === "loading" && } +
+ )} + /> + )} {filtered.length > 0 ? filtered.map((tag, index) => ( @@ -1102,6 +1196,7 @@ function QueuesDropdown({ ) } + className="text-text-bright" > {queue.name} @@ -1187,15 +1282,15 @@ function MachinesDropdown({ return true; }} > - {filtered.map((item, index) => ( - + ))} @@ -1343,7 +1438,12 @@ export function VersionsDropdown({ {filtered.length > 0 ? filtered.map((version) => ( - + } + className="text-text-bright" + > {version.version} {version.isCurrent ? Current : null} @@ -1392,118 +1492,67 @@ function AppliedVersionsFilter() { ); } +const rootOnlyShortcut = { key: "o" }; + function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) { - const { value, values, replace } = useSearchParams(); + const { value, replace } = useSearchParams(); const searchValue = value("rootOnly"); const rootOnly = searchValue !== undefined ? searchValue === "true" : defaultValue; const batchId = value("batchId"); const runId = value("runId"); const scheduleId = value("scheduleId"); - const tasks = values("tasks"); - const disabled = !!batchId || !!runId || !!scheduleId || tasks.length > 0; + const disabled = !!batchId || !!runId || !!scheduleId; return ( - { - replace({ - rootOnly: checked ? "true" : "false", - }); - }} - /> + + }> + { + replace({ + rootOnly: checked ? "true" : "false", + cursor: undefined, + direction: undefined, + }); + }} + /> + + +
+ Toggle root only + +
+
+
); } -function RunIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const runIdValue = value("runId"); - - const [runId, setRunId] = useState(runIdValue); - - const apply = useCallback(() => { - clearSearchValue(); - replace({ - cursor: undefined, - direction: undefined, - runId: runId === "" ? undefined : runId?.toString(), - }); - - setOpen(false); - }, [runId, replace]); - - let error: string | undefined = undefined; - if (runId) { - if (!runId.startsWith("run_")) { - error = "Run IDs start with 'run_'"; - } else if (runId.length !== 25 && runId.length !== 29) { - error = "Run IDs are 25/30 characters long"; - } - } +function validateRunId(value: string): string | undefined { + if (!value.startsWith("run_")) return "Run IDs start with 'run_'"; + if (value.length !== 25 && value.length !== 29) return "Run IDs are 25 or 29 characters long"; +} +function RunIdDropdown( + props: Omit< + IdFilterDropdownProps, + "label" | "placeholder" | "paramKey" | "validate" | "inputWidth" + > +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setRunId(e.target.value)} - variant="small" - className="w-[27ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } @@ -1539,91 +1588,22 @@ function AppliedRunIdFilter() { ); } -function BatchIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const batchIdValue = value("batchId"); - - const [batchId, setBatchId] = useState(batchIdValue); - - const apply = useCallback(() => { - clearSearchValue(); - replace({ - cursor: undefined, - direction: undefined, - batchId: batchId === "" ? undefined : batchId?.toString(), - }); - - setOpen(false); - }, [batchId, replace]); - - let error: string | undefined = undefined; - if (batchId) { - if (!batchId.startsWith("batch_")) { - error = "Batch IDs start with 'batch_'"; - } else if (batchId.length !== 27 && batchId.length !== 31) { - error = "Batch IDs are 27 or 31 characters long"; - } - } +function validateBatchId(value: string): string | undefined { + if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'"; + if (value.length !== 27 && value.length !== 31) return "Batch IDs are 27 or 31 characters long"; +} +function BatchIdDropdown( + props: Omit +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setBatchId(e.target.value)} - variant="small" - className="w-[29ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } @@ -1659,91 +1639,22 @@ function AppliedBatchIdFilter() { ); } -function ScheduleIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const scheduleIdValue = value("scheduleId"); - - const [scheduleId, setScheduleId] = useState(scheduleIdValue); - - const apply = useCallback(() => { - clearSearchValue(); - replace({ - cursor: undefined, - direction: undefined, - scheduleId: scheduleId === "" ? undefined : scheduleId?.toString(), - }); - - setOpen(false); - }, [scheduleId, replace]); - - let error: string | undefined = undefined; - if (scheduleId) { - if (!scheduleId.startsWith("sched")) { - error = "Schedule IDs start with 'sched_'"; - } else if (scheduleId.length !== 27) { - error = "Schedule IDs are 27 characters long"; - } - } +function validateScheduleId(value: string): string | undefined { + if (!value.startsWith("sched_")) return "Schedule IDs start with 'sched_'"; + if (value.length !== 27) return "Schedule IDs are 27 characters long"; +} +function ScheduleIdDropdown( + props: Omit +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setScheduleId(e.target.value)} - variant="small" - className="w-[29ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } @@ -1779,89 +1690,21 @@ function AppliedScheduleIdFilter() { ); } -function ErrorIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const errorIdValue = value("errorId"); - - const [errorId, setErrorId] = useState(errorIdValue); - - const apply = useCallback(() => { - clearSearchValue(); - replace({ - cursor: undefined, - direction: undefined, - errorId: errorId === "" ? undefined : errorId?.toString(), - }); - - setOpen(false); - }, [errorId, replace]); - - let error: string | undefined = undefined; - if (errorId) { - if (!errorId.startsWith("error_")) { - error = "Error IDs start with 'error_'"; - } - } +function validateErrorId(value: string): string | undefined { + if (!value.startsWith("error_")) return "Error IDs start with 'error_'"; +} +function ErrorIdDropdown( + props: Omit +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setErrorId(e.target.value)} - variant="small" - className="w-[29ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ ); } diff --git a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx index 3a30e0cb37e..e0fb819c8d1 100644 --- a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx +++ b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx @@ -1,21 +1,22 @@ -import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import * as Ariakit from "@ariakit/react"; +import { ClockIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { useNavigate } from "@remix-run/react"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { z } from "zod"; -import { Input } from "~/components/primitives/Input"; -import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; -import { useThrottle } from "~/hooks/useThrottle"; -import { Button } from "../../primitives/Buttons"; -import { Paragraph } from "../../primitives/Paragraph"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { SearchInput } from "~/components/primitives/SearchInput"; import { - Select, - SelectContent, - SelectGroup, SelectItem, - SelectTrigger, - SelectValue, -} from "../../primitives/SimpleSelect"; -import { ScheduleTypeCombo } from "./ScheduleType"; + SelectList, + SelectPopover, + SelectProvider, +} from "~/components/primitives/Select"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { Button } from "../../primitives/Buttons"; +import { ScheduleTypeIcon, scheduleTypeName } from "./ScheduleType"; +import { FilterMenuProvider } from "./SharedFilters"; export const ScheduleListFilters = z.object({ page: z.coerce.number().default(1), @@ -29,120 +30,232 @@ export const ScheduleListFilters = z.object({ export type ScheduleListFilters = z.infer; -const All = "ALL"; - type ScheduleFiltersProps = { possibleTasks: string[]; }; export function ScheduleFilters({ possibleTasks }: ScheduleFiltersProps) { - const navigate = useNavigate(); const location = useOptimisticLocation(); const searchParams = new URLSearchParams(location.search); - const { tasks, page, search, type } = ScheduleListFilters.parse( - Object.fromEntries(searchParams.entries()) + const hasFilters = + searchParams.has("tasks") || searchParams.has("search") || searchParams.has("type"); + + return ( +
+ + + + {hasFilters && } +
); +} - const hasFilters = searchParams.has("tasks") || searchParams.has("search"); +function ScheduleSearchInput() { + return ; +} - const handleFilterChange = useCallback((filterType: string, value: string | undefined) => { - if (value) { - searchParams.set(filterType, value); - } else { - searchParams.delete(filterType); - } - searchParams.delete("page"); - navigate(`${location.pathname}?${searchParams.toString()}`); - }, []); +const typeShortcut = { key: "y" }; - const handleTaskChange = useCallback((value: string | typeof All) => { - handleFilterChange("tasks", value === "ALL" ? undefined : value); - }, []); +function PermanentTypeFilter() { + const navigate = useNavigate(); + const location = useOptimisticLocation(); + const searchParams = new URLSearchParams(location.search); + const currentType = searchParams.get("type") ?? undefined; + const triggerRef = useRef(null); - const handleTypeChange = useCallback((value: string | typeof All) => { - handleFilterChange("type", value === "ALL" ? undefined : value); - }, []); + useShortcutKeys({ + shortcut: typeShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); - const handleSearchChange = useThrottle((value: string) => { - handleFilterChange("search", value.length === 0 ? undefined : value); - }, 300); + const handleChange = useCallback( + (value: string | string[]) => { + const selected = Array.isArray(value) ? value[0] : value; + const params = new URLSearchParams(location.search); + if (!selected || selected === "ALL") { + params.delete("type"); + } else { + params.set("type", selected); + } + params.delete("page"); + navigate(`${location.pathname}?${params.toString()}`); + }, + [location, navigate] + ); - const clearFilters = useCallback(() => { - searchParams.delete("page"); - searchParams.delete("enabled"); - searchParams.delete("tasks"); - searchParams.delete("search"); - navigate(`${location.pathname}?${searchParams.toString()}`); - }, []); + const typeLabel = currentType + ? scheduleTypeName(currentType.toUpperCase() as "IMPERATIVE" | "DECLARATIVE") + : "All types"; return ( -
- handleSearchChange(e.target.value)} - /> - - - - - - - - - {hasFilters && ( - + + ))} + + + )} + + ); +} + +function ClearFiltersButton() { + const navigate = useNavigate(); + const location = useOptimisticLocation(); + + const clearFilters = useCallback(() => { + const params = new URLSearchParams(location.search); + params.delete("page"); + params.delete("tasks"); + params.delete("search"); + params.delete("type"); + navigate(`${location.pathname}?${params.toString()}`); + }, [location, navigate]); + + return ( +
+
); } diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index 3e24f601f2a..0bdd7c4ac5f 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -1,5 +1,4 @@ import * as Ariakit from "@ariakit/react"; -import type { RuntimeEnvironment } from "@trigger.dev/database"; import { endOfDay, endOfMonth, @@ -11,20 +10,23 @@ import { subWeeks, } from "date-fns"; import parse from "parse-duration"; -import { startTransition, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { type ReactNode, startTransition, useCallback, useEffect, useRef, useState } from "react"; import simplur from "simplur"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { Callout } from "~/components/primitives/Callout"; import { DateTime } from "~/components/primitives/DateTime"; import { DateTimePicker } from "~/components/primitives/DateTimePicker"; +import { FormError } from "~/components/primitives/FormError"; +import { Header3 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioButtonCircle } from "~/components/primitives/RadioButton"; import { ComboboxProvider, SelectPopover, SelectProvider } from "~/components/primitives/Select"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useSearchParams } from "~/hooks/useSearchParam"; import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { cn } from "~/utils/cn"; import { organizationBillingPath } from "~/utils/pathBuilder"; import { Button, LinkButton } from "../../primitives/Buttons"; @@ -422,11 +424,7 @@ export function TimeFilter({
Filter by time period - +
)} @@ -1005,3 +1003,102 @@ function QuickDateButton({ ); } + +export type IdFilterDropdownProps = { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; + label: string; + placeholder: string; + paramKey: string; + validate?: (value: string) => string | undefined; + inputWidth?: string; +}; + +export function IdFilterDropdown({ + trigger, + clearSearchValue, + onClose, + label, + placeholder, + paramKey, + validate, + inputWidth = "w-[29ch]", +}: IdFilterDropdownProps) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const currentValue = value(paramKey); + + const [inputValue, setInputValue] = useState(currentValue); + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) setInputValue(currentValue); + } + + const apply = () => { + clearSearchValue(); + replace({ + cursor: undefined, + direction: undefined, + [paramKey]: inputValue === "" ? undefined : inputValue?.toString(), + }); + + setOpen(false); + }; + + const error = inputValue ? validate?.(inputValue) : undefined; + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + {label} + + setInputValue(e.target.value)} + variant="small" + className={cn(inputWidth, "font-mono")} + spellCheck={false} + /> + {error ? {error} : null} +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index fbede0e7cec..346fd25eee2 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -31,7 +31,7 @@ import { type NextRunListItem, } from "~/presenters/v3/NextRunListPresenter.server"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; -import { docsPath, v3RunSpanPath, v3TestPath,v3TestTaskPath } 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"; @@ -102,7 +102,7 @@ export function TaskRunsTable({ } const search = params.toString(); /** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */ - const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search); + const tableStateParam = disableAdjacentRows ? "" : encodeURIComponent(search); const showCompute = isManagedCloud; @@ -162,6 +162,7 @@ export function TaskRunsTable({ Task Version {filterableTaskRunStatuses.map((status) => ( @@ -185,6 +186,7 @@ export function TaskRunsTable({ Started
@@ -319,9 +321,16 @@ export function TaskRunsTable({ if (tableStateParam) { searchParams.set("tableState", tableStateParam); } - const path = v3RunSpanPath(organization, project, run.environment, run, { - spanId: run.spanId, - }, searchParams); + const path = v3RunSpanPath( + organization, + project, + run.environment, + run, + { + spanId: run.spanId, + }, + searchParams + ); return ( {allowSelection && ( @@ -427,7 +436,11 @@ export function TaskRunsTable({ - {run.isTest ? : "–"} + {run.isTest ? ( + + ) : ( + "–" + )} {run.createdAt ? : "–"} @@ -449,7 +462,7 @@ export function TaskRunsTable({ {isLoading && ( Loading… @@ -592,7 +605,9 @@ function BlankState({ isLoading, filters }: Pick or - + Run a test
diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx index ae416394147..3868a496d79 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -1,28 +1,24 @@ import * as Ariakit from "@ariakit/react"; -import { CalendarIcon, FingerPrintIcon, TagIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { FingerPrintIcon, TagIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { Form, useFetcher } from "@remix-run/react"; import { WaitpointTokenStatus, waitpointTokenStatuses } from "@trigger.dev/core/v3"; -import { ListChecks, ListFilterIcon } from "lucide-react"; +import { ListChecks } from "lucide-react"; import { matchSorter } from "match-sorter"; -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import { StatusIcon } from "~/assets/icons/StatusIcon"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { Button } from "~/components/primitives/Buttons"; -import { FormError } from "~/components/primitives/FormError"; -import { Input } from "~/components/primitives/Input"; -import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { ComboBox, - SelectButtonItem, SelectItem, SelectList, SelectPopover, SelectProvider, - SelectTrigger, shortcutFromIndex, } from "~/components/primitives/Select"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { Spinner } from "~/components/primitives/Spinner"; import { Tooltip, @@ -35,8 +31,15 @@ import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { type loader as tagsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags"; -import { TimeFilter, appliedSummary, FilterMenuProvider } from "./SharedFilters"; +import { + IdFilterDropdown, + type IdFilterDropdownProps, + appliedSummary, + FilterMenuProvider, + TimeFilter, +} from "./SharedFilters"; import { WaitpointStatusCombo, waitpointStatusTitle } from "./WaitpointStatus"; export const WaitpointSearchParamsSchema = z.object({ @@ -66,136 +69,33 @@ export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) { searchParams.has("statuses") || searchParams.has("tags") || searchParams.has("id") || - searchParams.has("idempotencyKey"); + searchParams.has("idempotencyKey") || + searchParams.has("period") || + searchParams.has("from") || + searchParams.has("to"); return ( -
- - - +
+ + + + + {hasFilters && ( -
-
); } -const filterTypes = [ - { - name: "statuses", - title: "Status", - icon: , - }, - { name: "tags", title: "Tags", icon: }, - { name: "id", title: "Waitpoint ID", icon: }, - { name: "idempotencyKey", title: "Idempotency key", icon: }, -] as const; - -type FilterType = (typeof filterTypes)[number]["name"]; - -const shortcut = { key: "f" }; - -function FilterMenu() { - const [filterType, setFilterType] = useState(); - - const filterTrigger = ( - - -
- } - variant={"secondary/small"} - shortcut={shortcut} - tooltipTitle={"Filter runs"} - > - Filter - - ); - - return ( - setFilterType(undefined)}> - {(search, setSearch) => ( - setSearch("")} - trigger={filterTrigger} - filterType={filterType} - setFilterType={setFilterType} - /> - )} - - ); -} - -function AppliedFilters() { - return ( - <> - - - - - - ); -} - -type MenuProps = { - searchValue: string; - clearSearchValue: () => void; - trigger: React.ReactNode; - filterType: FilterType | undefined; - setFilterType: (filterType: FilterType | undefined) => void; -}; - -function Menu(props: MenuProps) { - switch (props.filterType) { - case undefined: - return ; - case "statuses": - return props.setFilterType(undefined)} {...props} />; - case "tags": - return props.setFilterType(undefined)} {...props} />; - case "id": - return props.setFilterType(undefined)} {...props} />; - case "idempotencyKey": - return props.setFilterType(undefined)} {...props} />; - } -} - -function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { - const filtered = useMemo(() => { - return filterTypes.filter((item) => { - return item.title.toLowerCase().includes(searchValue.toLowerCase()); - }); - }, [searchValue]); - - return ( - - {trigger} - - - - {filtered.map((type, index) => ( - { - clearSearchValue(); - setFilterType(type.name); - }} - icon={type.icon} - shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} - > - {type.title} - - ))} - - - - ); -} - const statuses = waitpointTokenStatuses.map((status) => ({ title: waitpointStatusTitle(status), value: status, @@ -237,7 +137,6 @@ function StatusDropdown({ return true; }} > - {filtered.map((item, index) => { return ( @@ -249,7 +148,7 @@ function StatusDropdown({ - + @@ -267,30 +166,68 @@ function StatusDropdown({ ); } -function AppliedStatusFilter() { - const { values, del } = useSearchParams(); - const statuses = values("statuses"); +const statusShortcut = { key: "s" }; - if (statuses.length === 0) { - return null; - } +function PermanentStatusFilter() { + const { values, del } = useSearchParams(); + const selectedStatuses = values("statuses"); + const hasStatuses = selectedStatuses.length > 0 && !selectedStatuses.every((v) => v === ""); + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: statusShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={appliedSummary( - statuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus)) + + } + /> + } + > + {hasStatuses ? ( + } + value={appliedSummary( + selectedStatuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus)) + )} + onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+
+
+
+ Status +
)} - onRemove={() => del(["statuses", "cursor", "direction"])} - variant="secondary/small" - /> - + + +
+ Filter by status + +
+
+ } searchValue={search} clearSearchValue={() => setSearch("")} @@ -366,19 +303,21 @@ function TagsDropdown({ return true; }} > - ( -
- - {fetcher.state === "loading" && } -
- )} - /> + {!(filtered.length === 0 && fetcher.state !== "loading" && searchValue === "") && ( + ( +
+ + {fetcher.state === "loading" && } +
+ )} + /> + )} {filtered.length > 0 - ? filtered.map((tag, index) => ( - + ? filtered.map((tag) => ( + {tag} )) @@ -392,29 +331,64 @@ function TagsDropdown({ ); } -function AppliedTagsFilter() { - const { values, del } = useSearchParams(); +const tagsShortcut = { key: "g" }; +function PermanentTagsFilter() { + const { values, del } = useSearchParams(); const tags = values("tags"); - - if (tags.length === 0) { - return null; - } + const hasTags = tags.length > 0 && !tags.every((v) => v === ""); + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: tagsShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={appliedSummary(values("tags"))} - onRemove={() => del(["tags", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasTags ? ( + } + value={appliedSummary(tags)} + onRemove={() => del(["tags", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Tags +
+ )} +
+ +
+ Filter by tags + +
+
+
} searchValue={search} clearSearchValue={() => setSearch("")} @@ -424,117 +398,82 @@ function AppliedTagsFilter() { ); } -function WaitpointIdDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const idValue = value("id"); - - const [id, setId] = useState(idValue); - - const apply = useCallback(() => { - clearSearchValue(); - replace({ - cursor: undefined, - direction: undefined, - id: id === "" ? undefined : id?.toString(), - }); - - setOpen(false); - }, [id, replace]); - - let error: string | undefined = undefined; - if (id) { - if (!id.startsWith("waitpoint_")) { - error = "Waitpoint IDs start with 'waitpoint_'"; - } else if (id.length !== 35) { - error = "Waitpoint IDs are 35 characters long"; - } - } - +function WaitpointIdDropdown( + props: Omit +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setId(e.target.value)} - variant="small" - className="w-[27ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ { + if (!v.startsWith("waitpoint_")) return "Waitpoint IDs start with 'waitpoint_'"; + if (v.length !== 35) return "Waitpoint IDs are 35 characters long"; + return undefined; + }} + /> ); } -function AppliedWaitpointIdFilter() { - const { value, del } = useSearchParams(); - - if (value("id") === undefined) { - return null; - } +const waitpointIdShortcut = { key: "w" }; +function PermanentWaitpointIdFilter() { + const { value, del } = useSearchParams(); const id = value("id"); + const hasId = id !== undefined; + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: waitpointIdShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={id} - onRemove={() => del(["id", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasId ? ( + } + value={id} + onRemove={() => del(["id", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Waitpoint ID +
+ )} +
+ +
+ Filter by waitpoint ID + +
+
+
} searchValue={search} clearSearchValue={() => setSearch("")} @@ -544,115 +483,81 @@ function AppliedWaitpointIdFilter() { ); } -function IdempotencyKeyDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const idValue = value("idempotencyKey"); - - const [idempotencyKey, setIdempotencyKey] = useState(idValue); - - const apply = useCallback(() => { - clearSearchValue(); - replace({ - cursor: undefined, - direction: undefined, - idempotencyKey: idempotencyKey === "" ? undefined : idempotencyKey?.toString(), - }); - - setOpen(false); - }, [idempotencyKey, replace]); - - let error: string | undefined = undefined; - if (idempotencyKey) { - if (idempotencyKey.length === 0) { - error = "Idempotency keys need to be at least 1 character in length"; - } - } - +function IdempotencyKeyDropdown( + props: Omit +) { return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - className="max-w-[min(32ch,var(--popover-available-width))]" - > -
-
- - setIdempotencyKey(e.target.value)} - variant="small" - className="w-[27ch] font-mono" - spellCheck={false} - /> - {error ? {error} : null} -
-
- - -
-
-
-
+ { + if (v.length === 0) return "Idempotency keys need to be at least 1 character in length"; + return undefined; + }} + /> ); } -function AppliedIdempotencyKeyFilter() { - const { value, del } = useSearchParams(); - - if (value("idempotencyKey") === undefined) { - return null; - } +const idempotencyKeyShortcut = { key: "i" }; +function PermanentIdempotencyKeyFilter() { + const { value, del } = useSearchParams(); const idempotencyKey = value("idempotencyKey"); + const hasKey = idempotencyKey !== undefined; + const triggerRef = useRef(null); + + useShortcutKeys({ + shortcut: idempotencyKeyShortcut, + action: (e) => { + e.preventDefault(); + e.stopPropagation(); + triggerRef.current?.click(); + }, + }); return ( {(search, setSearch) => ( }> - } - value={idempotencyKey} - onRemove={() => del(["idempotencyKey", "cursor", "direction"])} - variant="secondary/small" - /> - + + } + /> + } + > + {hasKey ? ( + } + value={idempotencyKey} + onRemove={() => del(["idempotencyKey", "cursor", "direction"])} + variant="secondary/small" + className="pl-1" + /> + ) : ( +
+ + Idempotency key +
+ )} +
+ +
+ Filter by idempotency key + +
+
+
} searchValue={search} clearSearchValue={() => setSearch("")} diff --git a/apps/webapp/app/hooks/useFuzzyFilter.ts b/apps/webapp/app/hooks/useFuzzyFilter.ts index 3f0797179f2..ff4504ce8c2 100644 --- a/apps/webapp/app/hooks/useFuzzyFilter.ts +++ b/apps/webapp/app/hooks/useFuzzyFilter.ts @@ -10,8 +10,9 @@ import { matchSorter } from "match-sorter"; * @param params.items - Array of objects to filter * @param params.keys - Array of object keys to perform the fuzzy search on (supports dot-notation for nested properties) * @returns An object containing: - * - filterText: The current filter text - * - setFilterText: Function to update the filter text + * - filterText: The current filter text (the controlled value if provided, otherwise the internal state) + * - setFilterText: Updates the internal filter text. No-op when `filterText` is provided + * (controlled mode) β€” the parent owns the value in that case. * - filteredItems: The filtered array of items based on the current filter text * * @example @@ -26,11 +27,15 @@ import { matchSorter } from "match-sorter"; export function useFuzzyFilter({ items, keys, + filterText: controlledFilterText, }: { items: T[]; keys: (Extract | (string & {}))[]; + /** Optional controlled filter text. If provided, internal state is ignored. */ + filterText?: string; }) { - const [filterText, setFilterText] = useState(""); + const [internalFilterText, setInternalFilterText] = useState(""); + const filterText = controlledFilterText ?? internalFilterText; const filteredItems = useMemo(() => { const filterTerms = filterText @@ -43,7 +48,6 @@ export function useFuzzyFilter({ return items; } - // sort by the score of the first term return filterTerms.reduceRight( (results, term) => matchSorter(results, term, { @@ -55,7 +59,7 @@ export function useFuzzyFilter({ return { filterText, - setFilterText, + setFilterText: setInternalFilterText, filteredItems, }; } diff --git a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts index 2ec34614533..a6374c60be2 100644 --- a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts +++ b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts @@ -216,7 +216,7 @@ const overviewDashboard: BuiltInDashboard = { const llmDashboard: BuiltInDashboard = { key: "llm", - title: "AI Metrics", + title: "AI metrics", filters: ["tasks", "models", "prompts", "operations", "providers"], layout: { version: "1", diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 2cf8b844a9e..d61c357e02e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -5,8 +5,6 @@ import { ChevronUpIcon, ExclamationTriangleIcon, LightBulbIcon, - MagnifyingGlassIcon, - XMarkIcon, UserPlusIcon, VideoCameraIcon, } from "@heroicons/react/20/solid"; @@ -38,7 +36,6 @@ import { Callout } from "~/components/primitives/Callout"; import { formatDateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "~/components/primitives/Dialog"; import { Header2, Header3 } from "~/components/primitives/Headers"; -import { Input } from "~/components/primitives/Input"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { PopoverMenuItem } from "~/components/primitives/Popover"; @@ -50,6 +47,7 @@ import { ResizablePanelGroup, collapsibleHandleClassName, } from "~/components/primitives/Resizable"; +import { SearchInput } from "~/components/primitives/SearchInput"; import { Spinner } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; import { @@ -75,6 +73,7 @@ import { useEventSource } from "~/hooks/useEventSource"; import { useFuzzyFilter } from "~/hooks/useFuzzyFilter"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { @@ -88,7 +87,6 @@ import { uiPreferencesStorage, } from "~/services/preferences/uiPreferences.server"; import { requireUserId } from "~/services/session.server"; -import { motion } from "framer-motion"; import { cn } from "~/utils/cn"; import { docsPath, @@ -176,9 +174,11 @@ export default function Page() { const environment = useEnvironment(); const { tasks, activity, runningStats, durations, usefulLinksPreference } = useTypedLoaderData(); - const { filterText, setFilterText, filteredItems } = useFuzzyFilter({ + const { value } = useSearchParams(); + const { filteredItems } = useFuzzyFilter({ items: tasks, keys: ["slug", "filePath", "triggerSource"], + filterText: value("search") ?? "", }); const hasTasks = tasks.length > 0; @@ -244,16 +244,12 @@ export default function Page() { {tasks.length === 0 ? : null}
- - {!showUsefulLinks && ( + + {!showUsefulLinks && ( - ) : undefined - } - /> - - ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx index eaa040c4081..17dcfbc4619 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx @@ -30,6 +30,7 @@ import { TableHeader, TableHeaderCell, TableRow, + CopyableTableCell, } from "~/components/primitives/Table"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { BatchFilters, BatchListFilters } from "~/components/runs/v3/BatchFilters"; @@ -234,9 +235,9 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) { return ( - + {batch.friendlyId} - + {batch.batchVersion === "v1" ? ( @@ -286,7 +287,7 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) { {isLoading && ( Loading… diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index c7d54d1842e..39db1d96f3a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -132,10 +132,7 @@ const PurchaseSchema = z.discriminatedUnion("action", [ }), z.object({ action: z.literal("quota-increase"), - amount: z.coerce - .number() - .int("Must be a whole number") - .min(1, "Amount must be greater than 0"), + amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"), }), ]); @@ -329,23 +326,16 @@ export default function Page() { ) : ( <> -
+
-
- -
+
-
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" - )} - > +
@@ -438,14 +428,6 @@ export default function Page() { )}
-
1 && "justify-end border-t border-grid-dimmed px-2 py-3" - )} - > - -
@@ -545,12 +527,12 @@ export function BranchFilters() { return (
- +
); @@ -673,7 +655,13 @@ function PurchaseBranchesModal({ const [open, setOpen] = useState(false); useEffect(() => { const data = fetcher.data; - if (fetcher.state === "idle" && data !== null && typeof data === "object" && "ok" in data && data.ok) { + if ( + fetcher.state === "idle" && + data !== null && + typeof data === "object" && + "ok" in data && + data.ok + ) { setOpen(false); } }, [fetcher.state, fetcher.data]); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx index cd358b7e67d..283be35e50b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx @@ -1,6 +1,8 @@ +import { XMarkIcon } from "@heroicons/react/20/solid"; import { type LoaderFunctionArgs } from "@remix-run/node"; +import { Form } from "@remix-run/react"; import type { TaskTriggerSource } from "@trigger.dev/database"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import ReactGridLayout from "react-grid-layout"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -15,7 +17,8 @@ import { QueuesFilter } from "~/components/metrics/QueuesFilter"; import { ScopeFilter } from "~/components/metrics/ScopeFilter"; import { TitleWidget } from "~/components/metrics/TitleWidget"; import { CreateDashboardPageButton } from "~/components/navigation/DashboardDialogs"; -import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Button } from "~/components/primitives/Buttons"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { TimeFilter } from "~/components/runs/v3/SharedFilters"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; @@ -143,13 +146,6 @@ export default function Page() { - - -
@@ -165,6 +161,14 @@ export default function Page() { possiblePrompts={possiblePrompts} possibleOperations={possibleOperations} possibleProviders={possibleProviders} + filterAccessories={ + + } />
@@ -188,6 +192,7 @@ export function MetricDashboard({ onRenameWidget, onDeleteWidget, onDuplicateWidget, + filterAccessories, }: { /** The layout items (positions/sizes) - fully controlled from parent */ layout: LayoutItem[]; @@ -212,6 +217,7 @@ export function MetricDashboard({ onRenameWidget?: (widgetId: string, newTitle: string) => void; onDeleteWidget?: (widgetId: string) => void; onDuplicateWidget?: (widgetId: string, widget: WidgetData) => void; + filterAccessories?: ReactNode; }) { const { value, values } = useSearchParams(); const { width, containerRef, mounted } = useContainerWidth(); @@ -239,6 +245,13 @@ export function MetricDashboard({ const providers = values("providers").filter((v) => v !== ""); const activeFilters = filterConfig ?? ["tasks", "queues"]; + const hasAppliedFilters = + tasks.length > 0 || + queues.length > 0 || + models.length > 0 || + prompts.length > 0 || + operations.length > 0 || + providers.length > 0; const handleLayoutChange = useCallback( (newLayout: readonly LayoutItem[]) => { @@ -263,31 +276,48 @@ export function MetricDashboard({ return (
-
- - {activeFilters.includes("tasks") && ( - - )} - {activeFilters.includes("queues") && } - {activeFilters.includes("models") && ( - - )} - {activeFilters.includes("prompts") && ( - - )} - {activeFilters.includes("operations") && ( - - )} - {activeFilters.includes("providers") && ( - +
+
+ + {activeFilters.includes("tasks") && ( + + )} + {activeFilters.includes("queues") && } + {activeFilters.includes("models") && ( + + )} + {activeFilters.includes("prompts") && ( + + )} + {activeFilters.includes("operations") && ( + + )} + {activeFilters.includes("providers") && ( + + )} + + {hasAppliedFilters && ( +
+
+ {filterAccessories && ( +
{filterAccessories}
)} -
{ - const actionMessages: Record = { - add: "Failed to add widget", - update: "Failed to update widget", - delete: "Failed to delete widget", - duplicate: "Failed to duplicate widget", - layout: "Failed to save layout", - }; - - const message = actionMessages[action] || "Failed to save changes"; - - toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" }); - }, [toast]); + const handleSyncError = useCallback( + (error: Error, action: string) => { + const actionMessages: Record = { + add: "Failed to add widget", + update: "Failed to update widget", + delete: "Failed to delete widget", + duplicate: "Failed to duplicate widget", + layout: "Failed to save layout", + }; + + const message = actionMessages[action] || "Failed to save changes"; + + toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" }); + }, + [toast] + ); // Add title dialog state const [showAddTitleDialog, setShowAddTitleDialog] = useState(false); @@ -349,88 +352,99 @@ export default function Page() { })() : null; + const dashboardMenu = ( + + + +
+ + +
+
+
+ ); + + const filterAccessories = ( +
+ {widgetIsAtLimit ? ( + <> + + + + + + You've exceeded your widget limit + + You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard. + + + {widgetCanUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} + + + + + + ) : ( + <> + + + + )} + {dashboardMenu} +
+ ); + return ( - - {totalWidgetCount > 0 && - (widgetIsAtLimit ? ( - <> - - - - - - You've exceeded your widget limit - - You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this - dashboard. - - - {widgetCanUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" - /> - )} - - - - - - ) : ( - <> - - - - ))} - - - -
- - -
-
-
-
+ {totalWidgetCount === 0 && dashboardMenu}
@@ -471,6 +485,7 @@ export default function Page() { onRenameWidget={actions.renameWidget} onDeleteWidget={actions.deleteWidget} onDuplicateWidget={actions.duplicateWidget} + filterAccessories={filterAccessories} /> )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index f7f91f33274..9ab76ed49b7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -4,17 +4,20 @@ import { BookOpenIcon, InformationCircleIcon, LockClosedIcon, - MagnifyingGlassIcon, PencilSquareIcon, PlusIcon, TrashIcon, } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation, useRevalidator } from "@remix-run/react"; import { - type ActionFunctionArgs, - type LoaderFunctionArgs, - json, -} from "@remix-run/server-runtime"; + Form, + type MetaFunction, + Outlet, + useActionData, + useFetcher, + useNavigation, + useRevalidator, +} from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { useEffect, useMemo, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -30,9 +33,9 @@ import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; -import { InfoPanel } from "~/components/primitives/InfoPanel"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; +import { SearchInput } from "~/components/primitives/SearchInput"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -50,6 +53,7 @@ import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useFuzzyFilter } from "~/hooks/useFuzzyFilter"; +import { useSearchParams } from "~/hooks/useSearchParam"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; @@ -76,7 +80,11 @@ import { UserAvatar } from "~/components/UserProfilePhoto"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; import { fromPromise } from "neverthrow"; import { logger } from "~/services/logger.server"; -import { shouldSyncEnvVar, isPullEnvVarsEnabledForEnvironment, type TriggerEnvironmentType } from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { + shouldSyncEnvVar, + isPullEnvVarsEnabledForEnvironment, + type TriggerEnvironmentType, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; export const meta: MetaFunction = () => { return [ @@ -92,10 +100,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { try { const presenter = new EnvironmentVariablesPresenter(); - const { environmentVariables, environments, hasStaging, vercelIntegration } = await presenter.call({ - userId, - projectSlug: projectParam, - }); + const { environmentVariables, environments, hasStaging, vercelIntegration } = + await presenter.call({ + userId, + projectSlug: projectParam, + }); return typedjson({ environmentVariables, @@ -123,7 +132,9 @@ const schema = z.discriminatedUnion("action", [ action: z.literal("update-vercel-sync"), key: z.string(), environmentType: z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]), - syncEnabled: z.union([z.literal("true"), z.literal("false")]).transform((val) => val === "true"), + syncEnabled: z + .union([z.literal("true"), z.literal("false")]) + .transform((val) => val === "true"), }), ]); @@ -249,15 +260,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { export default function Page() { const [revealAll, setRevealAll] = useState(false); - const { environmentVariables, environments, vercelIntegration } = useTypedLoaderData(); + const { environmentVariables, environments, vercelIntegration } = + useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { filterText, setFilterText, filteredItems } = - useFuzzyFilter({ - items: environmentVariables, - keys: ["key", "value", "environment.type", "environment.branchName"], - }); + const { value } = useSearchParams(); + const urlSearch = value("search") ?? ""; + const { setFilterText, filteredItems } = useFuzzyFilter({ + items: environmentVariables, + keys: ["key", "value", "environment.type", "environment.branchName"], + }); + + useEffect(() => { + setFilterText(urlSearch); + }, [urlSearch, setFilterText]); // Add isFirst and isLast to each environment variable // They're set based on if they're the first or last time that `key` has been seen in the list @@ -314,18 +331,10 @@ export default function Page() {
{environmentVariables.length > 0 && (
- setFilterText(e.target.value)} - autoFocus - /> -
+ +
setRevealAll(e.valueOf())} @@ -351,7 +360,16 @@ export default function Page() { Value - Environment + + Environment + + + } + content="Dev environment variables specified here will be overridden by ones in your .env file when running locally." + className="max-w-60" + /> {vercelIntegration?.enabled && ( @@ -458,10 +476,11 @@ export default function Page() { /> {variable.updatedByUser.name}
- ) : (variable.lastUpdatedBy?.type === "integration" && variable.lastUpdatedBy?.integration === 'vercel' ) ? ( + ) : variable.lastUpdatedBy?.type === "integration" && + variable.lastUpdatedBy?.integration === "vercel" ? (
- - + + {variable.lastUpdatedBy.integration}
@@ -475,7 +494,7 @@ export default function Page() { - -
- - Dev environment variables specified here will be overridden by ones in your .env file - when running locally. - -
@@ -561,9 +573,12 @@ function EditEnvironmentVariablePanel({ return ( - + Edit environment variable @@ -715,14 +730,7 @@ function VercelSyncCheckbox({ if (!pullEnvVarsEnabledForEnv) { return ( {}} - /> - } + button={ {}} />} content="Enable 'Pull env vars before build' for this environment in Vercel settings." /> ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx index 964775bd88c..80392208886 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx @@ -290,6 +290,8 @@ const errorStatusOptions = [ const statusIcon = ; const statusShortcut = { key: "s" }; +const timeShortcut = { key: "d" }; +const alertsShortcut = { key: "c" }; function StatusFilter() { const { values, del } = useSearchParams(); @@ -306,8 +308,9 @@ function StatusFilter() { variant="secondary/small" shortcut={statusShortcut} tooltipTitle="Filter by status" + className="pl-1.5" > - Status + Status } searchValue={search} @@ -416,9 +419,10 @@ function FiltersBar({ return (
-
+
{list ? ( <> + @@ -426,43 +430,53 @@ function FiltersBar({ defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} labelName="Occurred" + shortcut={timeShortcut} /> - {hasFilters && ( -
+
-
+
Configure alerts… diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index af3cc30a246..28e49a014ff 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -260,20 +260,22 @@ function FiltersBar({ return (
-
+
{list ? ( <> + - {hasFilters && ( -
+
-
+
- + + }> + + + +
+ Toggle all details + +
+
+
); @@ -585,13 +690,13 @@ function CompareDialog({ return ( - - + + Compare models {rows.length > 0 ? (
- +
Metric @@ -1116,7 +1221,7 @@ export default function ModelsPage() { -
+
History diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index fdc0c505aec..debab4683ce 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -3,7 +3,6 @@ import { ArrowUpCircleIcon, BookOpenIcon, ChatBubbleLeftEllipsisIcon, - MagnifyingGlassIcon, PauseIcon, PlayIcon, RectangleStackIcon, @@ -32,6 +31,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components import { FormButtons } from "~/components/primitives/FormButtons"; import { Header3 } from "~/components/primitives/Headers"; import { Input } from "~/components/primitives/Input"; +import { SearchInput } from "~/components/primitives/SearchInput"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -59,7 +59,6 @@ import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { useThrottle } from "~/hooks/useThrottle"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; @@ -436,13 +435,8 @@ export default function Page() {
{success ? ( -
1 && "grid-rows-[auto_1fr_auto]" - )} - > -
+
+
- {pagination.totalPages > 1 && ( -
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" - )} - > -
1 && - "justify-end border-t border-grid-dimmed px-2 py-3" - )} - > - -
-
- )}
) : (
@@ -1074,39 +1047,7 @@ export function isEnvironmentPauseResumeFormSubmission( } export function QueueFilters() { - const [searchParams, setSearchParams] = useSearchParams(); - - const handleSearchChange = useThrottle((value: string) => { - if (value) { - setSearchParams((prev) => { - prev.set("query", value); - prev.delete("page"); - return prev; - }); - } else { - setSearchParams((prev) => { - prev.delete("query"); - prev.delete("page"); - return prev; - }); - } - }, 300); - - const search = searchParams.get("query") ?? ""; - - return ( -
- handleSearchChange(e.target.value)} - /> -
- ); + return ; } function BurstFactorTooltip({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index ca7e8b7b0c1..f555f98171e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -94,8 +94,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ...filters, }); - const session = await setRootOnlyFilterPreference(filters.rootOnly, request); - const cookieValue = await uiPreferencesStorage.commitSession(session); + // Only persist rootOnly when no tasks are filtered. While a task filter is active, + // the toggle's URL value can be a temporary auto-flip (or a user override scoped to + // the current task filter), and we don't want either bleeding into the saved + // session preference. Clearing the task filter restores the saved preference. + const shouldPersistRootOnly = !filters.tasks || filters.tasks.length === 0; + const headers = shouldPersistRootOnly + ? { + "Set-Cookie": await uiPreferencesStorage.commitSession( + await setRootOnlyFilterPreference(filters.rootOnly, request) + ), + } + : undefined; return typeddefer( { @@ -103,11 +113,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { rootOnlyDefault: filters.rootOnly, filters, }, - { - headers: { - "Set-Cookie": cookieValue, - }, - } + headers ? { headers } : undefined ); }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx index 769aa10cd75..b3181eefa36 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx @@ -151,50 +151,6 @@ export default function Page() { > Schedules docs - - {limits.used >= limits.limit ? ( - - - - - - You've exceeded your limit - - You've used {limits.used}/{limits.limit} of your schedules. - - - {canUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" - /> - )} - - - - ) : ( - - New schedule - - )} @@ -219,6 +175,54 @@ export default function Page() { totalPages={totalPages} showPageNumbers={false} /> + {limits.used >= limits.limit ? ( + + + + + + You've exceeded your limit + + You've used {limits.used}/{limits.limit} of your schedules. + + + {canUpgrade ? ( + + Upgrade + + ) : ( + Request more + } + defaultValue="help" + /> + )} + + + + ) : ( + + New schedule + + )}
@@ -416,58 +420,59 @@ function SchedulesTable({ }`; const isSelected = scheduleParam === schedule.friendlyId; const cellClass = schedule.active ? "" : "opacity-50"; + const selectedActionClass = isSelected ? "text-text-bright" : undefined; return ( - + {schedule.friendlyId} - + {schedule.taskIdentifier} - + - + {schedule.type === "IMPERATIVE" ? schedule.externalId ? schedule.externalId : "–" : "N/A"} - + {schedule.cron} - + {schedule.cronDescription} - + {schedule.timezone} - + - + {schedule.lastRun ? ( ) : ( "–" )} - + {schedule.type === "IMPERATIVE" ? schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–" : "N/A"} - +
{schedule.environments.map((env) => ( ))}
- + {schedule.type === "IMPERATIVE" ? ( ) : ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx index 9bd9443e95a..ea802850c35 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx @@ -138,8 +138,9 @@ function TaskSelector({
- {(pagination.next || pagination.previous) && ( -
- -
- )}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 1e44dbdc00b..90a81a5c2c3 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -403,7 +403,7 @@ export function UpsertScheduleForm({
Cancel diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index 4f097f61ce2..c8bb6264b4e 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -295,8 +295,9 @@ export async function convertRunListInputOptionsToFilterRunsOptions( convertedOptions.runId = options.runId.map((r) => RunId.toFriendlyId(r)); } - // Show all runs if we are filtering by batchId or runId - if (options.batchId || options.runId?.length || options.scheduleId || options.tasks?.length) { + // batchId/runId/scheduleId target specific runs, so rootOnly is meaningless and forced off. + // tasks is intentionally excluded so rootOnly can narrow a task filter to root runs only. + if (options.batchId || options.runId?.length || options.scheduleId) { convertedOptions.rootOnly = false; } From ead1e5a53d62a6c27aaafc3897307cdb1220b64b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 9 May 2026 08:59:40 +0100 Subject: [PATCH 014/491] feat(webapp): reload LLM pricing registry on Redis pub/sub (#3534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a Redis pub/sub reload path to the webapp's in-memory LLM pricing registry. When enabled on a process, the registry reloads from the database whenever a publish lands on the configured channel β€” instead of waiting for the existing 5-minute interval. Lets pricing/model changes propagate to cost enrichment within seconds. Subscription is **off by default** and opt-in per process. Only OTel-ingesting services need real-time freshness; dashboard and worker services run fine on the periodic interval and shouldn't pile onto each publish with a full-table reload. ## Design When `LLM_PRICING_RELOAD_PUBSUB_ENABLED=true`, subscribes via `createRedisClient` against `COMMON_WORKER_REDIS_*` and listens on `LLM_PRICING_RELOAD_CHANNEL` (default `llm-registry:reload`). The 5-minute periodic reload stays as a backstop, and a SIGTERM/SIGINT handler closes the subscription cleanly. The publisher side lives outside this PR β€” any process running in the same Redis namespace can trigger a reload by `PUBLISH llm-registry:reload `. Includes a `.server-changes/` note for the changelog. ### Debounced reload Bursts of publishes are coalesced. The first publish schedules a reload at T+`LLM_PRICING_RELOAD_DEBOUNCE_MS` (default 1s); subsequent publishes during that window are no-ops because the trailing reload picks up everything when it queries the DB. Bounds reload rate to at most 1 per debounce window regardless of publisher chattiness, so a runaway upstream publisher can't fan out into a flood of full-table-scan reloads. ## Test plan - [ ] With `LLM_PRICING_RELOAD_PUBSUB_ENABLED=false` (default): `redis-cli PUBSUB NUMSUB llm-registry:reload` returns `0` while the webapp is up - [ ] With it set to `true`: returns `>= 1` - [ ] `redis-cli PUBLISH llm-registry:reload test` returns `1` (one subscriber received) on a subscribed process - [ ] Mutate an `LlmModel` row externally, publish on the channel, observe the registry's match() picks up the change without waiting for the 5-min tick - [ ] Publish 100x in rapid succession; confirm only one reload fires within the debounce window --- .../llm-pricing-registry-reload-channel.md | 6 ++ apps/webapp/app/env.server.ts | 8 ++ .../app/v3/llmPricingRegistry.server.ts | 73 +++++++++++++++++-- 3 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 .server-changes/llm-pricing-registry-reload-channel.md diff --git a/.server-changes/llm-pricing-registry-reload-channel.md b/.server-changes/llm-pricing-registry-reload-channel.md new file mode 100644 index 00000000000..ec1daad0a31 --- /dev/null +++ b/.server-changes/llm-pricing-registry-reload-channel.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +The LLM pricing registry now reloads from the database whenever a publish lands on `LLM_PRICING_RELOAD_CHANNEL` on the worker Redis, instead of waiting for the next 5-minute interval. LLM model and pricing changes reflect in cost enrichment within seconds. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 7588ebb2b38..e530182bff8 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1427,6 +1427,14 @@ const EnvironmentSchema = z // LLM cost tracking LLM_COST_TRACKING_ENABLED: BoolEnv.default(true), LLM_PRICING_RELOAD_INTERVAL_MS: z.coerce.number().int().default(5 * 60 * 1000), // 5 minutes + LLM_PRICING_RELOAD_CHANNEL: z.string().default("llm-registry:reload"), + LLM_PRICING_RELOAD_DEBOUNCE_MS: z.coerce.number().int().default(1000), + // Whether to subscribe this process to the LLM_PRICING_RELOAD_CHANNEL. + // Default off β€” only OTel-ingesting services need real-time pricing + // freshness; dashboard/worker processes are fine on the existing + // 5-minute periodic reload. In multi-service deployments, set this to + // true on the span-ingesting services. + LLM_PRICING_RELOAD_PUBSUB_ENABLED: BoolEnv.default(false), LLM_PRICING_SEED_ON_STARTUP: BoolEnv.default(false), LLM_PRICING_READY_TIMEOUT_MS: z.coerce.number().int().default(500), LLM_METRICS_BATCH_SIZE: z.coerce.number().int().default(5000), diff --git a/apps/webapp/app/v3/llmPricingRegistry.server.ts b/apps/webapp/app/v3/llmPricingRegistry.server.ts index 2212c41779d..eb186e15213 100644 --- a/apps/webapp/app/v3/llmPricingRegistry.server.ts +++ b/apps/webapp/app/v3/llmPricingRegistry.server.ts @@ -1,7 +1,9 @@ import { ModelPricingRegistry, seedLlmPricing } from "@internal/llm-model-catalog"; import { prisma, $replica } from "~/db.server"; import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; import { signalsEmitter } from "~/services/signals.server"; +import { createRedisClient } from "~/redis.server"; import { singleton } from "~/utils/singleton"; import { setLlmPricingRegistry } from "./utils/enrichCreatableEvents.server"; @@ -27,7 +29,7 @@ export const llmPricingRegistry = singleton("llmPricingRegistry", () => { console.error("Failed to initialize LLM pricing registry", err); }); - // Periodic reload + // Periodic reload (backstop for the pub/sub path below) const reloadInterval = env.LLM_PRICING_RELOAD_INTERVAL_MS; const interval = setInterval(() => { registry.reload().catch((err) => { @@ -35,12 +37,69 @@ export const llmPricingRegistry = singleton("llmPricingRegistry", () => { }); }, reloadInterval); - signalsEmitter.on("SIGTERM", () => { - clearInterval(interval); - }); - signalsEmitter.on("SIGINT", () => { - clearInterval(interval); - }); + // Pub/sub reload is opt-in per process (default off). Without it, the + // registry stays accurate via the existing 5-minute interval. Enable on + // the OTel-ingesting services where pricing freshness directly affects + // span cost enrichment; dashboard and worker services don't need it and + // shouldn't pile onto each publish with a full-table reload. + if (env.LLM_PRICING_RELOAD_PUBSUB_ENABLED) { + const subscriber = createRedisClient("llm-pricing:subscriber", { + keyPrefix: "llm-pricing:subscriber:", + host: env.COMMON_WORKER_REDIS_HOST, + port: env.COMMON_WORKER_REDIS_PORT, + username: env.COMMON_WORKER_REDIS_USERNAME, + password: env.COMMON_WORKER_REDIS_PASSWORD, + tlsDisabled: env.COMMON_WORKER_REDIS_TLS_DISABLED === "true", + clusterMode: env.COMMON_WORKER_REDIS_CLUSTER_MODE_ENABLED === "1", + }); + + subscriber.subscribe(env.LLM_PRICING_RELOAD_CHANNEL).catch((err) => { + logger.warn("Failed to subscribe to LLM pricing reload channel", { + channel: env.LLM_PRICING_RELOAD_CHANNEL, + error: err instanceof Error ? err.message : String(err), + }); + }); + + // Coalesce reload calls so a burst of publishes only triggers one + // reload. The first publish schedules a reload at + // T+LLM_PRICING_RELOAD_DEBOUNCE_MS; subsequent publishes during that + // window are no-ops because the trailing reload picks up everything + // when it queries the DB. Bounds reload rate to at most 1 per debounce + // window regardless of publisher chattiness. + const debounceMs = env.LLM_PRICING_RELOAD_DEBOUNCE_MS; + let pendingReloadTimer: NodeJS.Timeout | null = null; + + function scheduleReload() { + if (pendingReloadTimer) return; + pendingReloadTimer = setTimeout(() => { + pendingReloadTimer = null; + registry.reload().catch((err) => { + logger.warn("Failed to reload LLM pricing registry from pub/sub", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }, debounceMs); + } + + subscriber.on("message", (channel) => { + if (channel !== env.LLM_PRICING_RELOAD_CHANNEL) return; + scheduleReload(); + }); + + signalsEmitter.on("SIGTERM", () => { + clearInterval(interval); + if (pendingReloadTimer) clearTimeout(pendingReloadTimer); + void subscriber.quit().catch(() => {}); + }); + signalsEmitter.on("SIGINT", () => { + clearInterval(interval); + if (pendingReloadTimer) clearTimeout(pendingReloadTimer); + void subscriber.quit().catch(() => {}); + }); + } else { + signalsEmitter.on("SIGTERM", () => clearInterval(interval)); + signalsEmitter.on("SIGINT", () => clearInterval(interval)); + } return registry; }); From 6cdd8814a30f957ed86e03ca4d33dece7cb47419 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 10 May 2026 21:31:01 +0100 Subject: [PATCH 015/491] fix(webapp): Fix for resizable side panel getting stuck at its min-size (#3538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Run-view inspector panel was glitching out on Firefox: visual flicker on close, locking up at min size, and intermittent `panelHasSpace` invariant errors. Root cause is the underlying `react-window-splitter` library's collapse animation, which uses `@react-spring/rafz` and interacts poorly with Firefox. - Disabled the library's collapse animation on Firefox only, app-wide (every consumer of `RESIZABLE_PANEL_ANIMATION`). Chromium and Safari behaviour is unchanged. ## Changes - **Firefox animation skip** in `RESIZABLE_PANEL_ANIMATION` β€” UA-detected at module load, resolves to `undefined` for Firefox so the library's animation actor completes in one frame instead of running its rAF loop. - **Inspector min raised 50px β†’ 250px** so dragging can't shrink the panel into a near-useless width. - **`autosaveId` bumped `v2` β†’ `v3`** to invalidate stale persisted snapshots (the library has a `// TODO` branch that ignores prop changes for already-registered panels, so existing users would otherwise still see the old 50px min). - **`react-window-splitter` pinned** to exact `0.4.1` to protect the patch from drifting if line offsets change in a patch release. - **Two hunks added to the existing `@window-splitter/state` patch:** - Removed the library's auto-collapse-on-drag block entirely. Every collapsible panel in the app is parent-controlled, and that block was triggering state-machine deadlocks when handlers were no-ops. Drag-to-collapse is now disabled across the app; collapse is only triggered explicitly (close button, ESC, URL change, etc.). - In `getDeltaForEvent`, fall back to the panel's `default` before its `min` when expanding β€” so the first ever click on a span opens the inspector at 500px, not 250px. ## Local testing confirmed - [x] Firefox: open a run, click various spans β†’ panel opens instantly at 500px, drags freely between 250px and max, closes instantly to 0. No console errors. - [x] Chrome/Chromium: same flow, but with smooth open/close animation as before. - [x] Safari: same as Chrome. - [x] Reload mid-session β†’ panel restores cleanly to the dragged size. - [x] Other resizable panels in the app (logs, deployments, schedules, batches, bulk-actions, runs index) still animate on Chromium/Safari. ## Notes - Linear: TRI-8584 - Branch contains intermediate commits exploring an unsuccessful snapshot-validator approach; they're reverted by the final commit. Cumulative diff is 6 files. Squash on merge if you'd prefer a clean history. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .server-changes/fix-resizable-panel-stuck.md | 6 + .../app/components/primitives/Resizable.tsx | 14 +- .../route.tsx | 2 +- .../route.tsx | 4 +- apps/webapp/package.json | 2 +- package.json | 2 +- patches/@window-splitter__state@0.4.1.patch | 28 - patches/@window-splitter__state@1.1.3.patch | 114 + pnpm-lock.yaml | 2150 +++-------------- 9 files changed, 521 insertions(+), 1801 deletions(-) create mode 100644 .server-changes/fix-resizable-panel-stuck.md delete mode 100644 patches/@window-splitter__state@0.4.1.patch create mode 100644 patches/@window-splitter__state@1.1.3.patch diff --git a/.server-changes/fix-resizable-panel-stuck.md b/.server-changes/fix-resizable-panel-stuck.md new file mode 100644 index 00000000000..1a36f0c71e6 --- /dev/null +++ b/.server-changes/fix-resizable-panel-stuck.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix the run-view inspector panel glitching out and locking up in Firefox. Disabled the underlying resizable library's collapse animation on Firefox (where its `requestAnimationFrame`-driven actor caused visual glitches and intermittent state-machine errors) while keeping it intact for Chromium and Safari, and bumped the inspector minimum from 50px to 250px so dragging can't shrink the panel into a near-useless width. diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx index 2efaae4258e..b2964c8e958 100644 --- a/apps/webapp/app/components/primitives/Resizable.tsx +++ b/apps/webapp/app/components/primitives/Resizable.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useRef } from "react"; -import { PanelGroup, Panel, PanelResizer } from "react-window-splitter"; +import { PanelGroup, Panel, PanelResizer } from "@window-splitter/react"; import { cn } from "~/utils/cn"; const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps) => ( @@ -69,10 +69,14 @@ const ResizableHandle = ({ ); -const RESIZABLE_PANEL_ANIMATION = { - easing: "ease-in-out" as const, - duration: 200, -}; +// react-window-splitter drives the collapse animation through @react-spring/rafz, +// which has timing/interaction issues with Firefox that produce visual glitches +// (alternating frames, panels stuck at min, panelHasSpace invariant violations). +// Disable the animation on Firefox; it works correctly in Chromium and Safari. +const RESIZABLE_PANEL_ANIMATION = + typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent) + ? undefined + : ({ easing: "ease-in-out", duration: 300 } as const); const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index d61c357e02e..f8e28fc6888 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -15,7 +15,7 @@ import { DiscordIcon } from "@trigger.dev/companyicons"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import type { TaskRunStatus } from "@trigger.dev/database"; import { Fragment, Suspense, useCallback, useEffect, useRef, useState } from "react"; -import type { PanelHandle } from "react-window-splitter"; +import type { PanelHandle } from "@window-splitter/react"; import { Bar, BarChart, ResponsiveContainer, Tooltip, type TooltipProps } from "recharts"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index f35376a4275..601ffb2d766 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -115,7 +115,7 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP const resizableSettings = { parent: { - autosaveId: "panel-run-parent-v2", + autosaveId: "panel-run-parent-v3", handleId: "parent-handle", main: { id: "run", @@ -124,7 +124,7 @@ const resizableSettings = { inspector: { id: "inspector", default: "500px" as const, - min: "50px" as const, + min: "250px" as const, }, }, tree: { diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 5b2725288dd..0afb011cce0 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -135,6 +135,7 @@ "@upstash/ratelimit": "^1.1.3", "@vercel/sdk": "^1.19.1", "@whatwg-node/fetch": "^0.9.14", + "@window-splitter/react": "1.1.3", "ai": "^4.3.19", "assert-never": "^1.2.1", "aws4fetch": "^1.0.18", @@ -199,7 +200,6 @@ "react-resizable-panels": "^2.0.9", "react-stately": "^3.29.1", "react-use": "17.5.1", - "react-window-splitter": "^0.4.1", "recharts": "^2.15.2", "regression": "^2.0.1", "remix-auth": "^3.6.0", diff --git a/package.json b/package.json index 12b126848ff..30f27bade95 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@sentry/remix@9.46.0": "patches/@sentry__remix@9.46.0.patch", "@upstash/ratelimit@1.1.3": "patches/@upstash__ratelimit.patch", "antlr4ts@0.5.0-alpha.4": "patches/antlr4ts@0.5.0-alpha.4.patch", - "@window-splitter/state@0.4.1": "patches/@window-splitter__state@0.4.1.patch" + "@window-splitter/state@1.1.3": "patches/@window-splitter__state@1.1.3.patch" }, "overrides": { "typescript": "5.5.4", diff --git a/patches/@window-splitter__state@0.4.1.patch b/patches/@window-splitter__state@0.4.1.patch deleted file mode 100644 index 8d99b717ba8..00000000000 --- a/patches/@window-splitter__state@0.4.1.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/dist/commonjs/index.js b/dist/commonjs/index.js -index acb542b1b71a7e808173d938d16f45a484334f94..dc9289461b761f8f0d5c72919f48f94e394addfd 100644 ---- a/dist/commonjs/index.js -+++ b/dist/commonjs/index.js -@@ -107,6 +107,9 @@ function prepareSnapshot(snapshot) { - if (item.max && item.max !== "1fr") { - item.max.value = new big_js_1.default(item.max.value); - } -+ if (item.default && typeof item.default === "object" && item.default.value !== undefined) { -+ item.default.value = new big_js_1.default(item.default.value); -+ } - } - else { - item.size.value = new big_js_1.default(item.size.value); -diff --git a/dist/esm/index.js b/dist/esm/index.js -index 8891ac0141135a3a885bd704d9d443458c7a01bf..34cd7251f2298e7f9bfedfe4cadb797aa790b59a 100644 ---- a/dist/esm/index.js -+++ b/dist/esm/index.js -@@ -81,6 +81,9 @@ export function prepareSnapshot(snapshot) { - if (item.max && item.max !== "1fr") { - item.max.value = new Big(item.max.value); - } -+ if (item.default && typeof item.default === "object" && item.default.value !== undefined) { -+ item.default.value = new Big(item.default.value); -+ } - } - else { - item.size.value = new Big(item.size.value); diff --git a/patches/@window-splitter__state@1.1.3.patch b/patches/@window-splitter__state@1.1.3.patch new file mode 100644 index 00000000000..91397bdac9f --- /dev/null +++ b/patches/@window-splitter__state@1.1.3.patch @@ -0,0 +1,114 @@ +diff --git a/dist/commonjs/index.js b/dist/commonjs/index.js +index e3bdcf702702392e9a06c981545f659ee7c5970e..d88ae6b2dc5b4cf1970cb693f58a926bd12a8f45 100644 +--- a/dist/commonjs/index.js ++++ b/dist/commonjs/index.js +@@ -757,30 +757,14 @@ function updateLayout(context, dragEvent) { + panelAfter.onCollapseChange.current(false); + } + } +- const panelBeforeIsAboutToCollapse = panelBefore.currentValue.value.eq(getUnitPixelValue(context, panelBefore.min)); +- // If the panel was expanded and now is at it's min size, collapse it +- if (!dragEvent.disregardCollapseBuffer && +- panelBefore.collapsible && +- panelBeforeIsAboutToCollapse) { +- if (panelBefore.onCollapseChange?.current && +- panelBefore.collapseIsControlled && +- !dragEvent.controlled && +- !dragEvent.isVirtual) { +- panelBefore.onCollapseChange.current(true); +- return { dragOvershoot: newDragOvershoot }; +- } +- // Make it collapsed +- panelBefore.collapsed = true; +- panelBeforeNewValue = getUnitPixelValue(context, panelBefore.collapsedSize); +- // Add the extra space created to the before panel +- panelAfterNewValue = panelAfter.currentValue.value.add(panelBeforePreviousValue.minus(panelBeforeNewValue)); +- if (panelBefore.onCollapseChange?.current && +- !panelBefore.collapseIsControlled && +- !dragEvent.controlled && +- !dragEvent.isVirtual) { +- panelBefore.onCollapseChange.current(true); +- } +- } ++ // Drag-to-collapse is disabled in this fork: every consumer of the ++ // library uses controlled `collapsed` props and triggers collapse ++ // explicitly (close button, ESC, URL change, etc.). The original auto- ++ // collapse-on-drag logic that lived here would notify the parent when a ++ // collapsible panel reached its min during a drag β€” keeping it for our ++ // (controlled-only) case caused state-machine deadlocks when handlers ++ // were no-ops, so the block is removed entirely. Panels just clamp at ++ // `min` during drag now. + panelBefore.currentValue = { type: "pixel", value: panelBeforeNewValue }; + panelAfter.currentValue = { type: "pixel", value: panelAfterNewValue }; + const leftoverSpace = new big_js_1.default(getGroupSize(context)).minus(newItems.reduce((acc, b) => acc.add(isPanelData(b) ? b.currentValue.value : b.size.value), new big_js_1.default(0))); +@@ -940,7 +924,12 @@ function setCookie(name, jsonData) { + function getDeltaForEvent(context, event) { + const panel = getPanelWithId(context, event.panelId); + if (event.type === "expandPanel") { +- return new big_js_1.default(panel.sizeBeforeCollapse ?? getUnitPixelValue(context, panel.min)).minus(panel.currentValue.value); ++ // Fall back to `default` before `min` so the first-ever expand of a ++ // panel that started life collapsed lands at its configured default ++ // size rather than getting stuck at `min`. ++ const defaultPx = panel.default ? getUnitPixelValue(context, panel.default) : undefined; ++ const target = panel.sizeBeforeCollapse ?? defaultPx ?? getUnitPixelValue(context, panel.min); ++ return new big_js_1.default(target).minus(panel.currentValue.value); + } + const collapsedSize = getUnitPixelValue(context, panel.collapsedSize); + return panel.currentValue.value.minus(collapsedSize); +diff --git a/dist/esm/index.js b/dist/esm/index.js +index f8fddd70c0f1aaed29f2fb0ca0d8093d8ce66335..d1dae8beb1447afca47b91e796b8279135f50c36 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -728,30 +728,14 @@ function updateLayout(context, dragEvent) { + panelAfter.onCollapseChange.current(false); + } + } +- const panelBeforeIsAboutToCollapse = panelBefore.currentValue.value.eq(getUnitPixelValue(context, panelBefore.min)); +- // If the panel was expanded and now is at it's min size, collapse it +- if (!dragEvent.disregardCollapseBuffer && +- panelBefore.collapsible && +- panelBeforeIsAboutToCollapse) { +- if (panelBefore.onCollapseChange?.current && +- panelBefore.collapseIsControlled && +- !dragEvent.controlled && +- !dragEvent.isVirtual) { +- panelBefore.onCollapseChange.current(true); +- return { dragOvershoot: newDragOvershoot }; +- } +- // Make it collapsed +- panelBefore.collapsed = true; +- panelBeforeNewValue = getUnitPixelValue(context, panelBefore.collapsedSize); +- // Add the extra space created to the before panel +- panelAfterNewValue = panelAfter.currentValue.value.add(panelBeforePreviousValue.minus(panelBeforeNewValue)); +- if (panelBefore.onCollapseChange?.current && +- !panelBefore.collapseIsControlled && +- !dragEvent.controlled && +- !dragEvent.isVirtual) { +- panelBefore.onCollapseChange.current(true); +- } +- } ++ // Drag-to-collapse is disabled in this fork: every consumer of the ++ // library uses controlled `collapsed` props and triggers collapse ++ // explicitly (close button, ESC, URL change, etc.). The original auto- ++ // collapse-on-drag logic that lived here would notify the parent when a ++ // collapsible panel reached its min during a drag β€” keeping it for our ++ // (controlled-only) case caused state-machine deadlocks when handlers ++ // were no-ops, so the block is removed entirely. Panels just clamp at ++ // `min` during drag now. + panelBefore.currentValue = { type: "pixel", value: panelBeforeNewValue }; + panelAfter.currentValue = { type: "pixel", value: panelAfterNewValue }; + const leftoverSpace = new Big(getGroupSize(context)).minus(newItems.reduce((acc, b) => acc.add(isPanelData(b) ? b.currentValue.value : b.size.value), new Big(0))); +@@ -911,7 +895,12 @@ function setCookie(name, jsonData) { + function getDeltaForEvent(context, event) { + const panel = getPanelWithId(context, event.panelId); + if (event.type === "expandPanel") { +- return new Big(panel.sizeBeforeCollapse ?? getUnitPixelValue(context, panel.min)).minus(panel.currentValue.value); ++ // Fall back to `default` before `min` so the first-ever expand of a ++ // panel that started life collapsed lands at its configured default ++ // size rather than getting stuck at `min`. ++ const defaultPx = panel.default ? getUnitPixelValue(context, panel.default) : undefined; ++ const target = panel.sizeBeforeCollapse ?? defaultPx ?? getUnitPixelValue(context, panel.min); ++ return new Big(target).minus(panel.currentValue.value); + } + const collapsedSize = getUnitPixelValue(context, panel.collapsedSize); + return panel.currentValue.value.minus(collapsedSize); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7c70806a1d..a67f5ba9a72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,9 +52,9 @@ patchedDependencies: '@upstash/ratelimit@1.1.3': hash: e5922e50fbefb7b2b24950c4b1c5c9ddc4cd25464439c9548d2298c432debe74 path: patches/@upstash__ratelimit.patch - '@window-splitter/state@0.4.1': - hash: da01382a3c0d3f4f66db5b09d6f0833cc21eaf8db00f97e2c1738797673b57c0 - path: patches/@window-splitter__state@0.4.1.patch + '@window-splitter/state@1.1.3': + hash: ecf02927f78361c14d8f8347604fd355ba36f2fc4f9ba9a08cd63adc101b7327 + path: patches/@window-splitter__state@1.1.3.patch antlr4ts@0.5.0-alpha.4: hash: b5d41129ddbf7c4cbb0288244b2c8d041ee0803c5102c1181c180408d3b579f4 path: patches/antlr4ts@0.5.0-alpha.4.patch @@ -563,6 +563,9 @@ importers: '@whatwg-node/fetch': specifier: ^0.9.14 version: 0.9.14 + '@window-splitter/react': + specifier: 1.1.3 + version: 1.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) ai: specifier: ^4.3.19 version: 4.3.19(react@18.2.0)(zod@3.25.76) @@ -755,9 +758,6 @@ importers: react-use: specifier: 17.5.1 version: 17.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - react-window-splitter: - specifier: ^0.4.1 - version: 0.4.1(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) recharts: specifier: ^2.15.2 version: 2.15.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1147,7 +1147,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -2921,9 +2921,6 @@ importers: packages: - '@adobe/css-tools@4.4.0': - resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} - '@ai-sdk/anthropic@2.0.4': resolution: {integrity: sha512-ii2bZEUPwBitUiK1dpX+HsOarcDGY71G9TVdSJqbfXSVqa+speJNZ8PA/bjuNMml0NyX8VxNsaMg3SwBUCZspA==} engines: {node: '>=18'} @@ -5639,29 +5636,26 @@ packages: cpu: [x64] os: [win32] + '@internationalized/date@3.12.1': + resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==} + '@internationalized/date@3.5.1': resolution: {integrity: sha512-LUQIfwU9e+Fmutc/DpRTGXSdgYZLBegi4wygCWDSVmUdLTaMHsQyASDiJtREwanwKuQLq0hY76fCJ9J/9I2xOQ==} - '@internationalized/date@3.5.5': - resolution: {integrity: sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==} - '@internationalized/message@3.1.1': resolution: {integrity: sha512-ZgHxf5HAPIaR0th+w0RUD62yF6vxitjlprSxmLJ1tam7FOekqRSDELMg4Cr/DdszG5YLsp5BG3FgHgqquQZbqw==} - '@internationalized/message@3.1.4': - resolution: {integrity: sha512-Dygi9hH1s7V9nha07pggCkvmRfDd3q2lWnMGvrJyrOwYMe1yj4D2T9BoH9I6MGR7xz0biQrtLPsqUkqXzIrBOw==} - '@internationalized/number@3.5.0': resolution: {integrity: sha512-ZY1BW8HT9WKYvaubbuqXbbDdHhOUMfE2zHHFJeTppid0S+pc8HtdIxFxaYMsGjCb4UsF+MEJ4n2TfU7iHnUK8w==} - '@internationalized/number@3.5.3': - resolution: {integrity: sha512-rd1wA3ebzlp0Mehj5YTuTI50AQEx80gWFyHcQu+u91/5NgdwBecO8BH6ipPfE+lmQ9d63vpB3H9SHoIUiupllw==} + '@internationalized/number@3.6.6': + resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==} '@internationalized/string@3.2.0': resolution: {integrity: sha512-Xx3Sy3f2c9ctT+vh8c7euEaEHQZltp0euZ3Hy4UfT3E13r6lxpUS3kgKyumEjboJZSnaZv7JhqWz3D75v+IxQg==} - '@internationalized/string@3.2.3': - resolution: {integrity: sha512-9kpfLoA8HegiWTeCbR2livhdVeKobCnVv8tlJ6M2jF+4tcMqDo94ezwlnrUANBWPgd8U7OXIHCk2Ov2qhk4KXw==} + '@internationalized/string@3.2.8': + resolution: {integrity: sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==} '@ioredis/commands@1.2.0': resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} @@ -7921,11 +7915,6 @@ packages: '@radix-ui/rect@1.0.1': resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==} - '@react-aria/breadcrumbs@3.5.16': - resolution: {integrity: sha512-OXLKKu4SmjnSaSHkk4kow5/aH/SzlHWPJt+Uq3xec9TwDOr/Ob8aeFVGFoY0HxfGozuQlUz+4e+d29vfA0jNWg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/breadcrumbs@3.5.9': resolution: {integrity: sha512-asbXTL5NjeHl1+YIF0K70y8tNHk8Lb6VneYH8yOkpLO49ejyNDYBK0tp0jtI9IZAQiTa2qkhYq58c9LloTwebQ==} peerDependencies: @@ -7936,17 +7925,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/button@3.9.8': - resolution: {integrity: sha512-MdbMQ3t5KSCkvKtwYd/Z6sgw0v+r1VQFRYOZ4L53xOkn+u140z8vBpNeWKZh/45gxGv7SJn9s2KstLPdCWmIxw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/calendar@3.5.11': - resolution: {integrity: sha512-VLhBovLVu3uJXBkHbgEippmo/K58QLcc/tSJQ0aJUNyHsrvPgHEcj484cb+Uj/yOirXEIzaoW6WEvhcdKrb49Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/calendar@3.5.4': resolution: {integrity: sha512-8k7khgea5kwfWriZJWCADNB0R2d7g5A6tTjUEktK4FFZcTb0RCubFejts4hRyzKlF9XHUro2dfh6sbZrzfMKDQ==} peerDependencies: @@ -7958,29 +7936,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/checkbox@3.14.6': - resolution: {integrity: sha512-LICY1PR3WsW/VbuLMjZbxo75+poeo3XCXGcUnk6hxMlWfp/Iy/XHVsHlGu9stRPKRF8BSuOGteaHWVn6IXfwtA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/combobox@3.10.3': - resolution: {integrity: sha512-EdDwr2Rp1xy7yWjOYHt2qF1IpAtUrkaNKZJzlIw1XSwcqizQY6E8orNPdZr6ZwD6/tgujxF1N71JTKyffrR0Xw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/combobox@3.8.2': resolution: {integrity: sha512-q8Kdw1mx6nSSydXqRagRuyKH1NPGvpSOFjUfgxdO8ZqaEEuZX3ObOoiO/DLtXDndViNc03dMbMpfuJoLYXfCtg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/datepicker@3.11.2': - resolution: {integrity: sha512-6sbLln3VXSBcBRDgSACBzIzF/5KV5NlNOhZvXPFE6KqFw6GbevjZQTv5BNDXiwA3CQoawIRF7zgRvTANw8HkNA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/datepicker@3.9.1': resolution: {integrity: sha512-bdlY2H/zwe3hQf64Lp1oGTf7Va8ennDyAv4Ffowb+BOoL8+FB9smtGyONKe87zXu7VJL2M5xYAi4n7c004PM+w==} peerDependencies: @@ -7993,50 +7954,22 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/dialog@3.5.17': - resolution: {integrity: sha512-lvfEgaqg922J1hurscqCS600OZQVitGtdpo81kAefJaUzMnCxzrYviyT96aaW0simHOlimbYF5js8lxBLZJRaw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/dnd@3.5.1': resolution: {integrity: sha512-7OPGePdle+xNYHAIAUOvIETRMfnkRt7h/C0bCkxUR2GYefEbTzfraso4ppNH2JZ7fCRd0K/Qe+jvQklwusHAKA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/dnd@3.7.2': - resolution: {integrity: sha512-NuE3EGqoBbe9aXAO9mDfbu4kMO7S4MCgkjkCqYi16TWfRUf38ajQbIlqodCx91b3LVN3SYvNbE3D4Tj5ebkljw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/focus@3.16.0': resolution: {integrity: sha512-GP6EYI07E8NKQQcXHjpIocEU0vh0oi0Vcsd+/71fKS0NnTR0TUOEeil0JuuQ9ymkmPDTu51Aaaa4FxVsuN/23A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/focus@3.18.2': - resolution: {integrity: sha512-Jc/IY+StjA3uqN73o6txKQ527RFU7gnG5crEl5Xy3V+gbYp2O5L3ezAo/E0Ipi2cyMbG6T5Iit1IDs7hcGu8aw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/form@3.0.1': resolution: {integrity: sha512-6586oODMDR4/ciGRwXjpvEAg7tWGSDrXE//waK0n5e5sMuzlPOo1DHc5SpPTvz0XdJsu6VDt2rHdVWVIC9LEyw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/form@3.0.8': - resolution: {integrity: sha512-8S2QiyUdAgK43M3flohI0R+2rTyzH088EmgeRArA8euvJTL16cj/oSOKMEgWVihjotJ9n6awPb43ZhKboyNsMg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/grid@3.10.3': - resolution: {integrity: sha512-l0r9mz05Gwjq3t6JOTNQOf+oAoWN0bXELPJtIr8m0XyXMPFCQe1xsTaX8igVQdrDmXyBc75RAWS0BJo2JF2fIA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/grid@3.8.6': resolution: {integrity: sha512-JlQDkdm5heG1FfRyy5KnB8b6s/hRqSI6Xt2xN2AccLX5kcbfFr2/d5KVxyf6ahfa4Gfd46alN6477ju5eTWJew==} peerDependencies: @@ -8049,37 +7982,16 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/gridlist@3.9.3': - resolution: {integrity: sha512-bb9GnKKeuL6NljoVUcHxr9F0cy/2WDOXRYeMikTnviRw6cuX95oojrhFfCUvz2d6ID22Btrvh7LkE+oIPVuc+g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/i18n@3.10.0': resolution: {integrity: sha512-sviD5Y1pLPG49HHRmVjR+5nONrp0HK219+nu9Y7cDfUhXu2EjyhMS9t/n9/VZ69hHChZ2PnHYLEE2visu9CuCg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/i18n@3.12.2': - resolution: {integrity: sha512-PvEyC6JWylTpe8dQEWqQwV6GiA+pbTxHQd//BxtMSapRW3JT9obObAnb/nFhj3HthkUvqHyj0oO1bfeN+mtD8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/interactions@3.20.1': resolution: {integrity: sha512-PLNBr87+SzRhe9PvvF9qvzYeP4ofTwfKSorwmO+hjr3qoczrSXf4LRQlb27wB6hF10C7ZE/XVbUI1lj4QQrZ/g==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/interactions@3.22.2': - resolution: {integrity: sha512-xE/77fRVSlqHp2sfkrMeNLrqf2amF/RyuAS6T5oDJemRSgYM3UoxTbWjucPhfnoW7r32pFPHHgz4lbdX8xqD/g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/label@3.7.11': - resolution: {integrity: sha512-REgejE5Qr8cXG/b8H2GhzQmjQlII/0xQW/4eDzydskaTLvA7lF5HoJUE6biYTquH5va38d8XlH465RPk+bvHzA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/label@3.7.4': resolution: {integrity: sha512-3Y0yyrqpLzZdzHw+TOyzwuyx5wa2ujU5DGfKuL5GFnU9Ii4DtdwBGSYS7Yu7qadU+eQmG4OGhAgFVswbIgIwJw==} peerDependencies: @@ -8090,46 +8002,21 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/link@3.7.4': - resolution: {integrity: sha512-E8SLDuS9ssm/d42+3sDFNthfMcNXMUrT2Tq1DIZt22EsMcuEzmJ9B0P7bDP5RgvIw05xVGqZ20nOpU4mKTxQtA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/listbox@3.11.3': resolution: {integrity: sha512-PBrnldmyEYUUJvfDeljW8ITvZyBTfGpLNf0b5kfBPK3TDgRH4niEH2vYEcaZvSqb0FrpdvcunuTRXcOpfb+gCQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/listbox@3.13.3': - resolution: {integrity: sha512-htluPyDfFtn66OEYaJdIaFCYH9wGCNk30vOgZrQkPul9F9Cjce52tTyPVR0ERsf14oCUsjjS5qgeq3dGidRqEw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/live-announcer@3.3.1': resolution: {integrity: sha512-hsc77U7S16trM86d+peqJCOCQ7/smO1cybgdpOuzXyiwcHQw8RQ4GrXrS37P4Ux/44E9nMZkOwATQRT2aK8+Ew==} - '@react-aria/live-announcer@3.3.4': - resolution: {integrity: sha512-w8lxs35QrRrn6pBNzVfyGOeqWdxeVKf9U6bXIVwhq7rrTqRULL8jqy8RJIMfIs1s8G5FpwWYjyBOjl2g5Cu1iA==} - '@react-aria/menu@3.12.0': resolution: {integrity: sha512-Nsujv3b61WR0gybDKnBjAeyxDVJOfPLMggRUf9SQDfPWnrPXEsAFxaPaVcAkzlfI4HiQs1IxNwsKFNpc3PPZTQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/menu@3.15.3': - resolution: {integrity: sha512-vvUmVjJwIg3h2r+7isQXTwlmoDlPAFBckHkg94p3afrT1kNOTHveTsaVl17mStx/ymIioaAi3PrIXk/PZXp1jw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/meter@3.4.16': - resolution: {integrity: sha512-hJqKnEE6mmK2Psx5kcI7NZ44OfTg0Bp7DatQSQ4zZE4yhnykRRwxqSKjze37tPR63cCqgRXtQ5LISfBfG54c0Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/meter@3.4.9': resolution: {integrity: sha512-1/FHFmFmSyfQBJ2oH152lp4nps76v1UdhnFbIsmRIH+0g0IfMv1yDT2M9dIZ/b9DgVZSx527FmWOXm0eHGKD6w==} peerDependencies: @@ -8141,29 +8028,12 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/numberfield@3.11.6': - resolution: {integrity: sha512-nvEWiQcWRwj6O2JXmkXEeWoBX/GVZT9zumFJcew3XknGTWJUr3h2AOymIQFt9g4mpag8IgOFEpSIlwhtZHdp1A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/overlays@3.20.0': resolution: {integrity: sha512-2m7MpRJL5UucbEuu08lMHsiFJoDowkJV4JAIFBZYK1NzVH0vF/A+w9HRNM7jRwx2DUxE+iIsZnl8yKV/7KY8OQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/overlays@3.23.2': - resolution: {integrity: sha512-vjlplr953YAuJfHiP4O+CyrTlr6OaFgXAGrzWq4MVMjnpV/PT5VRJWYFHR0sUGlHTPqeKS4NZbi/xCSgl/3pGQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/progress@3.4.16': - resolution: {integrity: sha512-RbDIFQg4+/LG+KYZeLAijt2zH7K2Gp0CY9RKWdho3nU5l3/w57Fa7NrfDGWtpImrt7bR2nRmXMA6ESfr7THfrg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/progress@3.4.9': resolution: {integrity: sha512-CME1ZLsJHOmSgK8IAPOC/+vYO5Oc614mkEw5MluT/yclw5rMyjAkK1XsHLjEXy81uwPeiRyoQQIMPKG2/sMxFQ==} peerDependencies: @@ -8174,60 +8044,28 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/radio@3.10.7': - resolution: {integrity: sha512-o2tqIe7xd1y4HeCBQfz/sXIwLJuI6LQbVoCQ1hgk/5dGhQ0LiuXohRYitGRl9zvxW8jYdgLULmOEDt24IflE8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/searchfield@3.7.1': resolution: {integrity: sha512-ebhnV/reNByIZzpcQLHIo1RQ+BrYS8HdwX624i9R7dep1gxGHXYEaqL9aSY+RdngNerB4OeiWmB75em9beSpjQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/searchfield@3.7.8': - resolution: {integrity: sha512-SsF5xwH8Us548QgzivvbM7nhFbw7pu23xnRRIuhlP3MwOR3jRUFh17NKxf3Z0jvrDv/u0xfm3JKHIgaUN0KJ2A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/select@3.14.1': resolution: {integrity: sha512-pAy/+Xbj11Lx6bi/O1hWH0NSIDRxFb6V7N0ry2L8x7MALljh516VbpnAc5RgvbjbuKq0cHUAcdINOzOzpYWm4A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/select@3.14.9': - resolution: {integrity: sha512-tiNgMyA2G9nKnFn3pB/lMSgidNToxSFU7r6l4OcG+Vyr63J7B/3dF2lTXq8IYhlfOR3K3uQkjroSx52CmC3NDw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/selection@3.17.3': resolution: {integrity: sha512-xl2sgeGH61ngQeE05WOWWPVpGRTPMjQEFmsAWEprArFi4Z7ihSZgpGX22l1w7uSmtXM/eN/v0W8hUYUju5iXlQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/selection@3.19.3': - resolution: {integrity: sha512-GYoObXCXlmGK08hp7Qfl6Bk0U+bKP5YDWSsX+MzNjJsqzQSLm4S06tRB9ACM7gIo9dDCvL4IRxdSYTJAlJc6bw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/separator@3.3.9': resolution: {integrity: sha512-1wEXiaSJjq2+DR5TC0RKnUBsfZN+YXTzyI7XMzjQoc3YlclumX8wQtzPAOGOEjHB1JKUgo1Gw70FtupVXz58QQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/separator@3.4.2': - resolution: {integrity: sha512-Xql9Kg3VlGesEUC7QheE+L5b3KgBv0yxiUU+/4JP8V2vfU/XSz4xmprHEeq7KVQVOetn38iiXU8gA5g26SEsUA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/slider@3.7.11': - resolution: {integrity: sha512-2WAwjANXPsA2LHJ5nxxV4c7ihFAzz2spaBz8+FJ7MDYE7WroYnE8uAXElea1aGo+Lk0DTiAdepLpBkggqPNanw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/slider@3.7.4': resolution: {integrity: sha512-OFJWeGSL2duVDFs/kcjlWsY6bqCVKZgM0aFn2QN4wmID+vfBvBnqGHAgWv3BCePTAPS3+GBjMN002TrftorjwQ==} peerDependencies: @@ -8239,114 +8077,60 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/spinbutton@3.6.8': - resolution: {integrity: sha512-OJMAYRIZ0WrWE+5tZsywrSg4t+aOwl6vl/e1+J64YcGMM+p+AKd61KGG5T0OgNSORXjoVIZOmj6wZ6Od4xfPMw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/ssr@3.9.1': resolution: {integrity: sha512-NqzkLFP8ZVI4GSorS0AYljC13QW2sc8bDqJOkBvkAt3M8gbcAXJWVRGtZBCRscki9RZF+rNlnPdg0G0jYkhJcg==} engines: {node: '>= 12'} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/ssr@3.9.5': - resolution: {integrity: sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==} - engines: {node: '>= 12'} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/switch@3.6.0': resolution: {integrity: sha512-YNWc5fGLNXE4XlmDAKyqAdllRiClGR7ki4KGFY7nL+xR5jxzjCGU3S3ToMK5Op3QSMGZLxY/aYmC4O+MvcoADQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/switch@3.6.7': - resolution: {integrity: sha512-yBNvKylhc3ZRQ0+7mD0mIenRRe+1yb8YaqMMZr8r3Bf87LaiFtQyhRFziq6ZitcwTJz5LEWjBihxbSVvUrf49w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/table@3.13.3': resolution: {integrity: sha512-AzmETpyxwNqISTzwHJPs85x9gujG40IIsSOBUdp49oKhB85RbPLvMwhadp4wCVAoHw3erOC/TJxHtVc7o2K1LA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/table@3.15.3': - resolution: {integrity: sha512-nQCLjlEvyJHyuijHw8ESqnA9fxNJfQHx0WPcl08VDEb8VxcE/MVzSAIedSWaqjG5k9Oflz6o/F/zHtzw4AFAow==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/tabs@3.8.3': resolution: {integrity: sha512-Plw0K/5Qv35vYq7pHZFfQB2BF5OClFx4Abzo9hLVx4oMy3qb7i5lxmLBVbt81yPX/MdjYeP4zO1EHGBl4zMRhA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/tabs@3.9.5': - resolution: {integrity: sha512-aQZGAoOIg1B16qlvXIy6+rHbNBNVcWkGjOjeyvqTTPMjXt/FmElkICnqckI7MRJ1lTqzyppCOBitYOHSXRo8Uw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/tag@3.3.1': resolution: {integrity: sha512-w7d8sVZqxTo8VFfeg2ixLp5kawtrcguGznVY4mt5aE6K8LMJOeNVDqNNfolfyia80VjOWjeX+RpVdVJRdrv/GQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/tag@3.4.5': - resolution: {integrity: sha512-iyJuATQ8t2cdLC7hiZm143eeZze/MtgxaMq0OewlI9TUje54bkw2Q+CjERdgisIo3Eemf55JJgylGrTcalEJAg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/textfield@3.14.1': resolution: {integrity: sha512-UMepuYtDdCgrUF4dMphNxrUm23xOmR54aZD1pbp9cJyfioVkJN35BTXZVkD0D07gHLn4RhxKIZxBortQQrLB9g==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/textfield@3.14.8': - resolution: {integrity: sha512-FHEvsHdE1cMR2B7rlf+HIneITrC40r201oLYbHAp3q26jH/HUujzFBB9I20qhXjyBohMWfQLqJhSwhs1VW1RJQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/toggle@3.10.0': resolution: {integrity: sha512-6cUf4V9TuG2J7AvXUdU/GspEPFCubUOID3mrselSe563RViy+mMZk0vUEOdyoNanDcEXl58W4dE3SGWxFn71vg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/toggle@3.10.7': - resolution: {integrity: sha512-/RJQU8QlPZXRElZ3Tt10F5K5STgUBUGPpfuFUGuwF3Kw3GpPxYsA1YAVjxXz2MMGwS0+y6+U/J1xIs1AF0Jwzg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/tooltip@3.7.0': resolution: {integrity: sha512-+u9Sftkfe09IDyPEnbbreFKS50vh9X/WTa7n1u2y3PenI9VreLpUR6czyzda4BlvQ95e9jQz1cVxUjxTNaZmBw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/tooltip@3.7.7': - resolution: {integrity: sha512-UOTTDbbUz7OaE48VjNSWl+XQbYCUs5Gss4I3Tv1pfRLXzVtGYXv3ur/vRayvZR0xd12ANY26fZPNkSmCFpmiXw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-aria/utils@3.23.0': resolution: {integrity: sha512-fJA63/VU4iQNT8WUvrmll3kvToqMurD69CcgVmbQ56V7ZbvlzFi44E7BpnoaofScYLLtFWRjVdaHsohT6O/big==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-aria/utils@3.25.2': - resolution: {integrity: sha512-GdIvG8GBJJZygB4L2QJP1Gabyn2mjFsha73I2wSe+o4DYeGWoJiMZRM06PyTIxLH4S7Sn7eVDtsSBfkc2VY/NA==} + '@react-aria/utils@3.34.0': + resolution: {integrity: sha512-ZM1ZXIqpwGTJjjL6o3JhlZkEaBpQdxuOCqLEvwEwooaj5GsYI3E9UfOl5vy3UW6bYiEEWl9pNBntrb9CR9kItQ==} peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/visually-hidden@3.8.15': - resolution: {integrity: sha512-l+sJ7xTdD5Sd6+rDNDaeJCSPnHOsI+BaJyApvb/YcVgHa7rB47lp6TXCWUCDItcPY4JqRGyeByRJVrtzBFTWCw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 '@react-aria/visually-hidden@3.8.8': resolution: {integrity: sha512-Cn2PYKD4ijGDtF0+dvsh8qa4y7KTNAlkTG6h20r8Q+6UTyRNmtE2/26QEaApRF8CBiNy9/BZC/ZC4FK2OjvCoA==} @@ -8637,59 +8421,31 @@ packages: peerDependencies: react: ^18.2.0 - '@react-spring/rafz@9.7.4': - resolution: {integrity: sha512-mqDI6rW0Ca8IdryOMiXRhMtVGiEGLIO89vIOyFQXRIwwIMX30HLya24g9z4olDvFyeDW3+kibiKwtZnA4xhldA==} - '@react-stately/calendar@3.4.3': resolution: {integrity: sha512-OrEcdskszDjnjVnFuSiDC2PVBJ6lWMCJROD5s6W1LUehUtBp8LX9wPavAGHV43LbhN9ldj560sxaQ4WCddrRCA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/calendar@3.5.4': - resolution: {integrity: sha512-R2011mtFSXIjzMXaA+CZ1sflPm9XkTBMqVk77Bnxso2ZsG7FUX8nqFmaDavxwTuHFC6OUexAGSMs8bP9KycTNg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/checkbox@3.6.1': resolution: {integrity: sha512-rOjFeVBy32edYwhKiHj3ZLdLeO+xZ2fnBwxnOBjcygnw4Neygm8FJH/dB1J0hdYYR349yby86ED2x0wRc84zPw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/checkbox@3.6.8': - resolution: {integrity: sha512-c8TWjU67XHHBCpqj6+FXXhQUWGr2Pil1IKggX81pkedhWiJl3/7+WHJuZI0ivGnRjp3aISNOG8UNVlBEjS9E8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/collections@3.10.4': resolution: {integrity: sha512-OHhCrItGt4zB2bSrgObRo0H2SC7QlkH8ReGxo+NVIWchXRLRoiWBP7S+IwleewEo5gOqDVPY3hqA9n4iiI8twg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/collections@3.10.9': - resolution: {integrity: sha512-plyrng6hOQMG8LrjArMA6ts/DgWyXln3g90/hFNbqe/hdVYF53sDVsj8Jb+5LtoYTpiAlV6eOvy1XR0vPZUf8w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/combobox@3.8.1': resolution: {integrity: sha512-FaWkqTXQdWg7ptaeU4iPcqF/kxbRg2ZNUcvW/hiL/enciV5tRCsddvfNqvDvy1L30z9AUwlp9MWqzm/DhBITCw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/combobox@3.9.2': - resolution: {integrity: sha512-ZsbAcD58IvxZqwYxg9d2gOf8R/k5RUB2TPUiGKD6wgWfEKH6SDzY3bgRByHGOyMCyJB62cHjih/ZShizNTguqA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/data@3.11.0': resolution: {integrity: sha512-0BlPT58WrAtUvpiEfUuyvIsGFTzp/9vA5y+pk53kGJhOdc5tqBGHi9cg40pYE/i1vdHJGMpyHGRD9nkQb8wN3Q==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/datepicker@3.10.2': - resolution: {integrity: sha512-pa5IZUw+49AyOnddwu4XwU2kI5eo/1thbiIVNHP8uDpbbBrBkquSk3zVFDAGX1cu/I1U2VUkt64U/dxgkwaMQw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/datepicker@3.9.1': resolution: {integrity: sha512-o5xLvlZGJyAbTev2yruGlV2fzQyIDuYTgL19TTt0W0WCfjGGr/AAA9GjGXXmyoRA7sZMxqIPnnv7lNrdA38ofA==} peerDependencies: @@ -8700,72 +8456,34 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/dnd@3.4.2': - resolution: {integrity: sha512-VrHmNoNdVGrx5JHdz/zewmN+N8rlZe+vL/iAOLmvQ74RRLEz8KDFnHdlhgKg1AZqaSg3JJ18BlHEkS7oL1n+tA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/flags@3.0.0': resolution: {integrity: sha512-e3i2ItHbIa0eEwmSXAnPdD7K8syW76JjGe8ENxwFJPW/H1Pu9RJfjkCb/Mq0WSPN/TpxBb54+I9TgrGhbCoZ9w==} - '@react-stately/flags@3.0.3': - resolution: {integrity: sha512-/ha7XFA0RZTQsbzSPwu3KkbNMgbvuM0GuMTYLTBWpgBrovBNTM+QqI/PfZTdHg8PwCYF4H5Y8gjdSpdulCvJFw==} - '@react-stately/form@3.0.0': resolution: {integrity: sha512-C8wkfFmtx1escizibhdka5JvTy9/Vp173CS9cakjvWTmnjYYC1nOlzwp7BsYWTgerCFbRY/BU/Cf/bJDxPiUKQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/form@3.0.5': - resolution: {integrity: sha512-J3plwJ63HQz109OdmaTqTA8Qhvl3gcYYK7DtgKyNP6mc/Me2Q4tl2avkWoA+22NRuv5m+J8TpBk4AVHUEOwqeQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/grid@3.8.4': resolution: {integrity: sha512-rwqV1K4lVhaiaqJkt4TfYqdJoVIyqvSm98rKAYfCNzrKcivVpoiCMJ2EMt6WlYCjDVBdEOQ7fMV1I60IV0pntA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/grid@3.9.2': - resolution: {integrity: sha512-2gK//sqAqg2Xaq6UITTFQwFUJnBRgcW+cKBVbFt+F8d152xB6UwwTS/K79E5PUkOotwqZgTEpkrSFs/aVxCLpw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/list@3.10.2': resolution: {integrity: sha512-INt+zofkIg2KN8B95xPi9pJG7ZFWAm30oIm/lCPBqM3K1Nm03/QaAbiQj2QeJcOsG3lb7oqI6D6iwTolwJkjIQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/list@3.10.8': - resolution: {integrity: sha512-rHCiPLXd+Ry3ztR9DkLA5FPQeH4Zd4/oJAEDWJ77W3oBBOdiMp3ZdHDLP7KBRh17XGNLO/QruYoHWAQTPiMF4g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/menu@3.6.0': resolution: {integrity: sha512-OB6CjNyfOkAuirqx1oTL8z8epS9WDzLyrXjmRnxdiCU9EgRXLGAQNECuO7VIpl58oDry8tgRJiJ8fn8FivWSQA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/menu@3.8.2': - resolution: {integrity: sha512-lt6hIHmSixMzkKx1rKJf3lbAf01EmEvvIlENL20GLiU9cRbpPnPJ1aJMZ5Ad5ygglA7wAemAx+daPhlTQfF2rg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/numberfield@3.8.0': resolution: {integrity: sha512-1XvB8tDOvZKcFnMM6qNLEaTVJcIc0jRFS/9jtS8MzalZvh8DbKi0Ucm1bGU7S5rkCx2QWqZ0rGOIm2h/RlcpkA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/numberfield@3.9.6': - resolution: {integrity: sha512-p2R9admGLI439qZzB39dyANhkruprJJtZwuoGVtxW/VD0ficw6BrPVqAaKG25iwKPkmveleh9p8o+yRqjGedcQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/overlays@3.6.10': - resolution: {integrity: sha512-XxZ2qScT5JPwGk9qiVJE4dtVh3AXTcYwGRA5RsHzC26oyVVsegPqY2PmNJGblAh6Q57VyodoVUyebE0Eo5CzRw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/overlays@3.6.4': resolution: {integrity: sha512-tHEaoAGpE9dSnsskqLPVKum59yGteoSqsniTopodM+miQozbpPlSjdiQnzGLroy5Afx5OZYClE616muNHUILXA==} peerDependencies: @@ -8776,86 +8494,41 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/radio@3.10.7': - resolution: {integrity: sha512-ZwGzFR+sGd42DxRlDTp3G2vLZyhMVtgHkwv2BxazPHxPMvLO9yYl7+3PPNxAmhMB4tg2u9CrzffpGX2rmEJEXA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/searchfield@3.5.0': resolution: {integrity: sha512-SStjChkn/33pEn40slKQPnBnmQYyxVazVwPjiBkdeVejC42lUVairUTrGJgF0PNoZTbxn0so2/XzjqTC9T8iCw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/searchfield@3.5.6': - resolution: {integrity: sha512-gVzU0FeWiLYD8VOYRgWlk79Qn7b2eirqOnWhtI5VNuGN8WyNaCIuBp6SkXTW2dY8hs2Hzn8HlMbgy1MIc7130Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/select@3.6.1': resolution: {integrity: sha512-e5ixtLiYLlFWM8z1msDqXWhflF9esIRfroptZsltMn1lt2iImUlDRlOTZlMtPQzUrDWoiHXRX88sSKUM/jXjQQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/select@3.6.7': - resolution: {integrity: sha512-hCUIddw0mPxVy1OH6jhyaDwgNea9wESjf+MYdnnTG/abRB+OZv/dWScd87OjzVsHTHWcw7CN4ZzlJoXm0FJbKQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/selection@3.14.2': resolution: {integrity: sha512-mL7OoiUgVWaaF7ks5XSxgbXeShijYmD4G3bkBHhqkpugU600QH6BM2hloCq8KOUupk1y8oTljPtF9EmCv375DA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/selection@3.16.2': - resolution: {integrity: sha512-C4eSKw7BIZHJLPzwqGqCnsyFHiUIEyryVQZTJDt6d0wYBOHU6k1pW+Q4VhrZuzSv+IMiI2RkiXeJKc55f0ZXrg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/slider@3.5.0': resolution: {integrity: sha512-dOVpIxb7XKuiRxgpHt1bUSlsklciFki100tKIyBPR+Okar9iC/CwLYROYgVfLkGe77jEBNkor9tDLjDGEWcc1w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/slider@3.5.7': - resolution: {integrity: sha512-gEIGTcpBLcXixd8LYiLc8HKrBiGQJltrrEGoOvvTP8KVItXQxmeL+JiSsh8qgOoUdRRpzmAoFNUKGEg2/gtN8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/table@3.11.4': resolution: {integrity: sha512-dWINJIEOKQl4qq3moq+S8xCD3m+yJqBj0dahr+rOkS+t2uqORwzsusTM35D2T/ZHZi49S2GpE7QuDa+edCynPw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/table@3.12.2': - resolution: {integrity: sha512-dUcsrdALylhWz6exqIoqtR/dnrzjIAptMyAUPT378Y/mCYs4PxKkHSvtPEQrZhdQS1ALIIgfeg9KUVIempoXPw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/tabs@3.6.3': resolution: {integrity: sha512-Nj+Gacwa2SIzYIvHW40GsyX4Q6c8kF7GOuXESeQswbCjnwqhrSbDBp+ngPcUPUJxqFh6JhDCVwAS3wMhUoyUwA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/tabs@3.6.9': - resolution: {integrity: sha512-YZDqZng3HrRX+uXmg6u78x73Oi24G5ICpiXVqDKKDkO333XCA5H8MWItiuPZkYB2h3SbaCaLqSobLkvCoWYpNQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/toggle@3.7.0': resolution: {integrity: sha512-TRksHkCJk/Xogq4181g3CYgJf+EfsJCqX5UZDSw1Z1Kgpvonjmdf6FAfQfCh9QR2OuXUL6hOLUDVLte5OPI+5g==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/toggle@3.7.7': - resolution: {integrity: sha512-AS+xB4+hHWa3wzYkbS6pwBkovPfIE02B9SnuYTe0stKcuejpWKo5L3QMptW0ftFYsW3ZPCXuneImfObEw2T01A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/tooltip@3.4.12': - resolution: {integrity: sha512-QKYT/cze7n9qaBsk7o5ais3jRfhYCzcVRfps+iys/W+/9FFbbhjfQG995Lwi6b+vGOHWfXxXpwmyIO2tzM1Iog==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/tooltip@3.4.6': resolution: {integrity: sha512-uL93bmsXf+OOgpKLPEKfpDH4z+MK2CuqlqVxx7rshN0vjWOSoezE5nzwgee90+RpDrLNNNWTNa7n+NkDRpI1jA==} peerDependencies: @@ -8866,16 +8539,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-stately/tree@3.8.4': - resolution: {integrity: sha512-HFNclIXJ/3QdGQWxXbj+tdlmIX/XwCfzAMB5m26xpJ6HtJhia6dtx3GLfcdyHNjmuRbAsTBsAAnnVKBmNRUdIQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/utils@3.10.3': - resolution: {integrity: sha512-moClv7MlVSHpbYtQIkm0Cx+on8Pgt1XqtPx6fy9rQFb2DNc9u1G3AUVnqA17buOkH1vLxAtX4MedlxMWyRCYYA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-stately/utils@3.9.0': resolution: {integrity: sha512-yPKFY1F88HxuZ15BG2qwAYxtpE4HnIU0Ofi4CuBE0xC6I8mwo4OQjDzi+DZjxQngM9D6AeTTD6F1V8gkozA0Gw==} peerDependencies: @@ -8891,66 +8554,31 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/breadcrumbs@3.7.7': - resolution: {integrity: sha512-ZmhXwD2LLzfEA2OvOCp/QvXu8A/Edsrn5q0qUDGsmOZj9SCVeT82bIv8P+mQnATM13mi2gyoik6102Jc1OscJA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/button@3.9.1': resolution: {integrity: sha512-bf9iTar3PtqnyV9rA+wyFyrskZKhwmOuOd/ifYIjPs56YNVXWH5Wfqj6Dx3xdFBgtKx8mEVQxVhoX+WkHX+rtw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/button@3.9.6': - resolution: {integrity: sha512-8lA+D5JLbNyQikf8M/cPP2cji91aVTcqjrGpDqI7sQnaLFikM8eFR6l1ZWGtZS5MCcbfooko77ha35SYplSQvw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/calendar@3.4.3': resolution: {integrity: sha512-96x57ctX5wNEl+8et3sc2NQm8neOJayEeqOQQpyPtI7jyvst/xBrKCwysf9W/dhgPlUC+KeBAYFWfjd5hFVHYA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/calendar@3.4.9': - resolution: {integrity: sha512-O/PS9c21HgO9qzxOyZ7/dTccxabFZdF6tj3UED4DrBw7AN3KZ7JMzwzYbwHinOcO7nUcklGgNoAIHk45UAKR9g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/checkbox@3.6.0': resolution: {integrity: sha512-vgbuJzQpVCNT5AZWV0OozXCnihqrXxoZKfJFIw0xro47pT2sn3t5UC4RA9wfjDGMoK4frw1K/4HQLsQIOsPBkw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/checkbox@3.8.3': - resolution: {integrity: sha512-f4c1mnLEt0iS1NMkyZXgT3q3AgcxzDk7w6MSONOKydcnh0xG5L2oefY14DhVDLkAuQS7jThlUFwiAs+MxiO3MA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/combobox@3.10.0': resolution: {integrity: sha512-1IXSNS02TPbguyYopaW2snU6sZusbClHrEyVr4zPeexTV4kpUUBNXOzFQ+eSQRR0r2XW57Z0yRW4GJ6FGU0yCA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/combobox@3.12.1': - resolution: {integrity: sha512-bd5YwHZWtgnJx4jGbplWbYzXj7IbO5w3IY5suNR7r891rx6IktquZ8GQwyYH0pQ/x+X5LdK2xI59i6+QC2PmlA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/datepicker@3.7.1': resolution: {integrity: sha512-5juVDULOytNzkotqX8j5mYKJckeIpkgbHqVSGkPgLw0++FceIaSZ6RH56cqLup0pO45paqIt9zHh+QXBYX+syg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/datepicker@3.8.2': - resolution: {integrity: sha512-Ih4F0bNVGrEuwCD8XmmBAspuuOBsj/Svn/pDFtC2RyAZjXfWh+sI+n4XLz/sYKjvARh5TUI8GNy9smYS4vYXug==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/dialog@3.5.12': - resolution: {integrity: sha512-JmpQbSpXltqEyYfEwoqDolABIiojeExkqolHNdQlayIsfFuSxZxNwXZPOpz58Ri/iwv21JP7K3QF0Gb2Ohxl9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/dialog@3.5.7': resolution: {integrity: sha512-geYoqAyQaTLG43AaXdMUVqZXYgkSifrD9cF7lR2kPAT0uGFv0YREi6ieU+aui8XJ83EW0xcxP+EPWd2YkN4D4w==} peerDependencies: @@ -8961,36 +8589,16 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/grid@3.2.8': - resolution: {integrity: sha512-6PJrpukwMqlv3IhJSDkJuVbhHM8Oe6hd2supWqd9adMXrlSP7QHt9a8SgFcFblCCTx8JzUaA0PvY5sTudcEtOQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/link@3.5.2': resolution: {integrity: sha512-/s51/WejmpLiyxOgP89s4txgxYoGaPe8pVDItVo1h4+BhU1Puyvgv/Jx8t9dPvo6LUXbraaN+SgKk/QDxaiirw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/link@3.5.7': - resolution: {integrity: sha512-2WyaVmm1qr9UrSG3Dq6iz+2ziuVp+DH8CsYZ9CA6aNNb6U18Hxju3LTPb4a5gM0eC7W0mQGNBmrgGlAdDZEJOw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/listbox@3.4.6': resolution: {integrity: sha512-XOQvrTqNh5WIPDvKiWiep8T07RAsMfjAXTjDbnjxVlKACUXkcwpts9kFaLnJ9LJRFt6DwItfP+WMkzvmx63/NQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/listbox@3.5.1': - resolution: {integrity: sha512-n5bOgD9lgfK1qaLtag9WPnu151SwXBCNn/OgGY/Br9mWRl+nPUEYtFcPX+2VCld7uThf54kwrTmzlFnaraIlcw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/menu@3.9.11': - resolution: {integrity: sha512-IguQVF70d7aHXgWB1Rd2a/PiIuLZ2Nt7lyayJshLcy/NLOYmgpTmTyn2WCtlA5lTfQwmQrNFf4EvnWkeljJXdA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/menu@3.9.6': resolution: {integrity: sha512-w/RbFInOf4nNayQDv5c2L8IMJbcFOkBhsT3xvvpTy+CHvJcQdjggwaV1sRiw7eF/PwB81k2CwigmidUzHJhKDg==} peerDependencies: @@ -9001,106 +8609,56 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/meter@3.4.3': - resolution: {integrity: sha512-Y2fX5CTAPGRKxVSeepbeyN6/K+wlF9pMRcNxTSU2qDwdoFqNCtTWMcWuCsU/Y2L/zU0jFWu4x0Vo7WkrcsgcMA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/numberfield@3.7.0': resolution: {integrity: sha512-gaGi+vqm1Y8LCWRsWYUjcGftPIzl+8W2VOfkgKMLM8y76nnwTPtmAqs+Ap1cg7sEJSfsiKMq93e9yvP3udrC2w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/numberfield@3.8.5': - resolution: {integrity: sha512-LVWggkxwd1nyVZomXBPfQA1E4I4/i4PBifjcDs2AfcV7q5RE9D+DVIDXsYucVOBxPlDOxiAq/T9ypobspWSwHw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/overlays@3.8.4': resolution: {integrity: sha512-pfgNlQnbF6RB/R2oSxyqAP3Uzz0xE/k5q4n5gUeCDNLjY5qxFHGE8xniZZ503nZYw6VBa9XMN1efDOKQyeiO0w==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/overlays@3.8.9': - resolution: {integrity: sha512-9ni9upQgXPnR+K9cWmbYWvm3ll9gH8P/XsEZprqIV5zNLMF334jADK48h4jafb1X9RFnj0WbHo6BqcSObzjTig==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/progress@3.5.1': resolution: {integrity: sha512-CqsUjczUK/SfuFzDcajBBaXRTW0D3G9S/yqLDj9e8E0ii+lGDLt1PHj24t1J7E88U2rVYqmM9VL4NHTt8o3IYA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/progress@3.5.6': - resolution: {integrity: sha512-Nh43sjQ5adyN1bTHBPRaIPhXUdBqP0miYeJpeMY3V/KUl4qmouJLwDnccwFG4xLm6gBfYe22lgbbV7nAfNnuTQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/radio@3.7.0': resolution: {integrity: sha512-EcwGAXzSHjSqpFZha7xn3IUrhPiJLj+0yb1Ip0qPmhWz0VVw2DwrkY7q/jfaKroVvQhTo2TbfGhcsAQrt0fRqg==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/radio@3.8.3': - resolution: {integrity: sha512-fUVJt4Bb6jOReFqnhHVNxWXH7t6c60uSFfoPKuXt/xI9LL1i2jhpur0ggpTfIn3qLIAmNBU6bKBCWAdr4KjeVQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/searchfield@3.5.2': resolution: {integrity: sha512-JAK2/Kg4Dr393FYfbRw0TlXKnJPX77sq1x/ZBxtO6p64+MuuIYKqw0i9PwDlo1PViw2QI5u8GFhKA2TgemY9uA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/searchfield@3.5.8': - resolution: {integrity: sha512-EcdqalHNIC6BJoRfmqUhAvXRd3aHkWlV1cFCz57JJKgUEFYyXPNrXd1b73TKLzTXEk+X/D6LKV15ILYpEaxu8w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/select@3.9.1': resolution: {integrity: sha512-EpKSxrnh8HdZvOF9dHQkjivAcdIp1K81FaxmvosH8Lygqh0iYXxAdZGtKLMyBoPI8YFhA+rotIzTcOqgCCnqWA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/select@3.9.6': - resolution: {integrity: sha512-cVSFR0eJLup/ht1Uto+y8uyLmHO89J6wNh65SIHb3jeVz9oLBAedP3YNI2qB+F9qFMUcA8PBSLXIIuT6gXzLgQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/shared@3.22.0': resolution: {integrity: sha512-yVOekZWbtSmmiThGEIARbBpnmUIuePFlLyctjvCbgJgGhz8JnEJOipLQ/a4anaWfzAgzSceQP8j/K+VOOePleA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/shared@3.24.1': - resolution: {integrity: sha512-AUQeGYEm/zDTN6zLzdXolDxz3Jk5dDL7f506F07U8tBwxNNI3WRdhU84G0/AaFikOZzDXhOZDr3MhQMzyE7Ydw==} + '@react-types/shared@3.34.0': + resolution: {integrity: sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==} peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 '@react-types/slider@3.7.0': resolution: {integrity: sha512-uyQXUVFfqc9SPUW0LZLMan2n232F/OflRafiHXz9viLFa9tVOupVa7GhASRAoHojwkjoJ1LjFlPih7g5dOZ0/Q==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/slider@3.7.5': - resolution: {integrity: sha512-bRitwQRQjQoOcKEdPMljnvm474dwrmsc6pdsVQDh/qynzr+KO9IHuYc3qPW53WVE2hMQJDohlqtCAWQXWQ5Vcg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/switch@3.5.0': resolution: {integrity: sha512-/wNmUGjk69bP6t5k2QkAdrNN5Eb9Rz4dOyp0pCPmoeE+5haW6sV5NmtkvWX1NSc4DQz1xL/a5b+A0vxPCP22Jw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/switch@3.5.5': - resolution: {integrity: sha512-SZx1Bd+COhAOs/RTifbZG+uq/llwba7VAKx7XBeX4LeIz1dtguy5bigOBgFTMQi4qsIVCpybSWEEl+daj4XFPw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/table@3.10.1': - resolution: {integrity: sha512-xsNh0Gm4GtNeSknZqkMsfGvc94fycmfhspGO+FzQKim2hB5k4yILwd+lHYQ2UKW6New9GVH/zN2Pd3v67IeZ2g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/table@3.9.2': resolution: {integrity: sha512-brw5JUANOzBa2rYNpN8AIl9nDZ9RwRZC6G/wTM/JhtirjC1S42oCtf8Ap5rWJBdmMG/5KOfcGNcAl/huyqb3gg==} peerDependencies: @@ -9111,26 +8669,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/tabs@3.3.9': - resolution: {integrity: sha512-3Q9kRVvg/qDyeJR/W1+C2z2OyvDWQrSLvOCvAezX5UKzww4rBEAA8OqBlyDwn7q3fiwrh/m64l6p+dbln+RdxQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/textfield@3.9.0': resolution: {integrity: sha512-D/DiwzsfkwlAg3uv8hoIfwju+zhB/hWDEdTvxQbPkntDr0kmN/QfI17NMSzbOBCInC4ABX87ViXLGxr940ykGA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - '@react-types/textfield@3.9.6': - resolution: {integrity: sha512-0uPqjJh4lYp1aL1HL9IlV8Cgp8eT0PcsNfdoCktfkLytvvBPmox2Pfm57W/d0xTtzZu2CjxhYNTob+JtGAOeXA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/tooltip@3.4.11': - resolution: {integrity: sha512-WPikHQxeT5Lb09yJEaW6Ja3ecE0g1YM6ukWYS2v/iZLUPn5YlYrGytspuCYQNSh/u7suCz4zRLEHYCl7OCigjw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@react-types/tooltip@3.4.6': resolution: {integrity: sha512-RaZewdER7ZcsNL99RhVHs8kSLyzIBkwc0W6eFZrxST2MD9J5GzkVWRhIiqtFOd5U1aYnxdJ6woq72Ef+le6Vfw==} peerDependencies: @@ -10470,10 +10013,6 @@ packages: resolution: {integrity: sha512-P6iIPyYQ+qH8CvGauAqanhVnjrnRe0IZFSYCeGkSRW9q3u8bdVn2NPI+lasFyVsEQn1J/IFmp5Aax41+dAP9wg==} engines: {node: '>=12'} - '@testing-library/jest-dom@6.5.0': - resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} @@ -10709,9 +10248,6 @@ packages: '@types/interpret@1.1.3': resolution: {integrity: sha512-uBaBhj/BhilG58r64mtDb/BEdH51HIQLgP5bmWzc5qCtFMja8dCk/IOJmk36j0lbi9QHwI6sbtUNGuqXdKCAtQ==} - '@types/invariant@2.2.37': - resolution: {integrity: sha512-IwpIMieE55oGWiXkQPSBY1nw1nFs6bsKXTFskNY8sdS17K24vyEBRQZEwlRS7ZmXCWnJcQtbxWzly+cODWGs2A==} - '@types/is-ci@3.0.0': resolution: {integrity: sha512-Q0Op0hdWbYd1iahB+IFNQcWXFq4O0Q5MwQP7uN0souuQ4rPg1vEYcnIOfr1gY+M+6rc8FGoRaBO1mOOvL29sEQ==} @@ -11285,8 +10821,19 @@ packages: resolution: {integrity: sha512-q76lDAafvHNGWedNAVHrz/EyYTS8qwRLcwne8SJQdRN5P3HydxU6XROFvJfTML6KZXQX2FDdGY4/SnaNyd7M0Q==} engines: {node: '>=16.0.0'} - '@window-splitter/state@0.4.1': - resolution: {integrity: sha512-JbsCYEWFXs79Gxudtv+XTYE2tdidPg8oGoIbUyh9QtdYUNUO/QKI2ORk2WFNIkk2ZzTWMXquI92s1nj4sAhZ+w==} + '@window-splitter/interface@1.1.3': + resolution: {integrity: sha512-GV7nunGpSqrlbR8pyI65aFMYlyFTO1VgWhT2cFsPkfYwmh5xBNWAWkJJtDMvbfwBHtvTGf4kTztx6e9LZSiSeQ==} + engines: {node: '>=18.0.0'} + + '@window-splitter/react@1.1.3': + resolution: {integrity: sha512-IDquSVO7WFLlx8NsrINaT+VewZZ1GpIV5E5AzeOBrGVI5UVy/b+jPFCWuUv6s7iuOIarX8wvuSaDDf3L4hqabg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + + '@window-splitter/state@1.1.3': + resolution: {integrity: sha512-aMm1sbA4P9zeg87tVgqnlkrirjhTi/D08QvgnoSjCihq4vBqVDICrzZ6wwmlmU1E0Rar1hWy3GTpnCcn8tdZqQ==} engines: {node: '>=18.0.0'} '@wolfy1339/lru-cache@11.0.2-patch.1': @@ -11296,15 +10843,6 @@ packages: '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} - '@xstate/react@4.1.2': - resolution: {integrity: sha512-orAidFrKCrU0ZwN5l/ABPlBfW2ziRDT2RrYoktRlZ0WRoLvA2E/uAC1JpZt43mCLtc8jrdwYCgJiqx1V8NvGTw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - xstate: ^5.18.1 - peerDependenciesMeta: - xstate: - optional: true - '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -12027,10 +11565,6 @@ packages: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} - chalk@3.0.0: - resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} - engines: {node: '>=8'} - chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -12497,9 +12031,6 @@ packages: resolution: {integrity: sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==} engines: {node: '>= 6'} - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -13007,9 +12538,6 @@ packages: dom-accessibility-api@0.5.15: resolution: {integrity: sha512-8o+oVqLQZoruQPYy3uAAQtc6YbtSiRq5aPJBhJ82YTJRHvI6ofhYAkC81WmjFTnfUbqg6T3aCglIpU9p/5e7Cw==} - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -14593,9 +14121,6 @@ packages: resolution: {integrity: sha512-YFMSV91JNBOSjw1cOfw2tup6hDP7mkz+2AUV7W1L1AM6ntgI75qC1ZeFpjPGMrWp+upmBRTX2fJWQ8c7jsUWpA==} engines: {node: '>=14'} - invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.3.2: resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} engines: {node: '>=12.22.0'} @@ -16834,9 +16359,6 @@ packages: performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} - performant-array-to-tree@1.11.0: - resolution: {integrity: sha512-YwCqIDvnaebXaKuKQhI5yJD6ryDc3FxvoeX/5ougXTKDUWb7s5S2BuBgIyftCa4sBe1+ZU5Kmi4RJy+pjjjrpw==} - periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} @@ -17529,11 +17051,11 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 - react-aria@3.34.3: - resolution: {integrity: sha512-wSprEI5EojDFCm357MxnKAxJZN68OYIt6UH6N0KCo6MEUAVZMbhMSmGYjw/kLK4rI7KrbJDqGqUMQkwc93W9Ng==} + react-aria@3.48.0: + resolution: {integrity: sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==} peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-collapse@5.1.1: resolution: {integrity: sha512-k6cd7csF1o9LBhQ4AGBIdxB60SUEUMQDAnL2z1YvYNr9KoKr+nDkhN6FK7uGaBd/rYrYfrMpzpmJEIeHRYogBw==} @@ -17674,6 +17196,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + react-stately@3.46.0: + resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -17702,14 +17229,6 @@ packages: react: '*' react-dom: '*' - react-window-splitter@0.4.1: - resolution: {integrity: sha512-7XjNxlsBqOySyPEf32GZXLIA7gIPLWqswnOwM4Aw15qiMChMm8einkz40RbLfvrD11EKHIU35vcUH1RDA+we9w==} - engines: {node: '>=18.0.0'} - deprecated: This package has moved to @window-splitter/react - peerDependencies: - react: '>=16' - react-dom: '>=16' - react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -17812,11 +17331,6 @@ packages: reduce-css-calc@2.1.8: resolution: {integrity: sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==} - reforest@0.13.0: - resolution: {integrity: sha512-f0It/s51f1UWCCCni0viULALDBhxWBPFnLmZRYtKcz4zYeNWqeNTdcnU/OpBry9tk+jyMQcH3MLK8UdzsAvA5w==} - peerDependencies: - react: '>=16.8' - regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -19504,9 +19018,6 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} - universal-cookie@7.2.0: - resolution: {integrity: sha512-PvcyflJAYACJKr28HABxkGemML5vafHmiL4ICe3e+BEKXRMt0GaFLZhAwgv637kFFnnfiSJ8e6jknrKkMrU+PQ==} - universal-github-app-jwt@1.2.0: resolution: {integrity: sha512-dncpMpnsKBk0eetwfN8D8OUHGfiDhhJ+mtsbMl+7PfW7mYjiH8LIcqRmYMtzYLgSh47HjfdBtrBwIQ/gizKR3g==} @@ -19602,6 +19113,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -20060,9 +19576,6 @@ packages: resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==} engines: {node: '>=0.4.0'} - xstate@5.18.1: - resolution: {integrity: sha512-m02IqcCQbaE/kBQLunwub/5i8epvkD2mFutnL17Oeg1eXTShe1sRF4D5mhv1dlaFO4vbW5gRGRhraeAD5c938g==} - xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -20176,28 +19689,11 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zustand@4.5.5: - resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} - engines: {node: '>=12.7.0'} - peerDependencies: - '@types/react': '>=16.8' - immer: '>=9.0.6' - react: '>=16.8' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@adobe/css-tools@4.4.0': {} - '@ai-sdk/anthropic@2.0.4(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -23059,7 +22555,7 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.5.11) - '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9) + '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9) '@open-draft/deferred-promise': 2.2.0 '@types/ws': 8.5.12 hono: 4.5.11 @@ -23745,7 +23241,7 @@ snapshots: dependencies: hono: 4.11.8 - '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)': + '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.5.11) ws: 8.18.3(bufferutil@4.0.9) @@ -23952,20 +23448,15 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@internationalized/date@3.5.1': - dependencies: - '@swc/helpers': 0.5.2 - - '@internationalized/date@3.5.5': + '@internationalized/date@3.12.1': dependencies: '@swc/helpers': 0.5.15 - '@internationalized/message@3.1.1': + '@internationalized/date@3.5.1': dependencies: - '@swc/helpers': 0.5.15 - intl-messageformat: 10.5.8 + '@swc/helpers': 0.5.2 - '@internationalized/message@3.1.4': + '@internationalized/message@3.1.1': dependencies: '@swc/helpers': 0.5.15 intl-messageformat: 10.5.8 @@ -23974,7 +23465,7 @@ snapshots: dependencies: '@swc/helpers': 0.5.15 - '@internationalized/number@3.5.3': + '@internationalized/number@3.6.6': dependencies: '@swc/helpers': 0.5.15 @@ -23982,7 +23473,7 @@ snapshots: dependencies: '@swc/helpers': 0.5.15 - '@internationalized/string@3.2.3': + '@internationalized/string@3.2.8': dependencies: '@swc/helpers': 0.5.15 @@ -25732,12 +25223,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.1 - '@radix-ui/react-compose-refs@1.1.0(@types/react@18.2.69)(react@18.2.0)': - dependencies: - react: 18.2.0 - optionalDependencies: - '@types/react': 18.2.69 - '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.1)(react@18.3.1)': dependencies: react: 18.3.1 @@ -27000,166 +26485,78 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 - '@react-aria/breadcrumbs@3.5.16(react@18.2.0)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/link': 3.7.4(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/breadcrumbs': 3.7.7(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/breadcrumbs@3.5.9(react@18.2.0)': + '@react-aria/breadcrumbs@3.5.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/link': 3.6.3(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/link': 3.6.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-types/breadcrumbs': 3.7.2(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/button@3.9.1(react@18.2.0)': + '@react-aria/button@3.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/toggle': 3.7.0(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 - - '@react-aria/button@3.9.8(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/toggle': 3.7.7(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/calendar@3.5.11(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@internationalized/date': 3.5.5 - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/calendar': 3.5.4(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/calendar': 3.4.9(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - react-dom '@react-aria/calendar@3.5.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@internationalized/date': 3.5.1 - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) + '@internationalized/date': 3.12.1 + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/live-announcer': 3.3.1 - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/calendar': 3.4.3(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) '@react-types/calendar': 3.4.3(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-aria/checkbox@3.13.0(react@18.2.0)': + '@react-aria/checkbox@3.13.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/form': 3.0.1(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) - '@react-aria/toggle': 3.10.0(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/form': 3.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/toggle': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/checkbox': 3.6.1(react@18.2.0) '@react-stately/form': 3.0.0(react@18.2.0) '@react-stately/toggle': 3.7.0(react@18.2.0) '@react-types/checkbox': 3.6.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/checkbox@3.14.6(react@18.2.0)': - dependencies: - '@react-aria/form': 3.0.8(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/toggle': 3.10.7(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/checkbox': 3.6.8(react@18.2.0) - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-stately/toggle': 3.7.7(react@18.2.0) - '@react-types/checkbox': 3.8.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 - - '@react-aria/combobox@3.10.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/listbox': 3.13.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/menu': 3.15.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/overlays': 3.23.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/selection': 3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/textfield': 3.14.8(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/combobox': 3.9.2(react@18.2.0) - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/combobox': 3.12.1(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - react-dom '@react-aria/combobox@3.8.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/i18n': 3.10.0(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/listbox': 3.11.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/live-announcer': 3.3.1 '@react-aria/menu': 3.12.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/overlays': 3.20.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/selection': 3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/textfield': 3.14.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/textfield': 3.14.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/collections': 3.10.4(react@18.2.0) '@react-stately/combobox': 3.8.1(react@18.2.0) '@react-stately/form': 3.0.0(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) '@react-types/combobox': 3.10.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/datepicker@3.11.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@internationalized/date': 3.5.5 - '@internationalized/number': 3.5.3 - '@internationalized/string': 3.2.3 - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/form': 3.0.8(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/spinbutton': 3.6.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/datepicker': 3.10.2(react@18.2.0) - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/calendar': 3.4.9(react@18.2.0) - '@react-types/datepicker': 3.8.2(react@18.2.0) - '@react-types/dialog': 3.5.12(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -27169,11 +26566,11 @@ snapshots: '@internationalized/date': 3.5.1 '@internationalized/number': 3.5.0 '@internationalized/string': 3.2.0 - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/form': 3.0.1(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/form': 3.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/spinbutton': 3.6.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/utils': 3.23.0(react@18.2.0) '@react-stately/datepicker': 3.9.1(react@18.2.0) @@ -27189,257 +26586,140 @@ snapshots: '@react-aria/dialog@3.5.10(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/overlays': 3.20.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-types/dialog': 3.5.7(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/dialog@3.5.17(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/overlays': 3.23.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/dialog': 3.5.12(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) '@react-aria/dnd@3.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@internationalized/string': 3.2.0 - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) + '@internationalized/string': 3.2.8 + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/live-announcer': 3.3.1 '@react-aria/overlays': 3.20.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/dnd': 3.2.7(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/dnd@3.7.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@internationalized/string': 3.2.3 - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/overlays': 3.23.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/dnd': 3.4.2(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-aria/focus@3.16.0(react@18.2.0)': + '@react-aria/focus@3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - clsx: 2.1.1 - react: 18.2.0 - - '@react-aria/focus@3.18.2(react@18.2.0)': - dependencies: - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 clsx: 2.1.1 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/form@3.0.1(react@18.2.0)': + '@react-aria/form@3.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/form': 3.0.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 - - '@react-aria/form@3.0.8(react@18.2.0)': - dependencies: - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/grid@3.10.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/selection': 3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/grid': 3.9.2(react@18.2.0) - '@react-stately/selection': 3.16.2(react@18.2.0) - '@react-types/checkbox': 3.8.3(react@18.2.0) - '@react-types/grid': 3.2.8(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - react-dom '@react-aria/grid@3.8.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/live-announcer': 3.3.1 '@react-aria/selection': 3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/collections': 3.10.4(react@18.2.0) '@react-stately/grid': 3.8.4(react@18.2.0) '@react-stately/selection': 3.14.2(react@18.2.0) - '@react-stately/virtualizer': 3.6.6(react@18.2.0) + '@react-stately/virtualizer': 3.6.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-types/checkbox': 3.6.0(react@18.2.0) '@react-types/grid': 3.2.3(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) '@react-aria/gridlist@3.7.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/grid': 3.8.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/selection': 3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/list': 3.10.2(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/gridlist@3.9.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/grid': 3.10.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/selection': 3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/list': 3.10.8(react@18.2.0) - '@react-stately/tree': 3.8.4(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-aria/i18n@3.10.0(react@18.2.0)': + '@react-aria/i18n@3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@internationalized/date': 3.5.1 + '@internationalized/date': 3.12.1 '@internationalized/message': 3.1.1 - '@internationalized/number': 3.5.0 - '@internationalized/string': 3.2.0 + '@internationalized/number': 3.6.6 + '@internationalized/string': 3.2.8 '@react-aria/ssr': 3.9.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/i18n@3.12.2(react@18.2.0)': - dependencies: - '@internationalized/date': 3.5.5 - '@internationalized/message': 3.1.4 - '@internationalized/number': 3.5.3 - '@internationalized/string': 3.2.3 - '@react-aria/ssr': 3.9.5(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/interactions@3.20.1(react@18.2.0)': + '@react-aria/interactions@3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@react-aria/ssr': 3.9.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/interactions@3.22.2(react@18.2.0)': - dependencies: - '@react-aria/ssr': 3.9.5(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/label@3.7.11(react@18.2.0)': - dependencies: - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/label@3.7.4(react@18.2.0)': + '@react-aria/label@3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/link@3.6.3(react@18.2.0)': + '@react-aria/link@3.6.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-types/link': 3.5.2(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/link@3.7.4(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/link': 3.5.7(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom '@react-aria/listbox@3.11.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/selection': 3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/collections': 3.10.4(react@18.2.0) '@react-stately/list': 3.10.2(react@18.2.0) '@react-types/listbox': 3.4.6(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/listbox@3.13.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/selection': 3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/list': 3.10.8(react@18.2.0) - '@react-types/listbox': 3.5.1(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -27448,318 +26728,170 @@ snapshots: dependencies: '@swc/helpers': 0.5.15 - '@react-aria/live-announcer@3.3.4': - dependencies: - '@swc/helpers': 0.5.15 - '@react-aria/menu@3.12.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/overlays': 3.20.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/selection': 3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/collections': 3.10.4(react@18.2.0) '@react-stately/menu': 3.6.0(react@18.2.0) '@react-stately/tree': 3.7.5(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) '@react-types/menu': 3.9.6(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/menu@3.15.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/overlays': 3.23.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/selection': 3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/menu': 3.8.2(react@18.2.0) - '@react-stately/tree': 3.8.4(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/menu': 3.9.11(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-aria/meter@3.4.16(react@18.2.0)': - dependencies: - '@react-aria/progress': 3.4.16(react@18.2.0) - '@react-types/meter': 3.4.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/meter@3.4.9(react@18.2.0)': + '@react-aria/meter@3.4.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/progress': 3.4.9(react@18.2.0) + '@react-aria/progress': 3.4.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-types/meter': 3.3.6(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom '@react-aria/numberfield@3.10.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/spinbutton': 3.6.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/textfield': 3.14.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/textfield': 3.14.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/form': 3.0.0(react@18.2.0) '@react-stately/numberfield': 3.8.0(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) '@react-types/numberfield': 3.7.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/numberfield@3.11.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/spinbutton': 3.6.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/textfield': 3.14.8(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-stately/numberfield': 3.9.6(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/numberfield': 3.8.5(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) '@react-aria/overlays@3.20.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/ssr': 3.9.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-aria/visually-hidden': 3.8.8(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/visually-hidden': 3.8.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/overlays': 3.6.4(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) '@react-types/overlays': 3.8.4(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/overlays@3.23.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/ssr': 3.9.5(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-aria/visually-hidden': 3.8.15(react@18.2.0) - '@react-stately/overlays': 3.6.10(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/overlays': 3.8.9(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-aria/progress@3.4.16(react@18.2.0)': + '@react-aria/progress@3.4.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/progress': 3.5.6(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/progress@3.4.9(react@18.2.0)': - dependencies: - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-types/progress': 3.5.1(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/radio@3.10.0(react@18.2.0)': + '@react-aria/radio@3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/form': 3.0.1(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/form': 3.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/radio': 3.10.1(react@18.2.0) '@react-types/radio': 3.7.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/radio@3.10.7(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/form': 3.0.8(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/radio': 3.10.7(react@18.2.0) - '@react-types/radio': 3.8.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/searchfield@3.7.1(react@18.2.0)': + '@react-aria/searchfield@3.7.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/textfield': 3.14.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/textfield': 3.14.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/searchfield': 3.5.0(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) '@react-types/searchfield': 3.5.2(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/searchfield@3.7.8(react@18.2.0)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/textfield': 3.14.8(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/searchfield': 3.5.6(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/searchfield': 3.5.8(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom '@react-aria/select@3.14.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/form': 3.0.1(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) + '@react-aria/form': 3.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/listbox': 3.11.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/menu': 3.12.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/selection': 3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-aria/visually-hidden': 3.8.8(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/visually-hidden': 3.8.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/select': 3.6.1(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) '@react-types/select': 3.9.1(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/select@3.14.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/form': 3.0.8(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/listbox': 3.13.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/menu': 3.15.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/selection': 3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-aria/visually-hidden': 3.8.15(react@18.2.0) - '@react-stately/select': 3.6.7(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/select': 3.9.6(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) '@react-aria/selection@3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/selection': 3.14.2(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/selection@3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/selection': 3.16.2(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-aria/separator@3.3.9(react@18.2.0)': - dependencies: - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/separator@3.4.2(react@18.2.0)': - dependencies: - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/slider@3.7.11(react@18.2.0)': + '@react-aria/separator@3.3.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/slider': 3.5.7(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/slider': 3.7.5(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/slider@3.7.4(react@18.2.0)': + '@react-aria/slider@3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/slider': 3.5.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@react-types/slider': 3.7.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom '@react-aria/spinbutton@3.6.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/i18n': 3.10.0(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/live-announcer': 3.3.1 - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/spinbutton@3.6.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -27769,227 +26901,131 @@ snapshots: '@swc/helpers': 0.5.15 react: 18.2.0 - '@react-aria/ssr@3.9.5(react@18.2.0)': - dependencies: - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/switch@3.6.0(react@18.2.0)': + '@react-aria/switch@3.6.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/toggle': 3.10.0(react@18.2.0) + '@react-aria/toggle': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/toggle': 3.7.0(react@18.2.0) '@react-types/switch': 3.5.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 - - '@react-aria/switch@3.6.7(react@18.2.0)': - dependencies: - '@react-aria/toggle': 3.10.7(react@18.2.0) - '@react-stately/toggle': 3.7.7(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/switch': 3.5.5(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 + transitivePeerDependencies: + - react-dom '@react-aria/table@3.13.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/grid': 3.8.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/live-announcer': 3.3.1 - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-aria/visually-hidden': 3.8.8(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/visually-hidden': 3.8.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/collections': 3.10.4(react@18.2.0) '@react-stately/flags': 3.0.0 '@react-stately/table': 3.11.4(react@18.2.0) - '@react-stately/virtualizer': 3.6.6(react@18.2.0) + '@react-stately/virtualizer': 3.6.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-types/checkbox': 3.6.0(react@18.2.0) '@react-types/grid': 3.2.3(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@react-types/table': 3.9.2(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-aria/table@3.15.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/grid': 3.10.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-aria/visually-hidden': 3.8.15(react@18.2.0) - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/flags': 3.0.3 - '@react-stately/table': 3.12.2(react@18.2.0) - '@react-types/checkbox': 3.8.3(react@18.2.0) - '@react-types/grid': 3.2.8(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/table': 3.10.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - '@react-aria/tabs@3.8.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/selection': 3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/tabs': 3.6.3(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@react-types/tabs': 3.3.4(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-aria/tabs@3.9.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/selection': 3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/tabs': 3.6.9(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/tabs': 3.3.9(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - '@react-aria/tag@3.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@react-aria/gridlist': 3.7.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/selection': 3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/list': 3.10.2(react@18.2.0) '@react-types/button': 3.9.1(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - - '@react-aria/tag@3.4.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@react-aria/gridlist': 3.9.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/selection': 3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/list': 3.10.8(react@18.2.0) - '@react-types/button': 3.9.6(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@react-aria/textfield@3.14.1(react@18.2.0)': + '@react-aria/textfield@3.14.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/form': 3.0.1(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/form': 3.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/form': 3.0.0(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@react-types/textfield': 3.9.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/textfield@3.14.8(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/form': 3.0.8(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/textfield': 3.9.6(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/toggle@3.10.0(react@18.2.0)': + '@react-aria/toggle@3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/toggle': 3.7.0(react@18.2.0) '@react-types/checkbox': 3.6.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom - '@react-aria/toggle@3.10.7(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/toggle': 3.7.7(react@18.2.0) - '@react-types/checkbox': 3.8.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-aria/tooltip@3.7.0(react@18.2.0)': + '@react-aria/tooltip@3.7.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/focus': 3.16.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-stately/tooltip': 3.4.6(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@react-types/tooltip': 3.4.6(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 - - '@react-aria/tooltip@3.7.7(react@18.2.0)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-stately/tooltip': 3.4.12(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/tooltip': 3.4.11(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 + transitivePeerDependencies: + - react-dom '@react-aria/utils@3.23.0(react@18.2.0)': dependencies: '@react-aria/ssr': 3.9.1(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 clsx: 2.1.1 react: 18.2.0 - '@react-aria/utils@3.25.2(react@18.2.0)': + '@react-aria/utils@3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/ssr': 3.9.5(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - clsx: 2.1.1 - react: 18.2.0 - - '@react-aria/visually-hidden@3.8.15(react@18.2.0)': - dependencies: - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + react-aria: 3.48.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + react-stately: 3.46.0(react@18.2.0) - '@react-aria/visually-hidden@3.8.8(react@18.2.0)': + '@react-aria/visually-hidden@3.8.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom '@react-email/body@0.0.7(react@18.3.1)': dependencies: @@ -28219,23 +27255,12 @@ snapshots: dependencies: react: 18.3.1 - '@react-spring/rafz@9.7.4': {} - '@react-stately/calendar@3.4.3(react@18.2.0)': dependencies: - '@internationalized/date': 3.5.1 + '@internationalized/date': 3.12.1 '@react-stately/utils': 3.9.0(react@18.2.0) '@react-types/calendar': 3.4.3(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/calendar@3.5.4(react@18.2.0)': - dependencies: - '@internationalized/date': 3.5.5 - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/calendar': 3.4.9(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28244,28 +27269,13 @@ snapshots: '@react-stately/form': 3.0.0(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) '@react-types/checkbox': 3.6.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/checkbox@3.6.8(react@18.2.0)': - dependencies: - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/checkbox': 3.8.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 '@react-stately/collections@3.10.4(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/collections@3.10.9(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28278,38 +27288,13 @@ snapshots: '@react-stately/select': 3.6.1(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) '@react-types/combobox': 3.10.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/combobox@3.9.2(react@18.2.0)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-stately/list': 3.10.8(react@18.2.0) - '@react-stately/overlays': 3.6.10(react@18.2.0) - '@react-stately/select': 3.6.7(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/combobox': 3.12.1(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 '@react-stately/data@3.11.0(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/datepicker@3.10.2(react@18.2.0)': - dependencies: - '@internationalized/date': 3.5.5 - '@internationalized/string': 3.2.3 - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-stately/overlays': 3.6.10(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/datepicker': 3.8.2(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28328,14 +27313,7 @@ snapshots: '@react-stately/dnd@3.2.7(react@18.2.0)': dependencies: '@react-stately/selection': 3.14.2(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/dnd@3.4.2(react@18.2.0)': - dependencies: - '@react-stately/selection': 3.16.2(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28343,19 +27321,9 @@ snapshots: dependencies: '@swc/helpers': 0.4.14 - '@react-stately/flags@3.0.3': - dependencies: - '@swc/helpers': 0.5.15 - '@react-stately/form@3.0.0(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/form@3.0.5(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28364,16 +27332,7 @@ snapshots: '@react-stately/collections': 3.10.4(react@18.2.0) '@react-stately/selection': 3.14.2(react@18.2.0) '@react-types/grid': 3.2.3(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/grid@3.9.2(react@18.2.0)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/selection': 3.16.2(react@18.2.0) - '@react-types/grid': 3.2.8(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28382,16 +27341,7 @@ snapshots: '@react-stately/collections': 3.10.4(react@18.2.0) '@react-stately/selection': 3.14.2(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/list@3.10.8(react@18.2.0)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/selection': 3.16.2(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28399,43 +27349,19 @@ snapshots: dependencies: '@react-stately/overlays': 3.6.4(react@18.2.0) '@react-types/menu': 3.9.6(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/menu@3.8.2(react@18.2.0)': - dependencies: - '@react-stately/overlays': 3.6.10(react@18.2.0) - '@react-types/menu': 3.9.11(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 '@react-stately/numberfield@3.8.0(react@18.2.0)': dependencies: - '@internationalized/number': 3.5.0 + '@internationalized/number': 3.6.6 '@react-stately/form': 3.0.0(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) '@react-types/numberfield': 3.7.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 - '@react-stately/numberfield@3.9.6(react@18.2.0)': - dependencies: - '@internationalized/number': 3.5.3 - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/numberfield': 3.8.5(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/overlays@3.6.10(react@18.2.0)': - dependencies: - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/overlays': 3.8.9(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - '@react-stately/overlays@3.6.4(react@18.2.0)': dependencies: '@react-stately/utils': 3.9.0(react@18.2.0) @@ -28448,16 +27374,7 @@ snapshots: '@react-stately/form': 3.0.0(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) '@react-types/radio': 3.7.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/radio@3.10.7(react@18.2.0)': - dependencies: - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/radio': 3.8.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28468,30 +27385,13 @@ snapshots: '@swc/helpers': 0.5.15 react: 18.2.0 - '@react-stately/searchfield@3.5.6(react@18.2.0)': - dependencies: - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/searchfield': 3.5.8(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - '@react-stately/select@3.6.1(react@18.2.0)': dependencies: '@react-stately/form': 3.0.0(react@18.2.0) '@react-stately/list': 3.10.2(react@18.2.0) '@react-stately/overlays': 3.6.4(react@18.2.0) '@react-types/select': 3.9.1(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/select@3.6.7(react@18.2.0)': - dependencies: - '@react-stately/form': 3.0.5(react@18.2.0) - '@react-stately/list': 3.10.8(react@18.2.0) - '@react-stately/overlays': 3.6.10(react@18.2.0) - '@react-types/select': 3.9.6(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28499,34 +27399,18 @@ snapshots: dependencies: '@react-stately/collections': 3.10.4(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/selection@3.16.2(react@18.2.0)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 '@react-stately/slider@3.5.0(react@18.2.0)': dependencies: '@react-stately/utils': 3.9.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@react-types/slider': 3.7.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 - '@react-stately/slider@3.5.7(react@18.2.0)': - dependencies: - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/slider': 3.7.5(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - '@react-stately/table@3.11.4(react@18.2.0)': dependencies: '@react-stately/collections': 3.10.4(react@18.2.0) @@ -28535,40 +27419,19 @@ snapshots: '@react-stately/selection': 3.14.2(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) '@react-types/grid': 3.2.3(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@react-types/table': 3.9.2(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 - '@react-stately/table@3.12.2(react@18.2.0)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/flags': 3.0.3 - '@react-stately/grid': 3.9.2(react@18.2.0) - '@react-stately/selection': 3.16.2(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/grid': 3.2.8(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/table': 3.10.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - '@react-stately/tabs@3.6.3(react@18.2.0)': dependencies: '@react-stately/list': 3.10.2(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@react-types/tabs': 3.3.4(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 - '@react-stately/tabs@3.6.9(react@18.2.0)': - dependencies: - '@react-stately/list': 3.10.8(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/tabs': 3.3.9(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - '@react-stately/toggle@3.7.0(react@18.2.0)': dependencies: '@react-stately/utils': 3.9.0(react@18.2.0) @@ -28576,20 +27439,6 @@ snapshots: '@swc/helpers': 0.5.15 react: 18.2.0 - '@react-stately/toggle@3.7.7(react@18.2.0)': - dependencies: - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/checkbox': 3.8.3(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/tooltip@3.4.12(react@18.2.0)': - dependencies: - '@react-stately/overlays': 3.6.10(react@18.2.0) - '@react-types/tooltip': 3.4.11(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - '@react-stately/tooltip@3.4.6(react@18.2.0)': dependencies: '@react-stately/overlays': 3.6.4(react@18.2.0) @@ -28602,21 +27451,7 @@ snapshots: '@react-stately/collections': 3.10.4(react@18.2.0) '@react-stately/selection': 3.14.2(react@18.2.0) '@react-stately/utils': 3.9.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/tree@3.8.4(react@18.2.0)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.2.0) - '@react-stately/selection': 3.16.2(react@18.2.0) - '@react-stately/utils': 3.10.3(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - '@swc/helpers': 0.5.15 - react: 18.2.0 - - '@react-stately/utils@3.10.3(react@18.2.0)': - dependencies: + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 @@ -28625,65 +27460,40 @@ snapshots: '@swc/helpers': 0.5.15 react: 18.2.0 - '@react-stately/virtualizer@3.6.6(react@18.2.0)': + '@react-stately/virtualizer@3.6.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@swc/helpers': 0.5.15 react: 18.2.0 + transitivePeerDependencies: + - react-dom '@react-types/breadcrumbs@3.7.2(react@18.2.0)': dependencies: '@react-types/link': 3.5.2(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/breadcrumbs@3.7.7(react@18.2.0)': - dependencies: - '@react-types/link': 3.5.7(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/button@3.9.1(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/button@3.9.6(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/calendar@3.4.3(react@18.2.0)': dependencies: - '@internationalized/date': 3.5.1 - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/calendar@3.4.9(react@18.2.0)': - dependencies: - '@internationalized/date': 3.5.5 - '@react-types/shared': 3.24.1(react@18.2.0) + '@internationalized/date': 3.12.1 + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/checkbox@3.6.0(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/checkbox@3.8.3(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/combobox@3.10.0(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/combobox@3.12.1(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/datepicker@3.7.1(react@18.2.0)': @@ -28694,66 +27504,31 @@ snapshots: '@react-types/shared': 3.22.0(react@18.2.0) react: 18.2.0 - '@react-types/datepicker@3.8.2(react@18.2.0)': - dependencies: - '@internationalized/date': 3.5.5 - '@react-types/calendar': 3.4.9(react@18.2.0) - '@react-types/overlays': 3.8.9(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - react: 18.2.0 - - '@react-types/dialog@3.5.12(react@18.2.0)': - dependencies: - '@react-types/overlays': 3.8.9(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) - react: 18.2.0 - '@react-types/dialog@3.5.7(react@18.2.0)': dependencies: '@react-types/overlays': 3.8.4(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/grid@3.2.3(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/grid@3.2.8(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/link@3.5.2(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/link@3.5.7(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/listbox@3.4.6(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/listbox@3.5.1(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) - react: 18.2.0 - - '@react-types/menu@3.9.11(react@18.2.0)': - dependencies: - '@react-types/overlays': 3.8.9(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/menu@3.9.6(react@18.2.0)': dependencies: '@react-types/overlays': 3.8.4(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/meter@3.3.6(react@18.2.0)': @@ -28761,143 +27536,75 @@ snapshots: '@react-types/progress': 3.5.1(react@18.2.0) react: 18.2.0 - '@react-types/meter@3.4.3(react@18.2.0)': - dependencies: - '@react-types/progress': 3.5.6(react@18.2.0) - react: 18.2.0 - '@react-types/numberfield@3.7.0(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/numberfield@3.8.5(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/overlays@3.8.4(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/overlays@3.8.9(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/progress@3.5.1(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/progress@3.5.6(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/radio@3.7.0(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/radio@3.8.3(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/searchfield@3.5.2(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) '@react-types/textfield': 3.9.0(react@18.2.0) react: 18.2.0 - '@react-types/searchfield@3.5.8(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) - '@react-types/textfield': 3.9.6(react@18.2.0) - react: 18.2.0 - '@react-types/select@3.9.1(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/select@3.9.6(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/shared@3.22.0(react@18.2.0)': dependencies: react: 18.2.0 - '@react-types/shared@3.24.1(react@18.2.0)': + '@react-types/shared@3.34.0(react@18.2.0)': dependencies: react: 18.2.0 '@react-types/slider@3.7.0(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/slider@3.7.5(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/switch@3.5.0(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/switch@3.5.5(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) - react: 18.2.0 - - '@react-types/table@3.10.1(react@18.2.0)': - dependencies: - '@react-types/grid': 3.2.8(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/table@3.9.2(react@18.2.0)': dependencies: '@react-types/grid': 3.2.3(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/tabs@3.3.4(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/tabs@3.3.9(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/textfield@3.9.0(react@18.2.0)': dependencies: - '@react-types/shared': 3.22.0(react@18.2.0) - react: 18.2.0 - - '@react-types/textfield@3.9.6(react@18.2.0)': - dependencies: - '@react-types/shared': 3.24.1(react@18.2.0) - react: 18.2.0 - - '@react-types/tooltip@3.4.11(react@18.2.0)': - dependencies: - '@react-types/overlays': 3.8.9(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@react-types/tooltip@3.4.6(react@18.2.0)': dependencies: '@react-types/overlays': 3.8.4(react@18.2.0) - '@react-types/shared': 3.22.0(react@18.2.0) + '@react-types/shared': 3.34.0(react@18.2.0) react: 18.2.0 '@remix-run/changelog-github@0.0.5(encoding@0.1.13)': @@ -30650,16 +29357,6 @@ snapshots: lz-string: 1.4.4 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.5.0': - dependencies: - '@adobe/css-tools': 4.4.0 - aria-query: 5.3.0 - chalk: 3.0.0 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - lodash: 4.18.1 - redent: 3.0.0 - '@tokenizer/token@0.3.0': {} '@tootallnate/quickjs-emscripten@0.23.0': {} @@ -30941,8 +29638,6 @@ snapshots: dependencies: '@types/node': 20.14.14 - '@types/invariant@2.2.37': {} - '@types/is-ci@3.0.0': dependencies: ci-info: 3.8.0 @@ -31722,29 +30417,26 @@ snapshots: fast-url-parser: 1.1.3 tslib: 2.8.1 - '@window-splitter/state@0.4.1(patch_hash=da01382a3c0d3f4f66db5b09d6f0833cc21eaf8db00f97e2c1738797673b57c0)': + '@window-splitter/interface@1.1.3': + dependencies: + '@window-splitter/state': 1.1.3(patch_hash=ecf02927f78361c14d8f8347604fd355ba36f2fc4f9ba9a08cd63adc101b7327) + + '@window-splitter/react@1.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@react-aria/utils': 3.34.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@window-splitter/interface': 1.1.3 + '@window-splitter/state': 1.1.3(patch_hash=ecf02927f78361c14d8f8347604fd355ba36f2fc4f9ba9a08cd63adc101b7327) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + '@window-splitter/state@1.1.3(patch_hash=ecf02927f78361c14d8f8347604fd355ba36f2fc4f9ba9a08cd63adc101b7327)': dependencies: - '@react-spring/rafz': 9.7.4 - '@types/invariant': 2.2.37 big.js: 6.2.2 - invariant: 2.2.4 - universal-cookie: 7.2.0 - xstate: 5.18.1 '@wolfy1339/lru-cache@11.0.2-patch.1': {} '@xobotyi/scrollbar-width@1.9.5': {} - '@xstate/react@4.1.2(@types/react@18.2.69)(react@18.2.0)(xstate@5.18.1)': - dependencies: - react: 18.2.0 - use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.69)(react@18.2.0) - use-sync-external-store: 1.2.2(react@18.2.0) - optionalDependencies: - xstate: 5.18.1 - transitivePeerDependencies: - - '@types/react' - '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -32557,11 +31249,6 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 - chalk@3.0.0: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -33042,8 +31729,6 @@ snapshots: css-what@5.1.0: {} - css.escape@1.5.1: {} - cssesc@3.0.0: {} csstype@3.1.1: {} @@ -33523,8 +32208,6 @@ snapshots: dom-accessibility-api@0.5.15: {} - dom-accessibility-api@0.6.3: {} - dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -35670,10 +34353,6 @@ snapshots: intl-parse-accept-language@1.0.0: {} - invariant@2.2.4: - dependencies: - loose-envify: 1.4.0 - ioredis@5.3.2: dependencies: '@ioredis/commands': 1.2.0 @@ -38258,8 +36937,6 @@ snapshots: performance-now@2.1.0: {} - performant-array-to-tree@1.11.0: {} - periscopic@3.1.0: dependencies: '@types/estree': 1.0.8 @@ -38931,86 +37608,58 @@ snapshots: react-aria@3.31.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@internationalized/string': 3.2.0 - '@react-aria/breadcrumbs': 3.5.9(react@18.2.0) - '@react-aria/button': 3.9.1(react@18.2.0) + '@react-aria/breadcrumbs': 3.5.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/button': 3.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/calendar': 3.5.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/checkbox': 3.13.0(react@18.2.0) + '@react-aria/checkbox': 3.13.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/combobox': 3.8.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/datepicker': 3.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/dialog': 3.5.10(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/dnd': 3.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/focus': 3.16.0(react@18.2.0) + '@react-aria/focus': 3.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/gridlist': 3.7.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/i18n': 3.10.0(react@18.2.0) - '@react-aria/interactions': 3.20.1(react@18.2.0) - '@react-aria/label': 3.7.4(react@18.2.0) - '@react-aria/link': 3.6.3(react@18.2.0) + '@react-aria/i18n': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/interactions': 3.20.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/label': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/link': 3.6.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/listbox': 3.11.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/menu': 3.12.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/meter': 3.4.9(react@18.2.0) + '@react-aria/meter': 3.4.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/numberfield': 3.10.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/overlays': 3.20.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/progress': 3.4.9(react@18.2.0) - '@react-aria/radio': 3.10.0(react@18.2.0) - '@react-aria/searchfield': 3.7.1(react@18.2.0) + '@react-aria/progress': 3.4.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/radio': 3.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/searchfield': 3.7.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/select': 3.14.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/selection': 3.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/separator': 3.3.9(react@18.2.0) - '@react-aria/slider': 3.7.4(react@18.2.0) + '@react-aria/separator': 3.3.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/slider': 3.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/ssr': 3.9.1(react@18.2.0) - '@react-aria/switch': 3.6.0(react@18.2.0) + '@react-aria/switch': 3.6.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/table': 3.13.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/tabs': 3.8.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/tag': 3.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/textfield': 3.14.1(react@18.2.0) - '@react-aria/tooltip': 3.7.0(react@18.2.0) + '@react-aria/textfield': 3.14.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@react-aria/tooltip': 3.7.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/utils': 3.23.0(react@18.2.0) - '@react-aria/visually-hidden': 3.8.8(react@18.2.0) + '@react-aria/visually-hidden': 3.8.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-types/shared': 3.22.0(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-aria@3.34.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0): - dependencies: - '@internationalized/string': 3.2.3 - '@react-aria/breadcrumbs': 3.5.16(react@18.2.0) - '@react-aria/button': 3.9.8(react@18.2.0) - '@react-aria/calendar': 3.5.11(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/checkbox': 3.14.6(react@18.2.0) - '@react-aria/combobox': 3.10.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/datepicker': 3.11.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/dialog': 3.5.17(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/dnd': 3.7.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/focus': 3.18.2(react@18.2.0) - '@react-aria/gridlist': 3.9.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/i18n': 3.12.2(react@18.2.0) - '@react-aria/interactions': 3.22.2(react@18.2.0) - '@react-aria/label': 3.7.11(react@18.2.0) - '@react-aria/link': 3.7.4(react@18.2.0) - '@react-aria/listbox': 3.13.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/menu': 3.15.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/meter': 3.4.16(react@18.2.0) - '@react-aria/numberfield': 3.11.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/overlays': 3.23.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/progress': 3.4.16(react@18.2.0) - '@react-aria/radio': 3.10.7(react@18.2.0) - '@react-aria/searchfield': 3.7.8(react@18.2.0) - '@react-aria/select': 3.14.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/selection': 3.19.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/separator': 3.4.2(react@18.2.0) - '@react-aria/slider': 3.7.11(react@18.2.0) - '@react-aria/ssr': 3.9.5(react@18.2.0) - '@react-aria/switch': 3.6.7(react@18.2.0) - '@react-aria/table': 3.15.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/tabs': 3.9.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/tag': 3.4.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@react-aria/textfield': 3.14.8(react@18.2.0) - '@react-aria/tooltip': 3.7.7(react@18.2.0) - '@react-aria/utils': 3.25.2(react@18.2.0) - '@react-aria/visually-hidden': 3.8.15(react@18.2.0) - '@react-types/shared': 3.24.1(react@18.2.0) + react-aria@3.48.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + '@internationalized/date': 3.12.1 + '@internationalized/number': 3.6.6 + '@internationalized/string': 3.2.8 + '@react-types/shared': 3.34.0(react@18.2.0) + '@swc/helpers': 0.5.15 + aria-hidden: 1.2.4 + clsx: 2.1.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + react-stately: 3.46.0(react@18.2.0) + use-sync-external-store: 1.6.0(react@18.2.0) react-collapse@5.1.1(react@18.2.0): dependencies: @@ -39057,7 +37706,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -39094,8 +37743,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3(bufferutil@4.0.9) - socket.io-client: 4.7.3(bufferutil@4.0.9) + socket.io: 4.7.3 + socket.io-client: 4.7.3 sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -39320,6 +37969,16 @@ snapshots: '@react-types/shared': 3.22.0(react@18.2.0) react: 18.2.0 + react-stately@3.46.0(react@18.2.0): + dependencies: + '@internationalized/date': 3.12.1 + '@internationalized/number': 3.6.6 + '@internationalized/string': 3.2.8 + '@react-types/shared': 3.34.0(react@18.2.0) + '@swc/helpers': 0.5.15 + react: 18.2.0 + use-sync-external-store: 1.6.0(react@18.2.0) + react-style-singleton@2.2.3(@types/react@18.2.69)(react@18.2.0): dependencies: get-nonce: 1.0.1 @@ -39377,22 +38036,6 @@ snapshots: ts-easing: 0.2.0 tslib: 2.8.1 - react-window-splitter@0.4.1(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): - dependencies: - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.69)(react@18.2.0) - '@testing-library/jest-dom': 6.5.0 - '@window-splitter/state': 0.4.1(patch_hash=da01382a3c0d3f4f66db5b09d6f0833cc21eaf8db00f97e2c1738797673b57c0) - '@xstate/react': 4.1.2(@types/react@18.2.69)(react@18.2.0)(xstate@5.18.1) - invariant: 2.2.4 - react: 18.2.0 - react-aria: 3.34.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - react-dom: 18.2.0(react@18.2.0) - reforest: 0.13.0(@types/react@18.2.69)(react@18.2.0) - xstate: 5.18.1 - transitivePeerDependencies: - - '@types/react' - - immer - react@18.2.0: dependencies: loose-envify: 1.4.0 @@ -39520,15 +38163,6 @@ snapshots: css-unit-converter: 1.1.2 postcss-value-parser: 3.3.1 - reforest@0.13.0(@types/react@18.2.69)(react@18.2.0): - dependencies: - performant-array-to-tree: 1.11.0 - react: 18.2.0 - zustand: 4.5.5(@types/react@18.2.69)(react@18.2.0) - transitivePeerDependencies: - - '@types/react' - - immer - regenerator-runtime@0.13.11: {} regenerator-runtime@0.14.1: {} @@ -40322,7 +38956,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3(bufferutil@4.0.9): + socket.io-client@4.7.3: dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40351,7 +38985,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3(bufferutil@4.0.9): + socket.io@4.7.3: dependencies: accepts: 1.3.8 base64id: 2.0.0 @@ -41663,11 +40297,6 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 - universal-cookie@7.2.0: - dependencies: - '@types/cookie': 0.6.0 - cookie: 0.6.0 - universal-github-app-jwt@1.2.0: dependencies: '@types/jsonwebtoken': 9.0.10 @@ -41785,6 +40414,10 @@ snapshots: dependencies: react: 19.0.0 + use-sync-external-store@1.6.0(react@18.2.0): + dependencies: + react: 18.2.0 + util-deprecate@1.0.2: {} util@0.12.5: @@ -42350,8 +40983,6 @@ snapshots: xmlhttprequest-ssl@2.0.0: {} - xstate@5.18.1: {} - xtend@4.0.2: {} y18n@4.0.3: {} @@ -42470,11 +41101,4 @@ snapshots: zod@3.25.76: {} - zustand@4.5.5(@types/react@18.2.69)(react@18.2.0): - dependencies: - use-sync-external-store: 1.2.2(react@18.2.0) - optionalDependencies: - '@types/react': 18.2.69 - react: 18.2.0 - zwitch@2.0.4: {} From 567e2a2c3207b7c01133f81caf5c7bdb86651306 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 11 May 2026 07:17:07 +0100 Subject: [PATCH 016/491] feat(webapp,redis): handle READONLY / LOADING during ElastiCache failover (#3548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary During an ElastiCache role swap (failover) or node-type change (vertical scale), the ioredis TCP/TLS connection stays open but the server starts answering with `READONLY` (the client is talking to a node that became a replica) or `LOADING` (node still loading data from disk). Without an explicit hook, those errors surface to caller code as `ReplyError` instances β€” every write op on the affected connection fails until the cluster fully cuts over. This PR adds `reconnectOnError` to every prod ioredis client so the disconnect + reconnect + retry cycle absorbs these errors and caller code never sees them. ## Fix ```ts export function defaultReconnectOnError(err: Error): boolean | 1 | 2 { const msg = err.message ?? ""; if (msg.startsWith("READONLY") || msg.startsWith("LOADING")) return 2; return false; } ``` Returning `2` tells ioredis to disconnect, reconnect, and re-issue the failed command. After reconnect, DNS / SG state routes the new socket to a writable node. The helper lives in `@internal/redis` and is wired into both the shared `createRedisClient` (which covers RunQueue, schedule-engine, redis-worker, and every other internal-package consumer) and the direct `new Redis(...)` call sites in the webapp. V1-only marqs files are intentionally not migrated. ## Test plan - [x] `pnpm run typecheck --filter webapp` - [x] `pnpm run typecheck --filter @internal/run-engine` - [x] Verified end-to-end against a live ElastiCache vertical-scale event β€” caller-surfaced errors went from tens of thousands during the cutover window down to a handful per ioredis client - [ ] Confirm steady-state behavior unchanged after deploy --- .../redis-reconnect-on-readonly-loading.md | 6 ++++++ .../app/presenters/v3/DevPresence.server.ts | 3 ++- apps/webapp/app/redis.server.ts | 3 +++ .../services/autoIncrementCounter.server.ts | 3 ++- .../inputStreamWaitpointCache.server.ts | 2 ++ .../platformNotificationCounter.server.ts | 2 ++ .../realtime/redisRealtimeStreams.server.ts | 8 +++++++- .../sessionStreamWaitpointCache.server.ts | 2 ++ .../services/taskIdentifierCache.server.ts | 2 ++ apps/webapp/app/v3/handleSocketIo.server.ts | 2 ++ internal-packages/redis/src/index.ts | 20 +++++++++++++++++++ 11 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 .server-changes/redis-reconnect-on-readonly-loading.md diff --git a/.server-changes/redis-reconnect-on-readonly-loading.md b/.server-changes/redis-reconnect-on-readonly-loading.md new file mode 100644 index 00000000000..3173d99860b --- /dev/null +++ b/.server-changes/redis-reconnect-on-readonly-loading.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Add `reconnectOnError` to the shared ioredis client config so READONLY / LOADING reply errors during ElastiCache node-type changes trigger a disconnect-reconnect-retry cycle instead of surfacing to caller code. diff --git a/apps/webapp/app/presenters/v3/DevPresence.server.ts b/apps/webapp/app/presenters/v3/DevPresence.server.ts index fa606cf9f1b..d751b6d7114 100644 --- a/apps/webapp/app/presenters/v3/DevPresence.server.ts +++ b/apps/webapp/app/presenters/v3/DevPresence.server.ts @@ -1,4 +1,5 @@ import Redis, { type RedisOptions } from "ioredis"; +import { defaultReconnectOnError } from "@internal/redis"; import { env } from "~/env.server"; const PRESENCE_KEY_PREFIX = "dev-presence:connection:"; @@ -7,7 +8,7 @@ export class DevPresence { private redis: Redis; constructor(options: RedisOptions) { - this.redis = new Redis(options); + this.redis = new Redis({ reconnectOnError: defaultReconnectOnError, ...options }); } async isConnected(environmentId: string) { diff --git a/apps/webapp/app/redis.server.ts b/apps/webapp/app/redis.server.ts index 55d490821e3..01efa0d3e68 100644 --- a/apps/webapp/app/redis.server.ts +++ b/apps/webapp/app/redis.server.ts @@ -1,4 +1,5 @@ import { Cluster, Redis, type ClusterNode, type ClusterOptions } from "ioredis"; +import { defaultReconnectOnError } from "@internal/redis"; import { logger } from "./services/logger.server"; export type RedisWithClusterOptions = { @@ -42,6 +43,7 @@ export function createRedisClient( username: options.username, password: options.password, enableAutoPipelining: true, + reconnectOnError: defaultReconnectOnError, ...(options.tlsDisabled ? { checkServerIdentity: () => { @@ -69,6 +71,7 @@ export function createRedisClient( password: options.password, enableAutoPipelining: true, keyPrefix: options.keyPrefix, + reconnectOnError: defaultReconnectOnError, ...(options.tlsDisabled ? {} : { tls: {} }), }); } diff --git a/apps/webapp/app/services/autoIncrementCounter.server.ts b/apps/webapp/app/services/autoIncrementCounter.server.ts index 205e6ef9c1c..b11a3f4e11d 100644 --- a/apps/webapp/app/services/autoIncrementCounter.server.ts +++ b/apps/webapp/app/services/autoIncrementCounter.server.ts @@ -1,4 +1,5 @@ import Redis, { RedisOptions } from "ioredis"; +import { defaultReconnectOnError } from "@internal/redis"; import { Prisma, PrismaClientOrTransaction, PrismaTransactionOptions, prisma } from "~/db.server"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; @@ -11,7 +12,7 @@ export class AutoIncrementCounter { private _redis: Redis; constructor(private options: AutoIncrementCounterOptions) { - this._redis = new Redis(options.redis); + this._redis = new Redis({ reconnectOnError: defaultReconnectOnError, ...options.redis }); } async incrementInTransaction( diff --git a/apps/webapp/app/services/inputStreamWaitpointCache.server.ts b/apps/webapp/app/services/inputStreamWaitpointCache.server.ts index ab360c837a8..5bfd632633a 100644 --- a/apps/webapp/app/services/inputStreamWaitpointCache.server.ts +++ b/apps/webapp/app/services/inputStreamWaitpointCache.server.ts @@ -1,4 +1,5 @@ import { Redis } from "ioredis"; +import { defaultReconnectOnError } from "@internal/redis"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; import { logger } from "./logger.server"; @@ -24,6 +25,7 @@ function initializeRedis(): Redis | undefined { password: env.CACHE_REDIS_PASSWORD, keyPrefix: "tr:", enableAutoPipelining: true, + reconnectOnError: defaultReconnectOnError, ...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), }); } diff --git a/apps/webapp/app/services/platformNotificationCounter.server.ts b/apps/webapp/app/services/platformNotificationCounter.server.ts index dc2970045da..3bd4fa49afb 100644 --- a/apps/webapp/app/services/platformNotificationCounter.server.ts +++ b/apps/webapp/app/services/platformNotificationCounter.server.ts @@ -1,4 +1,5 @@ import { Redis } from "ioredis"; +import { defaultReconnectOnError } from "@internal/redis"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; import { logger } from "./logger.server"; @@ -18,6 +19,7 @@ function initializeRedis(): Redis | undefined { password: env.CACHE_REDIS_PASSWORD, keyPrefix: "tr:", enableAutoPipelining: true, + reconnectOnError: defaultReconnectOnError, ...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), }); } diff --git a/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts b/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts index 99ad10c8ee4..b5a3c896701 100644 --- a/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts @@ -1,5 +1,6 @@ import { Logger, LogLevel } from "@trigger.dev/core/logger"; import Redis, { RedisOptions } from "ioredis"; +import { defaultReconnectOnError } from "@internal/redis"; import { env } from "~/env.server"; import { StreamIngestor, StreamResponder, StreamResponseOptions } from "./types"; @@ -35,6 +36,7 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { private get sharedRedis(): Redis { if (!this._sharedRedis) { this._sharedRedis = new Redis({ + reconnectOnError: defaultReconnectOnError, ...this.options.redis, connectionName: "realtime:shared", }); @@ -56,7 +58,11 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { signal: AbortSignal, options?: StreamResponseOptions ): Promise { - const redis = new Redis({ ...this.options.redis, connectionName: "realtime:streamResponse" }); + const redis = new Redis({ + reconnectOnError: defaultReconnectOnError, + ...this.options.redis, + connectionName: "realtime:streamResponse", + }); const streamKey = `stream:${runId}:${streamId}`; let isCleanedUp = false; diff --git a/apps/webapp/app/services/sessionStreamWaitpointCache.server.ts b/apps/webapp/app/services/sessionStreamWaitpointCache.server.ts index 050ebddeac3..93f4b397481 100644 --- a/apps/webapp/app/services/sessionStreamWaitpointCache.server.ts +++ b/apps/webapp/app/services/sessionStreamWaitpointCache.server.ts @@ -1,4 +1,5 @@ import { Redis } from "ioredis"; +import { defaultReconnectOnError } from "@internal/redis"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; import { logger } from "./logger.server"; @@ -28,6 +29,7 @@ function initializeRedis(): Redis | undefined { password: env.CACHE_REDIS_PASSWORD, keyPrefix: "tr:", enableAutoPipelining: true, + reconnectOnError: defaultReconnectOnError, ...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), }); } diff --git a/apps/webapp/app/services/taskIdentifierCache.server.ts b/apps/webapp/app/services/taskIdentifierCache.server.ts index 9d243b5f740..04929c583cc 100644 --- a/apps/webapp/app/services/taskIdentifierCache.server.ts +++ b/apps/webapp/app/services/taskIdentifierCache.server.ts @@ -1,4 +1,5 @@ import { Redis } from "ioredis"; +import { defaultReconnectOnError } from "@internal/redis"; import type { TaskTriggerSource } from "@trigger.dev/database"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; @@ -53,6 +54,7 @@ function initializeRedis(): Redis | undefined { password: env.CACHE_REDIS_PASSWORD, keyPrefix: "tr:", enableAutoPipelining: true, + reconnectOnError: defaultReconnectOnError, ...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), }); } diff --git a/apps/webapp/app/v3/handleSocketIo.server.ts b/apps/webapp/app/v3/handleSocketIo.server.ts index 7a2c0c3e366..aec9ded6f49 100644 --- a/apps/webapp/app/v3/handleSocketIo.server.ts +++ b/apps/webapp/app/v3/handleSocketIo.server.ts @@ -15,6 +15,7 @@ import type { WorkerServerToClientEvents, } from "@trigger.dev/core/v3/workers"; import { ZodNamespace } from "@trigger.dev/core/v3/zodNamespace"; +import { defaultReconnectOnError } from "@internal/redis"; import { Redis } from "ioredis"; import { Namespace, Server, Socket } from "socket.io"; import { env } from "~/env.server"; @@ -93,6 +94,7 @@ function initializeSocketIOServerInstance() { username: env.REDIS_USERNAME, password: env.REDIS_PASSWORD, enableAutoPipelining: true, + reconnectOnError: defaultReconnectOnError, ...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), }); const subClient = pubClient.duplicate(); diff --git a/internal-packages/redis/src/index.ts b/internal-packages/redis/src/index.ts index bdd69315cff..0b02c14a3a2 100644 --- a/internal-packages/redis/src/index.ts +++ b/internal-packages/redis/src/index.ts @@ -3,12 +3,32 @@ import { Logger } from "@trigger.dev/core/logger"; export { Redis, type Callback, type RedisOptions, type Result, type RedisCommander } from "ioredis"; +/** + * Reply-error -> reconnect mapping. Without this hook, an ElastiCache + * vertical scale-up surfaces tens of thousands of READONLY / LOADING + * reply errors to caller code over a healthy TCP/TLS connection (the + * client keeps talking to a node whose role swapped underneath it). + * + * Returning 2 tells ioredis to disconnect, reconnect, and retry the + * command that triggered the error. After reconnect, DNS / SG routing + * should land on a writable primary. + * + * Empirical confirmation on the harness in TRI-8878: this option + * reduced a scale-up event from ~437,000 caller-surfaced errors to 2. + */ +export function defaultReconnectOnError(err: Error): boolean | 1 | 2 { + const msg = err.message ?? ""; + if (msg.startsWith("READONLY") || msg.startsWith("LOADING")) return 2; + return false; +} + const defaultOptions: Partial = { retryStrategy: (times: number) => { const delay = Math.min(times * 50, 1000); return delay; }, maxRetriesPerRequest: process.env.GITHUB_ACTIONS ? 50 : process.env.VITEST ? 5 : 20, + reconnectOnError: defaultReconnectOnError, }; const logger = new Logger("Redis", "debug"); From a5ba4065305e9e4f7bd97f04b763ff1ff8f1b9bb Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 11 May 2026 11:02:40 +0100 Subject: [PATCH 017/491] feat(webapp,redis): handle UNBLOCKED during ElastiCache role change (#3549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When ElastiCache demotes a primary to replica β€” during a Multi-AZ failover or a vertical node-type change β€” the demoting primary issues an `UNBLOCKED` reply to any in-flight blocking commands (`BLPOP`, `BRPOP`, `BLMOVE`, `XREADGROUP ... BLOCK`, etc.) to clear them before the role flips. ioredis surfaces these as `ReplyError` to caller code. The shared `defaultReconnectOnError` added in #3548 only matches `READONLY` and `LOADING`. This extends it to `UNBLOCKED` so the disconnect-reconnect-retry cycle handles BLPOP-shaped errors the same way the existing two cases handle non-blocking-command errors. ## Fix ```ts export function defaultReconnectOnError(err: Error): boolean | 1 | 2 { const msg = err.message ?? ""; if ( msg.startsWith("READONLY") || msg.startsWith("LOADING") || msg.startsWith("UNBLOCKED") ) { return 2; } return false; } ``` Returning `2` tells ioredis to disconnect, reconnect, and re-issue the command. For a BLPOP that means a fresh BLPOP against the new primary instead of the `UNBLOCKED` error escaping to the caller. ## Test plan - [ ] CI green - [ ] Trigger a Multi-AZ failover or a vertical scale event on an ElastiCache replication group whose clients are running blocking commands and confirm no `UNBLOCKED` errors surface to caller code during the cutover. --- .server-changes/redis-reconnect-on-unblocked.md | 6 ++++++ internal-packages/redis/src/index.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .server-changes/redis-reconnect-on-unblocked.md diff --git a/.server-changes/redis-reconnect-on-unblocked.md b/.server-changes/redis-reconnect-on-unblocked.md new file mode 100644 index 00000000000..10129f2b854 --- /dev/null +++ b/.server-changes/redis-reconnect-on-unblocked.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Extend the shared ioredis `reconnectOnError` hook (PR #3548) to also match `UNBLOCKED` reply errors so blocking commands like BLPOP transparently reconnect-and-retry when the ElastiCache primary forces them to unblock during a node role change. diff --git a/internal-packages/redis/src/index.ts b/internal-packages/redis/src/index.ts index 0b02c14a3a2..a1283d6153a 100644 --- a/internal-packages/redis/src/index.ts +++ b/internal-packages/redis/src/index.ts @@ -9,6 +9,12 @@ export { Redis, type Callback, type RedisOptions, type Result, type RedisCommand * reply errors to caller code over a healthy TCP/TLS connection (the * client keeps talking to a node whose role swapped underneath it). * + * UNBLOCKED is the BLPOP-shaped case: the Redis primary forcibly + * unblocks any blocking command on a connection whose node is about + * to be demoted, returning an UNBLOCKED reply. Surfaced 65 times on + * engine/v1/worker-actions/dequeue at the cutover instant during the + * TRI-8873 test-cloud scale-up dry-run. + * * Returning 2 tells ioredis to disconnect, reconnect, and retry the * command that triggered the error. After reconnect, DNS / SG routing * should land on a writable primary. @@ -18,7 +24,13 @@ export { Redis, type Callback, type RedisOptions, type Result, type RedisCommand */ export function defaultReconnectOnError(err: Error): boolean | 1 | 2 { const msg = err.message ?? ""; - if (msg.startsWith("READONLY") || msg.startsWith("LOADING")) return 2; + if ( + msg.startsWith("READONLY") || + msg.startsWith("LOADING") || + msg.startsWith("UNBLOCKED") + ) { + return 2; + } return false; } From 2b845455b0c01d21982c2b8c78176cc15b2fecaa Mon Sep 17 00:00:00 2001 From: Iss <74388823+isshaddad@users.noreply.github.com> Date: Mon, 11 May 2026 10:19:41 -0400 Subject: [PATCH 018/491] feat(webapp): admin back-office editors for org max projects and batch rate limit (#3475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds admin-only editors on the back-office org page for `Organization.maximumProjectCount` and `Organization.batchRateLimitConfig`, alongside the existing API rate limit editor. - Splits the back-office org page into per-section components (`ApiRateLimitSection`, `BatchRateLimitSection`, `MaxProjectsSection`) so each tool is self-contained β€” adding new sections later doesn't bloat the route. - Generalizes the rate-limit form into a reusable `RateLimitSection` component + `RateLimitDomain` server config so API and batch share the same UI, validation, and action handler. Each domain only owns its env defaults, DB column, and logger key. - "Saved." banner and validation errors are scoped to the section that submitted, not the page. Heads-up: the API rate-limit log key was renamed `admin.backOffice.rateLimit` β†’ `admin.backOffice.apiRateLimit` for symmetry with the new `admin.backOffice.batchRateLimit`. ## Test plan - [ ] As an admin, visit `/admin/back-office/orgs/:orgId` and confirm all three sections render with the org's current values (or system defaults). - [ ] Edit and save each section; confirm only that section shows the "Saved." banner. - [ ] Submit invalid input (e.g. `0` tokens, malformed interval); confirm errors render in the offending form only and the other sections stay closed. - [ ] Confirm a non-admin user is redirected away from the route. - [ ] After saving a rate-limit override, hit the org with traffic and confirm the new limit is enforced (API rate limit + batch rate limit code paths read the column at request time). --- .../admin-back-office-batch-rate-limit.md | 6 + .../admin-back-office-max-projects.md | 6 + .../backOffice/ApiRateLimitSection.server.ts | 55 ++ .../admin/backOffice/ApiRateLimitSection.tsx | 17 + .../BatchRateLimitSection.server.ts | 55 ++ .../backOffice/BatchRateLimitSection.tsx | 17 + .../backOffice/MaxProjectsSection.server.ts | 48 ++ .../admin/backOffice/MaxProjectsSection.tsx | 115 +++++ .../backOffice/RateLimitSection.server.ts | 76 +++ .../admin/backOffice/RateLimitSection.tsx | 306 +++++++++++ .../routes/admin.back-office.orgs.$orgId.tsx | 475 +++++------------- 11 files changed, 821 insertions(+), 355 deletions(-) create mode 100644 .server-changes/admin-back-office-batch-rate-limit.md create mode 100644 .server-changes/admin-back-office-max-projects.md create mode 100644 apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts create mode 100644 apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.tsx create mode 100644 apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.server.ts create mode 100644 apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.tsx create mode 100644 apps/webapp/app/components/admin/backOffice/MaxProjectsSection.server.ts create mode 100644 apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx create mode 100644 apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts create mode 100644 apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx diff --git a/.server-changes/admin-back-office-batch-rate-limit.md b/.server-changes/admin-back-office-batch-rate-limit.md new file mode 100644 index 00000000000..8802845f928 --- /dev/null +++ b/.server-changes/admin-back-office-batch-rate-limit.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Admin back office: edit an organization's batch rate limit (`batchRateLimitConfig`) from the org page, alongside the existing API rate limit editor. The rate-limit form UI is now shared between the API and batch sections. diff --git a/.server-changes/admin-back-office-max-projects.md b/.server-changes/admin-back-office-max-projects.md new file mode 100644 index 00000000000..698fdfe12ff --- /dev/null +++ b/.server-changes/admin-back-office-max-projects.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Admin back office: edit an organization's `maximumProjectCount` from the org page, beneath the API rate limit editor. diff --git a/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts new file mode 100644 index 00000000000..4855c4c2465 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts @@ -0,0 +1,55 @@ +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { type Duration } from "~/services/rateLimiter.server"; +import { API_RATE_LIMIT_INTENT } from "./ApiRateLimitSection"; +import { + handleRateLimitAction, + resolveEffectiveRateLimit, + type RateLimitActionResult, + type RateLimitDomain, +} from "./RateLimitSection.server"; +import type { EffectiveRateLimit } from "./RateLimitSection"; + +export const apiRateLimitDomain: RateLimitDomain = { + intent: API_RATE_LIMIT_INTENT, + systemDefault: () => ({ + type: "tokenBucket", + refillRate: env.API_RATE_LIMIT_REFILL_RATE, + interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.API_RATE_LIMIT_MAX, + }), + apply: async (orgId, next, adminUserId) => { + const existing = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { apiRateLimiterConfig: true }, + }); + if (!existing) { + throw new Response(null, { status: 404 }); + } + await prisma.organization.update({ + where: { id: orgId }, + data: { apiRateLimiterConfig: next as any }, + }); + logger.info("admin.backOffice.apiRateLimit", { + adminUserId, + orgId, + previous: existing.apiRateLimiterConfig, + next, + }); + }, +}; + +export function resolveEffectiveApiRateLimit( + override: unknown +): EffectiveRateLimit { + return resolveEffectiveRateLimit(override, apiRateLimitDomain); +} + +export function handleApiRateLimitAction( + formData: FormData, + orgId: string, + adminUserId: string +): Promise { + return handleRateLimitAction(formData, orgId, adminUserId, apiRateLimitDomain); +} diff --git a/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.tsx b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.tsx new file mode 100644 index 00000000000..b27956f4360 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.tsx @@ -0,0 +1,17 @@ +import { + RateLimitSection, + type RateLimitWrapperProps, +} from "./RateLimitSection"; + +export const API_RATE_LIMIT_INTENT = "set-rate-limit"; +export const API_RATE_LIMIT_SAVED_VALUE = "rate-limit"; + +export function ApiRateLimitSection(props: RateLimitWrapperProps) { + return ( + + ); +} diff --git a/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.server.ts b/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.server.ts new file mode 100644 index 00000000000..83a368094a9 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.server.ts @@ -0,0 +1,55 @@ +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { type Duration } from "~/services/rateLimiter.server"; +import { BATCH_RATE_LIMIT_INTENT } from "./BatchRateLimitSection"; +import { + handleRateLimitAction, + resolveEffectiveRateLimit, + type RateLimitActionResult, + type RateLimitDomain, +} from "./RateLimitSection.server"; +import type { EffectiveRateLimit } from "./RateLimitSection"; + +export const batchRateLimitDomain: RateLimitDomain = { + intent: BATCH_RATE_LIMIT_INTENT, + systemDefault: () => ({ + type: "tokenBucket", + refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE, + interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.BATCH_RATE_LIMIT_MAX, + }), + apply: async (orgId, next, adminUserId) => { + const existing = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { batchRateLimitConfig: true }, + }); + if (!existing) { + throw new Response(null, { status: 404 }); + } + await prisma.organization.update({ + where: { id: orgId }, + data: { batchRateLimitConfig: next as any }, + }); + logger.info("admin.backOffice.batchRateLimit", { + adminUserId, + orgId, + previous: existing.batchRateLimitConfig, + next, + }); + }, +}; + +export function resolveEffectiveBatchRateLimit( + override: unknown +): EffectiveRateLimit { + return resolveEffectiveRateLimit(override, batchRateLimitDomain); +} + +export function handleBatchRateLimitAction( + formData: FormData, + orgId: string, + adminUserId: string +): Promise { + return handleRateLimitAction(formData, orgId, adminUserId, batchRateLimitDomain); +} diff --git a/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.tsx b/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.tsx new file mode 100644 index 00000000000..0e52124d290 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.tsx @@ -0,0 +1,17 @@ +import { + RateLimitSection, + type RateLimitWrapperProps, +} from "./RateLimitSection"; + +export const BATCH_RATE_LIMIT_INTENT = "set-batch-rate-limit"; +export const BATCH_RATE_LIMIT_SAVED_VALUE = "batch-rate-limit"; + +export function BatchRateLimitSection(props: RateLimitWrapperProps) { + return ( + + ); +} diff --git a/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.server.ts b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.server.ts new file mode 100644 index 00000000000..ec27234a306 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.server.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { MAX_PROJECTS_INTENT } from "./MaxProjectsSection"; + +const SetMaxProjectsSchema = z.object({ + intent: z.literal(MAX_PROJECTS_INTENT), + // Capped at PostgreSQL INTEGER max (Prisma Int) so oversized input fails + // validation cleanly instead of crashing the update. + maximumProjectCount: z.coerce.number().int().min(1).max(2_147_483_647), +}); + +export type MaxProjectsActionResult = + | { ok: true } + | { ok: false; errors: Record }; + +export async function handleMaxProjectsAction( + formData: FormData, + orgId: string, + adminUserId: string +): Promise { + const submission = SetMaxProjectsSchema.safeParse(Object.fromEntries(formData)); + if (!submission.success) { + return { ok: false, errors: submission.error.flatten().fieldErrors }; + } + + const existing = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { maximumProjectCount: true }, + }); + if (!existing) { + throw new Response(null, { status: 404 }); + } + + await prisma.organization.update({ + where: { id: orgId }, + data: { maximumProjectCount: submission.data.maximumProjectCount }, + }); + + logger.info("admin.backOffice.maxProjects", { + adminUserId, + orgId, + previous: existing.maximumProjectCount, + next: submission.data.maximumProjectCount, + }); + + return { ok: true }; +} diff --git a/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx new file mode 100644 index 00000000000..bf8ecf83161 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx @@ -0,0 +1,115 @@ +import { Form } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; + +export const MAX_PROJECTS_INTENT = "set-max-projects"; +export const MAX_PROJECTS_SAVED_VALUE = "max-projects"; + +type FieldErrors = Record | null; + +type Props = { + maximumProjectCount: number; + errors: FieldErrors; + savedJustNow: boolean; + isSubmitting: boolean; +}; + +export function MaxProjectsSection({ + maximumProjectCount, + errors, + savedJustNow, + isSubmitting, +}: Props) { + const hasFieldErrors = !!errors && Object.keys(errors).length > 0; + const fieldError = (field: string) => + errors && field in errors ? errors[field]?.[0] : undefined; + + const [isEditing, setIsEditing] = useState(hasFieldErrors); + const [value, setValue] = useState(String(maximumProjectCount)); + + useEffect(() => { + if (hasFieldErrors) setIsEditing(true); + }, [hasFieldErrors]); + + useEffect(() => { + if (savedJustNow && !hasFieldErrors) setIsEditing(false); + }, [savedJustNow, hasFieldErrors]); + + return ( +
+
+ Maximum projects + {!isEditing && ( + + )} +
+ + {savedJustNow && ( +
+ + Saved. + +
+ )} + + {!isEditing ? ( + + + Limit + + {maximumProjectCount.toLocaleString()} + + + + ) : ( +
+ +
+ + setValue(e.target.value)} + required + /> + {fieldError("maximumProjectCount")} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts b/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts new file mode 100644 index 00000000000..799fc3605df --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/RateLimitSection.server.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { + RateLimitTokenBucketConfig, + RateLimiterConfig, +} from "~/services/authorizationRateLimitMiddleware.server"; +import { + parseDurationToMs, + type EffectiveRateLimit, +} from "./RateLimitSection"; + +export type RateLimitDomain = { + intent: string; + systemDefault: () => RateLimiterConfig; + apply: ( + orgId: string, + next: RateLimitTokenBucketConfig, + adminUserId: string + ) => Promise; +}; + +export function resolveEffectiveRateLimit( + override: unknown, + domain: RateLimitDomain +): EffectiveRateLimit { + if (override == null) { + return { source: "default", config: domain.systemDefault() }; + } + const parsed = RateLimiterConfig.safeParse(override); + if (parsed.success) { + return { source: "override", config: parsed.data }; + } + // Column holds malformed JSON β€” fall back silently. Admin must investigate + // at the DB level; this UI can't recover it. + return { source: "default", config: domain.systemDefault() }; +} + +export type RateLimitActionResult = + | { ok: true } + | { ok: false; errors: Record }; + +export async function handleRateLimitAction( + formData: FormData, + orgId: string, + adminUserId: string, + domain: RateLimitDomain +): Promise { + const schema = z.object({ + intent: z.literal(domain.intent), + refillRate: z.coerce.number().int().min(1), + interval: z + .string() + .trim() + .refine((v) => parseDurationToMs(v) > 0, { + message: "Must be a duration like 10s, 1m, 500ms.", + }), + maxTokens: z.coerce.number().int().min(1), + }); + + const submission = schema.safeParse(Object.fromEntries(formData)); + if (!submission.success) { + return { ok: false, errors: submission.error.flatten().fieldErrors }; + } + + const built = RateLimitTokenBucketConfig.safeParse({ + type: "tokenBucket", + refillRate: submission.data.refillRate, + interval: submission.data.interval, + maxTokens: submission.data.maxTokens, + }); + if (!built.success) { + return { ok: false, errors: built.error.flatten().fieldErrors }; + } + + await domain.apply(orgId, built.data, adminUserId); + return { ok: true }; +} diff --git a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx new file mode 100644 index 00000000000..1af8abab3d9 --- /dev/null +++ b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx @@ -0,0 +1,306 @@ +import { Form } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; + +// Local shape mirrors the server-side discriminated union just enough for this +// view. Decoupled from the .server module so the component stays client-safe. +// Duration fields are always suffixed strings β€” the server's DurationSchema +// rejects anything else, so non-string overrides fall back to the default. +export type RateLimitConfig = + | { + type: "tokenBucket"; + refillRate: number; + interval: string; + maxTokens: number; + } + | { + type: "fixedWindow" | "slidingWindow"; + window: string; + tokens: number; + }; + +export type EffectiveRateLimit = { + source: "override" | "default"; + config: RateLimitConfig; +}; + +export type FieldErrors = Record | null; + +// Props shared by every per-domain wrapper (Api / Batch / future ones). +export type RateLimitWrapperProps = { + effective: EffectiveRateLimit; + errors: FieldErrors; + savedJustNow: boolean; + isSubmitting: boolean; +}; + +type Props = RateLimitWrapperProps & { + title: string; + intent: string; +}; + +export function RateLimitSection({ + title, + intent, + effective, + errors, + savedJustNow, + isSubmitting, +}: Props) { + const hasFieldErrors = !!errors && Object.keys(errors).length > 0; + const fieldError = (field: string) => + errors && field in errors ? errors[field]?.[0] : undefined; + + const current = + effective.config.type === "tokenBucket" ? effective.config : null; + + const [isEditing, setIsEditing] = useState(hasFieldErrors); + const [refillRate, setRefillRate] = useState( + current ? String(current.refillRate) : "" + ); + const [intervalStr, setIntervalStr] = useState( + current ? String(current.interval) : "" + ); + const [maxTokens, setMaxTokens] = useState( + current ? String(current.maxTokens) : "" + ); + + useEffect(() => { + if (hasFieldErrors) setIsEditing(true); + }, [hasFieldErrors]); + + useEffect(() => { + if (savedJustNow && !hasFieldErrors) setIsEditing(false); + }, [savedJustNow, hasFieldErrors]); + + const currentDescription = current + ? describeRateLimit( + current.refillRate, + parseDurationToMs(String(current.interval)), + current.maxTokens + ) + : null; + + const previewDescription = describeRateLimit( + Number(refillRate) || 0, + parseDurationToMs(intervalStr), + Number(maxTokens) || 0 + ); + + const cancelEdit = () => { + setRefillRate(current ? String(current.refillRate) : ""); + setIntervalStr(current ? String(current.interval) : ""); + setMaxTokens(current ? String(current.maxTokens) : ""); + setIsEditing(false); + }; + + return ( +
+
+ {title} + {!isEditing && ( + + )} +
+ + {savedJustNow && ( +
+ + Saved. + +
+ )} + + + Status:{" "} + {effective.source === "override" + ? "Custom override active." + : "Using system default."} + + + {!isEditing ? ( + <> + + {effective.config.type === "tokenBucket" ? ( + currentDescription ? ( + <> + + Sustained rate + + {currentDescription.sustained} + + + + Burst allowance + {currentDescription.burst} + + + ) : ( + + + Invalid interval on the stored config. + + + ) + ) : ( + <> + + Type + {effective.config.type} + + + Window + {String(effective.config.window)} + + + Tokens + + {effective.config.tokens.toLocaleString()} + + + + )} + + {effective.config.type !== "tokenBucket" && ( + + This override is a {effective.config.type} limit and can't be + edited from this form. Change it in the database directly. + + )} + + ) : ( +
+ + +
+ + setRefillRate(e.target.value)} + required + /> + {fieldError("refillRate")} +
+ +
+ + setIntervalStr(e.target.value)} + required + /> + {fieldError("interval")} +
+ +
+ + setMaxTokens(e.target.value)} + required + /> + {fieldError("maxTokens")} +
+ + + {previewDescription + ? `Preview: ${previewDescription.sustained} Β· ${previewDescription.burst}.` + : "Preview: enter valid values to see the effective limit."} + + +
+ + +
+
+ )} +
+ ); +} + +export function parseDurationToMs(duration: string): number { + const match = duration.trim().match(/^(\d+)\s*(ms|s|m|h|d)$/); + if (!match) return 0; + const value = parseInt(match[1], 10); + switch (match[2]) { + case "ms": + return value; + case "s": + return value * 1_000; + case "m": + return value * 60_000; + case "h": + return value * 3_600_000; + case "d": + return value * 86_400_000; + default: + return 0; + } +} + +function describeRateLimit( + refillRate: number, + intervalMs: number, + maxTokens: number +): { sustained: string; burst: string } | null { + if (refillRate <= 0 || intervalMs <= 0 || maxTokens <= 0) return null; + const perMin = (refillRate * 60_000) / intervalMs; + let sustained: string; + if (perMin >= 1) { + sustained = `${formatRateValue(perMin)} requests per minute`; + } else { + const perHour = perMin * 60; + if (perHour >= 1) { + sustained = `${formatRateValue(perHour)} requests per hour`; + } else { + const perDay = perHour * 24; + sustained = `${formatRateValue(perDay)} requests per day`; + } + } + return { + sustained, + burst: `${maxTokens.toLocaleString()} request burst allowance`, + }; +} + +function formatRateValue(value: number): string { + return value >= 10 ? Math.round(value).toLocaleString() : value.toFixed(1); +} diff --git a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx index 211a5a4fd2e..260fef32d96 100644 --- a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx +++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx @@ -1,102 +1,44 @@ -import { Form, useNavigation, useSearchParams } from "@remix-run/react"; +import { useNavigation, useSearchParams } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { useEffect, useState } from "react"; -import { redirect, typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson"; -import { z } from "zod"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { useEffect } from "react"; +import { + redirect, + typedjson, + useTypedActionData, + useTypedLoaderData, +} from "remix-typedjson"; +import { + API_RATE_LIMIT_INTENT, + API_RATE_LIMIT_SAVED_VALUE, + ApiRateLimitSection, +} from "~/components/admin/backOffice/ApiRateLimitSection"; +import { + handleApiRateLimitAction, + resolveEffectiveApiRateLimit, +} from "~/components/admin/backOffice/ApiRateLimitSection.server"; +import { + BATCH_RATE_LIMIT_INTENT, + BATCH_RATE_LIMIT_SAVED_VALUE, + BatchRateLimitSection, +} from "~/components/admin/backOffice/BatchRateLimitSection"; +import { + handleBatchRateLimitAction, + resolveEffectiveBatchRateLimit, +} from "~/components/admin/backOffice/BatchRateLimitSection.server"; +import { + MAX_PROJECTS_INTENT, + MAX_PROJECTS_SAVED_VALUE, + MaxProjectsSection, +} from "~/components/admin/backOffice/MaxProjectsSection"; +import { handleMaxProjectsAction } from "~/components/admin/backOffice/MaxProjectsSection.server"; +import { LinkButton } from "~/components/primitives/Buttons"; import { CopyableText } from "~/components/primitives/CopyableText"; -import { FormError } from "~/components/primitives/FormError"; -import { Header1, Header2 } from "~/components/primitives/Headers"; -import { Input } from "~/components/primitives/Input"; -import { Label } from "~/components/primitives/Label"; +import { Header1 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; -import * as Property from "~/components/primitives/PropertyTable"; import { prisma } from "~/db.server"; -import { env } from "~/env.server"; -import { - RateLimitTokenBucketConfig, - RateLimiterConfig, -} from "~/services/authorizationRateLimitMiddleware.server"; -import { logger } from "~/services/logger.server"; -import { type Duration } from "~/services/rateLimiter.server"; import { requireUser } from "~/services/session.server"; const SAVED_QUERY_KEY = "saved"; -const SAVED_QUERY_VALUE = "1"; - -type EffectiveRateLimit = { - source: "override" | "default"; - config: RateLimiterConfig; -}; - -function systemDefaultRateLimit(): RateLimiterConfig { - return { - type: "tokenBucket", - refillRate: env.API_RATE_LIMIT_REFILL_RATE, - interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, - maxTokens: env.API_RATE_LIMIT_MAX, - }; -} - -function resolveEffectiveRateLimit(override: unknown): EffectiveRateLimit { - if (override == null) { - return { source: "default", config: systemDefaultRateLimit() }; - } - const parsed = RateLimiterConfig.safeParse(override); - if (parsed.success) { - return { source: "override", config: parsed.data }; - } - // Column holds malformed JSON β€” fall back silently. Admin must investigate - // at the DB level; this UI can't recover it. - return { source: "default", config: systemDefaultRateLimit() }; -} - -function parseDurationToMs(duration: string): number { - const match = duration.trim().match(/^(\d+)\s*(ms|s|m|h|d)$/); - if (!match) return 0; - const value = parseInt(match[1], 10); - switch (match[2]) { - case "ms": - return value; - case "s": - return value * 1_000; - case "m": - return value * 60_000; - case "h": - return value * 3_600_000; - case "d": - return value * 86_400_000; - default: - return 0; - } -} - -function describeRateLimit( - refillRate: number, - intervalMs: number, - maxTokens: number -): { sustained: string; burst: string } | null { - if (refillRate <= 0 || intervalMs <= 0 || maxTokens <= 0) return null; - const perMin = (refillRate * 60_000) / intervalMs; - let sustained: string; - if (perMin >= 1) { - sustained = `${Math.round(perMin).toLocaleString()} requests per minute`; - } else { - const perHour = perMin * 60; - if (perHour >= 1) { - sustained = `${Math.round(perHour).toLocaleString()} requests per hour`; - } else { - const perDay = perHour * 24; - const formatted = - perDay >= 10 ? Math.round(perDay).toLocaleString() : perDay.toFixed(1); - sustained = `${formatted} requests per day`; - } - } - return { - sustained, - burst: `${maxTokens.toLocaleString()} request burst allowance`, - }; -} export async function loader({ request, params }: LoaderFunctionArgs) { const user = await requireUser(request); @@ -117,6 +59,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { title: true, createdAt: true, apiRateLimiterConfig: true, + batchRateLimitConfig: true, + maximumProjectCount: true, }, }); @@ -124,26 +68,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw new Response(null, { status: 404 }); } - const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig); + const apiEffective = resolveEffectiveApiRateLimit(org.apiRateLimiterConfig); + const batchEffective = resolveEffectiveBatchRateLimit( + org.batchRateLimitConfig + ); - return typedjson({ - org, - effective, - }); + return typedjson({ org, apiEffective, batchEffective }); } -const SetRateLimitSchema = z.object({ - intent: z.literal("set-rate-limit"), - refillRate: z.coerce.number().int().min(1), - interval: z - .string() - .trim() - .refine((v) => parseDurationToMs(v) > 0, { - message: "Must be a duration like 10s, 1m, 500ms.", - }), - maxTokens: z.coerce.number().int().min(1), -}); - export async function action({ request, params }: ActionFunctionArgs) { const user = await requireUser(request); if (!user.admin) { @@ -156,100 +88,84 @@ export async function action({ request, params }: ActionFunctionArgs) { } const formData = await request.formData(); - const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData)); - if (!submission.success) { - return typedjson( - { errors: submission.error.flatten().fieldErrors }, - { status: 400 } + const intent = formData.get("intent"); + + if (intent === MAX_PROJECTS_INTENT) { + const result = await handleMaxProjectsAction(formData, orgId, user.id); + if (!result.ok) { + return typedjson( + { section: MAX_PROJECTS_SAVED_VALUE, errors: result.errors }, + { status: 400 } + ); + } + return redirect( + `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${MAX_PROJECTS_SAVED_VALUE}` ); } - const existing = await prisma.organization.findFirst({ - where: { id: orgId }, - select: { apiRateLimiterConfig: true }, - }); - if (!existing) { - throw new Response(null, { status: 404 }); + if (intent === API_RATE_LIMIT_INTENT) { + const result = await handleApiRateLimitAction(formData, orgId, user.id); + if (!result.ok) { + return typedjson( + { section: API_RATE_LIMIT_SAVED_VALUE, errors: result.errors }, + { status: 400 } + ); + } + return redirect( + `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${API_RATE_LIMIT_SAVED_VALUE}` + ); } - const built = RateLimitTokenBucketConfig.safeParse({ - type: "tokenBucket", - refillRate: submission.data.refillRate, - interval: submission.data.interval, - maxTokens: submission.data.maxTokens, - }); - if (!built.success) { - return typedjson( - { errors: built.error.flatten().fieldErrors }, - { status: 400 } + if (intent === BATCH_RATE_LIMIT_INTENT) { + const result = await handleBatchRateLimitAction(formData, orgId, user.id); + if (!result.ok) { + return typedjson( + { section: BATCH_RATE_LIMIT_SAVED_VALUE, errors: result.errors }, + { status: 400 } + ); + } + return redirect( + `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${BATCH_RATE_LIMIT_SAVED_VALUE}` ); } - const next = built.data; - - await prisma.organization.update({ - where: { id: orgId }, - data: { apiRateLimiterConfig: next as any }, - }); - - logger.info("admin.backOffice.rateLimit", { - adminUserId: user.id, - orgId, - previous: existing.apiRateLimiterConfig, - next, - }); - return redirect( - `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${SAVED_QUERY_VALUE}` + return typedjson( + { section: null, errors: { intent: ["Unknown intent."] } }, + { status: 400 } ); } export default function BackOfficeOrgPage() { - const { org, effective } = useTypedLoaderData(); + const { org, apiEffective, batchEffective } = + useTypedLoaderData(); const actionData = useTypedActionData(); const navigation = useNavigation(); - const isSubmitting = navigation.state !== "idle"; - + const submittingIntent = navigation.formData?.get("intent"); + const isSubmittingApi = + navigation.state !== "idle" && submittingIntent === API_RATE_LIMIT_INTENT; + const isSubmittingBatch = + navigation.state !== "idle" && submittingIntent === BATCH_RATE_LIMIT_INTENT; + const isSubmittingMaxProjects = + navigation.state !== "idle" && submittingIntent === MAX_PROJECTS_INTENT; + + const errorSection = + actionData && "section" in actionData ? actionData.section : null; const errors = - actionData && "errors" in actionData ? actionData.errors : null; - const hasFieldErrors = - !!errors && typeof errors === "object" && Object.keys(errors).length > 0; - const fieldError = (field: string) => - errors && typeof errors === "object" && field in errors - ? (errors as Record)[field]?.[0] - : undefined; - - const current = - effective.config.type === "tokenBucket" ? effective.config : null; - - const [isEditing, setIsEditing] = useState(false); - const [refillRate, setRefillRate] = useState( - current ? String(current.refillRate) : "" - ); - const [intervalStr, setIntervalStr] = useState( - current ? String(current.interval) : "" - ); - const [maxTokens, setMaxTokens] = useState( - current ? String(current.maxTokens) : "" - ); + actionData && "errors" in actionData + ? (actionData.errors as Record) + : null; const [searchParams, setSearchParams] = useSearchParams(); - const savedJustNow = searchParams.get(SAVED_QUERY_KEY) === SAVED_QUERY_VALUE; - - // If a submit comes back with validation errors, re-open edit mode so the - // admin can see and correct them without clicking Edit again. - useEffect(() => { - if (hasFieldErrors) setIsEditing(true); - }, [hasFieldErrors]); - - // On successful save, drop back to view mode (the component stays mounted - // across the same-route redirect, so `isEditing` wouldn't reset on its own). - useEffect(() => { - if (savedJustNow) setIsEditing(false); - }, [savedJustNow]); + const savedSectionRaw = searchParams.get(SAVED_QUERY_KEY); + // If the action just returned errors for the same section, hide the + // "Saved." banner so it doesn't render alongside field errors. Suppressing + // here propagates to every read site (auto-dismiss + JSX comparisons). + const savedSection = + errors && errorSection === savedSectionRaw ? null : savedSectionRaw; // Auto-dismiss the "saved" banner after a few seconds. useEffect(() => { - if (!savedJustNow) return; + if (!savedSection) return; const t = setTimeout(() => { setSearchParams( (prev) => { @@ -260,28 +176,7 @@ export default function BackOfficeOrgPage() { ); }, 3000); return () => clearTimeout(t); - }, [savedJustNow, setSearchParams]); - - const currentDescription = current - ? describeRateLimit( - current.refillRate, - parseDurationToMs(String(current.interval)), - current.maxTokens - ) - : null; - - const previewDescription = describeRateLimit( - Number(refillRate) || 0, - parseDurationToMs(intervalStr), - Number(maxTokens) || 0 - ); - - const cancelEdit = () => { - setRefillRate(current ? String(current.refillRate) : ""); - setIntervalStr(current ? String(current.interval) : ""); - setMaxTokens(current ? String(current.maxTokens) : ""); - setIsEditing(false); - }; + }, [savedSection, setSearchParams]); return (
@@ -297,156 +192,26 @@ export default function BackOfficeOrgPage() {
-
-
- API rate limit - {!isEditing && ( - - )} -
- - {savedJustNow && ( -
- - Rate limit saved. - -
- )} - - - Status:{" "} - {effective.source === "override" - ? "Custom override active." - : "Using system default."} - - - {!isEditing ? ( - <> - - {effective.config.type === "tokenBucket" ? ( - currentDescription ? ( - <> - - Sustained rate - {currentDescription.sustained} - - - Burst allowance - {currentDescription.burst} - - - ) : ( - - - Invalid interval on the stored config. - - - ) - ) : ( - <> - - Type - {effective.config.type} - - - Window - {String(effective.config.window)} - - - Tokens - - {effective.config.tokens.toLocaleString()} - - - - )} - - {effective.config.type !== "tokenBucket" && ( - - This override is a {effective.config.type} limit and can't be - edited from this form. Change it in the database directly. - - )} - - ) : ( -
- - -
- - setRefillRate(e.target.value)} - required - /> - {fieldError("refillRate")} -
- -
- - setIntervalStr(e.target.value)} - required - /> - {fieldError("interval")} -
- -
- - setMaxTokens(e.target.value)} - required - /> - {fieldError("maxTokens")} -
- - - {previewDescription - ? `Preview: ${previewDescription.sustained} Β· ${previewDescription.burst}.` - : "Preview: enter valid values to see the effective limit."} - - -
- - -
-
- )} -
+ + + + +
); } From 5f1a3e1653078483da74807b9939209da90af889 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 12 May 2026 07:58:42 +0100 Subject: [PATCH 019/491] refactor(run-engine): route TTL expiration through the batch path only (#3554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary TTL expiration on queued runs was being scheduled twice: once via a per-run `expireRun` worker job (the original implementation) and once via the batch TTL system (added more recently). Both paths attempt to flip the same run to `EXPIRED`. The per-run job almost always won the race, leaving the batch consumer to observe runs already expired by the older path. This collapses TTL expiration onto the batch path so every queued TTLed run goes through a single Redis-backed sorted set + batch consumer instead of also getting its own scheduled redis-worker job. ## Design `engine.trigger` and `delayedRunSystem.enqueueDelayedRun` no longer call `ttlSystem.scheduleExpireRun`. The remaining `enqueueSystem.enqueueRun({ includeTtl: true })` already adds the run to the TTL sorted set; `TtlSystem.expireRunsBatch` flips it to `EXPIRED` when the TTL fires. Delayed runs get the same coverage by passing `includeTtl: true` on their post-delay enqueue, so the TTL is armed from the moment the run enters the queue (matching how the old job behaved β€” `parseNaturalLanguageDuration` is evaluated at enqueue time). The new path explicitly does not re-expire runs once they have been allocated a concurrency slot. That is intentional: TTL is for runs that are queued and have never started. Once a run has a slot it is on its way to executing. ## Test plan - [x] `pnpm run test --filter @internal/run-engine ./src/engine/tests/ttl.test.ts` β€” 15 tests, including a new "Re-enqueued runs are not expired by TTL once they have started" that locks in the queued-and-never-started contract. - [x] `pnpm run test --filter @internal/run-engine ./src/engine/tests/delays.test.ts` β€” 5 tests, including "Delayed run with a ttl" which now also asserts the TTL is armed from queue-enter time, not `createdAt`. - [x] `pnpm run test --filter @internal/run-engine ./src/engine/tests/lazyWaitpoint.test.ts` β€” 12 tests. - [x] `pnpm run typecheck --filter @internal/run-engine`. --- .server-changes/run-engine-single-ttl-path.md | 6 + .../run-engine/src/engine/index.ts | 10 +- .../src/engine/systems/delayedRunSystem.ts | 35 ++- .../engine/systems/pendingVersionSystem.ts | 5 + .../src/engine/tests/delays.test.ts | 45 ++- .../src/engine/tests/lazyWaitpoint.test.ts | 21 +- .../src/engine/tests/pendingVersion.test.ts | 116 ++++++++ .../run-engine/src/engine/tests/ttl.test.ts | 269 +++++++++++++++++- 8 files changed, 472 insertions(+), 35 deletions(-) create mode 100644 .server-changes/run-engine-single-ttl-path.md diff --git a/.server-changes/run-engine-single-ttl-path.md b/.server-changes/run-engine-single-ttl-path.md new file mode 100644 index 00000000000..cac5e3a3cb1 --- /dev/null +++ b/.server-changes/run-engine-single-ttl-path.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Route TTL expiration through the batch TTL path only. Removes the redundant per-run `expireRun` worker job, leaving the batch consumer as the single mechanism that flips runs to `EXPIRED` when their TTL elapses while still queued. diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 1725587df45..e0af0f2c4ff 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -797,7 +797,13 @@ export class RunEngine { } } else { try { - if (taskRun.ttl) { + // The new batch TTL path only expires runs still in the queue + // sorted set (waiting on a concurrency slot). For DEV + // environments where the dev CLI may not be running, fast-pathed + // runs can sit on the worker queue indefinitely and never get + // claimed for expiration. Keep the legacy per-run expireRun job + // armed for DEV so those runs still expire. + if (taskRun.ttl && environment.type === "DEVELOPMENT") { await this.ttlSystem.scheduleExpireRun({ runId: taskRun.id, ttl: taskRun.ttl }); } @@ -812,7 +818,7 @@ export class RunEngine { enableFastPath, }); } catch (enqueueError) { - this.logger.error("engine.trigger(): failed to schedule TTL or enqueue run", { + this.logger.error("engine.trigger(): failed to enqueue run", { runId: taskRun.id, friendlyId: taskRun.friendlyId, taskIdentifier: taskRun.taskIdentifier, diff --git a/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts b/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts index 740ce1a849f..32ab98bad6c 100644 --- a/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts @@ -144,13 +144,34 @@ export class DelayedRunSystem { return; } + // The batch TTL path only expires runs still in the queue sorted set. + // For DEV environments where the dev CLI may not be running, fast-pathed + // runs can sit on the worker queue indefinitely. Keep the legacy per-run + // expireRun job armed for DEV so those runs still expire. + if (run.ttl && run.runtimeEnvironment.type === "DEVELOPMENT") { + const expireAt = parseNaturalLanguageDuration(run.ttl); + if (expireAt) { + await this.$.worker.enqueue({ + id: `expireRun:${runId}`, + job: "expireRun", + payload: { runId }, + availableAt: expireAt, + }); + } + } + // Now we need to enqueue the run into the RunQueue - // Skip the lock in enqueueRun since we already hold it + // Skip the lock in enqueueRun since we already hold it. + // includeTtl: true so the run's TTL is armed from the moment it enters + // the queue (not from taskRun.createdAt). The TTL system tracks runs + // that are queued and have never started β€” delayed runs are first + // enqueued here, so this is the correct point to arm TTL. await this.enqueueSystem.enqueueRun({ run, env: run.runtimeEnvironment, batchId: run.batchId ?? undefined, skipRunLock: true, + includeTtl: true, }); const queuedAt = new Date(); @@ -183,18 +204,6 @@ export class DelayedRunSystem { }, }); - if (run.ttl) { - const expireAt = parseNaturalLanguageDuration(run.ttl); - - if (expireAt) { - await this.$.worker.enqueue({ - id: `expireRun:${runId}`, - job: "expireRun", - payload: { runId }, - availableAt: expireAt, - }); - } - } }); } diff --git a/internal-packages/run-engine/src/engine/systems/pendingVersionSystem.ts b/internal-packages/run-engine/src/engine/systems/pendingVersionSystem.ts index c22429ac0c9..9007cf86b2d 100644 --- a/internal-packages/run-engine/src/engine/systems/pendingVersionSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/pendingVersionSystem.ts @@ -98,6 +98,11 @@ export class PendingVersionSystem { run: updatedRun, env: backgroundWorker.runtimeEnvironment, tx, + // PENDING_VERSION re-enqueue is the first time this run is actually + // entering the run queue (the original enqueue was held back waiting + // for a worker version). Arm TTL here so the TTL system can expire it + // if it sits queued waiting on a concurrency slot. + includeTtl: true, }); }); diff --git a/internal-packages/run-engine/src/engine/tests/delays.test.ts b/internal-packages/run-engine/src/engine/tests/delays.test.ts index 8a93aa1ad14..81e3641cd74 100644 --- a/internal-packages/run-engine/src/engine/tests/delays.test.ts +++ b/internal-packages/run-engine/src/engine/tests/delays.test.ts @@ -201,6 +201,11 @@ describe("RunEngine delays", () => { }, queue: { redis: redisOptions, + ttlSystem: { + pollIntervalMs: 100, + batchSize: 10, + batchMaxWaitMs: 100, + }, }, runLock: { redis: redisOptions, @@ -230,7 +235,21 @@ describe("RunEngine delays", () => { taskIdentifier ); + // TTL only expires runs still queued waiting on a concurrency slot. + // Once the delay elapses, the run gets enqueued; saturate env concurrency + // so it stays queued so the new TTL path can expire it. + await engine.runQueue.updateEnvConcurrencyLimits({ + ...authenticatedEnvironment, + maximumConcurrencyLimit: 0, + }); + + const enqueuedAfterDelayTimes: number[] = []; + engine.eventBus.on("runEnqueuedAfterDelay", () => { + enqueuedAfterDelayTimes.push(Date.now()); + }); + //trigger the run + const triggerTime = Date.now(); const run = await engine.trigger( { number: 1, @@ -247,7 +266,7 @@ describe("RunEngine delays", () => { queue: "task/test-task", isTest: false, tags: [], - delayUntil: new Date(Date.now() + 1000), + delayUntil: new Date(triggerTime + 1000), ttl: "2s", }, prisma @@ -259,7 +278,7 @@ describe("RunEngine delays", () => { expect(executionData.snapshot.executionStatus).toBe("DELAYED"); expect(run.status).toBe("DELAYED"); - //wait for 1 seconds + //wait so the delay elapses and the run is enqueued await setTimeout(2_500); //should now be queued @@ -273,19 +292,29 @@ describe("RunEngine delays", () => { expect(run2.status).toBe("PENDING"); - //wait for 3 seconds - await setTimeout(3_000); + // TTL is armed at queue-enter time (not from triggerTime). With a 2s TTL + // and a 1s delay, the run becomes eligible to expire ~3s after trigger. + // Confirm the TTL was not armed against triggerTime (i.e. didn't already + // fire while still DELAYED), and that the run only expires after the + // queue-enter timestamp + ttl has elapsed. + expect(enqueuedAfterDelayTimes.length).toBe(1); + const enqueuedAt = enqueuedAfterDelayTimes[0]!; + expect(enqueuedAt - triggerTime).toBeGreaterThanOrEqual(1000); - //should now be expired - const executionData3 = await engine.getRunExecutionData({ runId: run.id }); - assertNonNullable(executionData3); - expect(executionData3.snapshot.executionStatus).toBe("FINISHED"); + //wait so the TTL fires (counted from when the run was enqueued) + await setTimeout(3_000); + // Status comes from the DB; the batch TTL path does not create + // execution snapshots, so getRunExecutionData may still show QUEUED. const run3 = await prisma.taskRun.findFirstOrThrow({ where: { id: run.id }, }); expect(run3.status).toBe("EXPIRED"); + assertNonNullable(run3.expiredAt); + // The expiry must happen after enqueue + ttl, not after trigger + ttl. + // Allow a small tolerance for poll interval + batch wait. + expect(run3.expiredAt.getTime()).toBeGreaterThanOrEqual(enqueuedAt + 2_000); } finally { await engine.quit(); } diff --git a/internal-packages/run-engine/src/engine/tests/lazyWaitpoint.test.ts b/internal-packages/run-engine/src/engine/tests/lazyWaitpoint.test.ts index bc24f9b6f1a..dedcff5b5b6 100644 --- a/internal-packages/run-engine/src/engine/tests/lazyWaitpoint.test.ts +++ b/internal-packages/run-engine/src/engine/tests/lazyWaitpoint.test.ts @@ -409,6 +409,7 @@ describe("RunEngine lazy waitpoint creation", () => { ttlSystem: { pollIntervalMs: 100, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -434,6 +435,12 @@ describe("RunEngine lazy waitpoint creation", () => { await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier); + // TTL only expires runs still queued waiting on a concurrency slot. + await engine.runQueue.updateEnvConcurrencyLimits({ + ...authenticatedEnvironment, + maximumConcurrencyLimit: 0, + }); + // Trigger a standalone run with TTL (no waitpoint) const run = await engine.trigger( { @@ -467,11 +474,15 @@ describe("RunEngine lazy waitpoint creation", () => { // Wait for TTL to expire await setTimeout(1_500); - // Verify run expired successfully (no throw) - const executionData = await engine.getRunExecutionData({ runId: run.id }); - assertNonNullable(executionData); - expect(executionData.run.status).toBe("EXPIRED"); - expect(executionData.snapshot.executionStatus).toBe("FINISHED"); + // Verify run expired successfully (no throw). + // The batch TTL path does not create execution snapshots, so check + // the status directly from the database rather than via + // getRunExecutionData. + const expiredRun = await prisma.taskRun.findUnique({ + where: { id: run.id }, + select: { status: true }, + }); + expect(expiredRun?.status).toBe("EXPIRED"); } finally { await engine.quit(); } diff --git a/internal-packages/run-engine/src/engine/tests/pendingVersion.test.ts b/internal-packages/run-engine/src/engine/tests/pendingVersion.test.ts index 65498e32ffe..38eaa00b213 100644 --- a/internal-packages/run-engine/src/engine/tests/pendingVersion.test.ts +++ b/internal-packages/run-engine/src/engine/tests/pendingVersion.test.ts @@ -309,4 +309,120 @@ describe("RunEngine pending version", () => { } } ); + + containerTest( + "PENDING_VERSION re-enqueue arms TTL on the queued message", + async ({ prisma, redisOptions }) => { + // When a run enters PENDING_VERSION (background worker doesn't yet have + // the task), the first enqueue happens but the message is dequeued and + // its TTL set entry is dropped while the run waits for a matching worker. + // Once a worker arrives, pendingVersionSystem re-enqueues. That + // re-enqueue is the first time the run is actually queued for a worker, + // so TTL must be armed at that point β€” not held over from the original + // enqueue. + const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + + const engine = new RunEngine({ + prisma, + worker: { + redis: redisOptions, + workers: 1, + tasksPerWorker: 10, + pollIntervalMs: 100, + }, + queue: { + redis: redisOptions, + processWorkerQueueDebounceMs: 50, + masterQueueConsumersDisabled: true, + ttlSystem: { + pollIntervalMs: 100, + batchSize: 10, + batchMaxWaitMs: 100, + }, + }, + runLock: { + redis: redisOptions, + }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + // Trigger a run with TTL β€” no background worker exists yet for this + // task, so it will end up in PENDING_VERSION. + const run = await engine.trigger( + { + number: 1, + friendlyId: "run_pvttl1", + environment: authenticatedEnvironment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "tpv1", + spanId: "spv1", + queue: "task/test-task", + isTest: false, + tags: [], + ttl: "10m", + }, + prisma + ); + + // A worker arrives that doesn't have this task β€” flushes the run to + // PENDING_VERSION. + await setupBackgroundWorker(engine, authenticatedEnvironment, ["test-task-other"]); + + await setTimeout(500); + + // The consumer attempt is what flushes the run to PENDING_VERSION β€” + // dequeue finds no matching task version and returns nothing. + const dequeuedEmpty = await engine.dequeueFromWorkerQueue({ + consumerId: "test_pv", + workerQueue: "main", + }); + expect(dequeuedEmpty.length).toBe(0); + + const executionDataAfter = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionDataAfter); + expect(executionDataAfter.run.status).toBe("PENDING_VERSION"); + + // Now a worker arrives WITH the task β€” pendingVersionSystem + // re-enqueues the run. + await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier); + + await setTimeout(1000); + + const executionDataReenqueued = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionDataReenqueued); + expect(executionDataReenqueued.run.status).toBe("PENDING"); + + // The re-enqueued message must carry ttlExpiresAt so the TTL set + // tracks it for expiration. + const message = await engine.runQueue.readMessage( + authenticatedEnvironment.organization.id, + run.id + ); + assertNonNullable(message); + expect(message.ttlExpiresAt).toBeDefined(); + expect(typeof message.ttlExpiresAt).toBe("number"); + } finally { + await engine.quit(); + } + } + ); }); diff --git a/internal-packages/run-engine/src/engine/tests/ttl.test.ts b/internal-packages/run-engine/src/engine/tests/ttl.test.ts index c1df00bf13f..e787d916f8a 100644 --- a/internal-packages/run-engine/src/engine/tests/ttl.test.ts +++ b/internal-packages/run-engine/src/engine/tests/ttl.test.ts @@ -1,6 +1,7 @@ import { containerTest, assertNonNullable } from "@internal/testcontainers"; import { trace } from "@internal/tracing"; import { expect } from "vitest"; +import { Decimal } from "@trigger.dev/database"; import { RunEngine } from "../index.js"; import { setTimeout } from "timers/promises"; import { EventBusEventArgs } from "../eventBus.js"; @@ -28,6 +29,7 @@ describe("RunEngine ttl", () => { ttlSystem: { pollIntervalMs: 100, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -58,6 +60,14 @@ describe("RunEngine ttl", () => { taskIdentifier ); + // TTL only expires runs still queued waiting on a concurrency slot. + // Force env concurrency to 0 so the run never gets dequeued and stays + // in the TTL set long enough for the consumer to expire it. + await engine.runQueue.updateEnvConcurrencyLimits({ + ...authenticatedEnvironment, + maximumConcurrencyLimit: 0, + }); + //trigger the run const run = await engine.trigger( { @@ -153,6 +163,7 @@ describe("RunEngine ttl", () => { ttlSystem: { pollIntervalMs: 100, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -231,6 +242,7 @@ describe("RunEngine ttl", () => { ttlSystem: { pollIntervalMs: 100, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -302,6 +314,221 @@ describe("RunEngine ttl", () => { } }); + containerTest( + "Re-enqueued runs are not expired by TTL once they have started", + async ({ prisma, redisOptions }) => { + // Contract: TTL only applies to runs that are queued and have never started. + // Once a run has been dequeued (started executing), a subsequent re-enqueue + // (e.g. after a waitpoint, checkpoint resume, or pending-version flow) + // must not re-arm TTL, even if the original TTL deadline has long passed. + const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + + const expiredEvents: EventBusEventArgs<"runExpired">[0][] = []; + + const engine = new RunEngine({ + prisma, + worker: { + redis: redisOptions, + workers: 1, + tasksPerWorker: 10, + pollIntervalMs: 100, + }, + queue: { + redis: redisOptions, + processWorkerQueueDebounceMs: 50, + masterQueueConsumersDisabled: true, + ttlSystem: { + pollIntervalMs: 100, + batchSize: 10, + batchMaxWaitMs: 100, + }, + }, + runLock: { + redis: redisOptions, + }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier); + + engine.eventBus.on("runExpired", (result) => { + expiredEvents.push(result); + }); + + const run = await engine.trigger( + { + number: 1, + friendlyId: "run_restart01", + environment: authenticatedEnvironment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t_re2", + spanId: "s_re2", + workerQueue: "main", + queue: "task/test-task", + isTest: false, + tags: [], + ttl: "1s", + }, + prisma + ); + + // Dequeue the run β€” this simulates the run starting to execute, which + // ZREMs its TTL set entry. + await engine.runQueue.processMasterQueueForEnvironment( + authenticatedEnvironment.id, + 10 + ); + const dequeued = await engine.dequeueFromWorkerQueue({ + consumerId: "test-consumer", + workerQueue: "main", + blockingPopTimeoutSeconds: 1, + }); + expect(dequeued.length).toBe(1); + + // Re-enqueue without includeTtl β€” this is what waitpoint/checkpoint + // resume paths do. + await engine.enqueueSystem.enqueueRun({ + run, + env: authenticatedEnvironment, + tx: prisma, + skipRunLock: true, + includeTtl: false, + }); + + // Wait well past the original 1s TTL deadline. The run was first + // enqueued ~0s ago, so this is far beyond the original deadline. + await setTimeout(2_500); + + // Run must still exist and must NOT have been expired. + expect(expiredEvents.length).toBe(0); + const reenqueuedRun = await prisma.taskRun.findUnique({ + where: { id: run.id }, + select: { status: true }, + }); + // Whatever status the dequeue/re-enqueue flow leaves the run in, it + // must NOT be EXPIRED β€” that's the contract this test locks in. + expect(reenqueuedRun?.status).not.toBe("EXPIRED"); + } finally { + await engine.quit(); + } + } + ); + + containerTest( + "DEV runs sitting on worker queue still expire via legacy per-run job", + async ({ prisma, redisOptions }) => { + // The batch TTL path only expires runs still in the queue sorted set. + // In DEV, runs are fast-pathed straight to the worker queue, and if the + // dev CLI isn't running they can sit there forever. The legacy per-run + // expireRun job is kept for DEV specifically to cover this case. + const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "DEVELOPMENT"); + + const engine = new RunEngine({ + prisma, + worker: { + redis: redisOptions, + workers: 1, + tasksPerWorker: 10, + pollIntervalMs: 100, + }, + queue: { + redis: redisOptions, + processWorkerQueueDebounceMs: 50, + masterQueueConsumersDisabled: true, + // TTL batch path is enabled but should never see this run: it goes + // straight to the worker queue via fast-path. The legacy per-run + // job is what should expire it. + ttlSystem: { + pollIntervalMs: 100, + batchSize: 10, + batchMaxWaitMs: 100, + }, + }, + runLock: { + redis: redisOptions, + }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier); + + let expiredEventData: EventBusEventArgs<"runExpired">[0] | undefined; + engine.eventBus.on("runExpired", (result) => { + expiredEventData = result; + }); + + // Trigger a DEV run with fast-path enabled and a short TTL. The run + // should land in the worker queue without entering the TTL set. + const run = await engine.trigger( + { + number: 1, + friendlyId: "run_devttl1", + environment: authenticatedEnvironment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "tdevttl1", + spanId: "sdevttl1", + workerQueue: "main", + queue: "task/test-task", + isTest: false, + tags: [], + ttl: "1s", + enableFastPath: true, + }, + prisma + ); + + // Wait past the TTL. The legacy per-run job should fire and expire it. + await setTimeout(1_500); + + assertNonNullable(expiredEventData); + const expiredRun = await prisma.taskRun.findUnique({ + where: { id: run.id }, + select: { status: true }, + }); + expect(expiredRun?.status).toBe("EXPIRED"); + } finally { + await engine.quit(); + } + } + ); + containerTest("Multiple runs expiring via TTL batch", async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); @@ -322,6 +549,7 @@ describe("RunEngine ttl", () => { ttlSystem: { pollIntervalMs: 100, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -347,6 +575,12 @@ describe("RunEngine ttl", () => { await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier); + // TTL only expires runs still queued waiting on a concurrency slot. + await engine.runQueue.updateEnvConcurrencyLimits({ + ...authenticatedEnvironment, + maximumConcurrencyLimit: 0, + }); + engine.eventBus.on("runExpired", (result) => { expiredEvents.push(result); }); @@ -384,8 +618,10 @@ describe("RunEngine ttl", () => { expect(executionData.snapshot.executionStatus).toBe("QUEUED"); } - // Wait for TTL to expire - await setTimeout(1_500); + // Wait for TTL to expire. Concurrent triggers can land in different + // 100ms TTL-poll windows, so allow enough headroom for any stragglers + // to be claimed in a subsequent poll and flushed. + await setTimeout(2_500); // All runs should be expired expect(expiredEvents.length).toBe(3); @@ -450,6 +686,7 @@ describe("RunEngine ttl", () => { ttlSystem: { pollIntervalMs: 100, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -538,6 +775,7 @@ describe("RunEngine ttl", () => { ttlSystem: { pollIntervalMs: 100, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -563,6 +801,12 @@ describe("RunEngine ttl", () => { await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier); + // TTL only expires runs still queued waiting on a concurrency slot. + await engine.runQueue.updateEnvConcurrencyLimits({ + ...authenticatedEnvironment, + maximumConcurrencyLimit: 0, + }); + engine.eventBus.on("runExpired", (result) => { expiredEvents.push(result); }); @@ -631,10 +875,9 @@ describe("RunEngine ttl", () => { const expiredEvents: EventBusEventArgs<"runExpired">[0][] = []; - // Disable worker to prevent the scheduleExpireRun job from firing before - // we can test the dequeue path. Use masterQueueConsumersDisabled so we can - // manually trigger dequeue via processMasterQueueForEnvironment. - // TTL consumers start independently and will expire the run after their poll interval. + // Use masterQueueConsumersDisabled so we can manually trigger dequeue via + // processMasterQueueForEnvironment. TTL consumers start independently and + // will expire the run after their poll interval. const engine = new RunEngine({ prisma, worker: { @@ -651,6 +894,7 @@ describe("RunEngine ttl", () => { ttlSystem: { pollIntervalMs: 5000, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -781,6 +1025,7 @@ describe("RunEngine ttl", () => { ttlSystem: { pollIntervalMs: 5000, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -878,7 +1123,6 @@ describe("RunEngine ttl", () => { async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - // Disable worker to prevent the scheduleExpireRun job from firing. // Use masterQueueConsumersDisabled so we can manually trigger dequeue. // Very long TTL consumer interval so it doesn't interfere. const engine = new RunEngine({ @@ -1246,6 +1490,7 @@ describe("RunEngine ttl", () => { ttlSystem: { pollIntervalMs: 100, batchSize: 10, + batchMaxWaitMs: 100, }, }, runLock: { @@ -1272,6 +1517,16 @@ describe("RunEngine ttl", () => { await setupBackgroundWorker(engine, authenticatedEnvironment, [parentTask, childTask]); + // TTL only expires runs still queued waiting on a concurrency slot. + // Cap env concurrency at exactly 1 (limit=1, burstFactor=1) so the + // parent takes the only slot and the child stays queued long enough + // for the new TTL path to expire it. + await engine.runQueue.updateEnvConcurrencyLimits({ + ...authenticatedEnvironment, + maximumConcurrencyLimit: 1, + concurrencyLimitBurstFactor: new Decimal(1.0), + }); + // Trigger the parent run const parentRun = await engine.trigger( { From 2301ed608c5c90a6cfa1c0912a7aed7d326e66f5 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 12 May 2026 11:03:34 +0100 Subject: [PATCH 020/491] refactor(run-engine): make taskIdentifier optional on run-queue messages (#3559) ## Summary Make `taskIdentifier` optional on the run-queue message schema. No behavior change in this PR; readers continue to accept payloads that include the field. A separate change will stop writing it on the wire to shrink the per-run payload that lives in Redis while runs wait to be dequeued. ## Design The field is written into every payload at enqueue time but no consumer reads it back on the dequeue path. Both the run-engine and supervisor derive `taskIdentifier` from the loaded `TaskRun` row instead. Relaxing the schema first means readers tolerate payloads that omit it, so the writer-side change can ship without producing schema-parse errors during a rolling deploy. `projectId` is left required: `WorkerQueueResolver.#getOverride` reads it for project-scoped runtime worker-queue overrides. ## Test plan - [x] `pnpm run typecheck --filter @internal/run-engine` - [x] `pnpm run typecheck --filter webapp` - [x] `pnpm run test ./src/run-queue/tests/enqueueMessage.test.ts ./src/run-queue/tests/workerQueueResolver.test.ts --run` (28/28 passing) --- internal-packages/run-engine/src/run-queue/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal-packages/run-engine/src/run-queue/types.ts b/internal-packages/run-engine/src/run-queue/types.ts index 1f16ccfa57a..0e6b7437d51 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -4,7 +4,8 @@ import type { MinimalAuthenticatedEnvironment } from "../shared/index.js"; export const InputPayload = z.object({ runId: z.string(), - taskIdentifier: z.string(), + /** Deprecated: not read on the V2 dequeue path; will stop being written in a follow-up. Optional to keep new readers compatible with old payloads that still include it, and vice versa. */ + taskIdentifier: z.string().optional(), orgId: z.string(), projectId: z.string(), environmentId: z.string(), From 1e4b896c3006ad3d687eb871fd5e8aefbed9c3ca Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 12 May 2026 11:23:36 +0100 Subject: [PATCH 021/491] Fix(webapp): Notification style updates (#3553) ### Style updates to the notifications - Tightened up the typography - Brighter background to make it stand out a bit more - A bit more padding to make it more readable - Show the close button on hover instead - Turned the notification into a separate component as it's shared on the admin page modal - Minor tweaks to the behavior of toggling the notification beween open/closed side menu states ### Before before ### After (with image) CleanShot 2026-05-11 at 17 22 01 ### After (no image) after --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../navigation/NotificationCard.tsx | 142 +++ .../navigation/NotificationPanel.tsx | 231 +---- .../webapp/app/routes/admin.notifications.tsx | 853 +++++++++--------- 3 files changed, 605 insertions(+), 621 deletions(-) create mode 100644 apps/webapp/app/components/navigation/NotificationCard.tsx diff --git a/apps/webapp/app/components/navigation/NotificationCard.tsx b/apps/webapp/app/components/navigation/NotificationCard.tsx new file mode 100644 index 00000000000..8b03c27fff9 --- /dev/null +++ b/apps/webapp/app/components/navigation/NotificationCard.tsx @@ -0,0 +1,142 @@ +import { XMarkIcon } from "@heroicons/react/20/solid"; +import { useLayoutEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { cn } from "~/utils/cn"; + +export function NotificationCard({ + title, + description, + image, + actionUrl, + onDismiss, + onCardClick, + onLinkClick, +}: { + title: string; + description: string; + image?: string; + actionUrl?: string; + onDismiss?: () => void; + onCardClick?: () => void; + onLinkClick?: () => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const descriptionRef = useRef(null); + + useLayoutEffect(() => { + const el = descriptionRef.current; + if (!el) return; + + const check = () => setIsOverflowing(el.scrollHeight - el.clientHeight > 1); + check(); + + const observer = new ResizeObserver(check); + observer.observe(el); + return () => observer.disconnect(); + }, [description]); + + const handleDismiss = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDismiss?.(); + }; + + const handleToggleExpand = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsExpanded((v) => !v); + }; + + const safeActionUrl = sanitizeUrl(actionUrl); + const safeImage = sanitizeUrl(image); + + return ( + + ); +} + +function getMarkdownComponents(onLinkClick?: () => void) { + return { + p: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( +
{ + e.stopPropagation(); + onLinkClick?.(); + }} + > + {children} + + ), + strong: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: React.ReactNode }) => {children}, + code: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + }; +} + +const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]); + +/** Sanitize a URL to prevent XSS via javascript: or data: URIs. Returns "" if invalid. */ +function sanitizeUrl(url: string | undefined): string { + if (!url) return ""; + try { + const parsed = new URL(url); + return SAFE_URL_PROTOCOLS.has(parsed.protocol) ? parsed.href : ""; + } catch { + return ""; + } +} diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx index fdfbb2f8742..15af60fde35 100644 --- a/apps/webapp/app/components/navigation/NotificationPanel.tsx +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -1,13 +1,12 @@ -import { BellAlertIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { BellAlertIcon } from "@heroicons/react/20/solid"; import { useFetcher } from "@remix-run/react"; -import { motion } from "framer-motion"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import { Header3 } from "~/components/primitives/Headers"; +import { useCallback, useEffect, useRef, useState } from "react"; +import simplur from "simplur"; +import { Button } from "~/components/primitives/Buttons"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { usePlatformNotifications } from "~/routes/resources.platform-notifications"; -import { cn } from "~/utils/cn"; +import { NotificationCard } from "./NotificationCard"; type Notification = { id: string; @@ -102,211 +101,57 @@ export function NotificationPanel({ return null; } + const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data; const card = ( handleDismiss(notification.id)} + onCardClick={() => { + fireClickBeacon(notification.id); + if (dismissOnAction) { + handleDismiss(notification.id); + } + }} onLinkClick={() => fireClickBeacon(notification.id)} /> ); return ( -
- {/* Expanded sidebar: show card directly */} - - {card} - - - {/* Collapsed sidebar: show bell icon that opens popover */} - +
+ {isCollapsed ? ( -
- - - {visibleNotifications.length} - -
- +
+ + + + + {visibleNotifications.length} + +
} - content="Notifications" + content={simplur`${visibleNotifications.length} notification[|s]`} side="right" sideOffset={8} disableHoverableContent - asChild /> - + ) : ( + card + )}
- + {card} ); } - -function NotificationCard({ - notification, - onDismiss, - onLinkClick, -}: { - notification: Notification; - onDismiss: (id: string) => void; - onLinkClick: () => void; -}) { - const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data; - const [isExpanded, setIsExpanded] = useState(false); - const [isOverflowing, setIsOverflowing] = useState(false); - const descriptionRef = useRef(null); - - useLayoutEffect(() => { - const el = descriptionRef.current; - if (el) { - setIsOverflowing(el.scrollHeight > el.clientHeight); - } - }, [description]); - - const handleDismiss = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onDismiss(notification.id); - }; - - const handleToggleExpand = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsExpanded((v) => !v); - }; - - const handleCardClick = () => { - onLinkClick(); - if (dismissOnAction) { - onDismiss(notification.id); - } - }; - - const Wrapper = actionUrl ? "a" : "div"; - const wrapperProps = actionUrl - ? { - href: actionUrl, - target: "_blank" as const, - rel: "noopener noreferrer" as const, - onClick: handleCardClick, - } - : {}; - - return ( - - {/* Header: title + dismiss */} -
- - {title} - - -
- - {/* Body: description + chevron */} -
-
-
-
- {description} -
- {(isOverflowing || isExpanded) && ( - - )} -
- {actionUrl && ( -
- -
- )} -
- - {image && ( - - )} -
-
- ); -} - -/** Sanitize image URL to prevent XSS via javascript: or data: URIs. */ -function sanitizeImageUrl(url: string): string { - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" || parsed.protocol === "http:") { - return parsed.href; - } - return ""; - } catch { - return ""; - } -} - -function getMarkdownComponents(onLinkClick: () => void) { - return { - p: ({ children }: { children?: React.ReactNode }) => ( -

{children}

- ), - a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( - { - e.stopPropagation(); - onLinkClick(); - }} - > - {children} - - ), - strong: ({ children }: { children?: React.ReactNode }) => ( - {children} - ), - em: ({ children }: { children?: React.ReactNode }) => {children}, - code: ({ children }: { children?: React.ReactNode }) => ( - {children} - ), - }; -} diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx index f05397d3c20..548fc16619b 100644 --- a/apps/webapp/app/routes/admin.notifications.tsx +++ b/apps/webapp/app/routes/admin.notifications.tsx @@ -1,9 +1,8 @@ -import { ChevronRightIcon, TrashIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { TrashIcon } from "@heroicons/react/20/solid"; import { useFetcher, useSearchParams } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; -import { useEffect, useRef, useState, useLayoutEffect } from "react"; -import ReactMarkdown from "react-markdown"; +import { useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { @@ -20,13 +19,19 @@ import { Button } from "~/components/primitives/Buttons"; import { Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "~/components/primitives/Dialog"; -import { Header3 } from "~/components/primitives/Headers"; +import { Checkbox, CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { NotificationCard } from "~/components/navigation/NotificationCard"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { TextArea } from "~/components/primitives/TextArea"; import { Table, TableBlankRow, @@ -48,13 +53,16 @@ import { updatePlatformNotification, } from "~/services/platformNotifications.server"; import { createSearchParams } from "~/utils/searchParams"; -import { cn } from "~/utils/cn"; const PAGE_SIZE = 20; const WEBAPP_TYPES = ["card", "changelog"] as const; const CLI_TYPES = ["info", "warn", "error", "success"] as const; +/** Sentinel for the discovery "match behavior" select meaning "none / not configured". */ +const DISCOVERY_MATCH_NONE = ""; +const DISCOVERY_MATCH_LABEL = "β€” none β€”"; + const SearchParams = z.object({ page: z.coerce.number().optional(), hideInactive: z.coerce.boolean().optional(), @@ -70,7 +78,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const { page: rawPage, hideInactive } = searchParams.params.getAll(); const page = rawPage ?? 1; - const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE, hideInactive: hideInactive ?? false }); + const data = await getAdminNotificationsList({ + page, + pageSize: PAGE_SIZE, + hideInactive: hideInactive ?? false, + }); return typedjson({ ...data, userId }); }; @@ -134,12 +146,12 @@ function parseNotificationFormData(formData: FormData) { : undefined; const discoveryFilePatterns = (formData.get("discoveryFilePatterns") as string) || ""; - const discoveryContentPattern = - (formData.get("discoveryContentPattern") as string) || undefined; - const discoveryMatchBehavior = (formData.get("discoveryMatchBehavior") as string) || ""; + const discoveryContentPattern = (formData.get("discoveryContentPattern") as string) || undefined; + const discoveryMatchBehavior = + (formData.get("discoveryMatchBehavior") as string) || DISCOVERY_MATCH_NONE; const discovery = - discoveryFilePatterns && discoveryMatchBehavior + discoveryFilePatterns && discoveryMatchBehavior !== DISCOVERY_MATCH_NONE ? { filePatterns: discoveryFilePatterns .split(",") @@ -191,7 +203,14 @@ function buildPayloadInput(fields: ReturnType) async function handleCreateAction(formData: FormData, userId: string, isPreview: boolean) { const fields = parseNotificationFormData(formData); - if (!fields.adminLabel || !fields.title || !fields.description || !fields.endsAt || !fields.surface || !fields.payloadType) { + if ( + !fields.adminLabel || + !fields.title || + !fields.description || + !fields.endsAt || + !fields.surface || + !fields.payloadType + ) { return typedjson({ error: "Missing required fields" }, { status: 400 }); } @@ -204,14 +223,18 @@ async function handleCreateAction(formData: FormData, userId: string, isPreview: ? { userId } : { ...(fields.scope === "USER" && fields.scopeUserId ? { userId: fields.scopeUserId } : {}), - ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId ? { organizationId: fields.scopeOrganizationId } : {}), - ...(fields.scope === "PROJECT" && fields.scopeProjectId ? { projectId: fields.scopeProjectId } : {}), + ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId + ? { organizationId: fields.scopeOrganizationId } + : {}), + ...(fields.scope === "PROJECT" && fields.scopeProjectId + ? { projectId: fields.scopeProjectId } + : {}), }), startsAt: isPreview ? new Date().toISOString() : fields.startsAt - ? new Date(fields.startsAt + "Z").toISOString() - : new Date().toISOString(), + ? new Date(fields.startsAt + "Z").toISOString() + : new Date().toISOString(), endsAt: isPreview ? new Date(Date.now() + 60 * 60 * 1000).toISOString() : new Date(fields.endsAt + "Z").toISOString(), @@ -256,7 +279,10 @@ async function handleArchiveAction(formData: FormData) { return typedjson({ success: true }); } catch (error) { logger.error("Failed to archive platform notification", { error, notificationId }); - return typedjson({ error: "Failed to archive notification, please try again." }, { status: 500 }); + return typedjson( + { error: "Failed to archive notification, please try again." }, + { status: 500 } + ); } } @@ -271,7 +297,10 @@ async function handleDeleteAction(formData: FormData) { return typedjson({ success: true }); } catch (error) { logger.error("Failed to delete platform notification", { error, notificationId }); - return typedjson({ error: "Failed to delete notification, please try again." }, { status: 500 }); + return typedjson( + { error: "Failed to delete notification, please try again." }, + { status: 500 } + ); } } @@ -286,7 +315,10 @@ async function handlePublishNowAction(formData: FormData) { return typedjson({ success: true }); } catch (error) { logger.error("Failed to publish platform notification", { error, notificationId }); - return typedjson({ error: "Failed to publish notification, please try again." }, { status: 500 }); + return typedjson( + { error: "Failed to publish notification, please try again." }, + { status: 500 } + ); } } @@ -294,7 +326,16 @@ async function handleEditAction(formData: FormData) { const notificationId = formData.get("notificationId") as string; const fields = parseNotificationFormData(formData); - if (!notificationId || !fields.adminLabel || !fields.title || !fields.description || !fields.endsAt || !fields.surface || !fields.payloadType || !fields.startsAt) { + if ( + !notificationId || + !fields.adminLabel || + !fields.title || + !fields.description || + !fields.endsAt || + !fields.surface || + !fields.payloadType || + !fields.startsAt + ) { return typedjson({ error: "Missing required fields" }, { status: 400 }); } @@ -305,8 +346,12 @@ async function handleEditAction(formData: FormData) { surface: fields.surface as "CLI" | "WEBAPP", scope: fields.scope as "USER" | "PROJECT" | "ORGANIZATION" | "GLOBAL", ...(fields.scope === "USER" && fields.scopeUserId ? { userId: fields.scopeUserId } : {}), - ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId ? { organizationId: fields.scopeOrganizationId } : {}), - ...(fields.scope === "PROJECT" && fields.scopeProjectId ? { projectId: fields.scopeProjectId } : {}), + ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId + ? { organizationId: fields.scopeOrganizationId } + : {}), + ...(fields.scope === "PROJECT" && fields.scopeProjectId + ? { projectId: fields.scopeProjectId } + : {}), startsAt: new Date(fields.startsAt + "Z").toISOString(), endsAt: new Date(fields.endsAt + "Z").toISOString(), priority: fields.priority, @@ -337,8 +382,12 @@ async function handleEditAction(formData: FormData) { export default function AdminNotificationsRoute() { const { notifications, total, page, pageCount } = useTypedLoaderData(); const [showCreate, setShowCreate] = useState(false); - const [detailNotification, setDetailNotification] = useState<(typeof notifications)[number] | null>(null); - const [editNotification, setEditNotification] = useState<(typeof notifications)[number] | null>(null); + const [detailNotification, setDetailNotification] = useState< + (typeof notifications)[number] | null + >(null); + const [editNotification, setEditNotification] = useState<(typeof notifications)[number] | null>( + null + ); const [urlSearchParams, setUrlSearchParams] = useSearchParams(); const hideInactive = urlSearchParams.get("hideInactive") === "true"; @@ -361,23 +410,21 @@ export default function AdminNotificationsRoute() {
{ if (!open) setShowCreate(false); }} + onOpenChange={(open) => { + if (!open) setShowCreate(false); + }} > - + - Create Notification + Create notification - setShowCreate(false)} - /> + setShowCreate(false)} /> @@ -386,12 +433,7 @@ export default function AdminNotificationsRoute() { {total} notifications (page {page} of {pageCount || 1})
@@ -427,7 +469,7 @@ export default function AdminNotificationsRoute() { @@ -448,33 +490,28 @@ export default function AdminNotificationsRoute() { {formatDate(n.endsAt)} - {n.stats.seen} + {n.stats.seen} - {n.stats.clicked} + {n.stats.clicked} - {n.stats.dismissed} + {n.stats.dismissed} -
- {status === "pending" && ( - - )} - {(status === "pending" || status === "releasing" || status === "active") && ( - )} - {status !== "archived" && ( - - )} + {status !== "archived" && }
@@ -494,7 +531,7 @@ export default function AdminNotificationsRoute() { if (!open) setDetailNotification(null); }} > - + {detailNotification && ( <> @@ -512,11 +549,11 @@ export default function AdminNotificationsRoute() { if (!open) setEditNotification(null); }} > - + {editNotification && ( <> - Edit Notification + Edit notification - + @@ -593,8 +630,8 @@ function DeleteConfirmationButton({ notificationId }: { notificationId: string } Delete notification - This will permanently delete this notification and all its interaction data. This - action cannot be undone. + This will permanently delete this notification and all its interaction data. This action + cannot be undone. @@ -651,13 +688,18 @@ function NotificationForm({ onClose: () => void; }) { const fetcher = useFetcher<{ success?: boolean; error?: string; previewId?: string }>(); - const [surface, setSurface] = useState<"CLI" | "WEBAPP">((n?.surface as "CLI" | "WEBAPP") ?? "WEBAPP"); + const [surface, setSurface] = useState<"CLI" | "WEBAPP">( + (n?.surface as "CLI" | "WEBAPP") ?? "WEBAPP" + ); const [payloadType, setPayloadType] = useState(n?.payloadType ?? "card"); const [scope, setScope] = useState(n?.scope ?? "GLOBAL"); const [title, setTitle] = useState(n?.payloadTitle ?? ""); const [description, setDescription] = useState(n?.payloadDescription ?? ""); const [actionUrl, setActionUrl] = useState(n?.payloadActionUrl ?? ""); const [image, setImage] = useState(n?.payloadImage ?? ""); + const [discoveryMatchBehavior, setDiscoveryMatchBehavior] = useState( + n?.payloadDiscovery?.matchBehavior ?? DISCOVERY_MATCH_NONE + ); const typeOptions = surface === "WEBAPP" ? WEBAPP_TYPES : CLI_TYPES; @@ -686,50 +728,67 @@ function NotificationForm({ )} -
-
- - -
+ + + + +
+ + +
+
- - + {(items) => + items.map((item) => ( + + {item} + + )) + } +
- - + {(items) => + items.map((item) => ( + + {item} + + )) + } +
-
- +
+
-
-
-
- - + {(items) => + items.map((item) => ( + + {item} + + )) + } +
+
- {scope === "USER" && ( -
- - -
- )} + {scope === "USER" && ( +
+ + +
+ )} - {scope === "ORGANIZATION" && ( -
- - -
- )} + {scope === "ORGANIZATION" && ( +
+ + +
+ )} - {scope === "PROJECT" && ( -
- - -
- )} -
+ {scope === "PROJECT" && ( +
+ + +
+ )} {/* CLI live preview */} {surface === "CLI" && (title || description) && (
-

- CLI Preview -

-
+ +
{title && (

@@ -796,109 +877,112 @@ function NotificationForm({

)} - {actionUrl && ( -

{actionUrl}

- )} + {actionUrl &&

{actionUrl}

}
)} -
- - setTitle(e.target.value)} - className="mt-1" - /> -
+
-
- - {surface === "WEBAPP" ? ( -
-