diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45b333973261..d29d970c75ff 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 21e9e7ba99c5..5d387de16b83 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,23 +286,67 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-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: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + 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@v6 + 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] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - name: Checkout uses: actions/checkout@v6 @@ -659,6 +703,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] @@ -674,7 +720,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: blacksmith-16vcpu-ubuntu-2404 + verify_runner: windows-11-arm platform: win target: nsis arch: arm64 @@ -690,6 +737,7 @@ jobs: relay_public_config, quality, test, + test_web, test_server, desktop_mac_arm64, desktop_mac_x64, @@ -698,7 +746,7 @@ jobs: desktop_win_x64, desktop_win_arm64, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && 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.result == 'success' && 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: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: 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 138528263fba..acdec8cf3d03 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -17499,10 +17499,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" @@ -17517,7 +17520,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), ), ), @@ -17670,12 +17673,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"); } @@ -17692,18 +17698,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 53c71b9eed71..f1795704844c 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"; @@ -304,6 +305,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 @@ -9684,6 +9688,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; @@ -9739,11 +9744,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({ @@ -9769,7 +9780,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.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 ? (