diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index f658dac0dee2..eea622cc3d98 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -39,7 +39,7 @@ runs: # package across filesystems instead of hard-linking it: install took 28-49s # against 9s plus an 11s restore. - name: Cache full pnpm store - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2ddddaf4178..b356eb0ab6ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,6 +252,7 @@ jobs: # The web suite is the largest outside the server. In the shared Test job it # started last and ran alone for about 40s after everything else finished. + # release.yml mirrors this test, test_web, and test_server split. test_web: name: Test Web needs: changes diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 08552d1cd53a..cbd61e0604ef 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -64,6 +64,13 @@ on: required: false default: false type: boolean + # A runner of the target's own platform and architecture, for a `runner` + # that cannot execute the target. The build job skips everything that + # runs the packaged binaries, and a second job runs it there. + verify_runner: + required: false + default: "" + type: string version: required: true type: string @@ -129,39 +136,6 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - # The runner's PowerShell 7.6/.NET 10 intermittently crashes during signing: - # https://github.com/dotnet/runtime/issues/131636 - # Put the .NET 8 release first for both workflow shells and electron-builder. - - name: Use PowerShell 7.4 for Windows ARM builds - if: inputs.platform == 'win' && inputs.arch == 'arm64' - shell: powershell - run: | - $ErrorActionPreference = "Stop" - $ProgressPreference = "SilentlyContinue" - $version = "7.4.20" - $archive = Join-Path $env:RUNNER_TEMP "PowerShell-$version-win-arm64.zip" - $installDir = Join-Path $env:RUNNER_TEMP "powershell-$version-arm64" - Invoke-WebRequest -UseBasicParsing ` - -Uri "https://github.com/PowerShell/PowerShell/releases/download/v$version/PowerShell-$version-win-arm64.zip" ` - -OutFile $archive - $expectedHash = "67eff749d5dae357501c95a3eec1dcd93f669824e3ca2b1d2adcdecdebf04f35" - if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne $expectedHash) { - throw "PowerShell archive checksum mismatch" - } - Expand-Archive -Path $archive -DestinationPath $installDir - $installDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - & "$installDir\pwsh.exe" -NoProfile -NonInteractive -Command '$PSVersionTable; [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture' - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Verify Windows ARM PowerShell selection - if: inputs.platform == 'win' && inputs.arch == 'arm64' - shell: pwsh - run: | - $expectedPath = Join-Path $env:RUNNER_TEMP "powershell-7.4.20-arm64\pwsh.exe" - if ((Get-Command pwsh.exe).Source -ne $expectedPath -or $PSVersionTable.PSVersion -ne [version]"7.4.20") { - throw "Windows ARM builds must use the pinned PowerShell runtime" - } - # Windows installs straight from the registry. There, linking the packages # takes the time, not downloading them, so a ~700 MB package cache did not # pay for its own restore. Across recent nightlies, restore plus install @@ -172,7 +146,7 @@ jobs: uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json - cache: ${{ inputs.platform != 'win' }} + cache: ${{ runner.os != 'Windows' }} run-install: false # pnpm checks the lockfile and policy before reusing this result. A missing @@ -245,7 +219,7 @@ jobs: # monitor, the desktop build's preflight skips its MSVC check, and node-pty # ships prebuilt Windows binaries, so the 30-60s install is skipped too. - name: Install Spectre-mitigated MSVC libs - if: inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' + if: runner.os == 'Windows' && steps.resource_monitor_cache.outputs.cache-hit != 'true' shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" @@ -261,27 +235,21 @@ jobs: exit $code } - - uses: ./.github/actions/setup-apt-mirrors - if: inputs.platform == 'linux' + - name: Install cargo-xwin + if: runner.os != 'Windows' && inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 + with: + tool: cargo-xwin@0.23.1 - - name: Install Linux desktop build libraries - if: inputs.platform == 'linux' + # Built here rather than inside the desktop build so the CLI archive, + # which bundles the same binary, can be built and uploaded first. + - name: Build resource monitor + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config - if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get install -y imagemagick - fi - - if command -v magick >/dev/null 2>&1; then - magick -version - else - convert -version - fi + run: cargo ${{ runner.os != 'Windows' && inputs.platform == 'win' && 'xwin build' || 'build' }} --locked --release --manifest-path native/resource-monitor/Cargo.toml --target "${{ inputs.rust_target }}" - name: Prepare Azure Trusted Signing - if: inputs.platform == 'win' + if: inputs.platform == 'win' && runner.os == 'Windows' shell: pwsh env: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -342,6 +310,153 @@ jobs: Select-Object -Unique "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV + # Off Windows, scripts/lib/windows-jsign.ts signs through jsign instead of + # the TrustedSigning PowerShell module. + - name: Install jsign + if: inputs.platform == 'win' && runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + dir="$RUNNER_TEMP/jsign" + mkdir -p "$dir" + curl -fsSL --retry 3 -o "$dir/jsign.jar" https://github.com/ebourg/jsign/releases/download/7.6/jsign-7.6.jar + echo "6cf58aa080e7ff82656f320cff8d4a3bff30408df8766806dcba3740dd839fa5 $dir/jsign.jar" | sha256sum -c - + printf '#!/bin/sh\nexec java -jar "%s" "$@"\n' "$dir/jsign.jar" > "$dir/jsign" + chmod +x "$dir/jsign" + echo "T3CODE_JSIGN=$dir/jsign" >> "$GITHUB_ENV" + + # The desktop build applies the same branding to apps/server/dist/client + # (resolveDesktopWebAssetBrand in scripts/build-desktop-artifact.ts); the + # CLI archive below ships that client and is now built first. + - name: Apply web client branding + if: inputs.cli_archive + shell: bash + run: | + brand=production + if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi + node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + + # The single-executable is built with a Node that supports --build-sea + # (25.7+); the repo itself stays on the engines.node version. A native + # build injects into the runner's own Node: tsdown's cross-target download + # path runs `tar` on a drive-letter path on Windows, which GNU tar reads as + # a remote host. Off Windows, a Windows target downloads its node.exe. + - name: Build CLI single-executable + if: inputs.cli_archive + shell: bash + env: + # The exact version, not a major: vp downloads it from nodejs.org/dist on + # the runner, and only exact versions have a dist directory. Keep in + # step with SEA_NODE_VERSION in apps/server/vite.config.ts. + VP_NODE_VERSION: "26.8.2" + # The executable bundles the server again, so it needs the same stamp + # as release.yml's JS bundle. + T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} + run: node apps/server/scripts/cli.ts build-exe --verbose ${{ runner.os != 'Windows' && inputs.platform == 'win' && format('--target win-{0}', inputs.arch) || '' }} + + - name: Import macOS signing certificate for the CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then + echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." + exit 0 + fi + keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" + keychain_password="$(openssl rand -hex 16)" + cert_path="$RUNNER_TEMP/t3-cli-signing.p12" + printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" + if [[ -z "$identity" ]]; then + echo "No Developer ID Application identity found in CSC_LINK." >&2 + exit 1 + fi + echo "::add-mask::$keychain_password" + echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "macOS CLI signing enabled." + + - name: Stage resource monitor for the CLI archive + if: inputs.cli_archive + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + + - name: Build CLI archive + if: inputs.cli_archive + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: | + set -euo pipefail + if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then + key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts \ + --platform "${{ inputs.platform }}" \ + --arch "${{ inputs.arch }}" \ + --version "${{ inputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + if: inputs.cli_archive && inputs.verify_runner == '' + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + + - name: Upload CLI archive + if: inputs.cli_archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + + - uses: ./.github/actions/setup-apt-mirrors + if: inputs.platform == 'linux' + + - name: Install Linux desktop build libraries + if: inputs.platform == 'linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev pkg-config + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get install -y imagemagick + fi + + if command -v magick >/dev/null 2>&1; then + magick -version + else + convert -version + fi + # The WSL backend runs the Linux CLI archive inside the distro, so the # Windows desktop embeds the same-arch archive the release attaches. # This job starts alongside that Linux job instead of after it, and the @@ -407,7 +522,7 @@ jobs: shell: bash env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} @@ -433,6 +548,9 @@ jobs: --skip-build --verbose ) + if [[ -n "${{ inputs.verify_runner }}" ]]; then + args+=(--keep-stage) + fi has_all() { for value in "$@"; do @@ -488,107 +606,35 @@ jobs: vp run dist:desktop:artifact "${args[@]}" - # The single-executable is built with a Node that supports --build-sea - # (25.7+); the repo itself stays on the engines.node version. It always - # injects into the runner's own Node: tsdown's cross-target download path - # runs `tar` on a drive-letter path on Windows, which GNU tar reads as a - # remote host, and a cross-built macOS binary cannot be smoke-tested. - - name: Build CLI single-executable - if: inputs.cli_archive - shell: bash - env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" - # The executable bundles the server again, so it needs the same stamp - # as release.yml's JS bundle. - T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} - run: node apps/server/scripts/cli.ts build-exe --verbose - - - name: Import macOS signing certificate for the CLI archive - if: inputs.cli_archive && inputs.platform == 'mac' - shell: bash - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - run: | - set -euo pipefail - if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then - echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." - exit 0 - fi - keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" - keychain_password="$(openssl rand -hex 16)" - cert_path="$RUNNER_TEMP/t3-cli-signing.p12" - printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" - security create-keychain -p "$keychain_password" "$keychain" - security set-keychain-settings -lut 21600 "$keychain" - security unlock-keychain -p "$keychain_password" "$keychain" - security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null - security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" - if [[ -z "$identity" ]]; then - echo "No Developer ID Application identity found in CSC_LINK." >&2 - exit 1 - fi - echo "::add-mask::$keychain_password" - echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" - echo "macOS CLI signing enabled." - - - name: Stage resource monitor for the CLI archive - if: inputs.cli_archive - shell: bash - run: | - set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ inputs.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" - mkdir -p "$target_dir" - cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" - - - name: Build CLI archive - if: inputs.cli_archive + # The verify job runs what this host could not: the packaged natives and + # the CLI. Its scripts are bundled so that job needs no install. + - name: Stage verification inputs + if: inputs.verify_runner != '' shell: bash - env: - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} run: | set -euo pipefail - if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then - key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - fi - node scripts/build-cli-archive.ts \ - --platform "${{ inputs.platform }}" \ - --arch "${{ inputs.arch }}" \ - --version "${{ inputs.version }}" \ - --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli - - - name: Smoke-test CLI archive - if: inputs.cli_archive - shell: bash - run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" - - - name: Upload CLI archive - if: inputs.cli_archive + mkdir -p verify + unpacked=$(ls -d "${TMPDIR:-/tmp}"/t3code-desktop-${{ inputs.platform }}-stage-*/app/dist/${{ inputs.platform }}-${{ inputs.arch }}-unpacked) + tar -C "$(dirname "$unpacked")" -cf verify/unpacked.tar "$(basename "$unpacked")" + esbuild=scripts/node_modules/.bin/esbuild + banner="import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);" + # build-desktop-artifact.ts's own import.meta.main guard must not run + # its CLI inside the verify bundle. + "$esbuild" scripts/verify-windows-desktop-artifact.ts --bundle --platform=node --format=esm \ + --target=node24 --define:import.meta.main=false --banner:js="$banner" \ + --outfile=verify/verify-windows-desktop-artifact.mjs + "$esbuild" scripts/smoke-cli-archive.ts --bundle --platform=node --format=esm \ + --target=node24 --banner:js="$banner" --outfile=verify/smoke-cli-archive.mjs + + - name: Upload verification inputs + if: inputs.verify_runner != '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: cli-${{ inputs.platform }}-${{ inputs.arch }} - path: release-cli/* + name: verify-${{ inputs.platform }}-${{ inputs.arch }} + path: verify/* if-no-files-found: error + retention-days: 1 + compression-level: 1 - name: Collect release assets shell: bash @@ -661,3 +707,81 @@ jobs: name: resource-monitor-${{ inputs.resource_key }} path: resource-monitor-publish/${{ inputs.resource_key }}/* if-no-files-found: error + + verify: + name: Verify + needs: [build] + if: inputs.verify_runner != '' + runs-on: ${{ inputs.verify_runner }} + timeout-minutes: 15 + steps: + - name: Download verification inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: verify-${{ inputs.platform }}-${{ inputs.arch }} + path: verify + + - name: Download CLI archive + if: inputs.cli_archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli + + - name: Verify packaged natives + shell: bash + run: | + set -euo pipefail + tar -C verify -xf verify/unpacked.tar + node verify/verify-windows-desktop-artifact.mjs \ + --app-dir "$(cygpath -w "$PWD/verify/${{ inputs.platform }}-${{ inputs.arch }}-unpacked")" \ + --arch "${{ inputs.arch }}" --build-version "${{ inputs.version }}" --verbose + + - name: Smoke-test CLI archive + if: inputs.cli_archive + shell: bash + run: node verify/smoke-cli-archive.mjs --archive release-cli/* --expect-version "${{ inputs.version }}" + + # cargo-xwin links this binary on Linux, and nothing else runs it before users do. + # Node rather than pwsh: PowerShell on Windows ARM crashes intermittently + # (https://github.com/dotnet/runtime/issues/131636), which is why this + # target stopped building on Windows. + - name: Smoke-test resource monitor + shell: bash + env: + MONITOR: verify/${{ inputs.platform }}-${{ inputs.arch }}-unpacked/resources/resource-monitor/t3-resource-monitor.exe + EXPECTED_ARCH: ${{ inputs.arch == 'arm64' && 'aarch64' || 'x86_64' }} + run: | + node --input-type=module <<'EOF' + import { spawn } from "node:child_process"; + const monitor = spawn(process.env.MONITOR, { stdio: ["pipe", "pipe", "inherit"] }); + monitor.stdout.setEncoding("utf8"); + let output = ""; + const exited = new Promise((resolve) => monitor.on("close", resolve)); + const hello = await new Promise((resolve, reject) => { + monitor.on("error", reject); + monitor.on("close", () => reject(new Error(`Resource monitor exited before hello: ${output}`))); + monitor.stdout.on("data", (chunk) => { + output += chunk; + const end = output.indexOf("\n"); + if (end !== -1) resolve(JSON.parse(output.slice(0, end))); + }); + }); + if (hello.type !== "hello" || hello.arch !== process.env.EXPECTED_ARCH) { + throw new Error(`Unexpected hello: ${JSON.stringify(hello)}`); + } + const send = (message) => monitor.stdin.write(`${JSON.stringify({ version: hello.version, ...message })}\n`); + send({ type: "configure", rootPid: process.pid, sampleIntervalMs: 1000 }); + send({ type: "sampleNow", requestId: "verify" }); + monitor.stdin.end(); + const exitCode = await exited; + const sample = output + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + .find((message) => message.requestId === "verify"); + if (exitCode !== 0 || sample?.type !== "snapshot" || !sample.processes.some((p) => p.pid === process.pid)) { + throw new Error(`Resource monitor exited ${exitCode} with: ${output}`); + } + console.log(`Resource monitor sampled ${sample.scannedProcessCount} processes.`); + EOF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac181064714d..2cbecee94cdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -256,7 +256,7 @@ jobs: run: vp run typecheck # Keep tests on their own runners, using the same package split and server - # shards as CI, so the release check job does not spend its budget on tests. + # shards as CI (test, test_web, test_server in ci.yml). Keep the two in sync. test: name: Release tests needs: [preflight] @@ -286,13 +286,57 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential - # The web package's browser project runs React tests in headless Chromium. + - name: Test + run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test + + # Web browser tests time out when they share a runner with the other package + # suites: Chromium gets too little CPU and diff workers miss their 10s start budget. + test_web: + name: Release web tests + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + + - name: Resolve Playwright version + id: playwright_version + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + - name: Install Chromium for web browser tests - run: node apps/web/node_modules/playwright/cli.js install --with-deps --only-shell chromium + if: steps.chromium_cache.outputs.cache-hit != 'true' + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi - name: Test - run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + run: vp run --filter @t3tools/web test + # Server test files run one at a time, so more small runners beat fewer big ones. test_server: name: Release server tests ${{ matrix.shard }} needs: [preflight] @@ -302,7 +346,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -675,6 +719,8 @@ jobs: resource_key: win32-x64 cli_archive: true + # Windows arm64 runners have 4 vCPUs, and the packaging is mostly CPU-bound, + # so the build runs on Linux and only the native checks run on arm64. desktop_win_arm64: name: Desktop Windows arm64 needs: [preflight, relay_public_config, build_bundle] @@ -690,7 +736,8 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows arm64 - runner: windows-11-arm + runner: ubuntu-24.04 + verify_runner: windows-11-arm platform: win target: nsis arch: arm64 @@ -706,6 +753,7 @@ jobs: relay_public_config, quality, test, + test_web, test_server, desktop_mac_arm64, desktop_mac_x64, @@ -714,7 +762,7 @@ jobs: desktop_win_x64, desktop_win_arm64, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.outputs.relay_url != '' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.outputs.relay_url != '' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 21a61e91cc8e..cf0f7c030c23 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -30,7 +30,7 @@ jobs: timeout-minutes: 45 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -40,7 +40,7 @@ jobs: # setup-vp's own cache restores a Linux-shaped store on Windows, which is # slower than no cache (see #7975). Cache pnpm's Windows store directly. - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: false @@ -52,7 +52,7 @@ jobs: run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - name: Cache packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.package_cache_path.outputs.path }} key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }} diff --git a/AGENTS.md b/AGENTS.md index efc48931a298..5c91465889c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,12 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. -- Web component tests are `*.browser.test.tsx` and render through `apps/web/test/render.tsx`. Acquire everything a test changes with `using`/`await using` so disposal undoes it even when the test fails; no `beforeEach`/`afterEach`, no module-level screens. Shared mock state that `vi.mock` factories read gets reset by a `setup()` call at the top of the test. `t3code/require-disposable-render` flags the old patterns. +- Web browser tests are `*.browser.test.tsx` or `*.browser.test.ts` and render with `await using screen = await render(...)` from `apps/web/test/render.tsx`. `t3code/require-disposable-render` errors on the old patterns. + - Acquire what the test drives after mounting (fake timers it advances, spies it sets up after mounting) after the render, so it disposes before the unmount. Anything the first render reads comes before it. + - A helper that renders and then waits or clicks puts the screen in an `AsyncDisposableStack` and `move()`s it out only on success, so a failed wait still unmounts. + - `rerender` needs a fresh element on every call. + - No lifecycle hooks and no module-level mutable state. Shared mock state that `vi.mock` factories read is reset by a `setup()` call at the top of the test. + - `router` is on by default. Pass `router: false` only with a one-line comment saying why. ```tsx it("saves on Enter", async () => { diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz deleted file mode 100644 index 33a1dcca0721..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz new file mode 100644 index 000000000000..581307cf91be Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz differ diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 1d57632eee5f..32d48da78b31 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -57,6 +57,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private var applyingNativeValue = false private var desiredLineHeightPx = 0 private var lastContentHeight = 0 + private var lastContentWidth = 0 private var contentInsetVertical = 0 private var tokensJson = "[]" private var tokens: List = emptyList() @@ -424,12 +425,19 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } private fun emitContentSizeIfNeeded() { - val height = editor.layout?.height ?: editor.measuredHeight - val contentHeight = height + contentInsetVertical * 2 - if (contentHeight == lastContentHeight) return + // The laid-out text without insets. A one-line draft reports its natural width, so + // JS can tell whether it fits a narrower row than the one it is laid out in; past + // one line only the height matters. Trailing whitespace hangs past a line without + // wrapping it, so getLineMax leaves it out. + val layout = editor.layout + val contentHeight = layout?.height ?: editor.measuredHeight + val contentWidth = if (layout?.lineCount == 1) layout.getLineMax(0).toInt() else 0 + if (contentHeight == lastContentHeight && contentWidth == lastContentWidth) return lastContentHeight = contentHeight + lastContentWidth = contentWidth + val density = resources.displayMetrics.density onComposerContentSizeChange( - mapOf("height" to contentHeight / resources.displayMetrics.density), + mapOf("width" to contentWidth / density, "height" to contentHeight / density), ) } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 7a659cc510ae..b816bafde14a 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -1230,7 +1230,28 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func emitContentSizeIfNeeded() { - let nextSize = textView.contentSize + // The laid-out text without insets. A one-line draft reports its natural width, so + // JS can tell whether it fits a narrower row than the one it is laid out in; past + // one line only the height matters, so layout stops at the second line. The width + // comes from the line fragment because usedRect spans the container once the view + // scrolls. A trailing newline's empty line only exists as the extra fragment. + let layout = textView.layoutManager + let container = textView.textContainer + var lines = 0 + var width: CGFloat = 0 + var height: CGFloat = 0 + let glyphs = NSRange(location: 0, length: layout.numberOfGlyphs) + layout.enumerateLineFragments(forGlyphRange: glyphs) { _, used, _, _, stop in + lines += 1 + width = used.width + height = used.maxY + if lines > 1 { stop.pointee = true } + } + if lines < 2, layout.extraLineFragmentTextContainer === container { + lines += 1 + height = max(height, layout.extraLineFragmentUsedRect.maxY) + } + let nextSize = CGSize(width: lines == 1 ? ceil(width) : 0, height: ceil(height)) guard abs(nextSize.width - lastContentSize.width) > 0.5 || abs(nextSize.height - lastContentSize.height) > 0.5 else { return diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c7e8406bc174..441ee7beb6d3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -134,7 +134,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.29.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.30.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index d6bbf9d99294..5c84720bedf9 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -13,6 +13,8 @@ import { type ViewStyle, } from "react-native"; +import Animated, { FadeIn, LinearTransition } from "react-native-reanimated"; + import { cn } from "../lib/cn"; import { AppText as Text } from "./ui/AppText"; import { SymbolView } from "./ui/AppSymbol"; @@ -21,6 +23,10 @@ import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; const COMPOSER_TOOLBAR_GAP = 8; const COMPOSER_TOOLBAR_FADE_WIDTH = 18; const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; +const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160); +// Android runs no composer layout transitions, so the toolbar never trails the dock. +const INLINE_CONTROL_WIDTH_TRANSITION = + Platform.OS === "android" ? undefined : LinearTransition.duration(180); export function ComposerInlineControl(props: { readonly accessibilityHint?: string; @@ -30,6 +36,8 @@ export function ComposerInlineControl(props: { readonly icon?: ComponentProps["name"]; readonly renderIcon?: (size: number) => ReactNode; readonly label: string; + /** Show only the icon and chevron; the label still names the control for accessibility. */ + readonly iconOnly?: boolean; readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; readonly selected?: boolean; @@ -38,56 +46,77 @@ export function ComposerInlineControl(props: { readonly showChevron?: boolean; }) { const { scale, smallIconSize } = useAndroidControlSizing(); + // The label fades in as the control widens, and leaves at once: an exiting view keeps + // drawing at its old place while the chevron moves into it. The clip keeps content laid out + // at the final width inside the frame while it eases there. + const animatesLabel = props.iconOnly !== undefined; return ( - - {props.renderIcon ? ( - - {props.renderIcon(smallIconSize)} - - ) : props.icon ? ( - - ) : null} - - {props.label} - - {props.showChevron === false ? null : ( - - )} - + {props.renderIcon ? ( + + {props.renderIcon(smallIconSize)} + + ) : props.icon ? ( + + ) : null} + {props.iconOnly ? null : ( + + + {props.label} + + + )} + {props.showChevron === false ? null : ( + + )} + + ); } diff --git a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx index b35c7cc58f8f..1b259f1ecbfa 100644 --- a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx @@ -1,5 +1,6 @@ import { AppActivityIndicator } from "./AppActivityIndicator"; -import { Column, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; +import { Column, Row, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; +import { weight } from "@expo/ui/jetpack-compose/modifiers"; import { useState, type ReactNode } from "react"; import { View } from "react-native"; @@ -43,6 +44,8 @@ export function AndroidScreenHeader(props: { readonly onBack?: () => void; readonly embedded?: boolean; readonly hideBottomBorder?: boolean; + /** One row with the subtitle after the title, for screens short on height. */ + readonly compact?: boolean; }) { const { colors } = useControlTheme(); const titleTypography = useScaledTextRole("title"); @@ -65,6 +68,7 @@ export function AndroidScreenHeader(props: { }} > @@ -95,6 +99,26 @@ export function AndroidScreenHeader(props: { > {props.title} + ) : props.compact ? ( + + + {props.title} + + + {props.subtitle} + + ) : ( : undefined} hideBottomBorder={props.hideBottomBorder} + compact={props.compact} actions={ search ? [ diff --git a/apps/mobile/src/components/ui/AppBar.types.ts b/apps/mobile/src/components/ui/AppBar.types.ts index 621006d949c0..e3db494219d7 100644 --- a/apps/mobile/src/components/ui/AppBar.types.ts +++ b/apps/mobile/src/components/ui/AppBar.types.ts @@ -68,6 +68,8 @@ export interface AppBarProps { readonly onPress?: () => void; }; readonly hideBottomBorder?: boolean; + /** Android: one title row with the subtitle inline. iOS ignores it. */ + readonly compact?: boolean; readonly trailing?: ReactNode; readonly matchSearchSurface?: boolean; readonly options?: AppNativeStackNavigationOptions; diff --git a/apps/mobile/src/components/ui/NativeAppBar.android.tsx b/apps/mobile/src/components/ui/NativeAppBar.android.tsx index 2c028fded803..22f8ca65cc42 100644 --- a/apps/mobile/src/components/ui/NativeAppBar.android.tsx +++ b/apps/mobile/src/components/ui/NativeAppBar.android.tsx @@ -10,9 +10,11 @@ export function NativeAppBar(props: { readonly title: ReactElement; readonly navigationIcon?: ReactElement; readonly actions?: ReactElement; + /** Shorter bar for a one-row title. */ + readonly singleLine?: boolean; }) { const { colors } = useControlTheme(); - const layout = useToolbarLayout(); + const layout = useToolbarLayout(false, props.singleLine); return ( // The bar's height is fixed, so React Native lays out the screen below it from the first frame // instead of at zero height until Compose has measured and reported the bar. diff --git a/apps/mobile/src/components/ui/useToolbarLayout.ts b/apps/mobile/src/components/ui/useToolbarLayout.ts index faa02c198289..736ac27ed4eb 100644 --- a/apps/mobile/src/components/ui/useToolbarLayout.ts +++ b/apps/mobile/src/components/ui/useToolbarLayout.ts @@ -4,8 +4,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; -/** Shared Android header geometry; class-based spacing uses the app's 14dp rem. */ -export function useToolbarLayout(embedded = false) { +/** + * Shared Android header geometry; class-based spacing uses the app's 14dp rem. + * `singleLine` sizes the toolbar for a title row with the subtitle inline. + */ +export function useToolbarLayout(embedded = false, singleLine = false) { const insets = useSafeAreaInsets(); const title = useScaledTextRole("title"); const subtitle = useScaledTextRole("label"); @@ -13,7 +16,9 @@ export function useToolbarLayout(embedded = false) { const { fontScale } = useWindowDimensions(); return { height: Math.ceil( - Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), + singleLine + ? Math.max(48, 48 * scale, title.lineHeight * fontScale + 1) + : Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), ), paddingTop: embedded ? 8 * scale : Math.max(insets.top, 12 * scale), paddingBottom: 7 * scale, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 480ca463dac8..07e70f448a72 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -15,6 +15,7 @@ import { import { createContext, use, + useDeferredValue, useEffect, useEffectEvent, useMemo, @@ -23,6 +24,7 @@ import { type ReactNode, } from "react"; import { Platform, useWindowDimensions, View } from "react-native"; +import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { runOnJS, useAnimatedStyle, @@ -73,7 +75,10 @@ import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-reque import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; -import { WorkspaceContentWidthContext } from "./workspace-content-width"; +import { + WorkspaceContentWidthContext, + WorkspaceSettledContentWidthContext, +} from "./workspace-content-width"; import { NativeWorkspaceModeContext, NativeWorkspaceInspectorContext, @@ -96,6 +101,13 @@ interface AdaptiveWorkspaceContextValue { readonly toggleAuxiliaryPane: () => void; readonly togglePrimarySidebar: () => void; readonly setAuxiliaryPaneWidth: (width: number) => void; + /** + * Android split view while the thread composer has the soft keyboard: the + * sidebar and rail step aside and the thread header drops to one line. + */ + readonly composerKeyboardActive: boolean; + /** The thread composer reports its focus so the layout can make room for typing. */ + readonly setComposerEditing: (editing: boolean) => void; } const compactLayout = deriveLayout({ width: 0, height: 0 }); @@ -122,6 +134,8 @@ const AdaptiveWorkspaceContext = createContext({ toggleAuxiliaryPane: () => undefined, togglePrimarySidebar: () => undefined, setAuxiliaryPaneWidth: () => undefined, + composerKeyboardActive: false, + setComposerEditing: () => undefined, }); export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { @@ -270,8 +284,19 @@ function AdaptiveWorkspaceLayoutContent( nativeWorkspace, usesSplitView: baseLayout.usesSplitView, }); + const [composerEditing, setComposerEditing] = useState(false); + const softKeyboardVisible = useKeyboardState((state) => state.isVisible); + // Keyed on the soft keyboard, not focus alone: a hardware keyboard leaves room + // for the sidebar, and Back closes the keyboard while the editor keeps focus. + // Deferred because the thread composer follows the keyboard only once its own render of this + // keyboard event commits; re-laying out the workspace in that commit held it behind the + // keyboard until the feed had reflowed. + const composerKeyboardActive = useDeferredValue( + Platform.OS === "android" && baseLayout.usesSplitView && composerEditing && softKeyboardVisible, + ); const showPrimarySidebar = - !workspacePage && (primarySidebarPinned || primarySidebarPreferredVisible); + !workspacePage && + (primarySidebarPinned || (primarySidebarPreferredVisible && !composerKeyboardActive)); const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView; const navigationRailWidth = shouldRenderNavigationRail && (showPrimarySidebar || workspacePage) @@ -391,6 +416,12 @@ function AdaptiveWorkspaceLayoutContent( if (primarySidebarPinned) { return; } + // A sidebar the keyboard set aside returns with the keyboard; the user's preference stays + // intact. One the user had hidden still opens. + if (composerKeyboardActive) { + void KeyboardController.dismiss(); + if (primarySidebarPreferredVisible) return; + } if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); setPrimarySidebarPreferredVisible(true); @@ -399,6 +430,7 @@ function AdaptiveWorkspaceLayoutContent( setPrimarySidebarPreferredVisible((current) => !current); }; const revealPrimarySidebar = () => { + if (composerKeyboardActive) void KeyboardController.dismiss(); if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } @@ -499,12 +531,10 @@ function AdaptiveWorkspaceLayoutContent( const contentTargetWidth = layout.usesSplitView ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) : null; - const [contentSizing, setContentSizing] = useState({ + const [listPaneSizing, setListPaneSizing] = useState({ viewportWidth, workspacePage, - target: contentTargetWidth, sidebarVisible: panes.primarySidebarVisible, - width: contentTargetWidth, listPaneTarget: layout.listPaneWidth, listPaneFloor: layout.listPaneWidth, listPaneCeiling: layout.listPaneWidth, @@ -512,69 +542,80 @@ function AdaptiveWorkspaceLayoutContent( }); if ( Platform.OS === "android" && - (contentSizing.viewportWidth !== viewportWidth || - contentSizing.workspacePage !== workspacePage || - contentSizing.target !== contentTargetWidth || - contentSizing.sidebarVisible !== panes.primarySidebarVisible || - contentSizing.listPaneTarget !== layout.listPaneWidth) + (listPaneSizing.viewportWidth !== viewportWidth || + listPaneSizing.workspacePage !== workspacePage || + listPaneSizing.sidebarVisible !== panes.primarySidebarVisible || + listPaneSizing.listPaneTarget !== layout.listPaneWidth) ) { - // A workspace page swaps the sidebar in the same frame, so nothing animates to wait for. - const sameViewport = - contentSizing.viewportWidth === viewportWidth && - contentSizing.workspacePage === workspacePage; - const openingSidebar = - sameViewport && !contentSizing.sidebarVisible && panes.primarySidebarVisible; // A book-posture hinge resizes the visible list pane instead of revealing it. const resizingSidebar = - sameViewport && - contentSizing.sidebarVisible && + listPaneSizing.viewportWidth === viewportWidth && + listPaneSizing.workspacePage === workspacePage && + listPaneSizing.sidebarVisible && panes.primarySidebarVisible && - contentSizing.listPaneTarget !== layout.listPaneWidth; - const keepWide = - (openingSidebar || resizingSidebar) && - contentSizing.width !== null && - contentTargetWidth !== null; - setContentSizing({ + listPaneSizing.listPaneTarget !== layout.listPaneWidth; + setListPaneSizing({ viewportWidth, workspacePage, - target: contentTargetWidth, sidebarVisible: panes.primarySidebarVisible, - // Clip the wide navigator while the sidebar opens or widens, then reflow once at the - // end. Narrowing it first exposes an empty strip before the sidebar arrives. - width: keepWide - ? Math.max(contentSizing.width ?? 0, contentTargetWidth ?? 0) - : contentTargetWidth, listPaneTarget: layout.listPaneWidth, // While a hinge resizes the list, its width follows the pane edge within this span. A // retarget mid-animation widens the span, since the edge is still somewhere inside it. listPaneFloor: resizingSidebar - ? Math.min(contentSizing.listPaneFloor ?? 0, layout.listPaneWidth ?? 0) + ? Math.min(listPaneSizing.listPaneFloor ?? 0, layout.listPaneWidth ?? 0) : layout.listPaneWidth, listPaneCeiling: resizingSidebar - ? Math.max(contentSizing.listPaneCeiling ?? 0, layout.listPaneWidth ?? 0) + ? Math.max(listPaneSizing.listPaneCeiling ?? 0, layout.listPaneWidth ?? 0) : layout.listPaneWidth, - revision: contentSizing.revision + 1, + revision: listPaneSizing.revision + 1, }); } - const settleContentWidth = (revision: number) => { - setContentSizing((current) => + const settleListPane = (revision: number) => { + setListPaneSizing((current) => current.revision !== revision || - (current.width === current.target && - current.listPaneFloor === current.listPaneTarget && + (current.listPaneFloor === current.listPaneTarget && current.listPaneCeiling === current.listPaneTarget) ? current : { ...current, - width: current.target, listPaneFloor: current.listPaneTarget, listPaneCeiling: current.listPaneTarget, }, ); }; - const listPaneFloor = contentSizing.listPaneFloor ?? 0; - const listPaneCeiling = contentSizing.listPaneCeiling ?? 0; - const contentSettledWidth = Platform.OS === "android" ? contentSizing.width : contentTargetWidth; - const contentRevision = contentSizing.revision; + const listPaneFloor = listPaneSizing.listPaneFloor ?? 0; + const listPaneCeiling = listPaneSizing.listPaneCeiling ?? 0; + const listPaneRevision = listPaneSizing.revision; + // Android lays the content out at its new width as soon as a pane toggles, then holds it + // against the edge that stays put while the other one slides, so nothing reflows after the + // panes land. A sliding sidebar uncovers the strip it is about to fill, or covers the content + // until it is gone. + const contentLeftTarget = + navigationRailWidth + + (shouldRenderPrimarySidebar && !workspacePage && panes.primarySidebarVisible + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0); + const [contentAnchor, setContentAnchor] = useState({ + viewportWidth, + left: contentLeftTarget, + inspector: inspectorColumnTargetWidth, + end: false, + }); + if ( + Platform.OS === "android" && + (contentAnchor.viewportWidth !== viewportWidth || + contentAnchor.left !== contentLeftTarget || + contentAnchor.inspector !== inspectorColumnTargetWidth) + ) { + setContentAnchor({ + viewportWidth, + left: contentLeftTarget, + inspector: inspectorColumnTargetWidth, + end: + contentAnchor.viewportWidth === viewportWidth && + contentAnchor.inspector === inspectorColumnTargetWidth, + }); + } const renderedSidebarWidth = useSharedValue( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, @@ -598,7 +639,7 @@ function AdaptiveWorkspaceLayoutContent( // The content beside it already took its final width when the page closed. renderedSidebarWidth.set( withTiming(targetWidth, snap ? { duration: 0 } : WORKSPACE_PANE_TIMING, (finished) => { - if (finished && Platform.OS === "android") runOnJS(settleContentWidth)(contentRevision); + if (finished && Platform.OS === "android") runOnJS(settleListPane)(listPaneRevision); }), ); }, [ @@ -609,8 +650,8 @@ function AdaptiveWorkspaceLayoutContent( panes.primarySidebarVisible, renderedSidebarWidth, renderedRailWidth, - settleContentWidth, - contentRevision, + settleListPane, + listPaneRevision, workspacePage, ]); const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); @@ -699,6 +740,8 @@ function AdaptiveWorkspaceLayoutContent( toggleAuxiliaryPane, togglePrimarySidebar, setAuxiliaryPaneWidth, + composerKeyboardActive, + setComposerEditing, }; if (nativeWorkspace) { @@ -796,18 +839,28 @@ function AdaptiveWorkspaceLayoutContent( + {/* Android holds the content still, so its floating controls keep its width. */} - {props.children} + + {props.children} + diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 2b23ad865f04..c4c0946fffd0 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -44,9 +44,11 @@ import { import { V5StackHeader } from "../../native/V5StackHeader.ios"; import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; import { + collapsedSplitTransition, nativeWorkspacePopAction, projectWorkspaceStack, partitionStackPresentations, + type CollapsedSplitColumn, } from "../../native/workspace-stack-projection"; import { NativePrimaryColumnContext, @@ -65,6 +67,7 @@ function ColumnScreen(props: { readonly selectedPullRequestKey?: string | null; readonly sidebarFilter?: NativeStackHeaderItemMenu; readonly onDismiss: (routeKey: string) => void; + readonly onAppearance: (type: "willAppear" | "didAppear" | "willDisappear", key: string) => void; readonly navigation: ViewProps["navigation"]; }) { const { descriptor } = props; @@ -85,15 +88,18 @@ function ColumnScreen(props: { onDismiss={(event) => { if (event.nativeEvent.isNativeDismiss) props.onDismiss(key); }} - onWillAppear={() => - props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }) - } - onDidAppear={() => - props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }) - } - onWillDisappear={() => - props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }) - } + onWillAppear={() => { + props.onAppearance("willAppear", key); + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }); + }} + onDidAppear={() => { + props.onAppearance("didAppear", key); + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }); + }} + onWillDisappear={() => { + props.onAppearance("willDisappear", key); + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }); + }} onDidDisappear={() => props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } }) } @@ -130,7 +136,8 @@ function WorkspaceColumns( const hostRef = useRef(null); const activeDetailKey = props.detail.at(-1)?.key; const compactColumn = activeDetailKey ? "secondary" : "primary"; - const shownCompactColumn = useRef(compactColumn); + const collapsed = useRef(!layout.usesSplitView); + const collapsedColumn = useRef({ visible: compactColumn, leaving: null }); const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; const pullRequestParams = props.detail.findLast( (route) => @@ -169,10 +176,24 @@ function WorkspaceColumns( }, [props.navigation], ); + const followCollapsedSplit = ( + type: "willAppear" | "didAppear" | "willDisappear", + key: string, + ) => { + if (!collapsed.current || layout.usesSplitView) return; + const transition = collapsedSplitTransition( + collapsedColumn.current, + { type, key }, + props.navigation.getState(), + props.primary.key, + ); + collapsedColumn.current = transition.column; + if (transition.pop) props.navigation.dispatch(transition.pop); + }; useEffect(() => { - if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return; - shownCompactColumn.current = compactColumn; + if (layout.usesSplitView || collapsedColumn.current.visible === compactColumn) return; + collapsedColumn.current = { ...collapsedColumn.current, visible: compactColumn }; hostRef.current?.show(compactColumn); }, [compactColumn, layout.usesSplitView]); @@ -185,6 +206,13 @@ function WorkspaceColumns( testID="adaptive-workspace-layout" preferredSplitBehavior="tile" topColumnForCollapsing={compactColumn} + onCollapse={() => { + collapsed.current = true; + collapsedColumn.current = { visible: compactColumn, leaving: null }; + }} + onExpand={() => { + collapsed.current = false; + }} preferredDisplayMode={ panes.primarySidebarVisible || (!layout.usesSplitView && !activeDetailKey) ? "oneBesideSecondary" @@ -211,6 +239,7 @@ function WorkspaceColumns( selectedThreadKey={selectedThreadKey} selectedPullRequestKey={selectedPullRequestKey} onDismiss={handleNativeDismiss} + onAppearance={followCollapsedSplit} navigation={props.navigation} /> @@ -252,6 +281,7 @@ function WorkspaceColumns( key={route.key} descriptor={descriptor} onDismiss={handleNativeDismiss} + onAppearance={followCollapsedSplit} navigation={props.navigation} sidebarFilter={sidebarFilter} /> diff --git a/apps/mobile/src/features/layout/workspace-content-width.ts b/apps/mobile/src/features/layout/workspace-content-width.ts index cf24b1be68b4..e4811586c0af 100644 --- a/apps/mobile/src/features/layout/workspace-content-width.ts +++ b/apps/mobile/src/features/layout/workspace-content-width.ts @@ -8,3 +8,12 @@ export const WorkspaceContentWidthContext = createContext | export function useWorkspaceContentWidth() { return use(WorkspaceContentWidthContext); } + +// The settled width itself. The feed lays its rows out at this width in the same commit that +// resizes the pane; waiting for its own onLayout re-measured every row twice, once against the +// old text widths, and the list jumped while it repositioned them. +export const WorkspaceSettledContentWidthContext = createContext(null); + +export function useWorkspaceSettledContentWidth() { + return use(WorkspaceSettledContentWidthContext); +} diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 8913502da919..36c58dc893d0 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -13,6 +13,7 @@ import type { } from "@t3tools/contracts"; import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES, + MAX_WEBHOOK_FILTERS, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; @@ -24,7 +25,12 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_WEBHOOK_PROMPT, + emptyWebhookFilterDraft, parseMaxDeliveryAge, + parseWebhookFilterDrafts, + removeWebhookFilterDraft, + WEBHOOK_FILTER_OPS, + type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native"; @@ -222,6 +228,84 @@ function SelectRow(props: { ); } +function WebhookFilterRows(props: { + readonly filters: ReadonlyArray; + readonly disabled: boolean; + readonly onChange: (filters: ReadonlyArray) => void; +}) { + const update = (index: number, patch: Partial) => + props.onChange( + props.filters.map((filter, at) => (at === index ? { ...filter, ...patch } : filter)), + ); + return ( + <> + {props.filters.map((filter, index) => ( + + + Filter {index + 1} + props.onChange(removeWebhookFilterDraft(props.filters, filter.id))} + className="active:opacity-70" + > + + + + update(index, { path })} + /> + option.op === filter.op)?.label ?? ""} + actions={WEBHOOK_FILTER_OPS.map((option) => ({ + id: option.op, + title: option.label, + state: option.op === filter.op ? ("on" as const) : undefined, + }))} + onSelect={(id) => { + const known = WEBHOOK_FILTER_OPS.find((option) => option.op === id); + if (known) update(index, { op: known.op }); + }} + /> + {filter.op === "exists" ? null : ( + update(index, { value })} + /> + )} + + ))} + {props.filters.length < MAX_WEBHOOK_FILTERS ? ( + props.onChange([...props.filters, emptyWebhookFilterDraft()])} + className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" + > + Add filter + + + ) : null} + {props.filters.length > 0 ? ( + + Runs only when every filter matches. Other requests are logged as filtered. Separate “is + one of” values with commas; a value can't contain a comma. + + ) : null} + + ); +} + function PickerRow(props: { readonly label: string; readonly value: string; @@ -627,6 +711,18 @@ function TaskForm({ ); return; } + const parsedFilters = parseWebhookFilterDrafts(draft.schedule.filters); + if (draft.schedule.mode === "webhook" && "error" in parsedFilters) { + Dialog.alert("Invalid filter", parsedFilters.error); + return; + } + if (draft.schedule.mode === "webhook" && schedule === null) { + Dialog.alert( + "Invalid webhook settings", + "Each dedupe key must contain a valid request placeholder. The coalesce window must be whole seconds from 10 to 3600, or blank.", + ); + return; + } if ( !draft.title.trim() || !draft.prompt.trim() || @@ -964,6 +1060,48 @@ function TaskForm({ setDraft({ ...draft, schedule: { ...draft.schedule, maxDeliveryAgeMinutes } }) } /> + + setDraft({ ...draft, schedule: { ...draft.schedule, filters } }) + } + /> + + setDraft({ ...draft, schedule: { ...draft.schedule, replayKey } }) + } + /> + + setDraft({ ...draft, schedule: { ...draft.schedule, coalesceKey } }) + } + /> + + setDraft({ ...draft, schedule: { ...draft.schedule, coalesceWindowSeconds } }) + } + /> + + Replay keys suppress repeats for 7 days. Coalescing runs the latest payload when the + window from the first delivery closes. Empty keys run normally. + ) : ( <> diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 69532b3fd264..aa7d36156e50 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -30,6 +30,37 @@ describe("scheduleDraftForTask", () => { expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule); }); + it("round-trips dedupe settings and rejects invalid windows", () => { + const dedupe = { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.id}}", + coalesceWindowSeconds: 3600, + }; + const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null, dedupe } }); + expect(scheduleFromDraft(draft)).toMatchObject({ dedupe }); + expect( + scheduleFromDraft({ + ...draft, + replayKey: " \n ", + coalesceKey: " \t ", + coalesceWindowSeconds: "", + }), + ).toMatchObject({ dedupe: {} }); + expect( + scheduleFromDraft({ + ...draft, + replayKey: ` ${dedupe.replayKey} `, + coalesceKey: `\t${dedupe.coalesceKey}\n`, + }), + ).toMatchObject({ dedupe }); + expect( + scheduleFromDraft({ ...draft, replayKey: "", coalesceKey: "", coalesceWindowSeconds: "" }), + ).toMatchObject({ dedupe: {} }); + for (const coalesceWindowSeconds of ["9", "3601", "1.5", "abc"]) { + expect(scheduleFromDraft({ ...draft, coalesceWindowSeconds })).toBeNull(); + } + }); + it("round-trips a webhook schedule without a signature", () => { const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } }); expect(draft.mode).toBe("webhook"); @@ -37,6 +68,7 @@ describe("scheduleDraftForTask", () => { type: "webhook", signature: null, maxDeliveryAgeMinutes: null, + dedupe: {}, }); }); @@ -50,6 +82,7 @@ describe("scheduleDraftForTask", () => { expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "0" })).toBeNull(); expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "" })).toMatchObject({ maxDeliveryAgeMinutes: null, + dedupe: {}, }); }); @@ -62,9 +95,26 @@ describe("scheduleDraftForTask", () => { const saved = scheduleFromDraft( scheduleDraftForTask({ schedule: { type: "webhook", signature } }), ); - expect(saved).toEqual({ type: "webhook", signature, maxDeliveryAgeMinutes: null }); + expect(saved).toEqual({ type: "webhook", signature, maxDeliveryAgeMinutes: null, dedupe: {} }); expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false); }); + + it("round-trips webhook filters and refuses an invalid one", () => { + const filters = [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.label.name", op: "in", value: ["needs review", "agent:fix"] }, + ] as const; + const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null, filters } }); + // Untouched rows are left out, so the server keeps whatever is stored now. + expect(scheduleFromDraft(draft)).not.toHaveProperty("filters"); + expect(scheduleFromDraft({ ...draft, filters: [] })).toMatchObject({ filters: [] }); + expect(scheduleFromDraft({ ...draft, filters: draft.filters.slice(1) })).toMatchObject({ + filters: filters.slice(1), + }); + expect( + scheduleFromDraft({ ...draft, filters: [{ id: "f1", path: "", op: "exists", value: "" }] }), + ).toBeNull(); + }); }); describe("hasScheduledTaskDraftChanges", () => { @@ -352,3 +402,12 @@ describe("scheduled task model defaults", () => { ).toBeNull(); }); }); + +it("rejects dedupe keys without valid request placeholders", () => { + expect( + scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", replayKey: "literal" }), + ).toBeNull(); + expect( + scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", coalesceKey: "{{body.bad" }), + ).toBeNull(); +}); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index eff6674aacc9..3ef1a2f3faf6 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -1,3 +1,4 @@ +import { isWebhookDedupeTemplate } from "@t3tools/contracts"; import type { ModelSelection, ServerConfig, @@ -9,7 +10,13 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; -import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { + parseMaxDeliveryAge, + parseWebhookFilterDrafts, + webhookFilterDrafts, + webhookFilterDraftsChanged, + type WebhookFilterDraft, +} from "@t3tools/client-runtime/scheduled-task-webhook"; import { resolveProjectSettings, type LegacyProjectSettingsFields, @@ -50,6 +57,12 @@ export type ScheduleDraft = { readonly intervalMinutes: string; readonly signature: ScheduledTaskWebhookSignature | null; readonly maxDeliveryAgeMinutes: string; + readonly replayKey: string; + readonly coalesceKey: string; + readonly coalesceWindowSeconds: string; + readonly filters: ReadonlyArray; + /** The filter rows the form opened with. */ + readonly savedFilters: ReadonlyArray; }; export const DEFAULT_SCHEDULE: ScheduleDraft = { @@ -59,6 +72,11 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = { intervalMinutes: "15", signature: null, maxDeliveryAgeMinutes: "", + replayKey: "", + coalesceKey: "", + coalesceWindowSeconds: "", + filters: [], + savedFilters: [], }; export function scheduleDraftForTask(task: Pick): ScheduleDraft { @@ -82,10 +100,18 @@ export function scheduleDraftForTask(task: Pick): Sch ...DEFAULT_SCHEDULE, mode: "webhook", signature: task.schedule.signature, + replayKey: task.schedule.dedupe?.replayKey ?? "", + coalesceKey: task.schedule.dedupe?.coalesceKey ?? "", + coalesceWindowSeconds: + task.schedule.dedupe?.coalesceWindowSeconds === undefined + ? "" + : String(task.schedule.dedupe.coalesceWindowSeconds), maxDeliveryAgeMinutes: task.schedule.maxDeliveryAgeMinutes == null ? "" : String(task.schedule.maxDeliveryAgeMinutes), + filters: webhookFilterDrafts(task.schedule.filters), + savedFilters: webhookFilterDrafts(task.schedule.filters), }; } } @@ -94,6 +120,21 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche if (draft.mode === "webhook") { const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); if (maxDeliveryAgeMinutes === undefined) return null; + const replayKey = draft.replayKey.trim(); + const coalesceKey = draft.coalesceKey.trim(); + if ([replayKey, coalesceKey].some((key) => key && !isWebhookDedupeTemplate(key))) return null; + const coalesceWindowSeconds = draft.coalesceWindowSeconds?.trim() + ? Number(draft.coalesceWindowSeconds) + : undefined; + if ( + coalesceWindowSeconds !== undefined && + (!Number.isInteger(coalesceWindowSeconds) || + coalesceWindowSeconds < 10 || + coalesceWindowSeconds > 3600) + ) + return null; + const parsedFilters = parseWebhookFilterDrafts(draft.filters); + if (!("filters" in parsedFilters)) return null; // No secret is sent, so the server keeps the stored one. return { type: "webhook", @@ -106,6 +147,19 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche prefix: draft.signature.prefix, }, maxDeliveryAgeMinutes, + ...(replayKey || coalesceKey || coalesceWindowSeconds !== undefined + ? { + dedupe: { + ...(replayKey ? { replayKey } : {}), + ...(coalesceKey ? { coalesceKey } : {}), + ...(coalesceWindowSeconds === undefined ? {} : { coalesceWindowSeconds }), + }, + } + : { dedupe: {} }), + // Only edited filters are sent; the server keeps the stored ones otherwise. + ...(webhookFilterDraftsChanged(draft.savedFilters, draft.filters) + ? { filters: parsedFilters.filters } + : {}), }; } if (draft.mode === "interval") { @@ -160,6 +214,10 @@ function draftSignature(draft: ScheduledTaskDraft): string { [...draft.schedule.weekdays].sort((a, b) => a - b), draft.schedule.intervalMinutes, draft.schedule.maxDeliveryAgeMinutes, + draft.schedule.replayKey, + draft.schedule.coalesceKey, + draft.schedule.coalesceWindowSeconds, + draft.schedule.filters.map(({ path, op, value }) => [path, op, value]), draft.workspace, draft.baseRef, draft.checkoutPath, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1619612a5aef..8140517f4ab4 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,6 +1,7 @@ import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -187,6 +188,7 @@ export interface ThreadComposerProps { readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; + /** True while the draft is stacked above the controls rather than in the one-row capsule. */ readonly onExpandedChange?: (expanded: boolean) => void; readonly onEditorFocusChange?: (focused: boolean) => void; } @@ -220,6 +222,11 @@ const composerHeightTransition: LayoutAnimationFunction = (values) => { }; export const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : composerHeightTransition; +// Share of the one-row composer the draft keeps; a long model name truncates first. +const INLINE_EDITOR_MIN_WIDTH = "40%"; +// The one-row editor's horizontal padding plus room for the caret. +const INLINE_EDITOR_TEXT_SLACK = 10; +const INLINE_EDITOR_HEIGHT = 36; const COMPOSER_ATTACHMENT_ENTERING = Platform.OS === "android" @@ -495,8 +502,39 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; - const showsCompactDictation = isVoiceInputPresented && !isExpanded; - const isToolbarVisible = isExpanded || isVoiceInputPresented; + const { setComposerEditing } = useAdaptiveWorkspaceLayout(); + // The composer stays one row, with the model picker in it, until the draft would + // wrap. Then the draft stacks above the controls and returns to the row once it + // fits again. The native editor reports its laid-out text size: more than one + // line means the draft wrapped where it is, and the widest line is a one-line + // draft's natural width, which is compared with the row's text area so a draft + // laid out in the wider card still returns to the row when it fits, including + // when the pane widens. The measurements live in refs and only the verdict is + // state, so a keystroke that keeps the verdict costs no extra render. + const draftSizeRef = useRef({ width: 0, height: 0 }); + const rowGeometryRef = useRef({ surfaceWidth: 0, leadingWidth: 0, trailingWidth: 0 }); + const [draftOverflowsRow, setDraftOverflowsRow] = useState(false); + const lineHeight = bodyText.lineHeight; + const updateDraftOverflow = () => { + const { width, height } = draftSizeRef.current; + const row = rowGeometryRef.current; + const rowTextWidth = + row.surfaceWidth - row.leadingWidth - row.trailingWidth - INLINE_EDITOR_TEXT_SLACK; + setDraftOverflowsRow( + height > lineHeight * 1.5 || (row.surfaceWidth > 0 && width > rowTextWidth), + ); + }; + const measureRow = (key: keyof typeof rowGeometryRef.current, value: number) => { + if (rowGeometryRef.current[key] === value) return; + rowGeometryRef.current = { ...rowGeometryRef.current, [key]: value }; + updateDraftOverflow(); + }; + const usesCard = isExpanded && draftMessage.length > 0 && draftOverflowsRow; + const showsCompactDictation = isVoiceInputPresented && !usesCard; + const isToolbarVisible = usesCard || isVoiceInputPresented; + const hasEditingAttachments = + (queuedEdit !== null && queuedEdit.existingAttachments.length > 0) || + stripAttachments.length > 0; const attachmentBlockReason = composerAttachmentUploadBlockReason({ environmentId: props.environmentId, attachments: draftAttachments, @@ -519,8 +557,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps !modelUnavailable; useEffect(() => { - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); + onExpandedChange?.(usesCard); + }, [usesCard, onExpandedChange]); + useEffect(() => () => setComposerEditing(false), [setComposerEditing]); const onPressPreview = (source: FilePreviewSource) => { wasExpandedBeforePreviewRef.current = isFocused; @@ -553,16 +592,14 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = () => { setIsFocused(true); - onExpandedChange?.(true); onEditorFocusChange?.(true); + setComposerEditing(true); }; const handleBlur = () => { setIsFocused(false); - if (!settingsSheetPresentation.keepsComposerExpanded) { - onExpandedChange?.(false); - } onEditorFocusChange?.(false); + setComposerEditing(false); }; const handleSend = async (followUp?: ActiveTurnComposerAction) => { if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; @@ -676,12 +713,80 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps [navigation, settingsSheetPresentation.onStackTransitionsFinished], ); + // Removable attachments: inside the card, or above the focused one-row composer. + const editingAttachments = hasEditingAttachments ? ( + <> + {queuedEdit !== null && queuedEdit.existingAttachments.length > 0 ? ( + + + + ) : null} + {stripAttachments.length > 0 ? ( + + undefined : props.onRemoveDraftImage} + onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} + onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} + onPressDocument={ + voiceInput.isBusy + ? undefined + : (attachment) => + openDraftDocument({ + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }) + } + /> + + ) : null} + + ) : null; + + // The row and the card's toolbar share this picker. It shrinks to the provider + // icon once the draft has text, so the draft keeps the width. + const modelControl = ( + + ( + + )} + label={currentModelOption?.label ?? currentModelSelection.model} + iconOnly={draftMessage.length > 0} + maxWidth="100%" + onPress={openSettings} + /> + + ); + return ( measureRow("surfaceWidth", event.nativeEvent.layout.width)} > {!voiceInput.isBusy && composerMenu.trigger && @@ -746,10 +852,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps + {isExpanded && !usesCard ? editingAttachments : null} - - ( - - )} - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth="100%" - onPress={openSettings} - /> - + {modelControl} )} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d733b751ace6..727cf5782444 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,6 +1,7 @@ import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; +import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width"; import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; import { enrichedContextLinkCustomization } from "../../native/useEnrichedLinkVariants"; @@ -1696,9 +1697,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { previousTextSize.current = workRowSizing.textSizeKey; listRef.current?.clearCaches({ mode: "sizes" }); }, [workRowSizing.textSizeKey, listRef]); - const [viewportWidth, setViewportWidth] = useState(() => + const settledPaneWidth = useWorkspaceSettledContentWidth(); + const [measuredViewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); + const viewportWidth = + settledPaneWidth !== null ? Math.round(settledPaneWidth) : measuredViewportWidth; const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); // LegendList's maintainScrollAtEnd alone re-pins the feed whenever the @@ -2402,6 +2406,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (previousContentWidth.current === contentWidth) return; previousContentWidth.current = contentWidth; if (hasHtmlRenders) listRef.current?.clearCaches({ mode: "sizes" }); + // A reflow can grow the visible rows past LegendList's near-end threshold in one batch, and + // maintainScrollAtEnd then stops following. Pin the end the feed was following once the new + // sizes are in. + if (!endFollowEnabledRef.current || !listRef.current?.getState().isAtEnd) return; + const frame = requestAnimationFrame(() => { + if (endFollowEnabledRef.current) void listRef.current?.scrollToEnd({ animated: false }); + }); + return () => cancelAnimationFrame(frame); }, [contentWidth, hasHtmlRenders, listRef]); const renderItem = (info: { item: PendingThreadFeedEntry; index: number }) => ( diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index 62e98edc0076..acdbaea83efa 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -20,7 +20,8 @@ export function ThreadHeader( }, ) { const goBack = useWorkspaceBackNavigation("threads"); - const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { layout, panes, toggleAuxiliaryPane, composerKeyboardActive } = + useAdaptiveWorkspaceLayout(); const { onOpenTerminal, onMergeBack } = props.gitControls; const native = useThreadHeaderOptions(props); const androidHeaderActions = useMemo>(() => { @@ -100,6 +101,7 @@ export function ThreadHeader( onBack={layout.usesSplitView ? undefined : goBack} actions={androidHeaderActions} hideBottomBorder + compact={composerKeyboardActive} /> {native.fallback} diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index e35f173a4729..d980164b32d3 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -1,7 +1,9 @@ +import { AppState } from "react-native"; import { describe, expect, it, vi } from "vite-plus/test"; import { createAppUpdateDeferral, + createAppUpdateForegroundRecheck, createAppUpdateLaunchCheck, FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, registerHiddenUpdateTap, @@ -19,6 +21,13 @@ vi.mock("expo-updates", () => ({ reloadAsync: vi.fn(), })); +vi.mock("react-native", () => ({ + AppState: { + currentState: "active", + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, +})); + function makeUpdateClient(overrides: Partial = {}): AppUpdateClient { return { isEnabled: true, @@ -640,3 +649,31 @@ describe("registerHiddenUpdateTap", () => { expect(count).toBe(0); }); }); + +describe("createAppUpdateForegroundRecheck", () => { + it("registers once and checks after returning from a long background stay", () => { + vi.stubGlobal("__DEV__", false); + const now = vi.spyOn(Date, "now"); + const listener = vi.mocked(AppState.addEventListener); + listener.mockClear(); + const client = makeUpdateClient(); + const start = createAppUpdateForegroundRecheck(client, createAppUpdateDeferral()); + + try { + start(); + start(); + expect(listener).toHaveBeenCalledOnce(); + const onChange = listener.mock.calls[0]![1]; + now.mockReturnValue(1000); + onChange("background"); + now.mockReturnValue(1000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS); + onChange("active"); + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + onChange("active"); + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + } finally { + now.mockRestore(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 2cfe3aa7aebf..6390256108ec 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -1,4 +1,6 @@ import * as Updates from "expo-updates"; +// Metro namespace imports evaluate unrelated React Native getters, including unavailable native modules. +import { AppState } from "react-native"; import { type AtomCommandResult, @@ -418,22 +420,19 @@ async function defaultFlushPendingWrites(): Promise { async function defaultIsSafeToRestartInBackground(): Promise { const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff"); if (isForegroundHandoffActive()) return false; - const { AppState } = await import("react-native"); return AppState.currentState === "background"; } function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void { - void import("react-native").then(({ AppState }) => { - const subscription = AppState.addEventListener("change", (state) => { - if (state !== "background") return; - subscription.remove(); - apply(); - }); - if (includeCurrent && AppState.currentState === "background") { - subscription.remove(); - apply(); - } + const subscription = AppState.addEventListener("change", (state) => { + if (state !== "background") return; + subscription.remove(); + apply(); }); + if (includeCurrent && AppState.currentState === "background") { + subscription.remove(); + apply(); + } } export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000; @@ -443,25 +442,23 @@ export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000; * the timer running. */ function defaultOnForegroundStay(apply: () => void): void { - void import("react-native").then(({ AppState }) => { - let timer: ReturnType | undefined; - const arm = () => { - timer ??= setTimeout(() => { - subscription.remove(); - apply(); - }, DEFERRED_INSTALL_PROMPT_AFTER_MS); - }; - const disarm = () => { - if (timer === undefined) return; - clearTimeout(timer); - timer = undefined; - }; - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") arm(); - else if (state === "background") disarm(); - }); - if (AppState.currentState === "active") arm(); + let timer: ReturnType | undefined; + const arm = () => { + timer ??= setTimeout(() => { + subscription.remove(); + apply(); + }, DEFERRED_INSTALL_PROMPT_AFTER_MS); + }; + const disarm = () => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") arm(); + else if (state === "background") disarm(); }); + if (AppState.currentState === "active") arm(); } const defaultAppUpdateEnvironment: AppUpdateEnvironment = { @@ -528,22 +525,20 @@ export function createAppUpdateForegroundRecheck( return () => { if (started || !isAppUpdateCheckAvailable(client)) return; started = true; - void import("react-native").then(({ AppState }) => { - let backgroundedAtMs: number | null = null; - AppState.addEventListener("change", (state) => { - if (state === "background") { - backgroundedAtMs = Date.now(); - return; - } - if (state !== "active") return; - const shouldCheck = shouldRecheckAppUpdateOnForeground( - backgroundedAtMs, - Date.now(), - deferral.pendingInstall, - ); - backgroundedAtMs = null; - if (shouldCheck) void runAppUpdateCheck({ client, deferral }); - }); + let backgroundedAtMs: number | null = null; + AppState.addEventListener("change", (state) => { + if (state === "background") { + backgroundedAtMs = Date.now(); + return; + } + if (state !== "active") return; + const shouldCheck = shouldRecheckAppUpdateOnForeground( + backgroundedAtMs, + Date.now(), + deferral.pendingInstall, + ); + backgroundedAtMs = null; + if (shouldCheck) void runAppUpdateCheck({ client, deferral }); }); }; } diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 9013a3dfcc9f..010c902364c4 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -41,6 +41,53 @@ function accountName(account: LimitAccount) { return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; } +// Same gap-and-tick as the Limits rows: two clipped copies of the segment leave a see-through gap. +function PaceGap({ + timeLeft, + children, +}: { + readonly timeLeft: number | null; + readonly children: ReactNode; +}) { + const [width, setWidth] = useState(0); + const tickX = + timeLeft === null || width < 6 + ? null + : Math.min(Math.max((width * timeLeft) / 100, 3), width - 3); + return ( + setWidth(event.nativeEvent.layout.width)} + > + {tickX === null + ? children + : ( + [ + [0, tickX - 3], + [tickX + 3, width], + ] as const + ).map(([start, end]) => ( + + + {children} + + + ))} + {tickX !== null ? ( + + ) : null} + + ); +} + function AccountSegment({ remaining, color, @@ -155,22 +202,18 @@ function PoolWindowCard({ accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${timeLeft === null ? "" : `, ${timeLeft}% of the window left`}`} accessibilityHint="Show account details" onPress={() => openAccount(account)} - className="h-7 min-w-0 flex-1 rounded-md bg-subtle" + className="h-7 min-w-0 flex-1 rounded-md" > - - - - {timeLeft !== null ? ( - - ) : null} + + + + + + 0 ? { width: barWidth } : undefined} + > + 30 && props.color ? { backgroundColor: props.color } : null, + ]} + /> + + + ); return ( @@ -57,27 +83,35 @@ function WindowRow(props: { {remaining}% left - - - 30 && props.color ? { backgroundColor: props.color } : null, - ]} - /> - - - {timeLeft !== null ? ( + setBarWidth(event.nativeEvent.layout.width)} + > + {tickX === null ? ( + bar + ) : ( + + {/* Clip the same full-width bar so neither fill proportions nor corner radii change. */} + {( + [ + [0, tickX - 3], + [tickX + 3, barWidth], + ] as const + ).map(([start, end]) => ( + + {bar} + + ))} + + )} + {tickX !== null ? ( ) : null} diff --git a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx index 41d8777f6dfb..9dba4f4a4f88 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx @@ -1,5 +1,5 @@ import type { NativeStackHeaderItem } from "@react-navigation/native-stack"; -import { act } from "react"; +import { act, createContext, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, expect, it, vi } from "vite-plus/test"; @@ -56,6 +56,43 @@ function renderDocument(path: string, placement: Placement) { act(() => root!.render()); } +// Like React Navigation's NavigationStateContext, whose default value throws when +// read. React restores a context's default outside render, so layout effects see it. +const ContainerStateContext = createContext({ + get getIsInitial(): never { + throw new Error("Couldn't find a navigation context."); + }, +}); +const containerState = {}; + +function ConnectingToolbar(props: { readonly label: string }) { + return ( + + + + + + ); +} + +function renderConnecting(label: string) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root ??= createRoot(document.createElement("div")); + act(() => + root!.render( + + + , + ), + ); +} + +function renderedCustomLabel() { + const item = harness.renderedItems[0]; + if (item?.type !== "custom") throw new Error("Expected the custom header element"); + return (item.element as ReactElement<{ "aria-label": string }>).props["aria-label"]; +} + function openRenderedDocument() { const menu = harness.renderedItems[0]; if (menu?.type !== "menu") throw new Error("Expected the native file menu"); @@ -98,3 +135,15 @@ it.each(["left", "right", "bottom"] as const)( expect(harness.openedDocuments).toEqual(["first/guide.pdf"]); }, ); + +it("applies a custom header element rendered inside a navigation container", () => { + renderConnecting("Connecting"); + expect(renderedCustomLabel()).toBe("Connecting"); + + const applied = harness.setOptionsCalls; + renderConnecting("Connecting"); + expect(harness.setOptionsCalls).toBe(applied); + + renderConnecting("Reconnecting"); + expect(renderedCustomLabel()).toBe("Reconnecting"); +}); diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 8240fbc74df4..698a345035cc 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -4,7 +4,7 @@ import type { NativeStackNavigationOptions, NativeStackNavigationProp, } from "@react-navigation/native-stack"; -import { use, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { isValidElement, use, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { NativeWorkspaceTabActivityContext } from "./v5-workspace-context"; import type { ColorValue } from "react-native"; @@ -55,6 +55,29 @@ function normalizeScreenOptions( return normalized as NativeStackNavigationOptions; } +// React tags elements, component types and contexts with `$$typeof`; their other +// fields are React internals. In development an element's `_owner` is a fiber that +// reaches the whole tree, including contexts that hold their default value outside +// render, and React Navigation's NavigationStateContext default throws when read. +function isReactObject(value: object): boolean { + return "$$typeof" in value; +} + +const identityIds = new WeakMap(); +let nextIdentityId = 0; + +function identitySignature(value: unknown): string { + if (value === null || (typeof value !== "object" && typeof value !== "function")) { + return String(value); + } + let id = identityIds.get(value); + if (id === undefined) { + id = nextIdentityId++; + identityIds.set(value, id); + } + return `identity:${id}`; +} + function optionsSignature(value: unknown, seen = new WeakSet()): string { if (value === null) return "null"; switch (typeof value) { @@ -75,6 +98,11 @@ function optionsSignature(value: unknown, seen = new WeakSet()): string return `bigint:${String(value)}`; case "object": { const object = value as object; + if (isValidElement(object)) { + // Elements are recreated on every render, so compare what they render. + return `element:${identitySignature(object.type)}:${String(object.key)}:${optionsSignature(object.props, seen)}`; + } + if (isReactObject(object)) return identitySignature(object); if (seen.has(object)) return "[circular]"; seen.add(object); if (Array.isArray(value)) { @@ -121,7 +149,7 @@ function stabilizeOptionFunctions( ); } if (value !== null && typeof value === "object") { - if (seen.has(value) || "current" in value) return value; + if (seen.has(value) || "current" in value || isReactObject(value)) return value; seen.add(value); return Object.fromEntries( Object.entries(value as Record).map(([key, entry]) => [ diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index f2d2699f7a89..dfbfff192171 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -99,6 +99,9 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; readonly onComposerSubmit?: (event: NativeSyntheticEvent<{ alternate: boolean }>) => void; + readonly onComposerContentSizeChange?: ( + event: NativeSyntheticEvent<{ readonly width: number; readonly height: number }>, + ) => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -125,6 +128,7 @@ export function ComposerEditor({ onFocus, onBlur, onSubmit, + onContentSizeChange, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -349,6 +353,11 @@ export function ComposerEditor({ }} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerContentSizeChange={ + onContentSizeChange === undefined + ? undefined + : (event) => onContentSizeChange(event.nativeEvent) + } onComposerSubmit={ onSubmit === undefined ? undefined diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index dce1589439ad..732df5f8f2e3 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -98,6 +98,9 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; + readonly onComposerContentSizeChange?: ( + event: NativeSyntheticEvent<{ readonly width: number; readonly height: number }>, + ) => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -123,6 +126,7 @@ export function ComposerEditor({ onPasteText, onFocus, onBlur, + onContentSizeChange, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -354,6 +358,11 @@ export function ComposerEditor({ }} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerContentSizeChange={ + onContentSizeChange === undefined + ? undefined + : (event) => onContentSizeChange(event.nativeEvent) + } /> ); diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 20554b4f1eb0..d621deb3c6ac 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -19,6 +19,7 @@ export function ComposerEditor({ singleLineCentered: _singleLineCentered, enterBehavior: _enterBehavior, readOnly = false, + onContentSizeChange, ...props }: ComposerEditorProps) { const inputRef = useRef(null); @@ -45,6 +46,7 @@ export function ComposerEditor({ editable={(props.editable ?? true) && !readOnly} selection={selection} onSelectionChange={(event) => props.onSelectionChange?.(event.nativeEvent.selection)} + onContentSizeChange={(event) => onContentSizeChange?.(event.nativeEvent.contentSize)} multiline={props.multiline ?? true} placeholderTextColorClassName={"accent-placeholder"} className="text-foreground" diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index 4e2ddd178b06..95726c9f8ba9 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -68,6 +68,14 @@ export interface ComposerEditorProps { readonly onPasteText?: (paste: ComposerTextPaste) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; + /** + * Size of the laid-out text in dp, without insets. Width is the widest line, so a + * one-line draft reports its natural width; only fires when the size changes. + */ + readonly onContentSizeChange?: (size: { + readonly width: number; + readonly height: number; + }) => void; /** * Hardware-keyboard Return behavior on iOS. No-op on Android, which has no * hardware Return handling. diff --git a/apps/mobile/src/native/workspace-stack-projection.test.ts b/apps/mobile/src/native/workspace-stack-projection.test.ts index e813c48faa6b..99901652473d 100644 --- a/apps/mobile/src/native/workspace-stack-projection.test.ts +++ b/apps/mobile/src/native/workspace-stack-projection.test.ts @@ -3,12 +3,14 @@ import type { ParamListBase, StackNavigationState } from "@react-navigation/nati import { describe, expect, it } from "vite-plus/test"; import { + collapsedSplitTransition, nativeStackPopAction, nativeWorkspacePopAction, nativeWorkspacePopCount, projectWorkspaceStack, partitionStackPresentations, reconcileStackScreens, + type CollapsedSplitColumn, } from "./workspace-stack-projection"; const requireNavigation = NodeModule.createRequire( @@ -185,6 +187,111 @@ describe("native card pop handoff", () => { }); }); +describe("collapsed split Back", () => { + const routerOptions = { + routeNames: history([home]).routeNames, + routeParamList: {}, + routeGetIdList: {}, + }; + type Appearance = "willAppear" | "didAppear" | "willDisappear"; + // UIKit's appearance callbacks for one collapsed transition, against the router at each step. + function replay( + steps: ReadonlyArray]>, + ) { + let column: CollapsedSplitColumn = { visible: "secondary", leaving: null }; + let pop: ReturnType = null; + for (const [type, key, state] of steps) { + const transition = collapsedSplitTransition(column, { type, key }, state, home.key); + column = transition.column; + pop ??= transition.pop; + } + return { visible: column.visible, pop }; + } + + it("returns the router to the list so the next selection opens a new detail", () => { + const state = history([home, thread]); + const back = replay([ + ["willDisappear", thread.key, state], + ["willAppear", home.key, state], + ["didAppear", home.key, state], + ]); + expect(back.visible).toBe("primary"); + const router = StackRouter({}); + const listed = router.getStateForAction(state, back.pop!, routerOptions); + expect(listed?.routes).toEqual([home]); + if (!listed || listed.stale !== false) throw new Error("Expected a rehydrated router history"); + const select = { + type: "NAVIGATE", + payload: { name: "Thread", params: { threadId: "next" } }, + } as const; + expect(router.getStateForAction(listed, select, routerOptions)?.routes[1]?.key).not.toBe( + thread.key, + ); + // Left on the hidden detail, the selection would only rename it and never show the column. + expect(router.getStateForAction(state, select, routerOptions)?.routes[1]?.key).toBe(thread.key); + }); + + it("clears every detail while keeping a presentation opened during the Back animation", () => { + const leaving = history([home, thread, files]); + const presented = history([home, thread, files, settings]); + const { pop } = replay([ + ["willDisappear", files.key, leaving], + ["didAppear", home.key, presented], + ]); + expect(StackRouter({}).getStateForAction(presented, pop!, routerOptions)?.routes).toEqual([ + home, + settings, + ]); + }); + + it("keeps the detail when it appears again before the list finishes appearing", () => { + const state = history([home, thread]); + const cancelledSwipe = replay([ + ["willDisappear", thread.key, state], + ["willAppear", home.key, state], + ["willAppear", thread.key, state], + ["didAppear", thread.key, state], + ]); + expect(cancelledSwipe).toEqual({ visible: "secondary", pop: null }); + expect( + replay([ + ["willDisappear", thread.key, state], + ["willAppear", thread.key, state], + ["didAppear", home.key, state], + ]).pop, + ).toBeNull(); + }); + + it("does not mistake JS navigation or a cold link for native Back", () => { + const pushed = history([home, thread, files]); + expect( + replay([ + ["willDisappear", thread.key, pushed], + ["didAppear", files.key, pushed], + ]), + ).toEqual({ visible: "secondary", pop: null }); + const presented = history([home, thread, settings]); + expect( + replay([ + ["willDisappear", thread.key, presented], + ["didAppear", home.key, presented], + ]).pop, + ).toBeNull(); + const popped = history([home]); + expect( + replay([ + ["willDisappear", thread.key, popped], + ["didAppear", home.key, popped], + ]).pop, + ).toBeNull(); + // A linked thread whose list appears first still needs its detail column shown. + expect(replay([["didAppear", home.key, history([home, thread])]])).toEqual({ + visible: "primary", + pop: null, + }); + }); +}); + describe("v5 stack handoff", () => { it("releases a completed native pop when the router acknowledges it", () => { const completed = new Set([files.key]); diff --git a/apps/mobile/src/native/workspace-stack-projection.ts b/apps/mobile/src/native/workspace-stack-projection.ts index 2d6afae419ee..71ebec0ed9c2 100644 --- a/apps/mobile/src/native/workspace-stack-projection.ts +++ b/apps/mobile/src/native/workspace-stack-projection.ts @@ -70,6 +70,53 @@ export function nativeStackPopAction( }; } +/** The column UIKit shows while a split is collapsed, and the active detail it began removing. */ +export interface CollapsedSplitColumn { + readonly visible: "primary" | "secondary"; + readonly leaving: string | null; +} + +/** + * A collapsed split nests the detail column's navigation controller in the list column's. Back + * from the detail root pops that controller whole, so no screen leaves its own stack or reports a + * dismissal: the list appearing after the active detail began leaving is the only sign of it. + */ +export function collapsedSplitTransition( + column: CollapsedSplitColumn, + event: { readonly type: "willAppear" | "didAppear" | "willDisappear"; readonly key: string }, + state: Pick, + primaryKey: string, +): { + readonly column: CollapsedSplitColumn; + readonly pop: ReturnType; +} { + switch (event.type) { + case "willDisappear": + // JS pushes, pops, and presentations have already moved the router past this screen. + return { + column: + state.index > 0 && state.routes[state.index]?.key === event.key + ? { ...column, leaving: event.key } + : column, + pop: null, + }; + case "willAppear": + // A cancelled swipe, or an expansion that keeps the detail on screen. + return { + column: column.leaving === event.key ? { ...column, leaving: null } : column, + pop: null, + }; + case "didAppear": + return { + column: { visible: event.key === primaryKey ? "primary" : "secondary", leaving: null }, + pop: + event.key === primaryKey && column.leaving + ? nativeStackPopAction(state, column.leaving, primaryKey) + : null, + }; + } +} + export function partitionStackPresentations( routes: readonly T[], isModal: (route: T) => boolean, diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 0ec8b1c37b29..b4dd358c17b1 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - Measures real local HTTP/WebSocket sockets. import * as NodeHttp from "node:http"; +import * as NodeUtil from "node:util"; import { assert, it } from "@effect/vitest"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; @@ -299,9 +300,16 @@ it.live( const events = turnEvents(provider, TRANSFER_HISTORY_TURN_COUNT, true); const stored = yield* sink.write({ events }); const last = stored.at(-1)!; - const terminal = (item: StreamItem) => + const threadTerminal = (item: StreamItem) => "sequence" in item && item.sequence >= last.sequence; - for (const queue of [tq, sq, ttq, ssq]) yield* collectUntil(queue, terminal); + // Shell items carry the shell read when their batch is projected, and unchanged + // shells are skipped, so a turn split across batches may never yield an item at + // the last sequence. Wait for the final shell instead. + const finalShell = yield* projection.getThreadShell(THREAD_ID); + const shellTerminal = (item: StreamItem) => + item.kind === "thread.updated" && NodeUtil.isDeepStrictEqual(item.thread, finalShell); + for (const queue of [tq, ttq]) yield* collectUntil(queue, threadTerminal); + for (const queue of [sq, ssq]) yield* collectUntil(queue, shellTerminal); const measuredTurnWebSocket = difference(thread.recorder.totals(), threadBefore); const measuredTurnShellWebSocket = difference(shell.recorder.totals(), shellBefore); const measuredTurnSecondClientWebSocket = difference( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c2d29e98047e..ae06aba0355d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1383,6 +1383,7 @@ const program = Effect.gen(function* () { (emitRunningCommandThenHangOnFirstPrompt && promptCount === 1) ) { const toolCallId = "tool-call-running-1"; + let runningCommandPublished: Promise = Promise.resolve(); if (runningCommandPidPath !== undefined) { const command = runningCommandIgnoresTerm ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s\n" "$$" "$child" > "$1"; wait "$child"' @@ -1390,19 +1391,24 @@ const program = Effect.gen(function* () { if (runningCommandSeparateSession) { const launcher = [ 'const { spawn } = require("node:child_process");', - "const child = spawn(process.argv[1], process.argv.slice(2), { stdio: 'ignore' });", + "const child = spawn(process.argv[1], process.argv.slice(2), { stdio: ['ignore', 'inherit', 'ignore'] });", "child.once('exit', (code, signal) => process.exitCode = code ?? (signal ? 1 : 0));", ].join(" "); const detachedCommand = runningCommandIgnoresTerm - ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; wait "$child"' - : 'sleep 120 & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; wait "$child"'; + ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; echo published; wait "$child"' + : 'sleep 120 & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; echo published; wait "$child"'; // Nested bash publishes "$PPID $$ $child" once it starts. Do not // write the launcher PID alone here: that races with bash and can - // clobber the triple that interrupt tests wait for. + // clobber the triple that interrupt tests wait for. It then prints a + // line, so an agent that exits after launch only exits once the + // triple exists instead of racing a slow launcher start. const detachedLauncher = NodeChildProcess.spawn( process.execPath, ["-e", launcher, "bash", "-c", detachedCommand, "bash", runningCommandPidPath], - { detached: true, stdio: "ignore" }, + { detached: true, stdio: ["ignore", "pipe", "ignore"] }, + ); + runningCommandPublished = new Promise((resolve) => + detachedLauncher.stdout.once("data", () => resolve()), ); detachedLauncher.unref(); } else { @@ -1460,6 +1466,7 @@ const program = Effect.gen(function* () { }, }); if (exitAfterRunningCommandLaunch) { + yield* Effect.promise(() => runningCommandPublished); yield* Effect.sleep("100 millis"); return yield* Effect.sync(() => process.exit(0)); } diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index db291aac7b74..91187bc2a131 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -116,7 +116,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { export const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", + "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -143,7 +143,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", { description: - "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run.", + "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Webhook schedule dedupe accepts replayKey, coalesceKey, and coalesceWindowSeconds (10–3600, default 120) to suppress replays for 7 days and retain the latest payload in each fixed window. Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.", parameters: OrchestratorMcpUpdateScheduledTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index cc6fc02e113d..1e4c0689d67d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -17528,10 +17528,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { const NESTED_TOOL_USE_ID = "toolu-steer-nested"; const FINISHED_TOOL_USE_ID = "toolu-steer-finished"; const calls: Array = []; + const backgroundRequested = yield* Deferred.make(); let beforeAnswer: Effect.Effect = Effect.void; + const lateAnswer = yield* Deferred.make(); const harness = yield* makeWakeHarnessWithOptions({ backgroundTasks: (toolUseId) => Effect.sync(() => calls.push(toolUseId)).pipe( + Effect.andThen(Deferred.succeed(backgroundRequested, undefined)), Effect.andThen( backgrounding === "answers after the turn ended" || backgrounding === "answers after the subagent ran again" @@ -17546,7 +17549,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { : backgrounding === "never answers" ? Effect.never : backgrounding === "answers after the steer went out" - ? Effect.sleep("6 seconds").pipe(Effect.as(true)) + ? Deferred.await(lateAnswer).pipe(Effect.as(true)) : Effect.succeed(true), ), ), @@ -17699,12 +17702,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); } - const steering = yield* Effect.forkScoped(steer); + // Started at once so the steer reads its backgrounding deadline before + // this test moves the clock. Its message is built with real file I/O, + // so a scheduled start could read the clock after the moves below. + const steering = yield* Effect.forkScoped(steer, { startImmediately: true }); if ( backgrounding === "never answers" || backgrounding === "answers after the steer went out" ) { - yield* awaitUntil(() => calls.length === 1, "background request"); + yield* Deferred.await(backgroundRequested); assert.equal(harness.offeredMessages.length, offered); yield* TestClock.adjust("5 seconds"); } @@ -17721,18 +17727,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); if (backgrounding === "never answers") { for (let repeat = 1; repeat <= 2; repeat++) { - const again = yield* Effect.forkScoped(steer); - for (let tick = 0; tick < 100 && again.pollUnsafe() === undefined; tick++) { - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - } + const again = yield* Effect.forkScoped(steer, { startImmediately: true }); + yield* TestClock.adjust("5 seconds"); assert.isTrue(Exit.isSuccess(yield* Fiber.await(again))); assert.equal(harness.offeredMessages.length, offered + 1 + repeat); } assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); } if (backgrounding === "answers after the steer went out") { - yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(lateAnswer, undefined); } else if ( backgrounding !== "succeeds" && backgrounding !== "answers after the subagent ran again" diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a8e58816619a..2396e61e2232 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -97,6 +97,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -305,6 +306,9 @@ const CLAUDE_WAKE_TURN_START_DEADLINE = "2 minutes"; // reply can land after the progress frame that settles it. Its transcript // stays the source of its answer this long after it settles. const WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS = 500; +// A steer waits at most this long, from its request, for its foreground +// subagents to move to the background before it is sent. +const BACKGROUND_MOVE_TIMEOUT_MS = 5_000; /** * When the CLI exits it stops the MCP servers it started, but not their children: a plugin's @@ -9798,6 +9802,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // its result arrives as a task notification like any background agent. const backgroundForegroundSubagents = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, + deadline: number, ) { const foreground: Array<{ readonly taskId: string; @@ -9853,11 +9858,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( backgroundMovesByToolUseId.set(toolUseId, move); moves.push(move); } - yield* Fiber.joinAll(moves).pipe(Effect.timeoutOption("5 seconds")); + const remaining = deadline - (yield* Clock.currentTimeMillis); + yield* Fiber.joinAll(moves).pipe(Effect.timeoutOption(Math.max(0, remaining))); }); const steerTurn = Effect.fn("ClaudeAdapterV2.steerTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2SteerInput) { + // Measured from the request, not from when the moves are forked: + // building the message and the moves can take clock time a timer + // started later would not count, and the steer would wait past it. + const backgroundDeadline = + (yield* Clock.currentTimeMillis) + BACKGROUND_MOVE_TIMEOUT_MS; const existing = yield* Ref.get(queryContext); if (existing === null) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ @@ -9883,7 +9894,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fileSystem, skills: yield* userInvocableSkills(currentTurn.input.runtimePolicy.cwd), }); - yield* backgroundForegroundSubagents(existing); + yield* backgroundForegroundSubagents(existing, backgroundDeadline); // The turn can end while the moves are answered. A steer offered // then would start a new turn the orchestrator never asked for, so // the steer fails and the orchestrator starts the follow-up turn. diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9a0dff477368..8dfbfe712a7d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -69,6 +69,7 @@ import Migration0063 from "./Migrations/063_DropUnusedV1Projections.ts"; import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.ts"; import Migration0065 from "./Migrations/065_CodexUsageNativeTurnIndex.ts"; import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts"; +import Migration0067 from "./Migrations/067_WebhookDedupe.ts"; export const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -138,6 +139,7 @@ export const migrationEntries = [ [64, "CanonicalizeThreadPullRequestLinks", Migration0064], [65, "CodexUsageNativeTurnIndex", Migration0065], [66, "RepairClientSettlementTimestamps", Migration0066], + [67, "WebhookDedupe", Migration0067], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index cd926c899d97..5bbf3cd9a7e6 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -15,7 +15,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 66 }, (_, index) => index + 1), + Array.from({ length: 67 }, (_, index) => index + 1), ); }), ); @@ -40,6 +40,7 @@ layer("055_OrchestrationV2", (it) => { [64, "CanonicalizeThreadPullRequestLinks"], [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], + [67, "WebhookDedupe"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -72,6 +73,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 64, name: "CanonicalizeThreadPullRequestLinks" }, { migration_id: 65, name: "CodexUsageNativeTurnIndex" }, { migration_id: 66, name: "RepairClientSettlementTimestamps" }, + { migration_id: 67, name: "WebhookDedupe" }, ]); const tables = yield* sql<{ readonly name: string }>` diff --git a/apps/server/src/persistence/Migrations/067_WebhookDedupe.ts b/apps/server/src/persistence/Migrations/067_WebhookDedupe.ts new file mode 100644 index 000000000000..b132e7c6dd14 --- /dev/null +++ b/apps/server/src/persistence/Migrations/067_WebhookDedupe.ts @@ -0,0 +1,29 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN last_run_webhook_delivery_id TEXT`; + yield* sql`CREATE TABLE scheduled_task_webhook_replay_keys ( + task_id TEXT NOT NULL REFERENCES scheduled_tasks(task_id) ON DELETE CASCADE, + replay_key TEXT NOT NULL, + delivery_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (task_id, replay_key) + )`; + yield* sql`CREATE INDEX idx_webhook_replay_expiry ON scheduled_task_webhook_replay_keys(expires_at)`; + yield* sql`CREATE INDEX idx_webhook_replay_delivery ON scheduled_task_webhook_replay_keys(delivery_id)`; + yield* sql`CREATE TABLE scheduled_task_webhook_pending ( + delivery_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES scheduled_tasks(task_id) ON DELETE CASCADE, + coalesce_key TEXT NOT NULL, + due_at INTEGER NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + task_json TEXT NOT NULL, + request_json TEXT NOT NULL, + delivery_ids_json TEXT NOT NULL + )`; + yield* sql`CREATE INDEX idx_webhook_pending_due ON scheduled_task_webhook_pending(state, due_at)`; + yield* sql`CREATE UNIQUE INDEX idx_webhook_pending_key + ON scheduled_task_webhook_pending(task_id, coalesce_key) WHERE state = 'pending'`; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index f1495d1e62a4..990d450cde6b 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -46,6 +46,7 @@ describe("V2 preview upgrade", () => { [64, "CanonicalizeThreadPullRequestLinks"], [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], + [67, "WebhookDedupe"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -135,6 +136,7 @@ describe("V2 preview upgrade", () => { [64, "CanonicalizeThreadPullRequestLinks"], [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], + [67, "WebhookDedupe"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index d9a962994040..ea681a30afe0 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -15,6 +15,7 @@ import { type ScheduledTaskListWebhookDeliveriesResult, type ScheduledTaskRotateWebhookTokenInput, type ScheduledTaskWebhookDeliveryOutcome, + type ScheduledTaskWebhookFilter, type ScheduledTaskListResult, type ScheduledTaskMutationResult, type ScheduledTaskRunNowInput, @@ -28,6 +29,7 @@ import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Duration from "effect/Duration"; import * as Metric from "effect/Metric"; import * as Layer from "effect/Layer"; @@ -53,15 +55,38 @@ import { renderWebhookPrompt, type WebhookRequest, } from "./webhookTemplate.ts"; +import { + acceptWebhookDedupe, + acceptImmediateWebhookDedupe, + releaseWebhookReplay, + decodePendingTask, + decodePendingRequest, + dedupeError, +} from "./webhookDedupe.ts"; +import { describeFailedWebhookFilter, findFailedWebhookFilter } from "./webhookFilter.ts"; import { constantTimeEquals, verifyWebhookSignature } from "./webhookVerification.ts"; export const WEBHOOK_ROUTE_PREFIX = "/api/hooks"; +const isScheduledTaskError = Schema.is(ScheduledTaskError); +const PROMPT_TOO_LONG = "The filled-in prompt is too long."; const WEBHOOK_DELIVERY_RETENTION = 50; +const WEBHOOK_DEDUPE_DELIVERY_RETENTION = 20; +const WEBHOOK_FILTERED_DELIVERY_RETENTION = 20; +const WEBHOOK_DEDUPE_LOG_PER_MINUTE = 10; +const WEBHOOK_FILTERED_LOG_PER_MINUTE = 10; const WEBHOOK_DELIVERY_LOG_BODY_LIMIT = 64 * 1024; const WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT = 64 * 1024; const WEBHOOK_MAX_QUEUED_PER_TASK = 20; /** Accepted deliveries per task per minute, enforced here as well as on the relay because the tunnel hostname is public too. */ const WEBHOOK_RATE_LIMIT_PER_MINUTE = 60; +const LEGACY_DELIVERY_OUTCOMES: ReadonlySet = new Set([ + "accepted", + "dispatch_failed", + "rejected_signature", + "disabled", + "rate_limited", + "expired", +]); interface WebhookOrigin { readonly relayHookBaseUrl: string | null; @@ -93,7 +118,7 @@ class WebhookDeliverySkipped extends Data.TaggedError("WebhookDeliverySkipped")< }> {} interface RateWindow { - readonly accepted: ReadonlyArray; + readonly accepted: ReadonlyArray<{ readonly at: number }>; readonly rejectedLogged: boolean; } @@ -108,12 +133,14 @@ export interface WebhookTriggerRequest extends WebhookRequest { export type WebhookDeliveryOutcome = | "accepted" | "duplicate" + | "coalesced" | "prompt_too_long" | "queue_full" | "rate_limited" | "disabled" | "rejected_signature" | "expired" + | "filtered" | "not_found" | "error"; @@ -121,7 +148,7 @@ export type WebhookTriggerResult = | { readonly _tag: "accepted"; readonly deliveryId: ScheduledTaskWebhookDeliveryId; - readonly outcome: "accepted" | "duplicate" | "prompt_too_long"; + readonly outcome: "accepted" | "duplicate" | "coalesced" | "prompt_too_long" | "filtered"; } | { readonly _tag: "not_found" } | { readonly _tag: "rejected_signature" } @@ -132,6 +159,9 @@ export type WebhookTriggerResult = } | { readonly _tag: "expired" }; +const webhookFilters = (filters: ReadonlyArray | undefined) => + filters?.length ? { filters } : {}; + const decodeTask = Schema.decodeUnknownEffect(ScheduledTask); const decodeTaskId = Schema.decodeUnknownOption(ScheduledTaskId); const decodeScheduleJson = Schema.decodeUnknownEffect( @@ -189,6 +219,7 @@ interface WebhookDeliveryRow { readonly signature_verified: number; readonly missing_fields_json: string; readonly rendered_prompt: string | null; + readonly pending_window: number; readonly error: string | null; } @@ -383,6 +414,16 @@ export const layer = Layer.effect( // finish late release their own count, never a recreated task's. const webhookQueued = yield* Ref.make>(new Map()); const webhookRateWindows = yield* Ref.make>(new Map()); + const webhookRejectedRateWindows = yield* Ref.make>( + new Map(), + ); + const webhookDedupeLogWindows = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const pendingInFlight = yield* Ref.make>(new Set()); + const webhookFilteredLogWindows = yield* Ref.make< + ReadonlyMap> + >(new Map()); const activeRuns = yield* Ref.make>(new Set()); // Sliding(1) coalesces the dirty-signal: every notification triggers a // full list() re-emit anyway, so a slow subscriber only ever needs the @@ -602,11 +643,12 @@ export const layer = Layer.effect( ), ); - const markRunning = (id: ScheduledTaskId, startedAtIso: string) => + const markRunning = (id: ScheduledTaskId, startedAtIso: string, webhookDeliveryId?: string) => sql<{ task_id: string }>` UPDATE scheduled_tasks SET updated_at = ${startedAtIso}, last_run_at = ${startedAtIso}, + last_run_webhook_delivery_id = ${webhookDeliveryId ?? null}, last_run_status = 'running', last_run_error = NULL WHERE task_id = ${id} @@ -648,9 +690,10 @@ export const layer = Layer.effect( // markCompleted, write a full terminal record so runDueTasks neither skips // the task forever (it filters out 'running' rows) nor re-fires it // immediately: the dispatch may already have gone out, so next_run_at must - // advance and run_count must count the attempt. + // advance. Pending webhook attempts count only with their completion state; + // other attempts count here. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( - function* (task: ScheduledTask, message: string) { + function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { const now = yield* localNow; const reread = yield* Effect.result(findTask(task.id)); if (Result.isSuccess(reread) && reread.success === null) return; @@ -661,12 +704,15 @@ export const layer = Layer.effect( last_run_error = ${message}, next_run_at = ${nextRunAt(source, now)}, updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE delivery_id = ${pendingDeliveryId ?? null} AND state = 'running' + ) THEN 0 ELSE 1 END WHERE task_id = ${task.id} AND last_run_status = 'running' `; yield* notifyChanged; }, - (effect, task, _message) => + (effect, task, _message, _pendingDeliveryId) => effect.pipe( Effect.catch((cause) => Effect.logWarning("Could not release stuck schedule task run", { @@ -680,7 +726,11 @@ export const layer = Layer.effect( const runTask = Effect.fn("ScheduledTaskService.runTask")(function* ( task: ScheduledTask, trigger: "scheduled" | "manual" | "webhook", - webhook?: { readonly deliveryId: string; readonly prompt: string }, + webhook?: { + readonly deliveryId: string; + readonly prompt: string; + readonly dispatched: Ref.Ref; + }, ) { const reserved = yield* Ref.modify(activeRuns, (active) => { if (active.has(task.id)) return [false, active] as const; @@ -714,6 +764,12 @@ export const layer = Layer.effect( } return { task, threadId: null }; } + if ( + webhook !== undefined && + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId} AND state = 'completed'`) + .length > 0 + ) + return { task: { ...active, lastRunStatus: "succeeded" as const }, threadId: null }; const parsedNextRunAt = active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); if ( @@ -739,7 +795,22 @@ export const layer = Layer.effect( if (reason !== null) return yield* new WebhookDeliverySkipped({ reason }); } - yield* markRunning(active.id, startedAtIso); + if (webhook === undefined) yield* markRunning(active.id, startedAtIso); + else + yield* sql + .withTransaction( + Effect.gen(function* () { + yield* markRunning(active.id, startedAtIso, webhook.deliveryId); + // Recovery must distinguish this delivery from other windows waiting on the same task. + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'running' WHERE delivery_id = ${webhook.deliveryId}`; + }), + ) + .pipe( + Effect.mapError((cause) => + taskError("Could not start webhook run.", { taskId: task.id, cause }), + ), + ); + yield* notifyChanged; const fireKey = @@ -796,8 +867,9 @@ export const layer = Layer.effect( .pipe(Effect.as(ThreadId.make(active.threadId))), ); - const completedAt = yield* localNow; const runSucceeded = result._tag === "Success"; + if (webhook !== undefined && runSucceeded) yield* Ref.set(webhook.dispatched, true); + const completedAt = yield* localNow; const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); const lastRunError = runSucceeded ? null : errorMessage(result.cause); const threadId = runSucceeded ? result.value : null; @@ -816,7 +888,7 @@ export const layer = Layer.effect( // startedAtIso in the guard ensures this writes only to the row this // run marked as running — a task deleted mid-run and recreated with // the same id (idempotent commandId replay) must not be stamped. - yield* markCompleted({ + const completion = markCompleted({ id: task.id, completedAtIso: completed.updatedAt, nextRunAtIso: completed.nextRunAt, @@ -824,11 +896,32 @@ export const layer = Layer.effect( error: lastRunError, startedAtIso, }); + if (webhook === undefined) yield* completion; + else + yield* sql + .withTransaction( + Effect.gen(function* () { + const pending = + yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; + if (pending.length === 0) yield* completion; + else { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} + WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; + if (counted.length > 0) yield* completion; + } + }), + ) + .pipe( + Effect.mapError((cause) => + taskError("Could not record webhook completion.", { taskId: task.id, cause }), + ), + ); yield* notifyChanged; } return { task: completed, threadId }; }).pipe( - Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause))), + Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause), webhook?.deliveryId)), Effect.ensuring( Ref.update(activeRuns, (active) => { const next = new Set(active); @@ -905,7 +998,11 @@ export const layer = Layer.effect( last_run_error = 'Run was interrupted by a server restart.', next_run_at = ${nextRunAt(decoded.success, now)}, updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE task_id IS ${row.task_id} AND state = 'running' + AND delivery_id = scheduled_tasks.last_run_webhook_delivery_id + ) THEN 0 ELSE 1 END WHERE task_id IS ${row.task_id} AND last_run_status = 'running' `; return; @@ -919,7 +1016,11 @@ export const layer = Layer.effect( SET last_run_status = 'failed', last_run_error = 'Run was interrupted by a server restart.', updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE task_id IS ${row.task_id} AND state = 'running' + AND delivery_id = scheduled_tasks.last_run_webhook_delivery_id + ) THEN 0 ELSE 1 END WHERE task_id IS ${row.task_id} AND last_run_status = 'running' `; }), @@ -992,6 +1093,20 @@ export const layer = Layer.effect( prefix: input.schedule.signature.prefix, }, maxDeliveryAgeMinutes: input.schedule.maxDeliveryAgeMinutes ?? null, + ...(input.schedule.dedupe === undefined + ? existingTask?.schedule.type === "webhook" && + existingTask.schedule.dedupe !== undefined + ? { dedupe: existingTask.schedule.dedupe } + : {} + : { dedupe: input.schedule.dedupe }), + ...webhookFilters( + // Omitted keeps the stored filters, so a client that predates + // them can re-save a task without clearing them. + input.schedule.filters ?? + (existingTask?.schedule.type === "webhook" + ? existingTask.schedule.filters + : undefined), + ), } : input.schedule; const webhook = @@ -1106,6 +1221,21 @@ export const layer = Layer.effect( next.delete(id); return next; }), + Ref.update(webhookRejectedRateWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookDedupeLogWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookFilteredLogWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), Ref.update(webhookPermits, (permits) => { const next = new Map(permits); next.delete(id); @@ -1205,6 +1335,7 @@ export const layer = Layer.effect( bodyBytes: row.body_bytes, outcome: row.outcome as ScheduledTaskWebhookDeliveryOutcome, signatureVerified: row.signature_verified === 1, + pendingWindow: row.outcome === "accepted" && row.pending_window === 1, missingFields: Option.getOrElse(decodeMissingFieldsJson(row.missing_fields_json), () => []), error: row.error, }); @@ -1213,11 +1344,18 @@ export const layer = Layer.effect( input, ) => sql` - SELECT * FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.id} + SELECT d.*, EXISTS (SELECT 1 FROM scheduled_task_webhook_pending p + WHERE p.delivery_id = d.delivery_id AND p.state IN ('pending', 'dispatching')) AS pending_window + FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} ORDER BY received_at DESC, rowid DESC `.pipe( - Effect.map((rows) => ({ deliveries: rows.map(decodeDeliverySummary) })), + Effect.map((rows) => ({ + deliveries: rows + .filter( + (row) => input.acceptAnyOutcome === true || LEGACY_DELIVERY_OUTCOMES.has(row.outcome), + ) + .map(decodeDeliverySummary), + })), Effect.mapError((cause) => taskError("Could not list webhook deliveries.", { taskId: input.id, cause }), ), @@ -1227,15 +1365,19 @@ export const layer = Layer.effect( "ScheduledTaskService.getWebhookDelivery", )(function* (input) { const rows = yield* sql` - SELECT * FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} + SELECT d.*, EXISTS (SELECT 1 FROM scheduled_task_webhook_pending p + WHERE p.delivery_id = d.delivery_id AND p.state IN ('pending', 'dispatching')) AS pending_window + FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} `.pipe( Effect.mapError((cause) => taskError("Could not load webhook delivery.", { taskId: input.id, cause }), ), ); const row = rows[0]; - if (row === undefined) { + if ( + row === undefined || + (input.acceptAnyOutcome !== true && !LEGACY_DELIVERY_OUTCOMES.has(row.outcome)) + ) { return yield* taskError("Webhook delivery not found.", { taskId: input.id }); } return { @@ -1250,6 +1392,24 @@ export const layer = Layer.effect( }; }); + const deliveryRequestBody = (request: WebhookTriggerRequest) => { + const truncated = request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT; + return { + truncated, + body: truncated + ? new TextDecoder().decode(request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT)) + : request.bodyText, + }; + }; + const replaceDeliveryRequest = (deliveryId: string, request: WebhookTriggerRequest) => { + const logged = deliveryRequestBody(request); + return sql`UPDATE scheduled_task_webhook_deliveries SET + method = ${request.method}, query = ${redactQuery(request.query)}, + headers_json = ${encodeHeadersJson(redactHeaders(request.headers))}, body = ${logged.body}, + body_bytes = ${request.body.byteLength}, body_truncated = ${logged.truncated ? 1 : 0} + WHERE delivery_id = ${deliveryId}`.pipe(Effect.asVoid, Effect.mapError(dedupeError)); + }; + const recordDelivery = (input: { readonly id: string; readonly taskId: ScheduledTaskId; @@ -1261,10 +1421,7 @@ export const layer = Layer.effect( readonly renderedPrompt: string | null; readonly error?: string; }) => { - const truncated = input.request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT; - const loggedBody = truncated - ? new TextDecoder().decode(input.request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT)) - : input.request.bodyText; + const { truncated, body: loggedBody } = deliveryRequestBody(input.request); return sql .withTransaction( Effect.gen(function* () { @@ -1295,15 +1452,28 @@ export const layer = Layer.effect( rendered_prompt = excluded.rendered_prompt, error = excluded.error `; + // Each suppression bucket retains its own rows, never real run history. + const bucket = (outcome: string, error: string | undefined) => + outcome === "filtered" + ? "filtered" + : outcome === "duplicate" || + outcome === "coalesced" || + outcome === "rate_limited" || + error === PROMPT_TOO_LONG + ? "dedupe" + : "main"; + const retentionBucket = bucket(input.outcome, input.error); + const rowBucket = sql.literal(`CASE WHEN outcome = 'filtered' THEN 'filtered' + WHEN outcome IN ('duplicate', 'coalesced', 'rate_limited') OR error = 'The filled-in prompt is too long.' THEN 'dedupe' + ELSE 'main' END`); yield* sql` DELETE FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} + WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} AND delivery_id NOT IN ( SELECT delivery_id FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} - -- rowid breaks timestamp ties in arrival order; delivery ids are random. + WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} ORDER BY received_at DESC, rowid DESC - LIMIT ${WEBHOOK_DELIVERY_RETENTION} + LIMIT ${retentionBucket === "main" ? WEBHOOK_DELIVERY_RETENTION : retentionBucket === "filtered" ? WEBHOOK_FILTERED_DELIVERY_RETENTION : WEBHOOK_DEDUPE_DELIVERY_RETENTION} ) `; }), @@ -1322,13 +1492,15 @@ export const layer = Layer.effect( WHERE delivery_id = ${deliveryId} `.pipe(Effect.ignore); - /** - * Sliding one-minute window counting every request with a valid token, - * including ones the signature check later rejects. - */ - const takeRateSlot = (id: ScheduledTaskId, nowMs: number) => + /** Sliding one-minute window for deliveries that can start work. */ + const takeRateSlot = ( + id: ScheduledTaskId, + nowMs: number, + rateWindows = webhookRateWindows, + reservation = { at: nowMs }, + ) => Ref.modify( - webhookRateWindows, + rateWindows, ( windows, ): readonly [ @@ -1336,7 +1508,7 @@ export const layer = Layer.effect( ReadonlyMap, ] => { const current = windows.get(id); - const recent = (current?.accepted ?? []).filter((at) => nowMs - at < 60_000); + const recent = (current?.accepted ?? []).filter((entry) => nowMs - entry.at < 60_000); if (recent.length >= WEBHOOK_RATE_LIMIT_PER_MINUTE) { const first = current?.rejectedLogged !== true; return [ @@ -1346,11 +1518,35 @@ export const layer = Layer.effect( } return [ "allowed", - new Map(windows).set(id, { accepted: [...recent, nowMs], rejectedLogged: false }), + new Map(windows).set(id, { accepted: [...recent, reservation], rejectedLogged: false }), ]; }, ); + const takeDedupeLogSlot = (id: ScheduledTaskId, nowMs: number) => + Ref.modify(webhookDedupeLogWindows, (windows) => { + const recent = (windows.get(id) ?? []).filter((at) => nowMs - at < 60_000); + return recent.length >= WEBHOOK_DEDUPE_LOG_PER_MINUTE + ? ([false, windows] as const) + : ([true, new Map(windows).set(id, [...recent, nowMs])] as const); + }); + const releaseReplay = (deliveryId: string) => + releaseWebhookReplay(deliveryId).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + Effect.mapError(dedupeError), + ); + /** Sliding one-minute window of filtered requests written to the delivery log. */ + const takeFilteredLogSlot = (id: ScheduledTaskId, nowMs: number) => + Ref.modify( + webhookFilteredLogWindows, + (windows): readonly [boolean, ReadonlyMap>] => { + const recent = (windows.get(id) ?? []).filter((at) => nowMs - at < 60_000); + return recent.length >= WEBHOOK_FILTERED_LOG_PER_MINUTE + ? [false, windows] + : [true, new Map(windows).set(id, [...recent, nowMs])]; + }, + ); + const webhookPermit = Effect.fn("ScheduledTaskService.webhookPermit")(function* ( id: ScheduledTaskId, ) { @@ -1367,6 +1563,83 @@ export const layer = Layer.effect( const serviceScope = yield* Effect.scope; + const releaseFailedDelivery = Effect.fn("ScheduledTaskService.releaseFailedDelivery")( + function* (deliveryId: string) { + yield* releaseReplay(deliveryId); + yield* sql`DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id IN ( + SELECT substr(value, ${"delivery:relay:".length + 1}) FROM scheduled_task_webhook_pending, json_each(delivery_ids_json) + WHERE delivery_id = ${deliveryId} AND value LIKE 'delivery:relay:%' + )`.pipe(Effect.mapError(dedupeError)); + if (deliveryId.startsWith("delivery:relay:")) + yield* sql`DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = ${deliveryId.slice("delivery:relay:".length)}`.pipe( + Effect.mapError(dedupeError), + ); + }, + (effect) => sql.withTransaction(effect).pipe(Effect.mapError(dedupeError)), + ); + + const runWebhookDelivery = Effect.fn("ScheduledTaskService.runWebhookDelivery")(function* ( + task: ScheduledTask, + deliveryId: string, + prompt: string, + ) { + const runOutcome = (outcome: "started" | "skipped" | "failed") => + Effect.all([ + Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), + Metrics.increment(Metrics.webhookRunsTotal, { outcome }), + ]); + const dispatched = yield* Ref.make(false); + return yield* runTask(task, "webhook", { deliveryId, prompt, dispatched }).pipe( + Effect.flatMap(({ task: completed }) => + completed.lastRunStatus === "failed" + ? runOutcome("failed").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), + Effect.andThen(releaseFailedDelivery(deliveryId)), + ) + : runOutcome("started"), + ), + Effect.catchTags({ + WebhookDeliverySkipped: (skipped) => + runOutcome("skipped").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), + Effect.andThen(releaseFailedDelivery(deliveryId)), + ), + }), + Effect.as(true), + // Dispatch success owns the replay key even if later bookkeeping fails. + Effect.catchCause((cause) => + Effect.gen(function* () { + const started = yield* Ref.get(dispatched); + yield* Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }); + yield* runOutcome(started ? "started" : "failed"); + yield* markDeliveryFailed( + deliveryId, + started + ? "The run started, but completion could not be recorded." + : "The run failed to start.", + ); + if (!started) { + yield* sql + .withTransaction( + Effect.gen(function* () { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'failed' + WHERE delivery_id = ${deliveryId} AND state = 'running' RETURNING delivery_id`; + if (counted.length > 0) + yield* sql`UPDATE scheduled_tasks SET run_count = run_count + 1 + WHERE task_id = ${task.id} AND created_at = ${task.createdAt}`; + }), + ) + .pipe(Effect.mapError(dedupeError)); + yield* releaseFailedDelivery(deliveryId); + } + // Pending windows retry the same command id to finish bookkeeping. + return !started; + }), + ), + ); + }); + // Delivery attributes belong to the enclosing triggerWebhook span. const observeDelivery = Effect.fnUntraced(function* ( request: WebhookTriggerRequest, @@ -1514,16 +1787,19 @@ export const layer = Layer.effect( ...(details.error === undefined ? {} : { error: details.error }), }); - // Only the first rejected request in a window is logged, so a flood - // cannot write rows or push the real deliveries out of the log. - const slot = yield* takeRateSlot(task.id, DateTime.toEpochMillis(now)); - if (slot !== "allowed") { - if (slot === "first_rejected") yield* log("rate_limited"); + // Reject floods before signature work or rejection-log writes; valid + // deliveries give this reservation back once validation succeeds. + const rejectionReservation = { at: DateTime.toEpochMillis(now) }; + const rejectionSlot = yield* takeRateSlot( + task.id, + DateTime.toEpochMillis(now), + webhookRejectedRateWindows, + rejectionReservation, + ); + if (rejectionSlot !== "allowed") { + if (rejectionSlot === "first_rejected") yield* log("rate_limited"); yield* observe("rate_limited"); - return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "rate_limited" as const, - }); + return yield* releaseClaim({ _tag: "rate_limited", outcome: "rate_limited" } as const); } if (!task.enabled) { yield* log("disabled"); @@ -1557,6 +1833,122 @@ export const layer = Layer.effect( return { _tag: "expired" as const }; } + const refundRejectionSlot = Ref.update(webhookRejectedRateWindows, (windows) => { + const current = windows.get(task.id); + if (current === undefined) return windows; + return new Map(windows).set(task.id, { + ...current, + accepted: current.accepted.filter((entry) => entry !== rejectionReservation), + }); + }); + + const failedFilter = findFailedWebhookFilter(schedule.filters ?? [], request); + if (failedFilter !== null) { + if (yield* takeFilteredLogSlot(task.id, DateTime.toEpochMillis(now))) { + yield* log("filtered", { + signatureVerified: signature !== null, + error: describeFailedWebhookFilter(failedFilter), + }); + } + yield* refundRejectionSlot; + yield* observe("filtered"); + return yield* releaseClaim({ + _tag: "accepted" as const, + deliveryId, + outcome: "filtered" as const, + }); + } + + const runWindow = (yield* Ref.get(webhookRateWindows)).get(task.id); + if ( + schedule.dedupe === undefined && + (runWindow?.accepted.filter((entry) => DateTime.toEpochMillis(now) - entry.at < 60_000) + .length ?? 0) >= WEBHOOK_RATE_LIMIT_PER_MINUTE + ) { + if ((yield* takeRateSlot(task.id, DateTime.toEpochMillis(now))) === "first_rejected") + yield* log("rate_limited"); + yield* observe("rate_limited"); + return yield* releaseClaim({ _tag: "rate_limited", outcome: "rate_limited" } as const); + } + const reserve = takeRateSlot(task.id, DateTime.toEpochMillis(now)).pipe( + Effect.flatMap((slot) => (slot === "allowed" ? Effect.void : Effect.fail(slot))), + ); + const queueKey = `${task.id}\u0000${task.createdAt}`; + const checkCapacity = Effect.gen(function* () { + const [row] = yield* sql<{ + count: number; + }>`SELECT COUNT(*) AS count FROM scheduled_task_webhook_pending + WHERE task_id = ${task.id} AND state IN ('pending', 'dispatching', 'running')`; + if ( + (row?.count ?? 0) + ((yield* Ref.get(webhookQueued)).get(queueKey) ?? 0) >= + WEBHOOK_MAX_QUEUED_PER_TASK + ) + return yield* Effect.fail("queue_full" as const); + }).pipe( + Effect.mapError((cause) => (cause === "queue_full" ? cause : dedupeError(cause))), + ); + const admit = (effect: Effect.Effect) => + sql.withTransaction(effect).pipe( + Effect.catch((cause) => + cause === "first_rejected" || cause === "rejected" || cause === "queue_full" + ? Effect.succeed({ + outcome: + cause === "queue_full" + ? ("queue_full" as const) + : ("rate_limited" as const), + log: cause === "first_rejected", + }) + : Effect.fail(isScheduledTaskError(cause) ? cause : dedupeError(cause)), + ), + ); + const deduped = + schedule.dedupe === undefined + ? "run" + : yield* admit( + Effect.gen(function* () { + const outcome = yield* acceptWebhookDedupe( + task, + request, + deliveryId, + DateTime.toEpochMillis(now), + reserve, + takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now)), + checkCapacity, + (ownerId) => replaceDeliveryRequest(ownerId, request), + ).pipe(Effect.provideService(SqlClient.SqlClient, sql)); + if ( + outcome === "pending" || + outcome === "coalesced" || + (outcome === "duplicate" && + (yield* takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now)))) + ) + yield* log(outcome === "pending" ? "accepted" : outcome, { + signatureVerified: signature !== null, + }); + return outcome; + }), + ); + if (typeof deduped !== "string") { + yield* refundRejectionSlot; + if (deduped.log) yield* log("rate_limited"); + yield* observe(deduped.outcome); + return yield* releaseClaim({ _tag: "rate_limited", outcome: deduped.outcome } as const); + } + if (deduped !== "run") { + if (deduped !== "coalesced" && deduped !== "coalesced_unlogged") + yield* refundRejectionSlot; + const outcome = + deduped === "pending" + ? "accepted" + : deduped === "coalesced_unlogged" + ? "coalesced" + : deduped; + yield* observe(outcome); + if (deduped === "duplicate") + return yield* releaseClaim({ _tag: "accepted", deliveryId, outcome } as const); + claimAcquired = false; + return { _tag: "accepted", deliveryId, outcome } as const; + } const frameId = yield* crypto.randomBytes(6).pipe( Effect.map((bytes) => Buffer.from(bytes).toString("hex")), Effect.mapError((cause) => taskError("Could not generate frame id.", { cause })), @@ -1569,85 +1961,192 @@ export const layer = Layer.effect( signatureVerified: signature !== null, missing: rendered.missing, renderedPrompt: rendered.prompt, - error: "The filled-in prompt is too long.", + error: PROMPT_TOO_LONG, }); + yield* releaseReplay(deliveryId); yield* observe("prompt_too_long"); - return { _tag: "accepted" as const, deliveryId, outcome: "prompt_too_long" as const }; + return yield* releaseClaim({ + _tag: "accepted" as const, + deliveryId, + outcome: "prompt_too_long" as const, + }); } // Bound the deliveries one task holds, so steady traffic to a stuck // task cannot pile up parked fibers. - const queueKey = `${task.id}\u0000${task.createdAt}`; - const queued = yield* Ref.modify(webhookQueued, (counts) => { - const count = counts.get(queueKey) ?? 0; - return count >= WEBHOOK_MAX_QUEUED_PER_TASK - ? ([false, counts] as const) - : ([true, new Map(counts).set(queueKey, count + 1)] as const); + yield* refundRejectionSlot; + let queueReserved = false; + const release = Effect.suspend(() => { + if (!queueReserved) return Effect.void; + queueReserved = false; + return Ref.update(webhookQueued, (counts) => { + const next = new Map(counts); + const count = (next.get(queueKey) ?? 1) - 1; + if (count <= 0) next.delete(queueKey); + else next.set(queueKey, count); + return next; + }); + }); + // Serialize immediate reservations with persisted window admission. + const reserveImmediate = Effect.gen(function* () { + yield* checkCapacity; + yield* reserve; + yield* Ref.update(webhookQueued, (counts) => + new Map(counts).set(queueKey, (counts.get(queueKey) ?? 0) + 1), + ); + queueReserved = true; }); - if (!queued) { - yield* observe("queue_full"); + const admitted = yield* admit( + Effect.gen(function* () { + const outcome = yield* acceptImmediateWebhookDedupe( + task, + request, + deliveryId, + DateTime.toEpochMillis(now), + reserveImmediate, + ).pipe(Effect.provideService(SqlClient.SqlClient, sql)); + if (outcome === "duplicate") { + if (yield* takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now))) + yield* log("duplicate", { signatureVerified: signature !== null }); + } else + yield* log("accepted", { + signatureVerified: signature !== null, + missing: rendered.missing, + renderedPrompt: rendered.prompt, + }); + return outcome; + }), + ).pipe(Effect.onError(() => release)); + if (typeof admitted !== "string") { + yield* release; + if (admitted.log) yield* log("rate_limited"); + yield* observe(admitted.outcome); return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "queue_full" as const, - }); + _tag: "rate_limited", + outcome: admitted.outcome, + } as const); + } + if (admitted === "duplicate") { + yield* release; + yield* observe("duplicate"); + return yield* releaseClaim({ + _tag: "accepted", + deliveryId, + outcome: "duplicate", + } as const); } - const release = Ref.update(webhookQueued, (counts) => { - const next = new Map(counts); - const count = (next.get(queueKey) ?? 1) - 1; - if (count <= 0) next.delete(queueKey); - else next.set(queueKey, count); - return next; - }); - yield* log("accepted", { - signatureVerified: signature !== null, - missing: rendered.missing, - renderedPrompt: rendered.prompt, - }).pipe(Effect.onError(() => release)); yield* observe("accepted"); const permit = yield* webhookPermit(task.id); - const runOutcome = (outcome: "started" | "skipped" | "failed") => - Effect.all([ - Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), - Metrics.increment(Metrics.webhookRunsTotal, { outcome }), - ]); - yield* runTask(task, "webhook", { deliveryId, prompt: rendered.prompt }).pipe( - Effect.flatMap(({ task: completed }) => - completed.lastRunStatus === "failed" - ? runOutcome("failed").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), - ) - : runOutcome("started"), - ), - Effect.catchTags({ - WebhookDeliverySkipped: (skipped) => - runOutcome("skipped").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), - ), - }), - // The log is readable over RPC, so it gets a fixed reason; the - // cause, which can carry request data, stays in the server log. - Effect.catchCause((cause) => - Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }).pipe( - Effect.andThen(runOutcome("failed")), - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), - ), - ), + yield* runWebhookDelivery(task, deliveryId, rendered.prompt).pipe( permit.withPermits(1), - Effect.withSpan("ScheduledTaskService.runWebhookDelivery", { - root: true, - attributes: { - "scheduled_task.id": task.id, - "scheduled_task.webhook.delivery_id": deliveryId, - }, - }), Effect.ensuring(release), Effect.forkIn(serviceScope), ); // Dispatch owns the accepted delivery now; retries must keep its claim. claimAcquired = false; return { _tag: "accepted" as const, deliveryId, outcome: "accepted" as const }; - }).pipe(Effect.onError(() => releaseClaim(undefined))), + }).pipe( + Effect.onError(() => + Effect.all([ + releaseClaim(undefined), + (request.relayDeliveryId === undefined || claimAcquired + ? releaseReplay(deliveryId) + : Effect.void + ).pipe(Effect.ignore), + ]), + ), + ), + ); + }); + + const dispatchPending = Effect.fn("ScheduledTaskService.dispatchPendingWebhooks")(function* () { + const now = DateTime.toEpochMillis(yield* localNow); + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' WHERE state = 'pending' AND due_at <= ${now}`; + const tasks = yield* sql<{ task_id: ScheduledTaskId }>` + SELECT DISTINCT task_id FROM scheduled_task_webhook_pending WHERE state IN ('dispatching', 'running', 'completed', 'failed') + `; + yield* Effect.forEach( + tasks, + ({ task_id: taskId }) => + Effect.gen(function* () { + const reserved = yield* Ref.modify(pendingInFlight, (active) => + active.has(taskId) + ? ([false, active] as const) + : ([true, new Set(active).add(taskId)] as const), + ); + if (!reserved) return; + const permit = yield* webhookPermit(taskId); + // One worker per task waits on its permit, then drains persisted rows in order. + // Other tasks remain independent and ticks never reload an active task's payloads. + const dispatch = Effect.gen(function* () { + while (true) { + const [entry] = yield* sql<{ + delivery_id: string; + task_json: string; + request_json: string; + state: string; + }>` + SELECT delivery_id, task_json, request_json, state FROM scheduled_task_webhook_pending + WHERE task_id = ${taskId} AND state IN ('dispatching', 'running', 'completed', 'failed') ORDER BY due_at, rowid LIMIT 1 + `; + if (entry === undefined) return; + const remove = sql`DELETE FROM scheduled_task_webhook_pending WHERE delivery_id = ${entry.delivery_id}`; + if (entry.state === "completed" || entry.state === "failed") { + if (entry.state === "failed") { + yield* markDeliveryFailed(entry.delivery_id, "The run failed to start."); + yield* releaseFailedDelivery(entry.delivery_id); + } + yield* remove; + continue; + } + const prepared = yield* Effect.gen(function* () { + const task = yield* decodePendingTask(entry.task_json); + const request = yield* decodePendingRequest(entry.request_json); + const frameId = Buffer.from(yield* crypto.randomBytes(6)).toString("hex"); + return { task, rendered: renderWebhookPrompt(task.prompt, request, frameId) }; + }).pipe(Effect.exit); + if (Exit.isFailure(prepared)) { + if (Cause.hasInterruptsOnly(prepared.cause)) + return yield* Effect.failCause(prepared.cause); + yield* markDeliveryFailed( + entry.delivery_id, + "The pending webhook payload could not be prepared.", + ); + yield* releaseFailedDelivery(entry.delivery_id); + yield* remove; + continue; + } + const { task, rendered } = prepared.value; + if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + yield* markDeliveryFailed(entry.delivery_id, PROMPT_TOO_LONG); + yield* releaseFailedDelivery(entry.delivery_id); + } else { + yield* sql`UPDATE scheduled_task_webhook_deliveries SET outcome = 'accepted', error = NULL, rendered_prompt = ${rendered.prompt.slice(0, WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT)}, missing_fields_json = ${encodeMissingFieldsJson(rendered.missing)} WHERE delivery_id = ${entry.delivery_id}`; + if (!(yield* runWebhookDelivery(task, entry.delivery_id, rendered.prompt))) + return; + } + yield* remove; + } + }); + yield* dispatch.pipe( + permit.withPermits(1), + Effect.catch((cause) => + Effect.logWarning("Pending webhook dispatch failed", { cause }), + ), + Effect.ensuring( + Ref.update(pendingInFlight, (active) => { + const next = new Set(active); + next.delete(taskId); + return next; + }), + ), + Effect.withSpan("ScheduledTaskService.dispatchPendingWebhook", { root: true }), + Effect.forkIn(serviceScope), + ); + }), + { discard: true }, ); }); + yield* scheduler.register("webhook-coalescing", dispatchPending()); return ScheduledTaskService.of({ list, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index ae8594a03f0a..e8a4957e84c0 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -3,8 +3,13 @@ import * as NodeCrypto from "node:crypto"; import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import { assert, it } from "@effect/vitest"; -import { ScheduledTaskUpsertInput, SecretRequestError } from "@t3tools/contracts"; +import { + ScheduledTaskUpsertInput, + ScheduledTaskWebhookDeliveryId, + SecretRequestError, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -24,9 +29,94 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; +import { renderWebhookPrompt } from "./webhookTemplate.ts"; const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); +it.effect( + "preserves omitted dedupe settings, releases duplicate claims, and retains coalesced claims", + () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const dedupe = { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }; + const { task } = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "webhook", dedupe } }), + ); + const updated = yield* service.upsert(yield* webhookTaskInput()); + assert.deepEqual( + updated.task.schedule.type === "webhook" && updated.task.schedule.dedupe, + dedupe, + ); + const request = (id: string, bodyText = "{}", relayDeliveryId?: string) => + requestFor(task, { + headers: { "delivery-id": id }, + bodyText, + body: new TextEncoder().encode(bodyText), + ...(relayDeliveryId === undefined ? {} : { relayDeliveryId }), + }); + yield* service.triggerWebhook(request("A")); + yield* Queue.take(launches); + yield* service.triggerWebhook(request("B1", '{"group":"B"}')); + for (let i = 0; i < 5; i++) { + const duplicate = yield* service.triggerWebhook(request("A", "{}", `duplicate-${i}`)); + assert.equal(duplicate._tag === "accepted" && duplicate.outcome, "duplicate"); + const coalesced = yield* service.triggerWebhook( + request(`B${i + 2}`, '{"group":"B"}', `coalesced-${i}`), + ); + assert.equal(coalesced._tag === "accepted" && coalesced.outcome, "coalesced"); + } + const claims = yield* sql<{ + relay_delivery_id: string; + }>`SELECT relay_delivery_id FROM scheduled_task_webhook_relay_deliveries ORDER BY relay_delivery_id`; + assert.deepEqual( + claims.map((claim) => claim.relay_delivery_id), + Array.from({ length: 5 }, (_, i) => `coalesced-${i}`), + ); + assert.isTrue( + (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.every( + (delivery) => delivery.outcome !== "duplicate" && delivery.outcome !== "coalesced", + ), + ); + const fresh = yield* service.triggerWebhook(request("C", "{}", "duplicate-0")); + assert.equal(fresh._tag === "accepted" && fresh.outcome, "accepted"); + yield* Queue.take(launches); + const cleared = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "webhook", dedupe: {} } }), + ); + assert.deepEqual( + cleared.task.schedule.type === "webhook" && cleared.task.schedule.dedupe, + {}, + ); + }), + ), +); + +const waitForSpan = (work: Effect.Effect, name: string) => + Effect.gen(function* () { + const done = yield* Deferred.make(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if (span.name === name) Deferred.doneUnsafe(done, Effect.void); + }; + return span; + }, + }); + const result = yield* work.pipe(Effect.withTracer(tracer)); + yield* Deferred.await(done); + return result; + }); + +const drainPendingDispatch = (workers: Map>) => + waitForSpan(workers.get("webhook-coalescing")!, "ScheduledTaskService.dispatchPendingWebhook"); + type LaunchInput = ThreadLaunchService.ThreadLaunchInput; const webhookTaskInput = (overrides: Record = {}) => @@ -70,20 +160,76 @@ const withService = ( readonly sql: SqlClient.SqlClient; readonly secretsByRef: Map; }) => Effect.Effect, - options: { readonly gate?: Deferred.Deferred; readonly relayHookBaseUrl?: string } = {}, + options: { + readonly onFrame?: () => void; + readonly frameGate?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; + readonly completedLaunches?: Set; + readonly failLaunches?: { remaining: number }; + readonly gate?: Deferred.Deferred; + readonly gateForTask?: (input: LaunchInput) => Deferred.Deferred | undefined; + readonly relayHookBaseUrl?: string; + readonly sql?: SqlClient.SqlClient; + readonly workers?: Map>; + } = {}, ) => Effect.gen(function* () { const launches = yield* Queue.unbounded(); const secretsByRef = new Map(); const layerDependencies = Layer.mergeAll( - NodePlatformCrypto.layer, - Scheduler.layer, + options.frameGate === undefined && options.onFrame === undefined + ? NodePlatformCrypto.layer + : Layer.effect( + Crypto.Crypto, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return { + ...crypto, + randomBytes: (size: number) => + Effect.suspend(() => { + if (size === 6) options.onFrame?.(); + return size === 6 && options.frameGate !== undefined + ? Deferred.succeed(options.frameGate.started, undefined).pipe( + Effect.andThen(Deferred.await(options.frameGate.release)), + Effect.andThen(crypto.randomBytes(size)), + ) + : crypto.randomBytes(size); + }), + }; + }), + ).pipe(Layer.provide(NodePlatformCrypto.layer)), + options.workers === undefined + ? Scheduler.layer + : Layer.succeed(Scheduler.Scheduler, { + register: (name: string, work: Effect.Effect) => + Effect.gen(function* () { + const context = yield* Effect.context(); + options.workers!.set(name, work.pipe(Effect.provideContext(context), Effect.orDie)); + }), + }), Layer.mock(ThreadLaunchService.ThreadLaunchService)({ launch: (input) => - Queue.offer(launches, input).pipe( - Effect.andThen(options.gate ? Deferred.await(options.gate) : Effect.void), - Effect.as({ threadId: "thread-1", resumed: false } as never), - ), + Effect.suspend(() => { + if (options.completedLaunches?.has(input.commandId)) + return Effect.succeed({ threadId: "thread-1", resumed: false } as never); + options.completedLaunches?.add(input.commandId); + return Queue.offer(launches, input).pipe( + Effect.andThen( + (options.gateForTask?.(input) ?? options.gate) + ? Deferred.await((options.gateForTask?.(input) ?? options.gate)!) + : Effect.void, + ), + Effect.andThen( + Effect.sync(() => { + if (options.failLaunches && options.failLaunches.remaining-- > 0) + throw new Error("injected start failure"); + }), + ), + Effect.as({ threadId: "thread-1", resumed: false } as never), + ); + }), }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", @@ -108,8 +254,18 @@ const withService = ( const service = yield* ScheduledTaskService.ScheduledTaskService; const sql = yield* SqlClient.SqlClient; return yield* body({ service, launches, sql, secretsByRef }); - }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)); + }).pipe( + Effect.provide( + Layer.fresh(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))), + ), + ); + }).pipe( + Effect.provide( + options.sql === undefined + ? SqlitePersistence.layerMemory + : Layer.succeed(SqlClient.SqlClient, options.sql), + ), + ); it.effect("dispatches exactly the rendered prompt and logs the delivery", () => withService(({ service, launches }) => @@ -132,7 +288,10 @@ it.effect("dispatches exactly the rendered prompt and logs the delivery", () => `scheduled-task:${task.id}:webhook:${result._tag === "accepted" ? result.deliveryId : ""}`, ); - const { deliveries } = yield* service.listWebhookDeliveries({ id: task.id }); + const { deliveries } = yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + }); assert.equal(deliveries.length, 1); assert.equal(deliveries[0]?.outcome, "accepted"); const { delivery } = yield* service.getWebhookDelivery({ @@ -188,7 +347,11 @@ it.effect("answers not found for a wrong token or unknown hook without logging", assert.equal(wrongToken._tag, "not_found"); const unknown = yield* service.triggerWebhook(requestFor(task, { hookId: "missing" })); assert.equal(unknown._tag, "not_found"); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 0, + ); }), ), ); @@ -253,9 +416,10 @@ it.effect("checks the configured signature and keeps the secret write-only", () ); assert.isTrue(resaved.task.webhook?.hasSecret); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => [delivery.outcome, delivery.signatureVerified], - ); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => [delivery.outcome, delivery.signatureVerified]); assert.deepEqual(outcomes.toSorted(), [ ["accepted", true], ["rejected_signature", false], @@ -300,23 +464,38 @@ it.effect("queues a burst of deliveries instead of dropping them", () => }), ); -it.effect("rate limits a hook past 60 deliveries a minute", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const results = yield* Effect.forEach(Array.from({ length: 61 }), () => - service.triggerWebhook(requestFor(task)), - ); - assert.equal(results.at(-2)?._tag, "disabled"); - assert.equal(results.at(-1)?._tag, "rate_limited"); - yield* Effect.forEach([1, 2, 3], () => service.triggerWebhook(requestFor(task))); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => delivery.outcome, - ); - assert.equal(outcomes.filter((outcome) => outcome === "rate_limited").length, 1); - }), - ), -); +it.effect("rate limits a hook past 60 deliveries a minute", () => { + let frames = 0; + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + const results = yield* Effect.forEach(Array.from({ length: 61 }), () => + service + .triggerWebhook(requestFor(task)) + .pipe( + Effect.tap((result) => + result._tag === "accepted" ? Queue.take(launches) : Effect.void, + ), + ), + ); + assert.equal(results.at(-2)?._tag, "accepted"); + assert.equal(results.at(-1)?._tag, "rate_limited"); + yield* Effect.forEach([1, 2, 3], () => service.triggerWebhook(requestFor(task))); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => delivery.outcome); + assert.equal(outcomes.filter((outcome) => outcome === "rate_limited").length, 1); + assert.equal(frames, 60); + }), + { + onFrame: () => { + frames++; + }, + }, + ); +}); it.effect("a save carrying a stale token cannot undo a rotation", () => withService(({ service }) => @@ -337,7 +516,10 @@ it.effect("keeps the newest 50 deliveries when they share a timestamp", () => const ids = yield* Effect.forEach(Array.from({ length: 55 }), (_, index) => service.triggerWebhook(requestFor(task, { query: `n=${index}` })).pipe(Effect.as(index)), ); - const { deliveries } = yield* service.listWebhookDeliveries({ id: task.id }); + const { deliveries } = yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + }); assert.equal(deliveries.length, 50); const first = yield* service.getWebhookDelivery({ id: task.id, @@ -368,7 +550,10 @@ it.effect("keeps credential headers and query values out of the delivery log", ( }, }), ); - const [summary] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [summary] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; const { delivery } = yield* service.getWebhookDelivery({ id: task.id, deliveryId: summary!.id, @@ -479,7 +664,10 @@ it.effect("caps deliveries waiting behind a stuck run", () => ); assert.equal(waiting.filter((result) => result._tag === "accepted").length, 19); assert.equal(waiting.at(-1)?._tag, "rate_limited"); - const logged = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const logged = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; assert.equal(logged.length, 20); yield* Deferred.succeed(gate, undefined); }), @@ -495,7 +683,10 @@ it.effect("logs a body's first 64 KiB by bytes, not characters", () => const text = "界".repeat(30_000); const body = new TextEncoder().encode(text); yield* service.triggerWebhook(requestFor(task, { body, bodyText: text })); - const [summary] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [summary] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; const { delivery } = yield* service.getWebhookDelivery({ id: task.id, deliveryId: summary!.id, @@ -559,7 +750,8 @@ it.effect("a held request already delivered directly runs only once", () => assert.equal(direct._tag, "accepted"); assert.deepEqual(replayed, { ...direct, outcome: "duplicate" } as typeof replayed); yield* Queue.take(launches); - const logged = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const logged = (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries; assert.equal(logged.length, 1); }), ), @@ -580,7 +772,9 @@ it.effect("relay retries recover failed acceptance without releasing another req const error = yield* service.triggerWebhook(request).pipe(Effect.flip); assert.equal(error.message, "Could not record webhook delivery."); assert.equal(yield* Queue.size(launches), 0); - assert.isEmpty((yield* service.listWebhookDeliveries({ id: task.id })).deliveries); + assert.isEmpty( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries, + ); yield* sql`DROP TRIGGER fail_webhook_delivery_log`; const retried = yield* Effect.all( @@ -606,7 +800,11 @@ it.effect("relay retries recover failed acceptance without releasing another req const replayed = yield* service.triggerWebhook(request); assert.equal(replayed._tag === "accepted" ? replayed.outcome : replayed._tag, "duplicate"); assert.equal(yield* Queue.size(launches), 0); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 1); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 1, + ); }), ), ); @@ -657,9 +855,9 @@ it.effect("a held request runs once even after the log has trimmed it", () => it.effect("a rate-limited held request can run on a later pass", () => withService(({ service, launches }) => Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); + const { task } = yield* service.upsert(yield* webhookTaskInput()); yield* Effect.forEach(Array.from({ length: 60 }), () => - service.triggerWebhook(requestFor(task)), + service.triggerWebhook(requestFor(task)).pipe(Effect.tap(() => Queue.take(launches))), ); const limited = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "later" })); assert.equal(limited._tag, "rate_limited"); @@ -678,7 +876,10 @@ it.effect("logs a held request at the time the relay received it", () => const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); const receivedAt = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 5 })); yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "relay-2", receivedAt })); - const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [delivery] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; assert.equal(delivery?.receivedAt, receivedAt); }), ), @@ -699,7 +900,10 @@ it.effect("a receive time in the future counts as now", () => ); assert.equal(result._tag, "accepted"); yield* Queue.take(launches); - const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [delivery] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; assert.equal(delivery?.receivedAt, DateTime.formatIso(now)); }), ), @@ -723,9 +927,10 @@ it.effect("skips a held request older than the task's max age", () => requestFor(task, { relayDeliveryId: "fresh", receivedAt: fresh }), ); assert.equal(ok._tag, "accepted"); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => delivery.outcome, - ); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => delivery.outcome); assert.includeMembers(outcomes, ["expired", "accepted"]); }), ), @@ -753,7 +958,11 @@ it.effect("deleting a task removes its delivery log", () => const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); yield* service.triggerWebhook(requestFor(task)); yield* service.delete({ id: task.id }); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 0, + ); }), ), ); @@ -953,3 +1162,1776 @@ it.effect("counts each handled request by what happened to it", () => }), ), ); + +it.effect("dedupes replay keys per task, persists them, and expires them after seven days", () => + withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const schedule = { type: "webhook", dedupe: { replayKey: "{{headers.delivery-id}}" } }; + const { task } = yield* service.upsert(yield* webhookTaskInput({ schedule })); + const request = requestFor(task, { headers: { "delivery-id": "A" } }); + const first = yield* service.triggerWebhook(request); + assert.equal(first._tag === "accepted" && first.outcome, "accepted"); + yield* Queue.take(launches); + const replay = yield* service.triggerWebhook(request); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + assert.equal(yield* Queue.size(launches), 0); + const other = yield* service.upsert( + yield* webhookTaskInput({ id: "scheduled-task:other", schedule }), + ); + yield* service.triggerWebhook(requestFor(other.task, { headers: request.headers })); + yield* Queue.take(launches); + yield* withService( + ({ service: restarted }) => + Effect.gen(function* () { + const replay = yield* restarted.triggerWebhook(request); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + }), + { sql, workers: new Map() }, + ); + yield* TestClock.adjust("7 days"); + const expired = yield* service.triggerWebhook(request); + assert.equal(expired._tag === "accepted" && expired.outcome, "accepted"); + yield* Queue.take(launches); + assert.include( + (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((d) => d.outcome), + "duplicate", + ); + }), + { workers: new Map() }, + ), +); + +it.effect.each([1, 3])( + "coalesces %i deliveries into one run with the latest body, headers and query", + (count) => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}} {{headers.version}} {{query.version}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.key}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + for (let value = 1; value <= count; value++) { + const bodyText = JSON.stringify({ key: "B", value }); + yield* service.triggerWebhook( + requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { version: String(value) }, + query: `version=${value}`, + }), + ); + if (value < count) yield* TestClock.adjust("2 seconds"); + } + const owner = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.find((delivery) => delivery.outcome === "accepted")!; + assert.isTrue(owner.pendingWindow); + const beforeDispatch = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + acceptAnyOutcome: true, + })).delivery; + assert.isTrue(beforeDispatch.pendingWindow); + assert.equal(beforeDispatch.body, JSON.stringify({ key: "B", value: count })); + assert.equal(beforeDispatch.headers.version, String(count)); + assert.equal(beforeDispatch.query, `version=${count}`); + assert.equal( + beforeDispatch.bodyBytes, + new TextEncoder().encode(beforeDispatch.body).byteLength, + ); + assert.equal(yield* Queue.size(launches), 0); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust(count === 1 ? "10 seconds" : "6 seconds"); + yield* workers.get("webhook-coalescing")!; + const launched = yield* Queue.take(launches); + assert.equal( + (launched.initialMessage!.text.match(new RegExp(`>${count}<`, "g")) ?? []).length, + 3, + ); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((d) => d.outcome); + const afterDispatch = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + acceptAnyOutcome: true, + })).delivery; + assert.isFalse(afterDispatch.pendingWindow); + assert.equal(afterDispatch.body, beforeDispatch.body); + assert.include(afterDispatch.renderedPrompt!, `>${count}<`); + assert.equal(outcomes.filter((o) => o === "accepted").length, 1); + assert.equal(outcomes.filter((o) => o === "coalesced").length, count - 1); + }), + { workers }, + ); + }, +); + +it.effect("recovers a persisted coalescing window after restarting the service", () => { + const workers = new Map>(); + return withService( + ({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.content-type}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* TestClock.adjust("4 seconds"); + const restartedWorkers = new Map>(); + yield* withService( + ({ launches }) => + Effect.gen(function* () { + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("6 seconds"); + yield* restartedWorkers.get("webhook-coalescing")!; + yield* Queue.take(launches); + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers }, + ); +}); + +it.effect("empty dedupe key renders run normally and malformed templates fail at save time", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{headers.absent}}", coalesceKey: "{{body.absent}}" }, + }, + }), + ); + for (let i = 0; i < 2; i++) { + const result = yield* service.triggerWebhook(requestFor(task, { headers: { absent: "" } })); + assert.equal(result._tag === "accepted" && result.outcome, "accepted"); + yield* Queue.take(launches); + } + for (const replayKey of ["literal", "{{body.bad"]) { + const invalid = yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey } }, + }).pipe(Effect.result); + assert.equal(invalid._tag, "Failure"); + } + for (const coalesceWindowSeconds of [9, 3601, 10.5]) { + const result = yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { coalesceWindowSeconds } }, + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + } + }), + ), +); + +it.effect("applies replay before coalescing and defaults the fixed window to 120 seconds", () => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { replayKey: "{{headers.delivery-id}}", coalesceKey: "{{body.group}}" }, + }, + }), + ); + const request = (id: string, value: string) => { + const bodyText = JSON.stringify({ group: "B", value }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id }, + }); + }; + yield* service.triggerWebhook(request("B1", "first")); + yield* service.triggerWebhook(request("B2", "latest")); + const replay = yield* service.triggerWebhook( + request("B2", "replay must not replace latest"), + ); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + yield* TestClock.adjust("119 seconds"); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("1 second"); + yield* workers.get("webhook-coalescing")!; + assert.include((yield* Queue.take(launches)).initialMessage!.text, ">latest<"); + }), + { workers }, + ); +}); + +it.effect("rolls back replay and pending state when recording a delivery fails", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.content-type}}", + coalesceKey: "{{headers.content-type}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* sql`CREATE TRIGGER fail_dedupe_log BEFORE INSERT ON scheduled_task_webhook_deliveries BEGIN SELECT RAISE(FAIL, 'injected'); END`; + yield* service.triggerWebhook(requestFor(task)).pipe(Effect.flip); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + yield* sql`DROP TRIGGER fail_dedupe_log`; + const retry = yield* service.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + yield* Queue.take(launches); + }), + { workers }, + ); +}); + +it.effect("starts a new fixed window at the deadline without replacing the expired payload", () => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.content-type}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const request = (bodyText: string) => + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }); + yield* service.triggerWebhook(request("first")); + yield* TestClock.adjust("10 seconds"); + yield* service.triggerWebhook(request("second")); + yield* workers.get("webhook-coalescing")!; + assert.include((yield* Queue.take(launches)).initialMessage!.text, ">first<"); + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + assert.include((yield* Queue.take(launches)).initialMessage!.text, ">second<"); + }), + { workers }, + ); +}); + +it.effect("pausing a pending task prevents dispatch and deletion clears its dedupe state", () => { + const workers = new Map>(); + return withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.content-type}}", + coalesceKey: "{{headers.content-type}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* service.setEnabled({ id: task.id, enabled: false }); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal(yield* Queue.size(launches), 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries[0]?.outcome, + "dispatch_failed", + ); + yield* service.delete({ id: task.id }); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { workers }, + ); +}); + +it.effect( + "caps duplicate writes at ten per minute without evicting runs or consuming their rate slots", + () => + withService(({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{body}}" } }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + const before = yield* deliveriesCounted("duplicate", "direct"); + for (let i = 0; i < 75; i++) { + const result = yield* service.triggerWebhook(requestFor(task)); + assert.equal(result._tag === "accepted" && result.outcome, "duplicate"); + } + assert.equal((yield* deliveriesCounted("duplicate", "direct")) - before, 75); + const rows = (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries; + assert.equal(rows.filter((row) => row.outcome === "duplicate").length, 10); + assert.equal(rows.filter((row) => row.outcome === "accepted").length, 1); + const keys = yield* sql<{ + replay_key: string; + }>`SELECT replay_key FROM scheduled_task_webhook_replay_keys`; + assert.match(keys[0]!.replay_key, /^[a-f0-9]{64}$/); + for (let i = 0; i < 59; i++) { + const bodyText = JSON.stringify({ i }); + const result = yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + assert.equal(result._tag, "accepted"); + yield* Queue.take(launches); + } + }), + ), +); + +it.effect("caps coalesced history while persisting the latest overflow payload", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { replayKey: "{{headers.delivery-id}}", coalesceKey: "{{body.group}}" }, + }, + }), + ); + const request = (i: number) => { + const bodyText = JSON.stringify({ group: "B", value: i }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": String(i) }, + }); + }; + for (let i = 0; i < 40; i++) + assert.equal((yield* service.triggerWebhook(request(i)))._tag, "accepted"); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 40); + const rows = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + assert.equal(rows.filter((row) => row.outcome === "coalesced").length, 10); + assert.equal(rows.filter((row) => row.outcome === "accepted").length, 1); + const pending = yield* sql<{ + coalesce_key: string; + }>`SELECT coalesce_key FROM scheduled_task_webhook_pending`; + assert.match(pending[0]!.coalesce_key, /^[a-f0-9]{64}$/); + yield* TestClock.adjust("120 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, ">39<"); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); +}); + +it.effect("a partially missing key is empty even with a literal prefix", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "gh:{{headers.absent}}", coalesceKey: "pr:{{body.absent}}" }, + }, + }), + ); + for (let i = 0; i < 2; i++) { + const result = yield* service.triggerWebhook(requestFor(task, { headers: { absent: "" } })); + assert.equal(result._tag === "accepted" && result.outcome, "accepted"); + yield* Queue.take(launches); + } + }), + ), +); + +it.effect("queue-full deliveries release replay keys so a later retry runs", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{headers.delivery-id}}" } }, + }), + ); + const request = (i: number) => + requestFor(task, { headers: { "delivery-id": String(i) } }); + for (let i = 0; i < 20; i++) yield* service.triggerWebhook(request(i)); + yield* Queue.take(launches); + const full = yield* service.triggerWebhook(request(20)); + assert.equal(full._tag === "rate_limited" && full.outcome, "queue_full"); + yield* Deferred.succeed(gate, undefined); + for (let i = 1; i < 20; i++) yield* Queue.take(launches); + const retry = yield* service.triggerWebhook(request(20)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + }), + { gate }, + ); + }), +); + +it.effect.each(["task_json", "request_json"] as const)( + "removes corrupt pending %s instead of retrying forever", + (column) => { + const workers = new Map>(); + return withService( + ({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{body}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* sql`UPDATE scheduled_task_webhook_pending SET ${sql(column)} = 'broken'`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!.outcome, + "dispatch_failed", + ); + yield* workers.get("webhook-coalescing")!; + }), + { workers }, + ); + }, +); + +it.effect( + "restarting after a coalesced launch before pending deletion does not launch or count it twice", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const accepted = yield* service.triggerWebhook(requestFor(task)); + yield* sql`CREATE TRIGGER fail_pending_delete BEFORE DELETE ON scheduled_task_webhook_pending BEGIN SELECT RAISE(FAIL,'crash after launch'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const launch = yield* Queue.take(launches); + assert.equal( + launch.commandId, + `scheduled-task:${task.id}:webhook:${accepted._tag === "accepted" && accepted.deliveryId}`, + ); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal( + (yield* sql`SELECT * FROM scheduled_task_webhook_pending WHERE state = 'completed'`) + .length, + 1, + ); + yield* sql`DROP TRIGGER fail_pending_delete`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: secondLaunches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(secondLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers }, + ); + }, +); + +it.effect("many windows on a busy task share one worker without delaying another task", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const workers = new Map>(); + yield* withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const schedule = { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }; + const { task: a } = yield* service.upsert( + yield* webhookTaskInput({ id: "scheduled-task:a", title: "A", schedule }), + ); + const { task: b } = yield* service.upsert( + yield* webhookTaskInput({ id: "scheduled-task:b", title: "B", schedule }), + ); + yield* service.triggerWebhook(requestFor(a)); + assert.equal((yield* Queue.take(launches)).title, "A"); + const pendingRequest = (task: typeof a, group: string) => { + const bodyText = JSON.stringify({ group }); + return requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }); + }; + for (let i = 0; i < 19; i++) { + const result = yield* service.triggerWebhook(pendingRequest(a, String(i))); + assert.equal(result._tag === "accepted" && result.outcome, "accepted"); + } + const overflow = pendingRequest(a, "overflow"); + const full = yield* service.triggerWebhook({ ...overflow, relayDeliveryId: "overflow" }); + assert.equal(full._tag === "rate_limited" && full.outcome, "queue_full"); + assert.equal((yield* service.triggerWebhook(requestFor(a)))._tag, "rate_limited"); + yield* service.triggerWebhook(pendingRequest(b, "B")); + let dispatchWorkers = 0; + const drained = yield* Deferred.make(); + let finishedWorkers = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "ScheduledTaskService.dispatchPendingWebhook") dispatchWorkers++; + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if ( + options.name === "ScheduledTaskService.dispatchPendingWebhook" && + ++finishedWorkers === 2 + ) + Deferred.doneUnsafe(drained, Effect.void); + }; + return span; + }, + }); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!.pipe(Effect.withTracer(tracer)); + assert.equal((yield* Queue.take(launches)).title, "B"); + for (let i = 0; i < 10; i++) + yield* workers.get("webhook-coalescing")!.pipe(Effect.withTracer(tracer)); + assert.equal(dispatchWorkers, 2); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE task_id = ${a.id}`) + .length, + 19, + ); + yield* Deferred.succeed(gate, undefined); + yield* Deferred.await(drained); + for (let i = 0; i < 19; i++) assert.equal((yield* Queue.take(launches)).title, "A"); + const retried = yield* service.triggerWebhook({ + ...overflow, + relayDeliveryId: "overflow", + }); + assert.equal(retried._tag === "accepted" && retried.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal((yield* Queue.take(launches)).title, "A"); + }), + { workers, gateForTask: (input) => (input.title === "A" ? gate : undefined) }, + ); + }), +); + +it.effect("guards bad-signature floods separately from valid run admission", () => + withService(({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { header: "signature", encoding: "hex", prefix: "", secret: "secret" }, + }, + }), + ); + const results = yield* Effect.forEach(Array.from({ length: 75 }), () => + service.triggerWebhook(requestFor(task)), + ); + assert.equal(results.filter((result) => result._tag === "rejected_signature").length, 60); + assert.equal(results.filter((result) => result._tag === "rate_limited").length, 15); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_deliveries`).length, 51); + }), + ), +); + +it.effect("start failures release both replay keys and relay claims for redelivery", () => + withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{body}}" } }, + }), + ); + const request = requestFor(task, { relayDeliveryId: "failed-start" }); + yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const retry = yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + assert.equal((yield* service.list()).tasks[0]!.lastRunStatus, "succeeded"); + }), + { failLaunches: { remaining: 1 } }, + ), +); + +it.effect("bounds oversized prompt floods without evicting run or filtered history", () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput({ prompt: "{{body}}" })); + const accepted = yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + assert.equal(accepted._tag, "accepted"); + // A sibling filter step owns these rows; suppression must preserve its bucket too. + yield* sql`INSERT INTO scheduled_task_webhook_deliveries + SELECT 'filtered-fixture', task_id, received_at, method, query, headers_json, body, + body_bytes, body_truncated, 'filtered', signature_verified, missing_fields_json, rendered_prompt, error + FROM scheduled_task_webhook_deliveries LIMIT 1`; + const bodyText = "x".repeat(200_000); + const request = requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }); + const outcomes = yield* Effect.forEach(Array.from({ length: 75 }), () => + service.triggerWebhook(request), + ); + assert.equal(outcomes.filter((result) => result._tag === "accepted").length, 60); + assert.equal(outcomes.filter((result) => result._tag === "rate_limited").length, 15); + const rows = yield* sql<{ + outcome: string; + }>`SELECT outcome FROM scheduled_task_webhook_deliveries`; + assert.equal(rows.filter((row) => row.outcome === "accepted").length, 1); + assert.equal(rows.filter((row) => row.outcome === "filtered").length, 1); + assert.equal(rows.length, 22); + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("61 seconds"); + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "accepted"); + yield* Queue.take(launches); + }), + ), +); + +it.effect("single delivery lookup hides new outcomes unless the client opts in", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}", coalesceKey: "{{body.group}}" }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + yield* service.triggerWebhook(requestFor(task)); + for (const value of [1, 2]) { + const bodyText = JSON.stringify({ group: "B", value }); + yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + } + const rows = (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries; + for (const row of rows.filter( + (row) => row.outcome === "duplicate" || row.outcome === "coalesced", + )) { + const input = { id: task.id, deliveryId: row.id }; + const hidden = yield* service.getWebhookDelivery(input).pipe(Effect.result); + assert.equal(hidden._tag, "Failure"); + assert.equal( + (yield* service.getWebhookDelivery({ ...input, acceptAnyOutcome: true })).delivery + .outcome, + row.outcome, + ); + } + assert.equal( + rows.filter((row) => row.outcome === "duplicate" || row.outcome === "coalesced").length, + 2, + ); + }), + ), +); + +it.effect("main retention keeps fifty runs and preserves filtered and dedupe buckets", () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + for (const outcome of ["filtered", "duplicate"]) { + yield* sql`WITH RECURSIVE n(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 20) + INSERT INTO scheduled_task_webhook_deliveries + SELECT ${outcome} || n.value, d.task_id, d.received_at, d.method, d.query, d.headers_json, d.body, + d.body_bytes, d.body_truncated, ${outcome}, d.signature_verified, d.missing_fields_json, d.rendered_prompt, d.error + FROM n CROSS JOIN (SELECT * FROM scheduled_task_webhook_deliveries WHERE outcome = 'accepted' LIMIT 1) d`; + } + for (let i = 0; i < 59; i++) { + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "accepted"); + yield* Queue.take(launches); + } + const counts = yield* sql<{ + outcome: string; + count: number; + }>`SELECT outcome, COUNT(*) AS count FROM scheduled_task_webhook_deliveries GROUP BY outcome ORDER BY outcome`; + assert.deepEqual(counts, [ + { outcome: "accepted", count: 50 }, + { outcome: "duplicate", count: 20 }, + { outcome: "filtered", count: 20 }, + ]); + }), + ), +); + +it.effect( + "a valid request refunds its own reservation without expiring an interleaved rejection", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { header: "signature", encoding: "hex", prefix: "", secret: "secret" }, + }, + }), + ); + const signature = NodeCrypto.createHmac("sha256", "secret") + .update(pullRequestBody) + .digest("hex"); + const valid = yield* service + .triggerWebhook(requestFor(task, { headers: { signature } })) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("30 seconds"); + assert.equal( + (yield* service.triggerWebhook(requestFor(task)))._tag, + "rejected_signature", + ); + yield* Deferred.succeed(release, undefined); + assert.equal((yield* Fiber.join(valid))._tag, "accepted"); + yield* Queue.take(launches); + // Only the valid reservation would have expired now; the rejection must still count. + yield* TestClock.adjust("31 seconds"); + for (let i = 0; i < 59; i++) + assert.equal( + (yield* service.triggerWebhook(requestFor(task)))._tag, + "rejected_signature", + ); + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "rate_limited"); + }), + { frameGate: { started, release } }, + ); + }), +); + +it.effect("keeps replay keys when dispatch succeeds but completion bookkeeping fails", () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}" }, + }, + }), + ); + yield* sql`CREATE TRIGGER fail_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* waitForSpan( + service.triggerWebhook(requestFor(task)), + "ScheduledTaskService.runWebhookDelivery", + ); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 1); + yield* sql`DROP TRIGGER fail_completion`; + const retry = yield* service.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + }), + ), +); + +it.effect("restart before a coalesced completion commits counts the delivery once", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const completedLaunches = new Set(); + const workers = new Map>(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{body}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + const launch = yield* Queue.take(launches); + assert.isTrue(completedLaunches.has(launch.commandId)); + assert.equal( + (yield* sql<{ state: string }>`SELECT state FROM scheduled_task_webhook_pending`)[0]! + .state, + "running", + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE state = 'completed'`) + .length, + 0, + ); + assert.equal((yield* service.list()).tasks[0]!.lastRunStatus, "running"); + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: recoveredLaunches }) => + Effect.gen(function* () { + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 0); + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(recoveredLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* restarted.list()).tasks[0]!.lastRunStatus, "succeeded"); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const retry = yield* restarted.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + }), + { sql, workers: restartedWorkers, completedLaunches }, + ); + }), + { workers, gate, completedLaunches }, + ); + }), +); + +it.effect.each([119_000, 120_000])( + "bounds coalesced prompt logs and checks trimmed length for %i body characters", + (length) => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: `{{body.absent}}${" ".repeat(10_000)}{{body.text}}`, + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const bodyText = JSON.stringify({ group: "B", text: "x".repeat(length) }); + const accepted = yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + assert.equal(accepted._tag, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const delivery = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + if (length === 119_000) { + const launch = yield* Queue.take(launches); + assert.isAbove(launch.initialMessage!.text.length, 120_000); + assert.equal(delivery.outcome, "accepted"); + const detail = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: delivery.id, + }); + assert.equal(detail.delivery.renderedPrompt!.length, 64 * 1024); + } else { + assert.equal(yield* Queue.size(launches), 0); + assert.equal(delivery.outcome, "dispatch_failed"); + assert.equal(delivery.error, "The filled-in prompt is too long."); + } + }), + { workers }, + ); + }, +); + +it.effect("retries coalesced completion bookkeeping without launching or counting twice", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}", coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* sql`CREATE TRIGGER fail_coalesced_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 1); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE state = 'completed'`) + .length, + 0, + ); + assert.equal((yield* service.list()).tasks[0]!.runCount, 0); + yield* sql`DROP TRIGGER fail_coalesced_completion`; + yield* drainPendingDispatch(workers); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const delivery = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + assert.equal(delivery.outcome, "accepted"); + assert.equal(delivery.error, null); + }), + { workers, completedLaunches: new Set() }, + ); +}); + +it.effect("propagates coalescing log failures with their task id and rolls back acceptance", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}", coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const request = requestFor(task, { relayDeliveryId: "coalescing-log-failure" }); + yield* sql`CREATE TRIGGER fail_coalescing_log BEFORE INSERT ON scheduled_task_webhook_deliveries + BEGIN SELECT RAISE(FAIL, 'injected coalescing log failure'); END`; + const error = yield* service.triggerWebhook(request).pipe(Effect.flip); + assert.equal(error.message, "Could not record webhook delivery."); + assert.equal(error.taskId, task.id); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + yield* sql`DROP TRIGGER fail_coalescing_log`; + assert.equal((yield* service.triggerWebhook(request))._tag, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + }), + { workers }, + ); +}); + +it.effect.each([1, 11])( + "keeps relay claims for %i coalesced updates after their window closes", + (count) => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const request = (value: number) => { + const bodyText = JSON.stringify({ group: "B", value }); + return requestFor(task, { + relayDeliveryId: `coalesced-relay-${value}`, + bodyText, + body: new TextEncoder().encode(bodyText), + }); + }; + yield* service.triggerWebhook(request(0)); + for (let i = 1; i <= count; i++) { + const result = yield* service.triggerWebhook(request(i)); + assert.equal(result._tag === "accepted" && result.outcome, "coalesced"); + } + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + count + 1, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, `>${count}<`); + const retry = yield* service.triggerWebhook(request(count)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); + }, +); + +it.effect("failed windows release every member's replay key and relay claim", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string) => { + const bodyText = JSON.stringify({ group: "same", value: id }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id }, + relayDeliveryId: id, + }); + }; + yield* service.triggerWebhook(request("A")); + for (let i = 0; i < 12; i++) yield* service.triggerWebhook(request(`B${i}`)); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 13, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const retry = yield* service.triggerWebhook(request("B11")); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "B11"); + assert.equal((yield* service.list()).tasks[0]!.runCount, 2); + }), + { workers, failLaunches: { remaining: 1 } }, + ); +}); + +it.effect( + "rejects a twenty-first persisted window and accepts its relay retry after draining", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.group}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{body.group}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (group: number) => { + const bodyText = JSON.stringify({ group }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + relayDeliveryId: `window-${group}`, + }); + }; + for (let i = 0; i < 20; i++) + assert.equal((yield* service.triggerWebhook(request(i)))._tag, "accepted"); + const overflow = yield* service.triggerWebhook(request(20)); + assert.equal(overflow._tag === "rate_limited" && overflow.outcome, "queue_full"); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 20); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 20); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 20, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + for (let i = 0; i < 20; i++) yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const retry = yield* service.triggerWebhook(request(20)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "20"); + assert.equal((yield* service.list()).tasks[0]!.runCount, 21); + }), + { workers }, + ); + }, +); + +it.effect( + "recovery finishes failed-window claim cleanup without counting the attempt twice", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string) => + requestFor(task, { headers: { "delivery-id": id }, relayDeliveryId: id }); + yield* service.triggerWebhook(request("A")); + yield* service.triggerWebhook(request("B")); + yield* sql`CREATE TRIGGER fail_claim_cleanup BEFORE DELETE ON scheduled_task_webhook_relay_deliveries + BEGIN SELECT RAISE(FAIL, 'cleanup unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal( + (yield* sql<{ state: string }>`SELECT state FROM scheduled_task_webhook_pending`)[0]! + .state, + "failed", + ); + yield* sql`DROP TRIGGER fail_claim_cleanup`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: restartedLaunches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(restartedLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, + 0, + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 0, + ); + const retry = yield* restarted.triggerWebhook(request("B")); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(restartedWorkers); + yield* Queue.take(restartedLaunches); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers, failLaunches: { remaining: 1 } }, + ); + }, +); + +it.effect("a full run budget still accepts replay hits and the latest open-window payload", () => { + const workers = new Map>(); + return withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, value: string, group?: string) => { + const bodyText = JSON.stringify({ value, ...(group === undefined ? {} : { group }) }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id }, + }); + }; + for (let i = 0; i < 59; i++) { + const result = yield* waitForSpan( + service.triggerWebhook(request(String(i), String(i))), + "ScheduledTaskService.runWebhookDelivery", + ); + assert.equal(result._tag === "accepted" && result.outcome, "accepted"); + yield* Queue.take(launches); + } + const opened = yield* service.triggerWebhook(request("B1", "old", "B")); + assert.equal(opened._tag === "accepted" && opened.outcome, "accepted"); + const duplicate = yield* service.triggerWebhook(request("0", "duplicate")); + assert.equal(duplicate._tag === "accepted" && duplicate.outcome, "duplicate"); + const update = yield* service.triggerWebhook(request("B2", "latest", "B")); + assert.equal(update._tag === "accepted" && update.outcome, "coalesced"); + assert.equal( + (yield* service.triggerWebhook(request("C", "new window", "C")))._tag, + "rate_limited", + ); + assert.equal( + (yield* service.triggerWebhook(request("D", "new immediate")))._tag, + "rate_limited", + ); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 1); + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "latest"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 60); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + }), + { workers }, + ); +}); + +it.effect("dispatches an overdue window exactly once after its deadline passed while stopped", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const task = yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + assert.equal(yield* Queue.size(launches), 0); + return task; + }), + { sql, workers: new Map() }, + ); + // The first service scope is closed before the deadline advances. + yield* TestClock.adjust("30 seconds"); + const restartedWorkers = new Map>(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + yield* Queue.take(launches); + for (let i = 0; i < 3; i++) yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + const secondRestartWorkers = new Map>(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + yield* secondRestartWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal( + (yield* service.list()).tasks.find((entry) => entry.id === task.id)!.runCount, + 1, + ); + }), + { sql, workers: secondRestartWorkers }, + ); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect("removes a window with a render defect and dispatches the task's next window", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.deep}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{headers.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, bodyText: string) => + requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id, group: id }, + relayDeliveryId: id, + }); + const bad = request( + "bad-render", + '{"deep":' + "[".repeat(20000) + "0" + "]".repeat(20000) + "}", + ); + assert.throws(() => renderWebhookPrompt(task.prompt, bad, "frame"), RangeError); + const accepted = yield* service.triggerWebhook(bad); + assert.equal(accepted._tag, "accepted"); + yield* service.triggerWebhook(request("good-render", '{"deep":"next window"}')); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "next window"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys WHERE delivery_id = 'delivery:relay:bad-render'`) + .length, + 0, + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = 'bad-render'`) + .length, + 0, + ); + const failed = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:relay:bad-render"), + acceptAnyOutcome: true, + })).delivery; + assert.equal(failed.outcome, "dispatch_failed"); + assert.equal(failed.error, "The pending webhook payload could not be prepared."); + yield* workers.get("webhook-coalescing")!; + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + }), + { workers }, + ); +}); + +it.effect.each([false, true])( + "counts an interrupted immediate run beside an unrelated running window (corrupt task: %s)", + (corrupt) => { + const workers = new Map>(); + const completedLaunches = new Set(); + return Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const window = yield* service.triggerWebhook( + requestFor(task, { headers: { group: "old" }, bodyText: '{"value":"old"}' }), + ); + yield* sql`CREATE TRIGGER fail_old_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + yield* sql`DROP TRIGGER fail_old_completion`; + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE state = 'running'`) + .length, + 1, + ); + const immediate = yield* service.triggerWebhook( + requestFor(task, { bodyText: '{"value":"immediate"}' }), + ); + yield* Queue.take(launches); + const [before] = yield* sql<{ + last_run_webhook_delivery_id: string; + model_selection_json: string; + run_count: number; + }>`SELECT last_run_webhook_delivery_id, model_selection_json, run_count FROM scheduled_tasks WHERE task_id = ${task.id}`; + assert.equal( + before!.last_run_webhook_delivery_id, + immediate._tag === "accepted" ? immediate.deliveryId : "", + ); + assert.notEqual( + before!.last_run_webhook_delivery_id, + window._tag === "accepted" ? window.deliveryId : "", + ); + assert.equal(before!.run_count, 0); + if (corrupt) + yield* sql`UPDATE scheduled_tasks SET model_selection_json = 'broken' WHERE task_id = ${task.id}`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: recoveredLaunches }) => + Effect.gen(function* () { + const [recovered] = yield* sql<{ + run_count: number; + last_run_status: string; + }>`SELECT run_count, last_run_status FROM scheduled_tasks WHERE task_id = ${task.id}`; + assert.equal(recovered!.run_count, 1); + assert.equal(recovered!.last_run_status, "failed"); + if (corrupt) + yield* sql`UPDATE scheduled_tasks SET model_selection_json = ${before!.model_selection_json} WHERE task_id = ${task.id}`; + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(recoveredLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, + 0, + ); + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + }), + { sql, workers: restartedWorkers, completedLaunches }, + ); + }), + { + workers, + completedLaunches, + gateForTask: (input) => + input.initialMessage?.text.includes(">immediate<") ? gate : undefined, + }, + ); + }); + }, +); + +it.effect("checks filters after the signature and max age, and runs only matching deliveries", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { + header: "x-hub-signature-256", + encoding: "hex", + prefix: "sha256=", + secret: "s3cret", + }, + maxDeliveryAgeMinutes: 30, + filters: [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.action", op: "in", value: ["opened", "reopened"] }, + ], + }, + }), + ); + const delivery = ( + event: string, + action: string, + options: { readonly signed?: boolean; readonly receivedAt?: string } = {}, + ) => { + const body = new TextEncoder().encode( + JSON.stringify({ action, pull_request: { url: "https://github.com/org/repo/pull/45" } }), + ); + const signature = `sha256=${NodeCrypto.createHmac("sha256", "s3cret").update(body).digest("hex")}`; + return requestFor(task, { + headers: { + "content-type": "application/json", + "x-github-event": event, + ...(options.signed === false ? {} : { "x-hub-signature-256": signature }), + }, + body, + bodyText: new TextDecoder().decode(body), + ...(options.receivedAt === undefined + ? {} + : { relayDeliveryId: "old", receivedAt: options.receivedAt }), + }); + }; + const now = yield* DateTime.now; + const old = DateTime.formatIso(DateTime.subtract(now, { minutes: 31 })); + + // A request failing a filter is still refused first for its signature or age. + const unsigned = yield* service.triggerWebhook(delivery("push", "opened", { signed: false })); + assert.equal(unsigned._tag, "rejected_signature"); + const tooOld = yield* service.triggerWebhook(delivery("push", "opened", { receivedAt: old })); + assert.equal(tooOld._tag, "expired"); + + const wrongEvent = yield* service.triggerWebhook(delivery("push", "opened")); + const wrongAction = yield* service.triggerWebhook(delivery("pull_request", "closed")); + assert.deepInclude(wrongEvent, { _tag: "accepted", outcome: "filtered" }); + assert.deepInclude(wrongAction, { _tag: "accepted", outcome: "filtered" }); + assert.equal(yield* Queue.size(launches), 0); + const [listed] = (yield* service.list()).tasks; + assert.equal(listed?.runCount, 0); + assert.equal(listed?.lastRunStatus, "never"); + + const matched = yield* service.triggerWebhook(delivery("pull_request", "opened")); + assert.deepInclude(matched, { _tag: "accepted", outcome: "accepted" }); + assert.include((yield* Queue.take(launches)).initialMessage?.text ?? "", "pull/45"); + + const deliveries = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + const filtered = deliveries + .filter((entry) => entry.outcome === "filtered") + .map((entry) => [entry.signatureVerified, entry.error]); + assert.sameDeepMembers(filtered, [ + [true, "Filter 1 (headers.x-github-event) did not match."], + [true, "Filter 2 (body.action) did not match."], + ]); + }), + ), +); + +const pullRequestFilterInput = () => + webhookTaskInput({ + schedule: { + type: "webhook", + filters: [{ path: "headers.x-github-event", op: "equals", value: "pull_request" }], + }, + }); +const githubEvent = (event: string) => ({ + headers: { "content-type": "application/json", "x-github-event": event }, +}); + +it.effect("filtered deliveries take no run slot and log at most 10 rows a minute", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* pullRequestFilterInput()); + const ignored = yield* Effect.forEach(Array.from({ length: 70 }), () => + service.triggerWebhook(requestFor(task, githubEvent("push"))), + ); + assert.isTrue( + ignored.every((result) => result._tag === "accepted" && result.outcome === "filtered"), + ); + const matched = yield* service.triggerWebhook(requestFor(task, githubEvent("pull_request"))); + assert.deepInclude(matched, { _tag: "accepted", outcome: "accepted" }); + yield* Queue.take(launches); + yield* Effect.forEach(Array.from({ length: 5 }), () => + service.triggerWebhook(requestFor(task, githubEvent("push"))), + ); + + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => delivery.outcome); + // 75 filtered requests in one window: only the first 10 are written. + assert.equal(outcomes.filter((outcome) => outcome === "filtered").length, 10); + assert.equal(outcomes.filter((outcome) => outcome === "accepted").length, 1); + + // Later windows log again, and retention keeps the filtered rows at 20. + for (const _ of [1, 2]) { + yield* TestClock.adjust("61 seconds"); + yield* Effect.forEach(Array.from({ length: 15 }), () => + service.triggerWebhook(requestFor(task, githubEvent("push"))), + ); + } + const later = (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries; + assert.equal(later.filter((delivery) => delivery.outcome === "filtered").length, 20); + assert.equal(later.filter((delivery) => delivery.outcome === "accepted").length, 1); + }), + ), +); + +it.effect("a filtered relay delivery releases its claim, so a redelivery is evaluated again", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* pullRequestFilterInput()); + const held = { relayDeliveryId: "relay-filtered" }; + const first = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("push") }), + ); + const again = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("push") }), + ); + assert.deepInclude(first, { _tag: "accepted", outcome: "filtered" }); + assert.deepInclude(again, { _tag: "accepted", outcome: "filtered" }); + const ran = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("pull_request") }), + ); + assert.deepInclude(ran, { _tag: "accepted", outcome: "accepted" }); + yield* Queue.take(launches); + const replayed = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("pull_request") }), + ); + assert.deepInclude(replayed, { _tag: "accepted", outcome: "duplicate" }); + }), + ), +); + +it.effect("a save without filters keeps the stored ones and an empty list clears them", () => + withService(({ service }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* pullRequestFilterInput()); + const filters = task.schedule.type === "webhook" ? task.schedule.filters : undefined; + assert.lengthOf(filters ?? [], 1); + + const kept = yield* service.upsert( + yield* webhookTaskInput({ title: "Renamed", schedule: { type: "webhook" } }), + ); + assert.deepEqual( + kept.task.schedule.type === "webhook" && kept.task.schedule.filters, + filters, + ); + + const cleared = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "webhook", filters: [] } }), + ); + assert.notProperty(cleared.task.schedule, "filters"); + }), + ), +); + +it.effect("leaves filtered rows out of the list for clients that only decode older outcomes", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* pullRequestFilterInput()); + yield* service.triggerWebhook(requestFor(task, githubEvent("push"))); + yield* service.triggerWebhook(requestFor(task, githubEvent("pull_request"))); + yield* Queue.take(launches); + const legacy = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + assert.deepEqual( + legacy.map((delivery) => delivery.outcome), + ["accepted"], + ); + const current = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + assert.sameMembers( + current.map((delivery) => delivery.outcome), + ["accepted", "filtered"], + ); + }), + ), +); + +it.effect("filters run before replay and coalescing without claiming ignored deliveries", () => { + const workers = new Map>(); + return withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + filters: [{ path: "body.run", op: "equals", value: "true" }], + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, run: boolean, value: string) => { + const bodyText = JSON.stringify({ run, group: "B", value }); + return requestFor(task, { + headers: { "delivery-id": id }, + bodyText, + body: new TextEncoder().encode(bodyText), + relayDeliveryId: id, + }); + }; + const ignored = yield* service.triggerWebhook(request("A", false, "ignored")); + assert.deepInclude(ignored, { _tag: "accepted", outcome: "filtered" }); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const filteredId = ignored._tag === "accepted" ? ignored.deliveryId : assert.fail(); + const oldClient = yield* service + .getWebhookDelivery({ id: task.id, deliveryId: filteredId }) + .pipe(Effect.result); + assert.equal(oldClient._tag, "Failure"); + const currentClient = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: filteredId, + acceptAnyOutcome: true, + }); + assert.equal(currentClient.delivery.outcome, "filtered"); + assert.deepInclude(yield* service.triggerWebhook(request("A", true, "first")), { + _tag: "accepted", + outcome: "accepted", + }); + assert.deepInclude( + yield* service.triggerWebhook({ + ...request("A", false, "ignored replay"), + relayDeliveryId: "ignored-replay", + }), + { _tag: "accepted", outcome: "filtered" }, + ); + assert.deepInclude(yield* service.triggerWebhook(request("B", false, "ignored update")), { + _tag: "accepted", + outcome: "filtered", + }); + assert.deepInclude(yield* service.triggerWebhook(request("B", true, "latest")), { + _tag: "accepted", + outcome: "coalesced", + }); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "latest"); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); +}); diff --git a/apps/server/src/scheduledTasks/webhookDedupe.ts b/apps/server/src/scheduledTasks/webhookDedupe.ts new file mode 100644 index 000000000000..21d40b25ac5e --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookDedupe.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Keys need a deterministic digest, not random bytes. +import * as NodeCrypto from "node:crypto"; +import { ScheduledTask, ScheduledTaskError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import { renderWebhookPrompt, type WebhookRequest } from "./webhookTemplate.ts"; + +function key(template: string | undefined, request: WebhookRequest): string { + if (template === undefined) return ""; + const rendered = renderWebhookPrompt(template, request, "", true); + return rendered.missing.length > 0 || rendered.prompt === "" + ? "" + : NodeCrypto.createHash("sha256").update(rendered.prompt).digest("hex"); +} + +export const releaseWebhookReplay = Effect.fn("ScheduledTaskService.releaseWebhookReplay")( + function* (deliveryId: string) { + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM scheduled_task_webhook_replay_keys WHERE delivery_id = ${deliveryId}`; + }, +); + +const hasReplayKey = Effect.fn(function* (taskId: string, replayKey: string, now: number) { + const sql = yield* SqlClient.SqlClient; + return ( + replayKey !== "" && + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys + WHERE task_id = ${taskId} AND replay_key = ${replayKey} AND expires_at > ${now}`).length > 0 + ); +}); +const claimReplayKey = Effect.fn(function* ( + taskId: string, + replayKey: string, + deliveryId: string, + now: number, +) { + if (replayKey === "") return; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM scheduled_task_webhook_replay_keys WHERE expires_at <= ${now}`; + yield* sql`INSERT INTO scheduled_task_webhook_replay_keys (task_id, replay_key, delivery_id, expires_at) + VALUES (${taskId}, ${replayKey}, ${deliveryId}, ${now + 7 * 24 * 60 * 60 * 1000})`; +}); + +export const acceptWebhookDedupe = Effect.fn("ScheduledTaskService.acceptWebhookDedupe")(function* ( + task: ScheduledTask, + request: WebhookRequest, + deliveryId: string, + now: number, + reserve: Effect.Effect, + reserveCoalescedLog: Effect.Effect, + checkCapacity: Effect.Effect, + replaceDeliveryRequest: (ownerId: string) => Effect.Effect, +) { + const sql = yield* SqlClient.SqlClient; + const dedupe = task.schedule.type === "webhook" ? task.schedule.dedupe : undefined; + if (dedupe === undefined) return "run" as const; + const replayKey = key(dedupe.replayKey, request); + if (yield* hasReplayKey(task.id, replayKey, now)) return "duplicate" as const; + const coalesceKey = key(dedupe.coalesceKey, request); + if (coalesceKey === "") return "run" as const; + const pending = yield* sql<{ + delivery_id: string; + }>`SELECT delivery_id FROM scheduled_task_webhook_pending + WHERE task_id = ${task.id} AND coalesce_key = ${coalesceKey} AND state = 'pending' AND due_at > ${now}`; + const logCoalesced = pending.length > 0 ? yield* reserveCoalescedLog : true; + if (pending.length === 0) { + yield* checkCapacity; + yield* reserve; + } + // Coalesced requests retain their validation slot, bounding writes without dropping accepted payloads. + yield* claimReplayKey(task.id, replayKey, pending[0]?.delivery_id ?? deliveryId, now); + const requestJson = yield* encodePendingRequest({ + method: request.method, + path: request.path, + query: request.query, + headers: request.headers, + bodyText: request.bodyText, + }); + if (pending.length > 0) { + yield* sql`UPDATE scheduled_task_webhook_pending SET request_json = ${requestJson}, + delivery_ids_json = json_insert(delivery_ids_json, '$[#]', ${deliveryId}) WHERE delivery_id = ${pending[0]!.delivery_id}`; + yield* replaceDeliveryRequest(pending[0]!.delivery_id); + return logCoalesced ? ("coalesced" as const) : ("coalesced_unlogged" as const); + } + // Expired windows are immutable, even if the scheduler has not dispatched them yet. + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' + WHERE task_id = ${task.id} AND coalesce_key = ${coalesceKey} AND state = 'pending' AND due_at <= ${now}`; + yield* sql`INSERT INTO scheduled_task_webhook_pending (delivery_id, task_id, coalesce_key, due_at, task_json, request_json, delivery_ids_json) + VALUES (${deliveryId}, ${task.id}, ${coalesceKey}, ${now + (dedupe.coalesceWindowSeconds ?? 120) * 1000}, ${yield* encodePendingTask(task)}, ${requestJson}, json_array(${deliveryId}))`; + return "pending" as const; +}); + +export const acceptImmediateWebhookDedupe = Effect.fn( + "ScheduledTaskService.acceptImmediateWebhookDedupe", +)(function* ( + task: ScheduledTask, + request: WebhookRequest, + deliveryId: string, + now: number, + reserve: Effect.Effect, +) { + const replayKey = key( + task.schedule.type === "webhook" ? task.schedule.dedupe?.replayKey : undefined, + request, + ); + if (yield* hasReplayKey(task.id, replayKey, now)) return "duplicate" as const; + yield* reserve; + yield* claimReplayKey(task.id, replayKey, deliveryId, now); + return "run" as const; +}); + +const PendingTaskJson = Schema.fromJsonString(ScheduledTask); +const encodePendingTask = Schema.encodeEffect(PendingTaskJson); +export const decodePendingTask = Schema.decodeUnknownEffect(PendingTaskJson); +const PendingRequestJson = Schema.fromJsonString( + Schema.Struct({ + method: Schema.String, + path: Schema.String, + query: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + bodyText: Schema.String, + }), +); +const encodePendingRequest = Schema.encodeEffect(PendingRequestJson); +export const decodePendingRequest = Schema.decodeUnknownEffect(PendingRequestJson); +export const dedupeError = (cause: unknown) => + new ScheduledTaskError({ message: "Could not persist webhook dedupe state.", cause }); diff --git a/apps/server/src/scheduledTasks/webhookFilter.test.ts b/apps/server/src/scheduledTasks/webhookFilter.test.ts new file mode 100644 index 000000000000..b8d9d2070832 --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookFilter.test.ts @@ -0,0 +1,91 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { ScheduledTaskWebhookFilter } from "@t3tools/contracts"; + +import { findFailedWebhookFilter, matchesWebhookFilter } from "./webhookFilter.ts"; +import type { WebhookRequest } from "./webhookTemplate.ts"; + +const request: WebhookRequest = { + method: "POST", + path: "/api/hooks/task-1", + query: "source=github", + headers: { "content-type": "application/json", "x-github-event": "pull_request" }, + bodyText: JSON.stringify({ + action: "labeled", + number: 45, + draft: false, + closed_at: null, + label: { name: "agent:review" }, + labels: ["bug"], + }), +}; + +const matches = (filter: ScheduledTaskWebhookFilter) => + findFailedWebhookFilter([filter], request) === null; + +describe("matchesWebhookFilter", () => { + it("compares scalars as strings", () => { + assert.isTrue(matches({ path: "body.action", op: "equals", value: "labeled" })); + assert.isTrue(matches({ path: "body.number", op: "equals", value: "45" })); + assert.isTrue(matches({ path: "body.draft", op: "equals", value: "false" })); + assert.isFalse(matches({ path: "body.action", op: "equals", value: "Labeled" })); + assert.isTrue(matches({ path: "body.action", op: "notEquals", value: "closed" })); + assert.isFalse(matches({ path: "body.action", op: "notEquals", value: "labeled" })); + assert.isTrue(matches({ path: "body.action", op: "in", value: ["opened", "labeled"] })); + assert.isTrue(matches({ path: "body.label.name", op: "startsWith", value: "agent:" })); + assert.isFalse(matches({ path: "body.label.name", op: "startsWith", value: "review" })); + }); + + it("reads headers case-insensitively and query values by name", () => { + assert.isTrue(matches({ path: "headers.X-GitHub-Event", op: "equals", value: "pull_request" })); + assert.isTrue(matches({ path: "query.source", op: "equals", value: "github" })); + }); + + it("lets objects and arrays satisfy only exists", () => { + for (const path of ["body.label", "body.labels"]) { + assert.isTrue(matches({ path, op: "exists" })); + assert.isFalse(matches({ path, op: "equals", value: "[object Object]" })); + assert.isFalse(matches({ path, op: "notEquals", value: "anything" })); + assert.isFalse(matches({ path, op: "in", value: ["bug"] })); + assert.isFalse(matches({ path, op: "startsWith", value: "" })); + } + }); + + it("fails every op on a missing or null path, including notEquals", () => { + for (const path of [ + "body.missing", + "body.label.name.deeper", + "headers.x-none", + "body.closed_at", + ]) { + assert.isFalse(matches({ path, op: "exists" })); + assert.isFalse(matches({ path, op: "notEquals", value: "x" })); + assert.isFalse(matchesWebhookFilter({ path, op: "startsWith", value: "" }, undefined)); + } + }); +}); + +describe("findFailedWebhookFilter", () => { + it("passes with no filters and reports the first failing filter", () => { + assert.isNull(findFailedWebhookFilter([], request)); + assert.deepEqual( + findFailedWebhookFilter( + [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.action", op: "equals", value: "opened" }, + { path: "body.missing", op: "exists" }, + ], + request, + ), + { index: 1, path: "body.action" }, + ); + }); + + it("does not need a JSON body for header filters", () => { + assert.isNull( + findFailedWebhookFilter([{ path: "headers.x-github-event", op: "exists" }], { + ...request, + bodyText: "not json", + }), + ); + }); +}); diff --git a/apps/server/src/scheduledTasks/webhookFilter.ts b/apps/server/src/scheduledTasks/webhookFilter.ts new file mode 100644 index 000000000000..7f46a15cc4fa --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookFilter.ts @@ -0,0 +1,49 @@ +import type { ScheduledTaskWebhookFilter } from "@t3tools/contracts"; + +import { webhookExpressionResolver, type WebhookRequest } from "./webhookTemplate.ts"; + +export interface FailedWebhookFilter { + readonly index: number; + readonly path: string; +} + +function scalarText(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return undefined; +} + +/** Placeholders render null as missing, so filters treat it the same way. */ +export function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { + if (filter.op === "exists") return value !== undefined && value !== null; + const text = scalarText(value); + if (text === undefined) return false; + switch (filter.op) { + case "equals": + return text === filter.value; + case "notEquals": + return text !== filter.value; + case "in": + return filter.value.includes(text); + case "startsWith": + return text.startsWith(filter.value); + } +} + +/** The first filter the request fails, or null when every filter matches. */ +export function findFailedWebhookFilter( + filters: ReadonlyArray, + request: WebhookRequest, +): FailedWebhookFilter | null { + if (filters.length === 0) return null; + const resolve = webhookExpressionResolver(request); + for (const [index, filter] of filters.entries()) { + if (!matchesWebhookFilter(filter, resolve(filter.path))) return { index, path: filter.path }; + } + return null; +} + +/** Filters are numbered from 1, in the order the settings form lists them. */ +export function describeFailedWebhookFilter(failed: FailedWebhookFilter): string { + return `Filter ${failed.index + 1} (${failed.path}) did not match.`; +} diff --git a/apps/server/src/scheduledTasks/webhookRoute.test.ts b/apps/server/src/scheduledTasks/webhookRoute.test.ts index 31261660c623..332e6778ba1b 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.test.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.test.ts @@ -222,6 +222,7 @@ describe("webhook route", () => { [{ _tag: "accepted", deliveryId, outcome: "accepted" }, 202, "accepted"], [{ _tag: "accepted", deliveryId, outcome: "duplicate" }, 202, "duplicate"], [{ _tag: "accepted", deliveryId, outcome: "prompt_too_long" }, 202, "prompt_too_long"], + [{ _tag: "accepted", deliveryId, outcome: "filtered" }, 202, "filtered"], [{ _tag: "not_found" }, 404, "not_found"], [{ _tag: "rejected_signature" }, 401, "rejected_signature"], [{ _tag: "disabled" }, 409, "disabled"], @@ -235,6 +236,7 @@ describe("webhook route", () => { const response = await handler(post("/api/hooks/id/tok", "{}")); expect(response.status).toBe(status); expect(response.headers.get("x-t3-hook-outcome")).toBe(outcome); + if (status === 429) expect(await response.json()).toEqual({ error: outcome }); } finally { await dispose(); } diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index ce67fa157d33..2566349d273b 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -118,7 +118,7 @@ export const layer = HttpApiBuilder.group( case "disabled": return json(409, { error: "hook_disabled" }, "disabled"); case "rate_limited": - return json(429, { error: "rate_limited" }, result.outcome); + return json(429, { error: result.outcome }, result.outcome); case "expired": return json(410, { error: "delivery_too_old" }, "expired"); case "error": diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index b129704f0abe..063d41a7ec46 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -112,16 +112,17 @@ function formatWebhookRequest(request: WebhookRequest): string { ].join("\n"); } -export function renderWebhookPrompt( - template: string, +/** + * Resolves a placeholder expression (`body.action`, `headers.x-github-event`) + * against the request. Webhook filters share it, so both read the same value. + */ +export function webhookExpressionResolver( request: WebhookRequest, - frameId: string, -): RenderedWebhookPrompt { - const missing: string[] = []; +): (expression: string) => unknown { let body: { parsed: unknown } | undefined; const parsedBody = () => (body ??= { parsed: parseBody(request) }).parsed; - const resolve = (expression: string): unknown => { + return (expression) => { const [root, ...segments] = expression.split("."); switch (root) { case "request": @@ -143,6 +144,16 @@ export function renderWebhookPrompt( return undefined; } }; +} + +export function renderWebhookPrompt( + template: string, + request: WebhookRequest, + frameId: string, + raw = false, +): RenderedWebhookPrompt { + const missing: string[] = []; + const resolve = webhookExpressionResolver(request); const tag = `webhook-data-${frameId}`; let framed = false; @@ -150,12 +161,13 @@ export function renderWebhookPrompt( const expression = rawExpression.trim(); const value = resolve(expression); const text = value === undefined || value === null ? undefined : stringify(value); - if (text === undefined) { + if (text === undefined || (raw && text === "")) { if (!missing.includes(expression)) missing.push(expression); return ""; } // An empty value carries no request data, so it needs no frame or note. if (text === "") return ""; + if (raw) return text; framed = true; return `<${tag}>${text.replace(MENTION, "$&\u2060")}`; }); diff --git a/apps/web/package.json b/apps/web/package.json index dbb889a1bb8e..17f40f695472 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,8 +65,8 @@ "katex": "^0.16.47", "lucide": "^0.564.0", "lucide-react": "^0.564.0", - "mermaid": "^11.17.2", "morphicons": "^1.7.1", + "pele": "^1.8.0", "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 75ccf03b8a69..01a343282cf0 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1420,7 +1420,7 @@ describe("ChatMarkdown math", () => { }); const [preview, after] = container.querySelectorAll("button"); preview!.focus(); - await act(() => userEvent.tab()); + await userEvent.tab(); expect(document.activeElement).toBe(after); if (formula.includes("notACommand")) { expect(page.getByRole("button", { name: /notACommand/ }).element()).toBe(preview); @@ -1439,7 +1439,7 @@ describe("ChatMarkdown math", () => { ); const { container } = screen; container.querySelector("button")!.focus(); - await act(() => userEvent.tab()); + await userEvent.tab(); expect(document.activeElement).toBe( container.querySelector('[aria-label="Scroll inline equation"]'), ); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index baf23d2b4cec..78468b0c8580 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1107,7 +1107,7 @@ function MarkdownMermaidCodeBlock({ } > - + {searching ? (