diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml new file mode 100644 index 0000000000..af5861e5f1 --- /dev/null +++ b/.github/workflows/_ci-clients.yml @@ -0,0 +1,148 @@ +name: CI / Clients +on: + workflow_call: + inputs: + web: + required: true + type: boolean + mobile: + required: true + type: boolean + lane: + required: true + type: string + outputs: + web_result: + value: ${{ jobs.results.outputs.web_result }} + mobile_result: + value: ${{ jobs.results.outputs.mobile_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + web: + name: Web + runs-on: ubuntu-latest + timeout-minutes: 15 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.web) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Web lint and format + run: just web-check + - name: Web build + run: just web-build + - name: Save pnpm store cache + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + mobile: + name: Mobile + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.mobile) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Compute Hermit cache key + id: hermit-bin-hash + run: | + hash="$(find ./bin ! -type d | sort | xargs openssl sha256 | openssl sha256 -r | cut -d' ' -f1)" + echo "hash=$hash" >> "$GITHUB_OUTPUT" + - name: Restore Hermit package cache + id: hermit-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ~/.cache/hermit/pkg + key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} + restore-keys: ${{ runner.os }}-hermit-cache- + - name: Prime Flutter SDK + run: flutter --version + - name: Save Hermit package cache + if: always() && steps.hermit-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + continue-on-error: true + with: + path: ~/.cache/hermit/pkg + key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} + - name: Restore pub cache + id: pub-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} + restore-keys: pub-${{ runner.os }}- + - name: Install dependencies + run: cd mobile && flutter pub get + - name: Save pub cache + if: always() && steps.pub-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + continue-on-error: true + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} + - name: Format check + run: cd mobile && dart format --output=none --set-exit-if-changed . + - name: Analyze + run: cd mobile && flutter analyze + - name: Test + run: cd mobile && flutter test + - name: Build Android debug APK + run: just mobile-build-android + + mobile-swift: + name: Mobile Swift + runs-on: macos-latest + timeout-minutes: 30 + if: inputs.lane == 'mobile-swift' && inputs.mobile + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Install Flutter dependencies + run: cd mobile && flutter pub get + - name: Build + run: swift build --package-path mobile/ios/BuzzPushKit + - name: Build release + run: swift build -c release --package-path mobile/ios/BuzzPushKit + - name: Test + run: swift test --package-path mobile/ios/BuzzPushKit + - name: Build complete unsigned iOS release + run: cd mobile && flutter build ios --release --no-codesign --no-pub + + results: + name: Results + if: ${{ always() }} + needs: [web, mobile, mobile-swift] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + web_result: ${{ needs.web.result }} + mobile_result: ${{ needs.mobile.result }} + steps: + - run: echo "Captured client job results" diff --git a/.github/workflows/_ci-desktop-macos.yml b/.github/workflows/_ci-desktop-macos.yml new file mode 100644 index 0000000000..728f17f324 --- /dev/null +++ b/.github/workflows/_ci-desktop-macos.yml @@ -0,0 +1,110 @@ +name: CI / Desktop macOS +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + outputs: + desktop_macos_result: + value: ${{ jobs.results.outputs.desktop_macos_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + desktop-build-macos: + name: Desktop Build (macOS) + runs-on: macos-latest + timeout-minutes: 45 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: desktop/src-tauri + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Create sidecar placeholders + run: | + TARGET=$(rustc -vV | sed -n 's|host: ||p') + mkdir -p desktop/src-tauri/binaries + touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" + touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" + touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" + touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" + touch "desktop/src-tauri/binaries/buzz-$TARGET" + # Mesh rev is derived from Cargo.lock so a dependency bump needs no + # lockstep edit here; the cache key tracks it automatically. + - name: Resolve mesh-llm rev + id: mesh_rev + run: | + set -euo pipefail + REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') + [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } + echo "rev=$REV" >> "$GITHUB_OUTPUT" + echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" + - name: Restore mesh llama build cache + id: llama_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ github.workspace }}/.cache/mesh-llama + key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} + - name: Build mesh llama native libraries + if: steps.llama_cache.outputs.cache-hit != 'true' + env: + MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} + run: | + set -euo pipefail + cargo fetch --manifest-path desktop/src-tauri/Cargo.toml + SHORT="$MESH_REV_SHORT" + MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1) + if [[ -z "$MESH_ROOT" ]]; then + echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch" + exit 1 + fi + export LLAMA_STAGE_BACKEND=metal + export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" + export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 + "$MESH_ROOT/scripts/prepare-llama.sh" pinned + "$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 + - name: Save mesh llama build cache + if: steps.llama_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ github.workspace }}/.cache/mesh-llama + key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} + - name: Build Tauri app + run: cd desktop && pnpm tauri build + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + MACOSX_DEPLOYMENT_TARGET: "10.15" + CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" + LLAMA_STAGE_BACKEND: metal + LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal + SKIPPY_LLAMA_AUTO_BUILD: "0" + + results: + name: Results + if: ${{ always() }} + needs: [desktop-build-macos] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + desktop_macos_result: ${{ needs.desktop-build-macos.result }} + steps: + - run: echo "Captured Desktop Build (macOS) result" diff --git a/.github/workflows/_ci-desktop.yml b/.github/workflows/_ci-desktop.yml new file mode 100644 index 0000000000..667b484141 --- /dev/null +++ b/.github/workflows/_ci-desktop.yml @@ -0,0 +1,244 @@ +name: CI / Desktop +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + outputs: + desktop_result: + value: ${{ jobs.results.outputs.desktop_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + desktop-core: + name: Desktop Core + runs-on: ubuntu-latest + timeout-minutes: 45 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: desktop/src-tauri + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install Tauri dependencies (Linux) + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 + sudo apt-get install -y --no-install-recommends \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + -o DPkg::Lock::Timeout=120 \ + build-essential \ + curl \ + file \ + libasound2-dev \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + patchelf \ + wget + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Desktop lint and format + run: just desktop-check + - name: Desktop unit tests + run: just desktop-test + - name: Desktop build + run: just desktop-build + - name: Desktop Tauri clippy + run: just desktop-tauri-clippy + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri check + run: just desktop-tauri-check + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri tests + run: just desktop-tauri-test + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri compiled-flag verification + run: just desktop-tauri-test-compiled-flags + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Upload desktop e2e artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-e2e-artifacts + path: | + desktop/playwright-report + desktop/test-results + if-no-files-found: ignore + - name: Save pnpm store cache + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + desktop-smoke-e2e: + name: Desktop Smoke E2E (${{ matrix.shard }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Get Playwright version + id: pw-version + run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" + - name: Restore Playwright browser cache + id: playwright-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd desktop && pnpm exec playwright install chromium + - name: Install Playwright system dependencies + run: cd desktop && pnpm exec playwright install-deps chromium + - name: Save Playwright browser cache + if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Desktop E2E build + run: pnpm -C desktop build:e2e + - name: Desktop smoke e2e + run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 + - name: Summarize flaky tests + if: ${{ !cancelled() }} + run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})" + working-directory: desktop + - name: Upload desktop smoke e2e artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-smoke-e2e-artifacts-${{ matrix.shard }} + path: | + desktop/playwright-report + desktop/playwright-report.json + desktop/test-results + if-no-files-found: ignore + retention-days: 7 + + desktop: + name: Desktop + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [desktop-core, desktop-smoke-e2e, desktop-windows-build] + if: always() && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + permissions: + contents: read + steps: + - name: Check desktop jobs + run: | + if [ "${{ needs.desktop-core.result }}" != "success" ]; then + echo "Desktop Core finished with: ${{ needs.desktop-core.result }}" + exit 1 + fi + if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then + echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}" + exit 1 + fi + if [ "${{ needs.desktop-windows-build.result }}" != "success" ]; then + echo "Desktop Windows Build finished with: ${{ needs.desktop-windows-build.result }}" + exit 1 + fi + echo "Desktop jobs passed" + + desktop-windows-build: + name: Desktop Windows Build + runs-on: windows-latest + timeout-minutes: 20 + if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.14.1 + package-manager-cache: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11.4.0 + - name: Install desktop dependencies + shell: bash + run: pnpm install --frozen-lockfile + - name: Build both protected-feature selections + shell: pwsh + run: | + Remove-Item Env:VITE_BUZZ_BESTIE -ErrorAction SilentlyContinue + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:VITE_BUZZ_BESTIE = "1" + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + results: + name: Results + if: ${{ always() }} + needs: [desktop] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + desktop_result: ${{ needs.desktop.result }} + steps: + - run: echo "Captured Desktop job results" diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml new file mode 100644 index 0000000000..bea9455b55 --- /dev/null +++ b/.github/workflows/_ci-relay.yml @@ -0,0 +1,613 @@ +name: CI / Relay and PostgreSQL +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + lane: + required: true + type: string + outputs: + desktop_e2e_relay_result: + value: ${{ jobs.results.outputs.desktop_e2e_relay_result }} + desktop_e2e_integration_result: + value: ${{ jobs.results.outputs.desktop_e2e_integration_result }} + backend_integration_result: + value: ${{ jobs.results.outputs.backend_integration_result }} + relay_e2e_result: + value: ${{ jobs.results.outputs.relay_e2e_result }} + postgres_tests_result: + value: ${{ jobs.results.outputs.postgres_tests_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + desktop-e2e-relay: + name: Desktop E2E Relay + runs-on: ubuntu-latest + # Cold builds can exceed 30 minutes when agent dependency changes invalidate + # the shared workspace cache before relay artifacts are produced. + timeout-minutes: 45 + if: inputs.lane == 'artifacts' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + permissions: + contents: read + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + # Reuse the relay binaries and backend test archive when none of their + # inputs changed (desktop-only PRs hit this every time). The key covers + # everything they embed, including migrations via sqlx migrate!. + - name: Restore relay artifacts cache + id: relay-artifacts-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + target/ci/buzz-relay + target/ci/git-credential-nostr + target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }} + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + with: + workspaces: | + . + desktop/src-tauri + save-if: ${{ github.event_name != 'pull_request' }} + # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; + # the bounded PR 5224 trial writes only to its isolated merge-ref scope. + - name: Set up sccache + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. + with: + version: v0.16.0 + - name: Install cargo-nextest + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Build relay artifacts + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + env: + RUSTC_WRAPPER: sccache + run: | + cargo build --profile ci -p buzz-relay -p git-credential-nostr + cargo nextest archive \ + --cargo-profile ci \ + -p buzz-db \ + -p buzz-relay \ + -p buzz-test-client \ + --lib \ + --test e2e_event_reminder \ + --archive-file target/ci/backend-integration-tests.tar.zst + postgres_package_args=() + while IFS= read -r package; do + postgres_package_args+=(-p "$package") + done < <(scripts/postgres-test-packages.sh) + if [[ "${#postgres_package_args[@]}" -eq 0 ]]; then + echo "no PostgreSQL test packages were discovered" >&2 + exit 1 + fi + cargo nextest archive \ + --cargo-profile ci \ + "${postgres_package_args[@]}" \ + --lib \ + --tests \ + --archive-file target/ci/postgres-tests.tar.zst + - name: Save relay artifacts cache + # PR-scoped exact-source entries cannot warm main or other PRs and churn + # the shared cache pool. sccache provides read-only PR reuse instead. + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + target/ci/buzz-relay + target/ci/git-credential-nostr + target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }} + - name: Upload relay artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-e2e-relay + path: | + target/ci/buzz-relay + target/ci/git-credential-nostr + target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst + if-no-files-found: error + retention-days: 1 + + postgres-tests: + name: PostgreSQL Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + if: inputs.lane == 'postgres' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: buzz + POSTGRES_PASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U buzz -d postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PG_BIN_DIR: /usr/bin + REDIS_URL: redis://localhost:6379 + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Tune disposable PostgreSQL + env: + PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + run: | + # Database-per-test cloning forces checkpoints. Durability is redundant + # for this disposable service and makes runtime depend on runner disk I/O. + psql --dbname postgres --set ON_ERROR_STOP=1 <<'SQL' + ALTER SYSTEM SET fsync = off; + ALTER SYSTEM SET full_page_writes = off; + ALTER SYSTEM SET synchronous_commit = off; + SELECT pg_reload_conf(); + SQL + psql --dbname postgres --tuples-only --no-align --command \ + "SELECT bool_and(setting = 'off') FROM pg_settings WHERE name IN ('fsync', 'full_page_writes', 'synchronous_commit')" \ + | grep --fixed-strings --line-regexp t + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Download backend test archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: PostgreSQL-backed tests + env: + BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres + PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + run: | + scripts/postgres-test-run.sh \ + --archive-file target/ci/postgres-tests.tar.zst + + desktop-e2e-integration-shard: + name: Desktop E2E Integration (${{ matrix.shard }}/2) + runs-on: ubuntu-latest + timeout-minutes: 20 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + strategy: + fail-fast: false + matrix: + shard: [1, 2] + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Start integration services + run: | + for attempt in 1 2 3; do + if docker compose up -d postgres redis minio minio-init; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "docker compose up failed after 3 attempts" >&2 + exit 1 + fi + echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 + sleep $((attempt * 5)) + done + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Get Playwright version + id: pw-version + run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" + - name: Restore Playwright browser cache + id: playwright-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd desktop && pnpm exec playwright install chromium + - name: Install Playwright system dependencies + run: cd desktop && pnpm exec playwright install-deps chromium + - name: Save Playwright browser cache + if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + - name: Desktop E2E build + run: pnpm -C desktop build:e2e + - name: Wait for integration services + run: | + wait_healthy() { + local service="$1" + local container="$2" + for attempt in $(seq 1 60); do + status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") + if [ "${status}" = "healthy" ]; then + echo "${service} is healthy" + return 0 + fi + sleep 2 + done + docker logs "${container}" || true + return 1 + } + wait_healthy "Postgres" "buzz-postgres" + wait_healthy "Redis" "buzz-redis" + wait_healthy "MinIO" "buzz-minio" + - name: Download relay binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The channel reconciler binds + # the deployment community ONCE at boot (outside its retry loop) and + # exits permanently on an unmapped host, so the 'localhost:3000' + # community MUST exist before the relay starts — the retry loop only + # handles late-seeded channels, not a late-seeded community. The relay + # migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the + # pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE + # below). lower(host) is the unique index → ON CONFLICT target. psql + # isn't on PATH in hermit → exec into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + # Use the already-running docker postgres for desired-state planning instead of + # downloading an embedded Postgres from Maven Central (transient-fetch flake source). + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_DB: buzz + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" + - name: Start relay + run: | + chmod +x ./target/ci/buzz-relay + nohup env \ + DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ + REDIS_URL=redis://localhost:6379 \ + RELAY_URL=ws://localhost:3000 \ + BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ + BUZZ_REQUIRE_AUTH_TOKEN=false \ + BUZZ_RECONCILE_CHANNELS=true \ + BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ + BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 \ + BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10000 \ + BUZZ_GIT_PROBE_WRITERS=8 \ + SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ + ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & + echo $! > /tmp/buzz-relay.pid + for attempt in $(seq 1 60); do + if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then + cat /tmp/buzz-relay.log + exit 1 + fi + status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) + if [ "${status_code}" = "200" ]; then + exit 0 + fi + sleep 1 + done + cat /tmp/buzz-relay.log + exit 1 + - name: Seed desktop e2e data + run: bash scripts/setup-desktop-test-data.sh + - name: Desktop relay-backed e2e + run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2 + - name: Summarize flaky tests + if: ${{ !cancelled() }} + run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)" + working-directory: desktop + - name: Upload desktop integration artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-e2e-integration-artifacts-${{ matrix.shard }} + path: | + desktop/playwright-report + desktop/playwright-report.json + desktop/test-results + /tmp/buzz-relay.log + if-no-files-found: ignore + retention-days: 7 + - name: Save pnpm store cache + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + desktop-e2e-integration: + name: Desktop E2E Integration + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [desktop-e2e-integration-shard] + if: always() && inputs.lane == 'required' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust) + permissions: + contents: read + steps: + - name: Check integration shards + run: | + if [ "${{ needs.desktop-e2e-integration-shard.result }}" != "success" ]; then + echo "Desktop E2E Integration shards finished with: ${{ needs.desktop-e2e-integration-shard.result }}" + exit 1 + fi + echo "Desktop E2E Integration shards passed" + + backend-integration: + name: Backend Integration (relay e2e) + runs-on: ubuntu-latest + timeout-minutes: 20 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Start integration services + run: | + for attempt in 1 2 3; do + if docker compose up -d postgres redis minio minio-init; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "docker compose up failed after 3 attempts" >&2 + exit 1 + fi + echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 + sleep $((attempt * 5)) + done + - name: Wait for integration services + run: | + wait_healthy() { + local service="$1" + local container="$2" + for attempt in $(seq 1 60); do + status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") + if [ "${status}" = "healthy" ]; then + echo "${service} is healthy" + return 0 + fi + sleep 2 + done + docker logs "${container}" || true + return 1 + } + wait_healthy "Postgres" "buzz-postgres" + wait_healthy "Redis" "buzz-redis" + wait_healthy "MinIO" "buzz-minio" + - name: Download relay artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The reminder scheduler binds + # the deployment community ONCE at boot and exits permanently on an + # unmapped host (no retry, unlike the channel reconciler), so the + # 'localhost:3000' community MUST exist before the relay starts — seeding + # after boot leaves the scheduler dead. The relay migrates at boot via + # BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply + # the schema here first (then drop AUTO_MIGRATE below). lower(host) is the + # unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec + # into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + # Use the already-running docker postgres for desired-state planning instead of + # downloading an embedded Postgres from Maven Central (transient-fetch flake source). + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_DB: buzz + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" + - name: Workflow message provenance unit tests + # The relay's workflow_sink suite is not selected by the infra-free + # unit job. Its ignored database cases run in the isolated PostgreSQL + # lane; keep the pure provenance cases covered here without duplication. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/workflow_sink/)' + - name: Start relay + run: | + chmod +x ./target/ci/buzz-relay + nohup env \ + DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ + REDIS_URL=redis://localhost:6379 \ + RELAY_URL=ws://localhost:3000 \ + BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ + BUZZ_REQUIRE_AUTH_TOKEN=false \ + BUZZ_RECONCILE_CHANNELS=true \ + BUZZ_GIT_PROBE_WRITERS=8 \ + SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ + ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & + echo $! > /tmp/buzz-relay.pid + for attempt in $(seq 1 60); do + if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then + cat /tmp/buzz-relay.log + exit 1 + fi + status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) + if [ "${status_code}" = "200" ]; then + exit 0 + fi + sleep 1 + done + cat /tmp/buzz-relay.log + exit 1 + - name: NIP-ER reminder e2e + # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path + # validation, author-only read filtering, and scheduler delivery against + # a live relay. The schema-drift / migration-version guarantee is owned + # by the buzz-db migration.rs unit tests, not this suite. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'binary(e2e_event_reminder)' \ + --run-ignored ignored-only + env: + RELAY_URL: ws://localhost:3000 + - name: Upload relay log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: backend-integration-relay-log + path: /tmp/buzz-relay.log + if-no-files-found: ignore + + relay-e2e: + name: Relay E2E + runs-on: ubuntu-latest + timeout-minutes: 20 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + save-if: ${{ github.event_name != 'pull_request' }} + # Reuse the relay + git-credential-nostr built by Desktop E2E Relay + # instead of compiling them a second time. + - name: Download relay binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: Start relay + run: | + chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr + ./scripts/start-relay-for-tests.sh --no-build + - name: Relay E2E tests + run: | + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr + - name: Media read-auth e2e + # Reads require kind:24242 `t=get` auth, so these binaries are the only + # coverage that a real relay rejects bare reads and honours host- and + # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so + # the lane never ran; select it here, where MinIO and the seeded + # 'localhost:3000' community already exist. + # --no-fail-fast: without it cargo stops after the first failing binary, + # so one broken case hides every later binary's result. + run: | + cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 + - name: Upload relay logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: relay-e2e-artifacts + path: /tmp/buzz-relay.log + if-no-files-found: ignore + + + results: + name: Results + if: ${{ always() }} + needs: [desktop-e2e-relay, postgres-tests, desktop-e2e-integration, backend-integration, relay-e2e] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + desktop_e2e_relay_result: ${{ needs.desktop-e2e-relay.result }} + desktop_e2e_integration_result: ${{ needs.desktop-e2e-integration.result }} + backend_integration_result: ${{ needs.backend-integration.result }} + relay_e2e_result: ${{ needs.relay-e2e.result }} + postgres_tests_result: ${{ needs.postgres-tests.result }} + steps: + - run: echo "Captured Relay and PostgreSQL job results" diff --git a/.github/workflows/_ci-rust.yml b/.github/workflows/_ci-rust.yml new file mode 100644 index 0000000000..f49cc96eb2 --- /dev/null +++ b/.github/workflows/_ci-rust.yml @@ -0,0 +1,219 @@ +name: CI / Rust +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + desktop_rust: + required: true + type: boolean + lane: + required: true + type: string + outputs: + rust_lint_result: + value: ${{ jobs.results.outputs.rust_lint_result }} + unit_tests_result: + value: ${{ jobs.results.outputs.unit_tests_result }} + windows_rust_result: + value: ${{ jobs.results.outputs.windows_rust_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + rust-lint: + name: Rust Lint + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust || inputs.desktop_rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + save-if: ${{ github.event_name != 'pull_request' }} + - name: Format check + run: just fmt-check + - name: Desktop Tauri format check + run: just desktop-tauri-fmt-check + - name: Clippy + run: just clippy + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + key: sherpa-cache-v1 + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Unit tests + run: just test-unit + + server-cross-compile: + name: Server Cross-Compile + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.lane == 'cross-compile' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + key: cross-${{ matrix.target }} + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install cross + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cross@0.2.5 + - name: Build server binaries + env: + TARGET: ${{ matrix.target }} + # PRs: compile + build-script gate only (no codegen/link). Main: full link gate. + CARGO_CMD: ${{ github.event_name == 'pull_request' && 'check' || 'build' }} + run: | + cross "$CARGO_CMD" --release --target "$TARGET" \ + -p buzz-relay \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p git-sign-nostr + + windows-rust: + name: Windows Rust (x86_64-pc-windows-msvc) + runs-on: windows-latest + # Windows runners are slow and this compiles the workspace + Tauri crate + # cold across four steps; budget generously. + timeout-minutes: 45 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust || inputs.desktop_rust) + permissions: + contents: read + env: + TARGET: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows + # runner — hermit, used by the Linux jobs, does not provide MSVC. The + # toolchain (1.95.0 + clippy via profile = default) comes from the + # repo-root rust-toolchain.toml, which the runner's preinstalled rustup + # honors on demand; the host triple already is x86_64-pc-windows-msvc. + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: | + . + desktop/src-tauri + key: windows-msvc + save-if: ${{ github.event_name != 'pull_request' }} + # Tauri validates externalBin at compile time, so the Tauri-crate steps + # below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's + # Windows naming (binaries/-.exe); empty files suffice for a + # type-check since nothing executes them. + - name: Create sidecar placeholders + shell: bash + run: | + mkdir -p desktop/src-tauri/binaries + for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" + done + - name: Clippy (workspace) + run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings + - name: Check (workspace) + run: cargo check --workspace --all-targets --target $env:TARGET + - name: Test (buzz-dev-mcp) + # The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests + # only gate if this crate is tested ON Windows. + # Serial: windows_resolver_tests mutate process-global env + # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. + run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 + - name: Test (buzz-agent auth coordinator) + # The auth coordinator single-flights on an OS advisory lock, which is + # LockFileEx on Windows; this integration suite drives real second + # processes on the same lock file, so it only exercises the Windows + # lock runtime if it runs ON Windows. Every other job compiles it but + # never executes it. Tests exercised on Windows: lock serialization + # (two coordinators race for the same key), cooldown sidecar sharing + # across processes, attempt-sidecar adoption (UserInitiated waiter + # adopts a predecessor's denial), and the in-process single-flight for + # same-key coalescing. Tests that are UNIX-ONLY and NOT executed here: + # crash-release (flock drop on SIGKILL, guarded by #[cfg(unix)]) and + # cross-process cache success/race (on-disk token handoff, also + # #[cfg(unix)]). + run: cargo test -p buzz-agent --target $env:TARGET --test databricks_auth_coordinator + # Smoke-test the new host-prereq contract: Git for Windows (which provides + # bash) is available on the runner, a shell command round-trips, and bash + # does NOT resolve from System32 (so WSL's launcher is never picked up). + # windows-latest runners have Git for Windows pre-installed; the unit tests + # above exercise the MCP resolver itself. This step verifies the host env. + - name: Smoke-test host Git Bash prereq (host env check) + shell: bash + run: | + set -euo pipefail + # Git for Windows ships bash.exe under its bin/ directory; confirm it + # resolves from the standard location the runtime resolver probes first. + bash_path=$(command -v bash 2>/dev/null || true) + [[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; } + echo "Resolved bash: $bash_path" + [[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; } + + # Run a basic pipeline through the resolved bash (same invocation the + # agent uses: bash -c '...'). + out=$(bash -c 'echo hello | tr a-z A-Z') + [[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; } + + # Confirm git itself works — agents run git commands frequently. + git --version + repo=$(mktemp -d) + cd "$repo" + git init -q + git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke + git log -1 --format=%s | grep -qx smoke + echo "Host bash resolved and functional; git commit round-trip passed" + - name: Check (Tauri crate) + run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target $env:TARGET + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Test (Tauri crate) + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + + + results: + name: Results + if: ${{ always() }} + needs: [rust-lint, unit-tests, server-cross-compile, windows-rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + rust_lint_result: ${{ needs.rust-lint.result }} + unit_tests_result: ${{ needs.unit-tests.result }} + windows_rust_result: ${{ needs.windows-rust.result }} + steps: + - run: echo "Captured Rust job results" diff --git a/.github/workflows/_ci-security.yml b/.github/workflows/_ci-security.yml new file mode 100644 index 0000000000..29855e8f78 --- /dev/null +++ b/.github/workflows/_ci-security.yml @@ -0,0 +1,42 @@ +name: CI / Security +on: + workflow_call: + inputs: + rust: + required: true + type: boolean + outputs: + security_result: + value: ${{ jobs.results.outputs.security_result }} + +env: + CARGO_TERM_COLOR: always + BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright + +jobs: + security: + name: Security + runs-on: ubuntu-latest + timeout-minutes: 20 + if: github.event_name == 'push' || inputs.rust + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Dependency policy + run: cargo-deny check + + + results: + name: Results + if: ${{ always() }} + needs: [security] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + security_result: ${{ needs.security.result }} + steps: + - run: echo "Captured Security job results" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24c34429c1..a663ef957c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,7 @@ jobs: - 'scripts/test-postgres-test-wrapper.sh' - 'deny.toml' - '.github/workflows/ci.yml' + - '.github/workflows/_ci-*.yml' - 'scripts/run-tests.sh' - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' @@ -81,6 +82,7 @@ jobs: - 'scripts/test-mobile-worktree-overrides.sh' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' + - '.github/workflows/_ci-*.yml' - name: Validate PostgreSQL test discovery if: github.event_name == 'push' || steps.filter.outputs.rust == 'true' run: | @@ -110,931 +112,11 @@ jobs: run: | scripts/test-rust-cache-contract.sh scripts/test-rust-cache-contract-regressions.sh + - name: CI required-context isolation contract + run: scripts/test-ci-required-context-isolation.sh - name: File size policy run: just file-size-check - rust-lint: - name: Rust Lint - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - save-if: ${{ github.event_name != 'pull_request' }} - - name: Format check - run: just fmt-check - - name: Desktop Tauri format check - run: just desktop-tauri-fmt-check - - name: Clippy - run: just clippy - - unit-tests: - name: Unit Tests - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - key: sherpa-cache-v1 - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install cargo-nextest - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Unit tests - run: just test-unit - - desktop-core: - name: Desktop Core - runs-on: ubuntu-latest - timeout-minutes: 45 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 2 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - workspaces: desktop/src-tauri - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install Tauri dependencies (Linux) - env: - DEBIAN_FRONTEND: noninteractive - run: | - sudo apt-get update \ - -o Acquire::Retries=3 \ - -o Acquire::http::Timeout=30 \ - -o Acquire::https::Timeout=30 - sudo apt-get install -y --no-install-recommends \ - -o Acquire::Retries=3 \ - -o Acquire::http::Timeout=30 \ - -o Acquire::https::Timeout=30 \ - -o DPkg::Lock::Timeout=120 \ - build-essential \ - curl \ - file \ - libasound2-dev \ - libayatana-appindicator3-dev \ - libgtk-3-dev \ - librsvg2-dev \ - libssl-dev \ - libwebkit2gtk-4.1-dev \ - libxdo-dev \ - patchelf \ - wget - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Desktop lint and format - run: just desktop-check - - name: Desktop unit tests - run: just desktop-test - - name: Desktop build - run: just desktop-build - - name: Desktop Tauri clippy - run: just desktop-tauri-clippy - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri check - run: just desktop-tauri-check - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri tests - run: just desktop-tauri-test - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri compiled-flag verification - run: just desktop-tauri-test-compiled-flags - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Upload desktop e2e artifacts - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-e2e-artifacts - path: | - desktop/playwright-report - desktop/test-results - if-no-files-found: ignore - - name: Save pnpm store cache - if: github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - - desktop-smoke-e2e: - name: Desktop Smoke E2E (${{ matrix.shard }}) - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Get Playwright version - id: pw-version - run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" - - name: Restore Playwright browser cache - id: playwright-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: cd desktop && pnpm exec playwright install chromium - - name: Install Playwright system dependencies - run: cd desktop && pnpm exec playwright install-deps chromium - - name: Save Playwright browser cache - if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Desktop E2E build - run: pnpm -C desktop build:e2e - - name: Desktop smoke e2e - run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 - - name: Summarize flaky tests - if: ${{ !cancelled() }} - run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})" - working-directory: desktop - - name: Upload desktop smoke e2e artifacts - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-smoke-e2e-artifacts-${{ matrix.shard }} - path: | - desktop/playwright-report - desktop/playwright-report.json - desktop/test-results - if-no-files-found: ignore - retention-days: 7 - - desktop: - name: Desktop - runs-on: ubuntu-latest - timeout-minutes: 5 - needs: [changes, desktop-core, desktop-smoke-e2e, desktop-windows-build] - if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') - permissions: - contents: read - steps: - - name: Check desktop jobs - run: | - if [ "${{ needs.desktop-core.result }}" != "success" ]; then - echo "Desktop Core finished with: ${{ needs.desktop-core.result }}" - exit 1 - fi - if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then - echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}" - exit 1 - fi - if [ "${{ needs.desktop-windows-build.result }}" != "success" ]; then - echo "Desktop Windows Build finished with: ${{ needs.desktop-windows-build.result }}" - exit 1 - fi - echo "Desktop jobs passed" - - desktop-e2e-relay: - name: Desktop E2E Relay - runs-on: ubuntu-latest - # Cold builds can exceed 30 minutes when agent dependency changes invalidate - # the shared workspace cache before relay artifacts are produced. - timeout-minutes: 45 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - env: - SCCACHE_GHA_ENABLED: "true" - SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - # Reuse the relay binaries and backend test archive when none of their - # inputs changed (desktop-only PRs hit this every time). The key covers - # everything they embed, including migrations via sqlx migrate!. - - name: Restore relay artifacts cache - id: relay-artifacts-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: | - target/ci/buzz-relay - target/ci/git-credential-nostr - target/ci/backend-integration-tests.tar.zst - target/ci/postgres-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml') }} - - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - with: - workspaces: | - . - desktop/src-tauri - save-if: ${{ github.event_name != 'pull_request' }} - # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; - # the bounded PR 5224 trial writes only to its isolated merge-ref scope. - - name: Set up sccache - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. - with: - version: v0.16.0 - - name: Install cargo-nextest - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Build relay artifacts - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - env: - RUSTC_WRAPPER: sccache - run: | - cargo build --profile ci -p buzz-relay -p git-credential-nostr - cargo nextest archive \ - --cargo-profile ci \ - -p buzz-db \ - -p buzz-relay \ - -p buzz-test-client \ - --lib \ - --test e2e_event_reminder \ - --archive-file target/ci/backend-integration-tests.tar.zst - postgres_package_args=() - while IFS= read -r package; do - postgres_package_args+=(-p "$package") - done < <(scripts/postgres-test-packages.sh) - if [[ "${#postgres_package_args[@]}" -eq 0 ]]; then - echo "no PostgreSQL test packages were discovered" >&2 - exit 1 - fi - cargo nextest archive \ - --cargo-profile ci \ - "${postgres_package_args[@]}" \ - --lib \ - --tests \ - --archive-file target/ci/postgres-tests.tar.zst - - name: Save relay artifacts cache - # PR-scoped exact-source entries cannot warm main or other PRs and churn - # the shared cache pool. sccache provides read-only PR reuse instead. - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: | - target/ci/buzz-relay - target/ci/git-credential-nostr - target/ci/backend-integration-tests.tar.zst - target/ci/postgres-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml') }} - - name: Upload relay artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-e2e-relay - path: | - target/ci/buzz-relay - target/ci/git-credential-nostr - target/ci/backend-integration-tests.tar.zst - target/ci/postgres-tests.tar.zst - if-no-files-found: error - retention-days: 1 - - postgres-tests: - name: PostgreSQL Tests - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: buzz - POSTGRES_PASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U buzz -d postgres" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - redis: - image: redis:7 - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - env: - PGHOST: localhost - PGPORT: "5432" - PGUSER: buzz - PG_BIN_DIR: /usr/bin - REDIS_URL: redis://localhost:6379 - PGSCHEMA_PLAN_HOST: localhost - PGSCHEMA_PLAN_PORT: "5432" - PGSCHEMA_PLAN_USER: buzz - PGSCHEMA_PLAN_PASSWORD: buzz_dev - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Tune disposable PostgreSQL - env: - PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} - run: | - # Database-per-test cloning forces checkpoints. Durability is redundant - # for this disposable service and makes runtime depend on runner disk I/O. - psql --dbname postgres --set ON_ERROR_STOP=1 <<'SQL' - ALTER SYSTEM SET fsync = off; - ALTER SYSTEM SET full_page_writes = off; - ALTER SYSTEM SET synchronous_commit = off; - SELECT pg_reload_conf(); - SQL - psql --dbname postgres --tuples-only --no-align --command \ - "SELECT bool_and(setting = 'off') FROM pg_settings WHERE name IN ('fsync', 'full_page_writes', 'synchronous_commit')" \ - | grep --fixed-strings --line-regexp t - - name: Install cargo-nextest - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Download backend test archive - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: PostgreSQL-backed tests - env: - BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres - PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} - run: | - scripts/postgres-test-run.sh \ - --archive-file target/ci/postgres-tests.tar.zst - - desktop-e2e-integration-shard: - name: Desktop E2E Integration (${{ matrix.shard }}/2) - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - strategy: - fail-fast: false - matrix: - shard: [1, 2] - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Start integration services - run: | - for attempt in 1 2 3; do - if docker compose up -d postgres redis minio minio-init; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "docker compose up failed after 3 attempts" >&2 - exit 1 - fi - echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 - sleep $((attempt * 5)) - done - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Get Playwright version - id: pw-version - run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" - - name: Restore Playwright browser cache - id: playwright-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: cd desktop && pnpm exec playwright install chromium - - name: Install Playwright system dependencies - run: cd desktop && pnpm exec playwright install-deps chromium - - name: Save Playwright browser cache - if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Desktop E2E build - run: pnpm -C desktop build:e2e - - name: Wait for integration services - run: | - wait_healthy() { - local service="$1" - local container="$2" - for attempt in $(seq 1 60); do - status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") - if [ "${status}" = "healthy" ]; then - echo "${service} is healthy" - return 0 - fi - sleep 2 - done - docker logs "${container}" || true - return 1 - } - wait_healthy "Postgres" "buzz-postgres" - wait_healthy "Redis" "buzz-redis" - wait_healthy "MinIO" "buzz-minio" - - name: Download relay binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: Apply schema and seed deployment community - # MT: the relay resolves each request's tenant from the communities host - # map and fails closed on an unmapped host. The channel reconciler binds - # the deployment community ONCE at boot (outside its retry loop) and - # exits permanently on an unmapped host, so the 'localhost:3000' - # community MUST exist before the relay starts — the retry loop only - # handles late-seeded channels, not a late-seeded community. The relay - # migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the - # pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE - # below). lower(host) is the unique index → ON CONFLICT target. psql - # isn't on PATH in hermit → exec into the buzz-postgres container. - env: - PGHOST: localhost - PGPORT: "5432" - PGUSER: buzz - PGPASSWORD: buzz_dev - PGDATABASE: buzz - # Use the already-running docker postgres for desired-state planning instead of - # downloading an embedded Postgres from Maven Central (transient-fetch flake source). - PGSCHEMA_PLAN_HOST: localhost - PGSCHEMA_PLAN_PORT: "5432" - PGSCHEMA_PLAN_DB: buzz - PGSCHEMA_PLAN_USER: buzz - PGSCHEMA_PLAN_PASSWORD: buzz_dev - run: | - ./bin/pgschema apply --file schema/schema.sql --auto-approve - docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql - docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -qtA -c " - INSERT INTO communities (id, host) - VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') - ON CONFLICT (lower(host)) DO NOTHING - ;" - - name: Start relay - run: | - chmod +x ./target/ci/buzz-relay - nohup env \ - DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ - REDIS_URL=redis://localhost:6379 \ - RELAY_URL=ws://localhost:3000 \ - BUZZ_BIND_ADDR=0.0.0.0:3000 \ - BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ - BUZZ_REQUIRE_AUTH_TOKEN=false \ - BUZZ_RECONCILE_CHANNELS=true \ - BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ - BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 \ - BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10000 \ - BUZZ_GIT_PROBE_WRITERS=8 \ - SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ - ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & - echo $! > /tmp/buzz-relay.pid - for attempt in $(seq 1 60); do - if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then - cat /tmp/buzz-relay.log - exit 1 - fi - status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) - if [ "${status_code}" = "200" ]; then - exit 0 - fi - sleep 1 - done - cat /tmp/buzz-relay.log - exit 1 - - name: Seed desktop e2e data - run: bash scripts/setup-desktop-test-data.sh - - name: Desktop relay-backed e2e - run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2 - - name: Summarize flaky tests - if: ${{ !cancelled() }} - run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)" - working-directory: desktop - - name: Upload desktop integration artifacts - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-e2e-integration-artifacts-${{ matrix.shard }} - path: | - desktop/playwright-report - desktop/playwright-report.json - desktop/test-results - /tmp/buzz-relay.log - if-no-files-found: ignore - retention-days: 7 - - name: Save pnpm store cache - if: github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - - desktop-e2e-integration: - name: Desktop E2E Integration - runs-on: ubuntu-latest - timeout-minutes: 5 - needs: [changes, desktop-e2e-integration-shard] - if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') - permissions: - contents: read - steps: - - name: Check integration shards - run: | - if [ "${{ needs.desktop-e2e-integration-shard.result }}" != "success" ]; then - echo "Desktop E2E Integration shards finished with: ${{ needs.desktop-e2e-integration-shard.result }}" - exit 1 - fi - echo "Desktop E2E Integration shards passed" - - backend-integration: - name: Backend Integration (relay e2e) - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Install cargo-nextest - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cargo-nextest@0.9.136 - - name: Start integration services - run: | - for attempt in 1 2 3; do - if docker compose up -d postgres redis minio minio-init; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "docker compose up failed after 3 attempts" >&2 - exit 1 - fi - echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 - sleep $((attempt * 5)) - done - - name: Wait for integration services - run: | - wait_healthy() { - local service="$1" - local container="$2" - for attempt in $(seq 1 60); do - status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") - if [ "${status}" = "healthy" ]; then - echo "${service} is healthy" - return 0 - fi - sleep 2 - done - docker logs "${container}" || true - return 1 - } - wait_healthy "Postgres" "buzz-postgres" - wait_healthy "Redis" "buzz-redis" - wait_healthy "MinIO" "buzz-minio" - - name: Download relay artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: Apply schema and seed deployment community - # MT: the relay resolves each request's tenant from the communities host - # map and fails closed on an unmapped host. The reminder scheduler binds - # the deployment community ONCE at boot and exits permanently on an - # unmapped host (no retry, unlike the channel reconciler), so the - # 'localhost:3000' community MUST exist before the relay starts — seeding - # after boot leaves the scheduler dead. The relay migrates at boot via - # BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply - # the schema here first (then drop AUTO_MIGRATE below). lower(host) is the - # unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec - # into the buzz-postgres container. - env: - PGHOST: localhost - PGPORT: "5432" - PGUSER: buzz - PGPASSWORD: buzz_dev - PGDATABASE: buzz - # Use the already-running docker postgres for desired-state planning instead of - # downloading an embedded Postgres from Maven Central (transient-fetch flake source). - PGSCHEMA_PLAN_HOST: localhost - PGSCHEMA_PLAN_PORT: "5432" - PGSCHEMA_PLAN_DB: buzz - PGSCHEMA_PLAN_USER: buzz - PGSCHEMA_PLAN_PASSWORD: buzz_dev - run: | - ./bin/pgschema apply --file schema/schema.sql --auto-approve - docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql - docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -qtA -c " - INSERT INTO communities (id, host) - VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') - ON CONFLICT (lower(host)) DO NOTHING - ;" - - name: Workflow message provenance unit tests - # The relay's workflow_sink suite is not selected by the infra-free - # unit job. Its ignored database cases run in the isolated PostgreSQL - # lane; keep the pure provenance cases covered here without duplication. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(/workflow_sink/)' - - name: Start relay - run: | - chmod +x ./target/ci/buzz-relay - nohup env \ - DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ - REDIS_URL=redis://localhost:6379 \ - RELAY_URL=ws://localhost:3000 \ - BUZZ_BIND_ADDR=0.0.0.0:3000 \ - BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ - BUZZ_REQUIRE_AUTH_TOKEN=false \ - BUZZ_RECONCILE_CHANNELS=true \ - BUZZ_GIT_PROBE_WRITERS=8 \ - SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ - ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & - echo $! > /tmp/buzz-relay.pid - for attempt in $(seq 1 60); do - if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then - cat /tmp/buzz-relay.log - exit 1 - fi - status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) - if [ "${status_code}" = "200" ]; then - exit 0 - fi - sleep 1 - done - cat /tmp/buzz-relay.log - exit 1 - - name: NIP-ER reminder e2e - # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path - # validation, author-only read filtering, and scheduler delivery against - # a live relay. The schema-drift / migration-version guarantee is owned - # by the buzz-db migration.rs unit tests, not this suite. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'binary(e2e_event_reminder)' \ - --run-ignored ignored-only - env: - RELAY_URL: ws://localhost:3000 - - name: Upload relay log - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: backend-integration-relay-log - path: /tmp/buzz-relay.log - if-no-files-found: ignore - - relay-e2e: - name: Relay E2E - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes, desktop-e2e-relay] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - save-if: ${{ github.event_name != 'pull_request' }} - # Reuse the relay + git-credential-nostr built by Desktop E2E Relay - # instead of compiling them a second time. - - name: Download relay binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: desktop-e2e-relay - path: target/ci - - name: Start relay - run: | - chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr - ./scripts/start-relay-for-tests.sh --no-build - - name: Relay E2E tests - run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture - cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture - cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture - env: - RELAY_URL: ws://localhost:3000 - GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr - - name: Media read-auth e2e - # Reads require kind:24242 `t=get` auth, so these binaries are the only - # coverage that a real relay rejects bare reads and honours host- and - # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so - # the lane never ran; select it here, where MinIO and the seeded - # 'localhost:3000' community already exist. - # --no-fail-fast: without it cargo stops after the first failing binary, - # so one broken case hides every later binary's result. - run: | - cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture - env: - RELAY_URL: ws://localhost:3000 - RELAY_HTTP_URL: http://localhost:3000 - - name: Upload relay logs - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: relay-e2e-artifacts - path: /tmp/buzz-relay.log - if-no-files-found: ignore - - web: - name: Web - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.web == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 2 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Restore pnpm store cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: pnpm-${{ runner.os }}- - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Web lint and format - run: just web-check - - name: Web build - run: just web-build - - name: Save pnpm store cache - if: github.event_name == 'push' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - - mobile: - name: Mobile - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.mobile == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 2 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Compute Hermit cache key - id: hermit-bin-hash - run: | - hash="$(find ./bin ! -type d | sort | xargs openssl sha256 | openssl sha256 -r | cut -d' ' -f1)" - echo "hash=$hash" >> "$GITHUB_OUTPUT" - - name: Restore Hermit package cache - id: hermit-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ~/.cache/hermit/pkg - key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} - restore-keys: ${{ runner.os }}-hermit-cache- - - name: Prime Flutter SDK - run: flutter --version - - name: Save Hermit package cache - if: always() && steps.hermit-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - continue-on-error: true - with: - path: ~/.cache/hermit/pkg - key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }} - - name: Restore pub cache - id: pub-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ~/.pub-cache - key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} - restore-keys: pub-${{ runner.os }}- - - name: Install dependencies - run: cd mobile && flutter pub get - - name: Save pub cache - if: always() && steps.pub-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - continue-on-error: true - with: - path: ~/.pub-cache - key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} - - name: Format check - run: cd mobile && dart format --output=none --set-exit-if-changed . - - name: Analyze - run: cd mobile && flutter analyze - - name: Test - run: cd mobile && flutter test - - name: Build Android debug APK - run: just mobile-build-android - - mobile-swift: - name: Mobile Swift - runs-on: macos-latest - timeout-minutes: 30 - needs: [changes] - if: needs.changes.outputs.mobile == 'true' - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Install Flutter dependencies - run: cd mobile && flutter pub get - - name: Build - run: swift build --package-path mobile/ios/BuzzPushKit - - name: Build release - run: swift build -c release --package-path mobile/ios/BuzzPushKit - - name: Test - run: swift test --package-path mobile/ios/BuzzPushKit - - name: Build complete unsigned iOS release - run: cd mobile && flutter build ios --release --no-codesign --no-pub - security: - name: Security - runs-on: ubuntu-latest - timeout-minutes: 20 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Dependency policy - run: cargo-deny check - dead-token-guard: name: Dead Token Reference Guard runs-on: ubuntu-latest @@ -1056,247 +138,272 @@ jobs: fi echo "No dead token references found." - server-cross-compile: - name: Server Cross-Compile - runs-on: ubuntu-latest - timeout-minutes: 30 + rust: + name: Rust + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' + uses: ./.github/workflows/_ci-rust.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: required + + rust-cross-compile-domain: + name: Rust Cross-Compile needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' - permissions: - contents: read - strategy: - fail-fast: false - matrix: - target: - - x86_64-unknown-linux-musl - - aarch64-unknown-linux-musl - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - key: cross-${{ matrix.target }} - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install cross - uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 - with: - tool: cross@0.2.5 - - name: Build server binaries - env: - TARGET: ${{ matrix.target }} - # PRs: compile + build-script gate only (no codegen/link). Main: full link gate. - CARGO_CMD: ${{ github.event_name == 'pull_request' && 'check' || 'build' }} - run: | - cross "$CARGO_CMD" --release --target "$TARGET" \ - -p buzz-relay \ - -p buzz-acp \ - -p buzz-agent \ - -p buzz-dev-mcp \ - -p git-credential-nostr \ - -p git-sign-nostr + uses: ./.github/workflows/_ci-rust.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: cross-compile - desktop-windows-build: - name: Desktop Windows Build - runs-on: windows-latest - timeout-minutes: 20 + desktop-domain: + name: Desktop Domain needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read + uses: ./.github/workflows/_ci-desktop.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + + relay-artifacts-domain: + name: Relay Artifact Producer + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-relay.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: artifacts + + postgres-domain: + name: PostgreSQL Domain + needs: [changes, relay-artifacts-domain] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-relay.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: postgres + + desktop-macos-domain: + name: Desktop macOS Domain + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-desktop-macos.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + + relay-domain: + name: Relay and PostgreSQL + needs: [changes, relay-artifacts-domain] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-relay.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + desktop: ${{ needs.changes.outputs.desktop == 'true' }} + desktop_rust: ${{ needs.changes.outputs.desktop-rust == 'true' }} + lane: required + + clients: + name: Clients + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.web == 'true' || needs.changes.outputs.mobile == 'true' + uses: ./.github/workflows/_ci-clients.yml + with: + web: ${{ needs.changes.outputs.web == 'true' }} + mobile: ${{ needs.changes.outputs.mobile == 'true' }} + lane: required + + mobile-swift-domain: + name: Mobile Swift Domain + needs: [changes] + if: needs.changes.outputs.mobile == 'true' + uses: ./.github/workflows/_ci-clients.yml + with: + web: ${{ needs.changes.outputs.web == 'true' }} + mobile: ${{ needs.changes.outputs.mobile == 'true' }} + lane: mobile-swift + + security-domain: + name: Security Domain + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/_ci-security.yml + with: + rust: ${{ needs.changes.outputs.rust == 'true' }} + + rust-lint: + name: Rust Lint + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + needs: [changes, rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 24.14.1 - package-manager-cache: false - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - with: - version: 11.4.0 - - name: Install desktop dependencies - shell: bash - run: pnpm install --frozen-lockfile - - name: Build both protected-feature selections - shell: pwsh - run: | - Remove-Item Env:VITE_BUZZ_BESTIE -ErrorAction SilentlyContinue - pnpm -C desktop build - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $env:VITE_BUZZ_BESTIE = "1" - pnpm -C desktop build - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Check Rust Lint result + env: + RESULT: ${{ needs.rust.outputs.rust_lint_result }} + run: test "$RESULT" = success + + unit-tests: + name: Unit Tests + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Unit Tests result + env: + RESULT: ${{ needs.rust.outputs.unit_tests_result }} + run: test "$RESULT" = success windows-rust: name: Windows Rust (x86_64-pc-windows-msvc) - runs-on: windows-latest - # Windows runners are slow and this compiles the workspace + Tauri crate - # cold across four steps; budget generously. - timeout-minutes: 45 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' - permissions: - contents: read - env: - TARGET: x86_64-pc-windows-msvc + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + needs: [changes, rust] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows - # runner — hermit, used by the Linux jobs, does not provide MSVC. The - # toolchain (1.95.0 + clippy via profile = default) comes from the - # repo-root rust-toolchain.toml, which the runner's preinstalled rustup - # honors on demand; the host triple already is x86_64-pc-windows-msvc. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - workspaces: | - . - desktop/src-tauri - key: windows-msvc - save-if: ${{ github.event_name != 'pull_request' }} - # Tauri validates externalBin at compile time, so the Tauri-crate steps - # below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's - # Windows naming (binaries/-.exe); empty files suffice for a - # type-check since nothing executes them. - - name: Create sidecar placeholders - shell: bash - run: | - mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do - touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" - done - - name: Clippy (workspace) - run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings - - name: Check (workspace) - run: cargo check --workspace --all-targets --target $env:TARGET - - name: Test (buzz-dev-mcp) - # The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests - # only gate if this crate is tested ON Windows. - # Serial: windows_resolver_tests mutate process-global env - # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. - run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 - - name: Test (buzz-agent auth coordinator) - # The auth coordinator single-flights on an OS advisory lock, which is - # LockFileEx on Windows; this integration suite drives real second - # processes on the same lock file, so it only exercises the Windows - # lock runtime if it runs ON Windows. Every other job compiles it but - # never executes it. Tests exercised on Windows: lock serialization - # (two coordinators race for the same key), cooldown sidecar sharing - # across processes, attempt-sidecar adoption (UserInitiated waiter - # adopts a predecessor's denial), and the in-process single-flight for - # same-key coalescing. Tests that are UNIX-ONLY and NOT executed here: - # crash-release (flock drop on SIGKILL, guarded by #[cfg(unix)]) and - # cross-process cache success/race (on-disk token handoff, also - # #[cfg(unix)]). - run: cargo test -p buzz-agent --target $env:TARGET --test databricks_auth_coordinator - # Smoke-test the new host-prereq contract: Git for Windows (which provides - # bash) is available on the runner, a shell command round-trips, and bash - # does NOT resolve from System32 (so WSL's launcher is never picked up). - # windows-latest runners have Git for Windows pre-installed; the unit tests - # above exercise the MCP resolver itself. This step verifies the host env. - - name: Smoke-test host Git Bash prereq (host env check) - shell: bash - run: | - set -euo pipefail - # Git for Windows ships bash.exe under its bin/ directory; confirm it - # resolves from the standard location the runtime resolver probes first. - bash_path=$(command -v bash 2>/dev/null || true) - [[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; } - echo "Resolved bash: $bash_path" - [[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; } - - # Run a basic pipeline through the resolved bash (same invocation the - # agent uses: bash -c '...'). - out=$(bash -c 'echo hello | tr a-z A-Z') - [[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; } - - # Confirm git itself works — agents run git commands frequently. - git --version - repo=$(mktemp -d) - cd "$repo" - git init -q - git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke - git log -1 --format=%s | grep -qx smoke - echo "Host bash resolved and functional; git commit round-trip passed" - - name: Check (Tauri crate) - run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target $env:TARGET + - name: Check Windows Rust result env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Test (Tauri crate) - run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + RESULT: ${{ needs.rust.outputs.windows_rust_result }} + run: test "$RESULT" = success + + desktop: + name: Desktop + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, desktop-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Desktop result env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" + RESULT: ${{ needs.desktop-domain.outputs.desktop_result }} + run: test "$RESULT" = success desktop-build-macos: name: Desktop Build (macOS) - runs-on: macos-latest - timeout-minutes: 45 - needs: [changes] - if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' - permissions: - contents: read + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, desktop-macos-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 - with: - workspaces: desktop/src-tauri - save-if: ${{ github.event_name != 'pull_request' }} - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Create sidecar placeholders - run: | - TARGET=$(rustc -vV | sed -n 's|host: ||p') - mkdir -p desktop/src-tauri/binaries - touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" - touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" - touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" - touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" - touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" - touch "desktop/src-tauri/binaries/buzz-$TARGET" - # Mesh rev is derived from Cargo.lock so a dependency bump needs no - # lockstep edit here; the cache key tracks it automatically. - - name: Resolve mesh-llm rev - id: mesh_rev - run: | - set -euo pipefail - REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') - [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } - echo "rev=$REV" >> "$GITHUB_OUTPUT" - echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" - - name: Restore mesh llama build cache - id: llama_cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ github.workspace }}/.cache/mesh-llama - key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} - - name: Build mesh llama native libraries - if: steps.llama_cache.outputs.cache-hit != 'true' + - name: Check Desktop Build (macOS) result env: - MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} - run: | - set -euo pipefail - cargo fetch --manifest-path desktop/src-tauri/Cargo.toml - SHORT="$MESH_REV_SHORT" - MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1) - if [[ -z "$MESH_ROOT" ]]; then - echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch" - exit 1 - fi - export LLAMA_STAGE_BACKEND=metal - export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" - export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 - "$MESH_ROOT/scripts/prepare-llama.sh" pinned - "$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 - - name: Save mesh llama build cache - if: steps.llama_cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ github.workspace }}/.cache/mesh-llama - key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} - - name: Build Tauri app - run: cd desktop && pnpm tauri build + RESULT: ${{ needs.desktop-macos-domain.outputs.desktop_macos_result }} + run: test "$RESULT" = success + + desktop-e2e-relay: + name: Desktop E2E Relay + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Desktop E2E Relay result env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - MACOSX_DEPLOYMENT_TARGET: "10.15" - CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" - LLAMA_STAGE_BACKEND: metal - LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal - SKIPPY_LLAMA_AUTO_BUILD: "0" + RESULT: ${{ needs.relay-artifacts-domain.outputs.desktop_e2e_relay_result }} + run: test "$RESULT" = success + + desktop-e2e-integration: + name: Desktop E2E Integration + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, relay-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Desktop E2E Integration result + env: + RESULT: ${{ needs.relay-domain.outputs.desktop_e2e_integration_result }} + run: test "$RESULT" = success + + backend-integration: + name: Backend Integration (relay e2e) + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, relay-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Backend Integration result + env: + RESULT: ${{ needs.relay-domain.outputs.backend_integration_result }} + run: test "$RESULT" = success + + postgres-tests: + name: PostgreSQL Tests + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, postgres-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check PostgreSQL Tests result + env: + RESULT: ${{ needs.postgres-domain.outputs.postgres_tests_result }} + run: test "$RESULT" = success + + relay-e2e: + name: Relay E2E + if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, relay-artifacts-domain, relay-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Relay E2E result + env: + RESULT: ${{ needs.relay-domain.outputs.relay_e2e_result }} + run: test "$RESULT" = success + + web: + name: Web + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.web == 'true') + needs: [changes, clients] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Web result + env: + RESULT: ${{ needs.clients.outputs.web_result }} + run: test "$RESULT" = success + + mobile: + name: Mobile + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.mobile == 'true') + needs: [changes, clients] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Mobile result + env: + RESULT: ${{ needs.clients.outputs.mobile_result }} + run: test "$RESULT" = success + + security: + name: Security + if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + needs: [changes, security-domain] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Check Security result + env: + RESULT: ${{ needs.security-domain.outputs.security_result }} + run: test "$RESULT" = success diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 6cee0b603d..7e90b07b6a 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -50,7 +50,7 @@ Open an owner-reviewed draft with `buzz agents draft-create --channel `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. - Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 4d20e30ee2..c73bd56031 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -4989,14 +4989,21 @@ pub(crate) async fn post_failure_notice( parent_event_id: parent_id, }) }); - let builder = - match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) { - Ok(b) => b, - Err(e) => { - tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); - return; - } - }; + let builder = match buzz_sdk::build_message( + channel_id, + content, + thread_ref.as_ref(), + &[], + false, + &[], + &[], + ) { + Ok(b) => b, + Err(e) => { + tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); + return; + } + }; let event = match builder.sign_with_keys(&rest.keys) { Ok(e) => e, Err(e) => { diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 88225469aa..70b5a8dcb2 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -669,6 +669,7 @@ async fn publish_setup_nudge( &[recipient_hex], // p-tag the verified effective asker false, &[], + &[], ) .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 37628669a1..8dabb00b12 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -69,7 +69,11 @@ pub(crate) const MAX_JWKS_KEYS: usize = 64; /// semantics** so prepared evidence built against an older contract is /// invalidated. Per-policy fields (issuer, class, bounds, …) are hashed /// separately and need no bump. -pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 1; +/// +/// v2 (PR #7221): `nostr_pubkey` absence now unconditionally rejects — the +/// per-issuer `require_attested_key` knob is removed and the NIP-FI v2 spec +/// requirement is always enforced. +pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 2; /// The transport-contract fingerprint folded into [`TransportContractId`]. /// **Bump on any change** to the client-attached parsing, attachment, @@ -347,7 +351,6 @@ pub struct IssuerPolicy { token_class: TokenClass, freshness: FreshnessClass, algorithms: Vec, - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, @@ -404,7 +407,6 @@ impl IssuerPolicy { token_class: TokenClass, freshness: FreshnessClass, algorithms: Vec, - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, @@ -467,7 +469,6 @@ impl IssuerPolicy { &token_class, freshness, &algorithms, - require_attested_key, skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, @@ -480,7 +481,6 @@ impl IssuerPolicy { token_class, freshness, algorithms, - require_attested_key, skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, @@ -514,11 +514,6 @@ impl IssuerPolicy { &self.algorithms } - /// Whether enrollment requires a `nostr_pubkey` claim equal to the actor. - pub const fn require_attested_key(&self) -> bool { - self.require_attested_key - } - /// The accepted clock skew, in seconds. pub const fn skew_seconds(&self) -> u64 { self.skew_seconds @@ -646,7 +641,6 @@ fn derive_assertion_policy_id( token_class: &TokenClass, freshness: FreshnessClass, algorithms: &[Algorithm], - require_attested_key: bool, skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, @@ -702,7 +696,6 @@ fn derive_assertion_policy_id( &mut hasher, algorithms.iter().map(|a| algorithm_tag(*a).as_bytes()), ); - hasher.update([u8::from(require_attested_key)]); hasher.update(skew_seconds.to_be_bytes()); hasher.update(maximum_assertion_age_seconds.to_be_bytes()); hasher.update(maximum_status_age_seconds.unwrap_or(0).to_be_bytes()); diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index 6d9f21a150..df75e70da0 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -896,7 +896,9 @@ async fn two_issuer_keys_and_generations_are_isolated() { fn sign(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { let now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "iat": now, "exp": now + 600}); let mut hdr = Header::new(Algorithm::ES256); hdr.kid = Some(kid.to_owned()); @@ -921,7 +923,6 @@ async fn two_issuer_keys_and_generations_are_isolated() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1112,7 +1113,9 @@ async fn shared_arc_source_verifier_observes_rotation() { fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { let now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "iat": now, "exp": now + 600}); let mut hdr = Header::new(Algorithm::ES256); hdr.kid = Some(kid.to_owned()); @@ -1171,7 +1174,6 @@ async fn shared_arc_source_verifier_observes_rotation() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1233,7 +1235,6 @@ fn jwks_contract_uri_canonicalization_convergence_and_divergence() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 30, 600, None, @@ -1489,7 +1490,9 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { let wall_now = chrono::Utc::now().timestamp(); + // nostr_pubkey is required unconditionally by spec v2. let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "iat": wall_now, "exp": wall_now + 600}); let mut hdr = Header::new(Algorithm::ES256); hdr.kid = Some(kid.to_owned()); @@ -1572,7 +1575,6 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, HARD_DEADLINE_SECS, None, diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs index 04b28a7b96..9b1d59b187 100644 --- a/crates/buzz-auth/src/nip_fi/startup/tests.rs +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -21,7 +21,6 @@ fn make_offline_policy(issuer: &str) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![JwtAlgorithm::ES256], - false, 0, 3600, None, @@ -37,7 +36,6 @@ fn make_status_policy(issuer: &str) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![JwtAlgorithm::ES256], - false, 0, 3600, Some(60), diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index aa3b5796a0..cf20b57a86 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -362,7 +362,7 @@ impl FederatedAssertionVerifier { enforce_claim_semantics(policy, &claims)?; let subject = claim_string(&claims, SUBJECT_CLAIM, MAX_SUBJECT_BYTES)?; - let asserted_key = parse_nostr_pubkey_claim(policy, &claims)?; + let asserted_key = parse_nostr_pubkey_claim(&claims)?; let now = Utc::now(); let deadlines = self.check_time_and_deadlines(policy, &key_set, &claims, now)?; @@ -720,20 +720,13 @@ fn enforce_claim_semantics( } /// Parse the fixed `nostr_pubkey` claim: lowercase hex of exactly one 32-byte -/// key. Bech32 and other aliases deny. Absence is permitted unless the policy -/// requires an attested key. +/// key. Bech32 and other aliases deny. Absence denies; the merged NIP-FI +/// spec v2 (PR #7214) requires the `nostr_pubkey` claim unconditionally. fn parse_nostr_pubkey_claim( - policy: &IssuerPolicy, claims: &Map, ) -> Result, VerifierError> { match claims.get(NOSTR_PUBKEY_CLAIM) { - None => { - if policy.require_attested_key() { - Err(VerifierError::ClaimRejected) - } else { - Ok(None) - } - } + None => Err(VerifierError::ClaimRejected), Some(value) => { let raw = value.as_str().ok_or(VerifierError::ClaimRejected)?; if raw.len() != 64 diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs index 990a3310e4..8f6c60c40a 100644 --- a/crates/buzz-auth/src/nip_fi/verifier/tests.rs +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -27,6 +27,9 @@ const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; const TEST_KID: &str = "test-key-1"; const ISSUER: &str = "https://issuer.example"; const AUDIENCE: &str = "https://relay.example"; +/// A canonical lowercase-hex nostr pubkey for tokens that are not testing +/// the nostr_pubkey claim specifically. Spec v2 requires the claim unconditionally. +const TEST_NOSTR_PUBKEY: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; /// A canonical JWKS contract for the default test issuer. Used wherever a /// `JwksSourceContract` is required but JWKS behavior is not under test. @@ -109,7 +112,6 @@ fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy TokenClass::AccessTokenAtJwt { subject_class }, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -131,7 +133,6 @@ fn dedicated_policy(issuer: &str) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -147,7 +148,6 @@ fn dedicated_policy_with_audiences(audiences: Vec) -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -163,7 +163,6 @@ fn dedicated_policy_with_algorithms(algorithms: Vec) -> IssuerPolicy TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, algorithms, - false, 60, 3600, None, @@ -198,6 +197,10 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val obj.entry("aud").or_insert(json!(AUDIENCE)); obj.entry("iat").or_insert(json!(now())); obj.entry("exp").or_insert(json!(now() + 600)); + // Spec v2 requires nostr_pubkey unconditionally; inject a canonical + // test pubkey so tokens that test other behaviours pass the claim check. + obj.entry(NOSTR_PUBKEY_CLAIM) + .or_insert(json!(TEST_NOSTR_PUBKEY)); } let mut header = Header::new(Algorithm::ES256); header.kid = Some(kid.to_owned()); @@ -206,6 +209,26 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val jsonwebtoken::encode(&header, &claims, &key).expect("sign") } +/// Mint a valid, signed token that deliberately omits `nostr_pubkey`. Used +/// only to exercise the unconditional missing-claim rejection path; the normal +/// `mint`/`mint_signed_by` helpers always inject the claim via `or_insert` so +/// they cannot produce an absent-claim token. +fn mint_no_pubkey(typ: Option<&str>, kid: &str, mut claims: Value) -> String { + { + let obj = claims.as_object_mut().expect("claims object"); + obj.entry("iss").or_insert(json!(ISSUER)); + obj.entry("aud").or_insert(json!(AUDIENCE)); + obj.entry("iat").or_insert(json!(now())); + obj.entry("exp").or_insert(json!(now() + 600)); + // Intentionally does NOT inject nostr_pubkey. + } + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(kid.to_owned()); + header.typ = typ.map(str::to_owned); + let key = EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign") +} + /// A resource-owner `at+jwt` claim set: valid subject-class marker plus client_id. fn resource_owner_claims() -> Value { json!({ "sub": "user-123", "client_id": "app-1", "sub_type": "user" }) @@ -240,7 +263,8 @@ fn valid_access_token_verifies() { let assertion = verifier.verify(&token).expect("verifies"); assert_eq!(assertion.identity().issuer(), ISSUER); assert_eq!(assertion.identity().subject(), "user-123"); - assert!(assertion.asserted_key().is_none()); + // Spec v2: nostr_pubkey is injected by mint() and unconditionally required. + assert!(assertion.asserted_key().is_some()); assert!(!assertion.authority_deadlines().is_empty()); assert_eq!(assertion.assertion_policy_id(), access_token_policy().id()); } @@ -698,22 +722,18 @@ fn uppercase_nostr_pubkey_denies() { } #[test] -fn missing_nostr_pubkey_denies_under_attested_key_policy() { - let policy = IssuerPolicy::new( - ISSUER.to_owned(), - vec![AUDIENCE.to_owned()], - TokenClass::DedicatedNipFi, - FreshnessClass::OfflineJwt, - vec![Algorithm::ES256], - true, // require attested key - 60, - 3600, - None, - test_jwks_contract(), - ) - .unwrap(); - let verifier = verifier_with(policy); - let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" })); +fn absent_nostr_pubkey_claim_denies() { + // `nostr_pubkey` absence must unconditionally reject — NIP-FI v2 dropped + // the per-issuer `require_attested_key` knob that previously made it + // optional. This is a direct falsifiable regression test: removing the + // `None => Err(VerifierError::ClaimRejected)` arm from + // `parse_nostr_pubkey_claim` must turn this test red. + let verifier = verifier_with(access_token_policy()); + let token = mint_no_pubkey( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "sub_type": "user" }), + ); assert_eq!( verifier.verify(&token).unwrap_err(), VerifierError::ClaimRejected @@ -1105,7 +1125,6 @@ fn current_status_policy() -> IssuerPolicy { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, Some(120), // maximum_status_age required for current-status @@ -1385,7 +1404,6 @@ fn assertion_policy_id_is_deterministic_and_semantic() { changed.token_class().clone(), FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 120, // different skew => different semantics 3600, None, @@ -1411,7 +1429,6 @@ fn offline_policy_rejects_inapplicable_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, Some(120), @@ -1430,7 +1447,6 @@ fn offline_policy_accepts_absent_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1449,7 +1465,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, None, @@ -1463,7 +1478,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { TokenClass::DedicatedNipFi, FreshnessClass::CurrentStatus, vec![Algorithm::ES256], - false, 60, 3600, Some(0), @@ -1579,7 +1593,6 @@ fn policy_with_contract(contract: crate::nip_fi::jwks::JwksSourceContract) -> Is TokenClass::DedicatedNipFi, FreshnessClass::OfflineJwt, vec![Algorithm::ES256], - false, 60, 3600, None, diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 8f8db4d289..ef9ce7c792 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -53,6 +53,17 @@ buzz channels topic --channel --topic "New topic" buzz reactions add --event --emoji "👍" buzz reactions get --event +# GIFs (requires relay to advertise buzz-gif / KLIPY) +buzz gifs search # trending GIFs +buzz gifs search --query "celebration" # search GIFs +buzz gifs share --slug # report selection to provider Recents +# Paste the `cdn_url` from a search result directly into messages send --content + +# Custom emoji in messages +# buzz messages send scans outgoing content for :shortcode: patterns and +# automatically attaches NIP-30 ["emoji", shortcode, url] tags from the +# workspace palette — identical to the desktop composer behavior. + # Users & Presence buzz users get # your own profile buzz users get --pubkey # single user @@ -130,6 +141,8 @@ stored rules in `validation_error` so an owner can remove and repair them. | `reactions` | `add` | React to a message | | | `remove` | Remove a reaction | | | `get` | List reactions | +| `gifs` | `search` | Search or browse trending GIFs (requires relay buzz-gif support) | +| | `share` | Report a selected GIF to the provider's Recents | | `dms` | `list` | List DM conversations | | | `open` | Open a DM (1–8 pubkeys) | | | `add-member` | Add member to DM group | diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 76d0e6fb95..75c87aa427 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -870,6 +870,47 @@ impl BuzzClient { .await } + /// POST a JSON body to a relay-relative path with NIP-98 authentication. + /// + /// Used by `buzz gifs search` and `buzz gifs share` to reach the relay's + /// KLIPY proxy endpoints. Returns the raw response body as a string (may + /// be empty for 204 No Content responses). + pub async fn post_json_authed( + &self, + path: &str, + body: &serde_json::Value, + ) -> Result { + let url = format!("{}{path}", self.relay_url); + let body_bytes = bytes::Bytes::from( + serde_json::to_vec(body) + .map_err(|e| CliError::Other(format!("request serialization failed: {e}")))?, + ); + self.with_retry_body(|| { + let body_bytes = body_bytes.clone(); + let url = url.clone(); + async move { + let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; + let resp = self + .with_auth_tag( + self.http + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body_bytes), + ) + .send() + .await?; + // 204 No Content: return empty string rather than failing on + // an empty body that cannot be parsed as JSON. + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(String::new()); + } + self.handle_response(resp).await + } + }) + .await + } + /// Submit a signed Nostr event via POST /events. /// /// For non-idempotent moderation command kinds (9040–9044), an ambiguous diff --git a/crates/buzz-cli/src/commands/emoji.rs b/crates/buzz-cli/src/commands/emoji.rs index d5dbff3f5c..32aef2f927 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -16,10 +16,22 @@ struct EmojiEntry { } /// Parse `["emoji", shortcode, url]` tags from one event into entries. +/// +/// Mirrors desktop `customEmojiFromTags` (`desktop/src/shared/api/customEmoji.ts`): +/// - Shortcode is canonicalized via `buzz_sdk::normalize_custom_emoji_shortcode` +/// (trim whitespace/colons, validate charset/length, lowercase). The relay +/// validates with the same fn at ingest but stores the original signed tag, +/// so a relay-valid stored key like `" :WAVE: "` must be normalized here or +/// it will never resolve against `scan_shortcodes` output. Malformed tags +/// (where normalization returns `Err`) are skipped. +/// - Entries with a missing or empty URL are skipped. +/// - Within one event the first occurrence of a normalized shortcode wins; +/// later duplicates are dropped. fn emoji_tags_of(event: &serde_json::Value) -> Vec { let Some(tags) = event.get("tags").and_then(|v| v.as_array()) else { return vec![]; }; + let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); for tag in tags { let Some(parts) = tag.as_array() else { @@ -28,16 +40,33 @@ fn emoji_tags_of(event: &serde_json::Value) -> Vec { if parts.first().and_then(|v| v.as_str()) != Some("emoji") { continue; } - let (Some(shortcode), Some(url)) = ( + let (Some(raw_shortcode), Some(url)) = ( parts.get(1).and_then(|v| v.as_str()), parts.get(2).and_then(|v| v.as_str()), ) else { continue; }; - out.push(EmojiEntry { - shortcode: shortcode.to_string(), - url: url.to_string(), - }); + // Skip entries with empty URL — they are malformed and would silently + // produce tags without a resolvable image. + if url.is_empty() { + continue; + } + // Canonicalize via the SDK normalizer: trim whitespace/colons, validate + // charset/length, lowercase. Relay validates with this same fn at + // ingest but stores the original tag — so a relay-valid key like + // " :WAVE: " must map to "wave" here or it will never resolve against + // scan_shortcodes output. Skip on Err (malformed tag). + let shortcode = match buzz_sdk::normalize_custom_emoji_shortcode(raw_shortcode) { + Ok(s) => s, + Err(_) => continue, + }; + // First occurrence within this event wins; later duplicates are dropped. + if seen.insert(shortcode.clone()) { + out.push(EmojiEntry { + shortcode, + url: url.to_string(), + }); + } } out } @@ -308,6 +337,94 @@ async fn cmd_import( publish_own_set(client, &final_set).await } +/// Scan `content` for `:shortcode:` patterns, mirroring the desktop's +/// `customEmojiTags.ts` algorithm exactly: +/// +/// - Pattern: `:([a-z0-9_-]+):` (case-insensitive; canonical lowercase emitted) +/// - One tag per distinct first-appearing shortcode +/// - Unknown shortcodes silently ignored +/// +/// Returns NIP-30 `["emoji", shortcode, url]` tag vectors for every +/// shortcode that resolves in the workspace palette. Returns an empty `Vec` +/// without a relay round-trip if no candidates appear in the content. +/// +/// Callers must pre-screen with `content.contains(':')` to skip this +/// function entirely for the common case of plain content. +pub async fn resolve_emoji_tags_for_content( + client: &BuzzClient, + content: &str, +) -> Result>, CliError> { + let candidates = scan_shortcodes(content); + if candidates.is_empty() { + return Ok(Vec::new()); + } + + // Fetch workspace palette (union of all members' kind:30030 sets). + let filter = serde_json::json!({ + "kinds": [buzz_sdk::kind::KIND_EMOJI_SET], + "#d": [CUSTOM_EMOJI_SET_D_TAG], + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?; + let palette = union_custom_emoji(&events); + let url_by_shortcode: std::collections::HashMap<&str, &str> = palette + .iter() + .map(|e| (e.shortcode.as_str(), e.url.as_str())) + .collect(); + + let tags: Vec> = candidates + .iter() + .filter_map(|sc| { + url_by_shortcode + .get(sc.as_str()) + .map(|url| vec!["emoji".to_string(), sc.clone(), url.to_string()]) + }) + .collect(); + + Ok(tags) +} + +/// Collect candidate shortcodes from `content` without a regex dependency. +/// +/// Implements `:([a-z0-9_-]+):` (applied case-insensitively with lowercase +/// normalization) using a hand-rolled single-pass scanner. Each distinct +/// shortcode appears exactly once in first-appearance order. +pub(crate) fn scan_shortcodes(content: &str) -> Vec { + let bytes = content.as_bytes(); + let len = bytes.len(); + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + let mut i = 0; + while i < len { + if bytes[i] != b':' { + i += 1; + continue; + } + // Found opening `:`. Scan forward for valid shortcode chars. + let start = i + 1; + let mut j = start; + while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] == b'-') + { + j += 1; + } + // Require at least one char and a closing `:`. + if j > start && j < len && bytes[j] == b':' { + // SAFETY: `content` is valid UTF-8 and the slice covers only ASCII. + let sc = content[start..j].to_lowercase(); + if seen.insert(sc.clone()) { + out.push(sc); + } + // Advance past the closing `:` so overlapping patterns like `:a::b:` + // are handled correctly (`:a:` consumed, next scan starts at `:`). + i = j + 1; + } else { + i += 1; + } + } + out +} + pub async fn dispatch(cmd: crate::EmojiCmd, client: &BuzzClient) -> Result<(), CliError> { use crate::EmojiCmd; match cmd { @@ -386,4 +503,308 @@ mod tests { assert_eq!(emojis[0].shortcode, "zort"); assert_eq!(emojis[0].url, "https://example.com/zort.png"); } + + // ── scan_shortcodes ────────────────────────────────────────────────────── + + // ── emoji_tags_of — normalization and dedup ────────────────────────────── + + #[test] + fn emoji_tags_of_normalizes_uppercase_shortcode_to_lowercase() { + // Relay stores the original case; scanner always lowercases; so a + // stored "WAVE" must map to "wave" for resolution to work. Also + // covers relay-valid keys with surrounding whitespace/colons. + let event = serde_json::json!({ + "created_at": 100, + "tags": [ + ["emoji", "WAVE", "https://example.com/wave.png"], + ["emoji", " :SweatBlob: ", "https://example.com/sweatblob.gif"], + ] + }); + let entries = emoji_tags_of(&event); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].shortcode, "wave"); + assert_eq!(entries[1].shortcode, "sweatblob"); + } + + #[test] + fn emoji_tags_of_skips_empty_url() { + // An entry with a missing or empty URL is malformed; it must be + // dropped so palette lookups never return an unusable image URL. + let event = serde_json::json!({ + "created_at": 100, + "tags": [ + ["emoji", "good", "https://example.com/good.png"], + ["emoji", "bad", ""], + ["emoji", "alsobad"], // missing url field entirely + ] + }); + let entries = emoji_tags_of(&event); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].shortcode, "good"); + } + + #[test] + fn emoji_tags_of_first_occurrence_wins_within_event() { + // Within one event the first occurrence of a (normalized) shortcode + // wins; a later duplicate tag for the same shortcode is dropped. + let event = serde_json::json!({ + "created_at": 100, + "tags": [ + ["emoji", "wave", "https://example.com/wave-first.png"], + ["emoji", "wave", "https://example.com/wave-second.png"], + ["emoji", "WAVE", "https://example.com/wave-uppercase.png"], + ] + }); + let entries = emoji_tags_of(&event); + assert_eq!( + entries.len(), + 1, + "all three normalize to 'wave'; only first kept" + ); + assert_eq!(entries[0].url, "https://example.com/wave-first.png"); + } + + #[test] + fn scan_finds_basic_shortcode() { + assert_eq!(scan_shortcodes(":wave:"), vec!["wave"]); + } + + #[test] + fn scan_finds_multiple_shortcodes_in_order() { + let result = scan_shortcodes(":wave: hello :party_parrot: world :tada:"); + assert_eq!(result, vec!["wave", "party_parrot", "tada"]); + } + + #[test] + fn scan_deduplicates_shortcodes() { + let result = scan_shortcodes(":wave: :wave: :wave:"); + assert_eq!(result, vec!["wave"]); + } + + #[test] + fn scan_normalizes_to_lowercase() { + let result = scan_shortcodes(":WAVE: :Wave:"); + assert_eq!(result, vec!["wave"]); + } + + #[test] + fn scan_ignores_invalid_chars_in_shortcode() { + // Spaces inside are not valid shortcode chars + let result = scan_shortcodes(":hello world:"); + assert!(result.is_empty()); + } + + #[test] + fn scan_empty_colons_not_matched() { + // "::" has zero chars between — must not match + assert!(scan_shortcodes("::").is_empty()); + } + + #[test] + fn scan_no_candidates_in_plain_content() { + assert!(scan_shortcodes("Hello world, no emoji here").is_empty()); + } + + #[test] + fn scan_handles_adjacent_shortcodes() { + // ":a::b:" — `:a:` consumed, then `:b:` starts at `:` + let result = scan_shortcodes(":a::b:"); + assert_eq!(result, vec!["a", "b"]); + } + + #[test] + fn scan_allows_hyphens_and_underscores() { + let result = scan_shortcodes(":party-parrot: :sweat_blob:"); + assert_eq!(result, vec!["party-parrot", "sweat_blob"]); + } + + // ── resolve_emoji_tags_for_content — send-path palette seam ───────────── + // + // These tests drive the production `resolve_emoji_tags_for_content` through + // a real `BuzzClient` against an axum fake `/query` server. They verify + // the full chain: scan → palette fetch → tag assembly. + + use crate::client::BuzzClient; + use axum::body::Bytes; + use axum::extract::State; + use axum::http::{HeaderMap, StatusCode}; + use axum::routing::post; + use axum::Router; + use nostr::Keys; + use std::net::SocketAddr; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + fn test_client(base_url: &str) -> BuzzClient { + BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + } + + /// Fake relay: serves a `/query` endpoint returning the given JSON body, + /// and records how many times it was called. + async fn fake_query_server(response_body: String) -> (String, Arc>) { + let call_count: Arc> = Arc::new(Mutex::new(0)); + type S = (Arc>, String); + let state: S = (call_count.clone(), response_body); + + let app = Router::new() + .route( + "/query", + post( + |State((count, body)): State, _headers: HeaderMap, _req: Bytes| async move { + *count.lock().unwrap() += 1; + (StatusCode::OK, [("content-type", "application/json")], body) + }, + ), + ) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), call_count) + } + + /// Palette response: two custom emoji — `wave` and `sweatblob`. + fn palette_response() -> String { + serde_json::json!([{ + "created_at": 100, + "tags": [ + ["d", "buzz:custom-emoji"], + ["emoji", "wave", "https://cdn.example.com/wave.png"], + ["emoji", "sweatblob", "https://cdn.example.com/sweatblob.gif"] + ] + }]) + .to_string() + } + + #[tokio::test] + async fn resolve_tags_known_shortcode_returns_correct_tag() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + let tags = resolve_emoji_tags_for_content(&client, "hello :wave:") + .await + .unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!( + tags[0], + vec!["emoji", "wave", "https://cdn.example.com/wave.png"] + ); + } + + #[tokio::test] + async fn resolve_tags_unknown_shortcode_is_filtered_out() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // :notarealemoji: is not in the palette — must produce no tags. + let tags = resolve_emoji_tags_for_content(&client, ":notarealemoji:") + .await + .unwrap(); + assert!(tags.is_empty()); + } + + #[tokio::test] + async fn resolve_tags_deduplicates_repeated_shortcode() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // `:wave:` appears twice; output must have exactly one tag for it. + let tags = resolve_emoji_tags_for_content(&client, ":wave: and :wave: again") + .await + .unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0][1], "wave"); + } + + #[tokio::test] + async fn resolve_tags_first_appearance_order() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // `:sweatblob:` before `:wave:` — tags must appear in that order. + let tags = resolve_emoji_tags_for_content(&client, ":sweatblob: :wave:") + .await + .unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0][1], "sweatblob"); + assert_eq!(tags[1][1], "wave"); + } + + #[tokio::test] + async fn resolve_tags_case_insensitive_match_emits_lowercase() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // `:WAVE:` must resolve to the lowercase `wave` tag. + let tags = resolve_emoji_tags_for_content(&client, ":WAVE:") + .await + .unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!( + tags[0][1], "wave", + "canonical tag shortcode must be lowercase" + ); + } + + #[tokio::test] + async fn resolve_tags_no_colon_content_skips_palette_query() { + let (url, call_count) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // Content with no `:` must return empty tags with ZERO relay queries. + let tags = resolve_emoji_tags_for_content(&client, "Hello world, no colons here") + .await + .unwrap(); + assert!(tags.is_empty()); + assert_eq!( + *call_count.lock().unwrap(), + 0, + "must not query the palette when content has no colon" + ); + } + + #[tokio::test] + async fn resolve_tags_unknown_only_content_still_queries_once() { + let (url, call_count) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // Content has `:` but the shortcode is not in the palette. + // One palette query should occur (candidates are non-empty), zero tags returned. + let tags = resolve_emoji_tags_for_content(&client, ":notreal:") + .await + .unwrap(); + assert!(tags.is_empty()); + assert_eq!( + *call_count.lock().unwrap(), + 1, + "must query palette once even when no shortcodes resolve" + ); + } + + #[tokio::test] + async fn resolve_tags_non_canonical_palette_key_resolves() { + // The relay validates shortcodes via normalize_custom_emoji_shortcode but + // stores the original signed tag. A relay-valid stored key like + // " :WAVE: " must resolve when content contains `:wave:`. + // This is the production-resolver regression that proves emoji_tags_of + // uses the SDK normalizer rather than a plain lowercase conversion. + let non_canonical_palette = serde_json::json!([{ + "created_at": 100, + "tags": [ + ["d", "buzz:custom-emoji"], + // Relay-valid but non-canonical: whitespace + surrounding colons + uppercase. + ["emoji", " :WAVE: ", "https://cdn.example.com/wave.png"], + ] + }]) + .to_string(); + let (url, _calls) = fake_query_server(non_canonical_palette).await; + let client = test_client(&url); + let tags = resolve_emoji_tags_for_content(&client, "hello :wave:") + .await + .unwrap(); + assert_eq!( + tags.len(), + 1, + "non-canonical palette key must resolve; got tags: {tags:?}" + ); + assert_eq!( + tags[0], + vec!["emoji", "wave", "https://cdn.example.com/wave.png"], + "resolved tag must use the canonical lowercase shortcode" + ); + } } diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs new file mode 100644 index 0000000000..0352d2cdb3 --- /dev/null +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -0,0 +1,1035 @@ +//! Agent GIF search and share via the relay's KLIPY proxy. +//! +//! `buzz gifs search` / `buzz gifs share` hit the relay-relative endpoints +//! advertised in the NIP-11 `gif` descriptor. No provider credential is held +//! by the agent — the relay proxies KLIPY and returns only allowlisted data. +//! +//! Sending a GIF is a normal message whose content contains the `cdn_url` +//! returned by search — no special send-path handling, no imeta. + +use crate::client::BuzzClient; +use crate::error::CliError; + +/// Gate: `supported_extensions` must contain this value. +const REQUIRED_EXTENSION: &str = "buzz-gif"; +/// Gate: `gif.provider` must be this value. +const REQUIRED_PROVIDER: &str = "klipy"; + +// --------------------------------------------------------------------------- +// Safe relay-relative path validation +// --------------------------------------------------------------------------- + +/// Validate that a NIP-11-advertised path is a safe relay-relative path. +/// +/// Mirrors the desktop `safeRelayPath` contract in +/// `desktop/src/features/gifs/api.ts:64-74` exactly: +/// - must be a string that starts with `/` +/// - must NOT start with `//` (avoids authority shift) +/// - must NOT contain `\` (Windows-style traversal) +/// - must NOT contain `%` (URL-encoded bypass attempts) +/// - must NOT contain `?` (query injection) +/// - must NOT contain `#` (fragment injection) +/// - no path segment may be `.` or `..` (traversal) +pub(crate) fn safe_relay_path(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path.contains('\\') + && !path.contains('%') + && !path.contains('?') + && !path.contains('#') + && !path.split('/').any(|seg| seg == "." || seg == "..") +} + +// --------------------------------------------------------------------------- +// Customer ID derivation +// --------------------------------------------------------------------------- + +/// Derive a stable, relay-scoped anonymous `customer_id` from secret key material. +/// +/// KLIPY requires a per-installation identifier that is stable and anonymous. +/// Using SHA-256 of the *public* key would be stable but NOT anonymous — the +/// input is public, so the ID is computable by any observer, and the same value +/// would appear across all relays (cross-relay linkability). +/// +/// Instead, we domain-separate with the relay URL and sign with the *secret* key: +/// `SHA-256(secret_key_bytes || '\0' || relay_url_bytes)` +/// This is: +/// - **stable**: deterministic given the same keypair + relay. +/// - **relay-scoped**: different relay → different ID, no cross-relay correlation. +/// - **not computable from public data**: requires secret key material. +/// - **stateless**: no file I/O, no storage. +/// +/// The first 16 bytes (32 hex chars) give 128 bits of uniqueness, ample for +/// KLIPY's per-installation needs. +fn customer_id(secret_key_bytes: &[u8], relay_url: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(secret_key_bytes); + hasher.update(b"\0"); // domain separator + hasher.update(relay_url.as_bytes()); + let hash = hasher.finalize(); + hex::encode(&hash[..16]) // 16 bytes → 32 hex chars +} + +// --------------------------------------------------------------------------- +// Locale +// --------------------------------------------------------------------------- + +/// Locale to send to KLIPY. Reads `LANG` first, falls back to `en_US`. +fn default_locale() -> String { + std::env::var("LANG") + .ok() + .and_then(|l| { + let code: String = l.split('.').next().unwrap_or("").chars().take(5).collect(); + if code.len() >= 2 { + Some(code) + } else { + None + } + }) + .unwrap_or_else(|| "en_US".to_string()) +} + +// --------------------------------------------------------------------------- +// NIP-11 descriptor resolution +// --------------------------------------------------------------------------- + +/// Parse the `gif` descriptor from a decoded NIP-11 JSON document. +/// +/// Shared between `resolve_gif_descriptor` (which fetches the document) and +/// tests (which inject a synthetic document directly). Separating the pure +/// parse logic from the I/O call makes the descriptor gates directly testable +/// without a fake HTTP server. +pub(crate) fn parse_gif_descriptor_info( + info: &serde_json::Value, +) -> Result<(String, String), CliError> { + // Gate 1: `supported_extensions` must contain `"buzz-gif"`. + let extensions = info + .get("supported_extensions") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>()) + .unwrap_or_default(); + if !extensions.contains(&REQUIRED_EXTENSION) { + return Err(CliError::Other(format!( + "this relay does not support GIF search (missing \"{REQUIRED_EXTENSION}\" in supported_extensions)" + ))); + } + + // Gate 2: `gif.provider` must be `"klipy"`. + let gif = info.get("gif").ok_or_else(|| { + CliError::Other("relay advertises buzz-gif but has no \"gif\" descriptor".to_string()) + })?; + let provider = gif.get("provider").and_then(|v| v.as_str()).unwrap_or(""); + if provider != REQUIRED_PROVIDER { + return Err(CliError::Other(format!( + "unsupported GIF provider \"{provider}\" (only \"{REQUIRED_PROVIDER}\" is supported)" + ))); + } + + // Gate 3: both paths must be present and pass the safe-path check. + let search = gif + .get("search") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let share = gif + .get("share") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if !safe_relay_path(&search) { + return Err(CliError::Other(format!( + "relay gif descriptor search path is not a safe relay-relative path: {search:?}" + ))); + } + if !safe_relay_path(&share) { + return Err(CliError::Other(format!( + "relay gif descriptor share path is not a safe relay-relative path: {share:?}" + ))); + } + + Ok((search, share)) +} + +/// Resolve the relay's `gif` descriptor from its NIP-11 document. +/// +/// Returns `(search_path, share_path)` as validated relay-relative strings. +/// Fails with a clear `CliError` if: +/// - the relay does not advertise `buzz-gif` +/// - the provider is not `klipy` +/// - either path is absent or fails the `safe_relay_path` check +pub(crate) async fn resolve_gif_descriptor( + client: &BuzzClient, +) -> Result<(String, String), CliError> { + let raw = client.get_public("/info").await?; + let info: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid NIP-11 response: {e}")))?; + parse_gif_descriptor_info(&info) +} + +// --------------------------------------------------------------------------- +// Response normalization +// --------------------------------------------------------------------------- + +/// Normalized GIF entry emitted by `buzz gifs search`. +/// +/// `cdn_url` is the URL to embed directly in a `buzz messages send --content` +/// argument. Agents paste it as-is; no further processing is needed. +#[derive(serde::Serialize)] +pub(crate) struct GifEntry { + pub cdn_url: String, + pub slug: String, + pub title: String, + pub width: u64, + pub height: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview_url: Option, +} + +/// Normalize the KLIPY `data.data` array to typed `GifEntry` records. +/// +/// Mirrors `normalizeKlipyGifs` in `desktop/src/features/gifs/api.ts`: +/// - skips items that are not `type: "gif"`, lack a `slug`, or have no +/// complete sendable asset +/// - asset fallback order for `cdn_url` (original): `md.gif`, `hd.gif`, +/// `sm.gif`, `xs.gif` +/// - asset fallback order for `preview_url`: `sm.webp`, `sm.gif`, +/// `xs.webp`, `xs.gif`, `md.webp` +/// - an item with no usable original or preview is silently skipped +/// - malformed envelopes (wrong outer shape) return an error rather +/// than a silent empty array +pub(crate) fn normalize_gif_response(raw: &str) -> Result, CliError> { + let parsed: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("invalid GIF search response: {e}")))?; + + // The relay wraps in {"result": true, "data": {"data": [...]}}. + // A missing outer envelope is an error, not a silent empty list. + let items = parsed + .get("data") + .and_then(|d| d.get("data")) + .and_then(|v| v.as_array()) + .ok_or_else(|| { + CliError::Other( + "GIF search response missing expected envelope data.data array".to_string(), + ) + })?; + + let mut out = Vec::new(); + for item in items { + // Only process type:"gif" items with a slug. + if item.get("type").and_then(|v| v.as_str()) != Some("gif") { + continue; + } + let slug = match item.get("slug").and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() => s.to_string(), + _ => continue, + }; + let title = item + .get("title") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "GIF".to_string()); + + let file = match item.get("file") { + Some(f) => f, + None => continue, + }; + + // cdn_url: md.gif → hd.gif → sm.gif → xs.gif + let original = first_complete_gif_asset( + file, + &[ + &["md", "gif"], + &["hd", "gif"], + &["sm", "gif"], + &["xs", "gif"], + ], + ); + // preview_url: sm.webp → sm.gif → xs.webp → xs.gif → md.webp + let preview = first_complete_gif_asset( + file, + &[ + &["sm", "webp"], + &["sm", "gif"], + &["xs", "webp"], + &["xs", "gif"], + &["md", "webp"], + ], + ); + + let (cdn_url, width, height) = match original { + Some(a) => a, + None => continue, + }; + + let preview_url = preview.map(|(u, _, _)| u); + + out.push(GifEntry { + cdn_url, + slug, + title, + width, + height, + preview_url, + }); + } + + Ok(out) +} + +/// Extract the URL, width, and height from the first complete asset at +/// `file[size][fmt]` where `size`/`fmt` pairs are tried in order. +/// "Complete" means url (non-empty string), width (number), height (number) +/// are all present — mirrors `isCompleteAsset` in the desktop. +fn first_complete_gif_asset( + file: &serde_json::Value, + candidates: &[&[&str; 2]], +) -> Option<(String, u64, u64)> { + for &[size, fmt] in candidates { + let asset = file.get(size).and_then(|s| s.get(fmt)); + if let Some(a) = asset { + let url = a.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let width = a.get("width").and_then(|v| v.as_u64()); + let height = a.get("height").and_then(|v| v.as_u64()); + if !url.is_empty() { + if let (Some(w), Some(h)) = (width, height) { + return Some((url.to_string(), w, h)); + } + } + } + } + None +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// `buzz gifs search [--query ] [--locale ]` +/// +/// Empty/omitted `query` returns KLIPY trending GIFs. Output is a JSON array +/// of normalized GIF objects; each entry's `cdn_url` is the URL to embed in a +/// `buzz messages send --content` argument. +pub async fn cmd_search( + client: &BuzzClient, + query: &str, + locale: Option<&str>, +) -> Result<(), CliError> { + let entries = search_entries(client, query, locale).await?; + println!( + "{}", + serde_json::to_string(&entries) + .map_err(|e| CliError::Other(format!("output serialization failed: {e}")))? + ); + Ok(()) +} + +/// Resolve NIP-11, POST the search, normalize and return typed GIF entries. +/// +/// Extracted from `cmd_search` so tests can assert the typed result directly +/// without capturing stdout. +pub(crate) async fn search_entries( + client: &BuzzClient, + query: &str, + locale: Option<&str>, +) -> Result, CliError> { + let (search_path, _) = resolve_gif_descriptor(client).await?; + let cid = customer_id( + client.keys().secret_key().as_secret_bytes(), + client.relay_url(), + ); + let locale = locale.map(|l| l.to_string()).unwrap_or_else(default_locale); + + let body = serde_json::json!({ + "query": query, + "customer_id": cid, + "locale": locale, + }); + let raw = client.post_json_authed(&search_path, &body).await?; + normalize_gif_response(&raw) +} + +/// `buzz gifs share --slug ` +/// +/// Reports a selected GIF to KLIPY so it can update Recents. The `slug` is +/// the provider identifier returned in search results. Prints +/// `{"accepted": true}` on success. +pub async fn cmd_share(client: &BuzzClient, slug: &str) -> Result<(), CliError> { + let (_, share_path) = resolve_gif_descriptor(client).await?; + let cid = customer_id( + client.keys().secret_key().as_secret_bytes(), + client.relay_url(), + ); + + let body = serde_json::json!({ + "slug": slug, + "customer_id": cid, + }); + // The relay returns 204 No Content on success; post_json_authed returns "". + client.post_json_authed(&share_path, &body).await?; + println!("{}", serde_json::json!({"accepted": true})); + Ok(()) +} + +pub async fn dispatch(cmd: crate::GifsCmd, client: &BuzzClient) -> Result<(), CliError> { + match cmd { + crate::GifsCmd::Search { query, locale } => { + cmd_search(client, query.as_deref().unwrap_or(""), locale.as_deref()).await + } + crate::GifsCmd::Share { slug } => cmd_share(client, &slug).await, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // safe_relay_path + // ----------------------------------------------------------------------- + + #[test] + fn safe_relay_path_accepts_normal_paths() { + assert!(safe_relay_path("/gifs/search")); + assert!(safe_relay_path("/gifs/share")); + assert!(safe_relay_path("/api/v2/gifs/search")); + } + + #[test] + fn safe_relay_path_rejects_adversarial_corpus() { + // Desktop adversarial corpus from desktop/src/features/gifs/api.test.mjs + let bad_paths = [ + "https://attacker.example/search", // absolute URL, no leading / + "//attacker.example/search", // protocol-relative → authority shift + "/\\attacker.example/search", // backslash + "/%5c%5cattacker.example/search", // percent-encoded + "/gifs/../admin", // dot-dot traversal + "/gifs/%2e%2e/admin", // percent-encoded dot-dot + "/gifs/search?redirect=https://attacker.example", // query injection + "/gifs/search#fragment", // fragment injection + ]; + for path in bad_paths { + assert!( + !safe_relay_path(path), + "expected safe_relay_path({path:?}) == false" + ); + } + } + + #[test] + fn safe_relay_path_rejects_empty_and_relative() { + assert!(!safe_relay_path("")); + assert!(!safe_relay_path("gifs/search")); // no leading / + assert!(!safe_relay_path("//")); + } + + // ----------------------------------------------------------------------- + // customer_id + // ----------------------------------------------------------------------- + + #[test] + fn customer_id_is_32_hex_chars_and_stable() { + let sk = [0xab_u8; 32]; + let id = customer_id(&sk, "https://relay.example"); + assert_eq!(id.len(), 32); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(id, customer_id(&sk, "https://relay.example")); + } + + #[test] + fn customer_id_is_relay_scoped() { + let sk = [0xcd_u8; 32]; + let id_a = customer_id(&sk, "https://relay-a.example"); + let id_b = customer_id(&sk, "https://relay-b.example"); + assert_ne!( + id_a, id_b, + "same key, different relay → different customer_id" + ); + } + + #[test] + fn customer_id_differs_for_different_keys() { + let id_a = customer_id(&[0xaa_u8; 32], "https://relay.example"); + let id_b = customer_id(&[0xbb_u8; 32], "https://relay.example"); + assert_ne!(id_a, id_b); + } + + #[test] + fn customer_id_not_equal_to_pubkey_hash() { + // The customer_id must NOT be derivable from the public key alone. + use sha2::{Digest, Sha256}; + let sk = [0xde_u8; 32]; + // What the old pubkey-hash approach would have produced (approximately): + let naive_hash = hex::encode(&Sha256::digest(hex::encode(sk).as_bytes())[..16]); + let actual = customer_id(&sk, "https://relay.example"); + assert_ne!( + actual, naive_hash, + "customer_id must not equal SHA-256(pubkey_hex)[..16]" + ); + } + + // ----------------------------------------------------------------------- + // default_locale + // ----------------------------------------------------------------------- + + #[test] + fn default_locale_is_nonempty() { + let locale = default_locale(); + assert!(!locale.is_empty()); + } + + // ----------------------------------------------------------------------- + // parse_gif_descriptor_info — production gate logic, no I/O + // ----------------------------------------------------------------------- + + #[test] + fn descriptor_missing_extension_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-emoji"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("buzz-gif"), + "error must mention buzz-gif, got: {err}" + ); + } + + #[test] + fn descriptor_wrong_provider_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "tenor", "search": "/gifs/search", "share": "/gifs/share" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("tenor"), + "error must mention the bad provider, got: {err}" + ); + } + + #[test] + fn descriptor_unsafe_search_path_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "//attacker.example/x", "share": "/gifs/share" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("search path"), + "error must mention search path, got: {err}" + ); + } + + #[test] + fn descriptor_unsafe_share_path_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/../admin" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("share path"), + "error must mention share path, got: {err}" + ); + } + + #[test] + fn descriptor_valid_passes() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } + }); + let (search, share) = parse_gif_descriptor_info(&info).unwrap(); + assert_eq!(search, "/gifs/search"); + assert_eq!(share, "/gifs/share"); + } + + // ----------------------------------------------------------------------- + // normalize_gif_response + // ----------------------------------------------------------------------- + + /// Fixture matching the shape used in desktop/tests/e2e/messaging.spec.ts + fn e2e_fixture() -> &'static str { + r#"{ + "result": true, + "data": { + "data": [ + { + "id": null, + "type": "gif", + "slug": "e2e-ship-it", + "title": "Ship it", + "file": { + "md": { "gif": { "height": 180, "size": 42, "url": "https://static.klipy.com/ship-it.gif", "width": 320 } }, + "sm": { "webp": { "height": 90, "size": 12, "url": "https://static.klipy.com/ship-it-sm.webp", "width": 160 } } + } + } + ] + } + }"# + } + + #[test] + fn normalize_extracts_cdn_url_and_preview() { + let entries = normalize_gif_response(e2e_fixture()).unwrap(); + assert_eq!(entries.len(), 1); + let e = &entries[0]; + assert_eq!(e.cdn_url, "https://static.klipy.com/ship-it.gif"); + assert_eq!(e.slug, "e2e-ship-it"); + assert_eq!(e.title, "Ship it"); + assert_eq!(e.width, 320); + assert_eq!(e.height, 180); + assert_eq!( + e.preview_url.as_deref(), + Some("https://static.klipy.com/ship-it-sm.webp") + ); + } + + #[test] + fn normalize_skips_non_gif_type() { + let raw = r#"{"result":true,"data":{"data":[ + {"type":"ad","slug":"s","file":{"md":{"gif":{"url":"https://x.com/a.gif","width":1,"height":1,"size":1}}}}, + {"type":"gif","slug":"real","title":"R","file":{"md":{"gif":{"url":"https://x.com/r.gif","width":2,"height":2,"size":2}}}} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].slug, "real"); + } + + #[test] + fn normalize_skips_items_without_slug() { + let raw = r#"{"result":true,"data":{"data":[ + {"type":"gif","file":{"md":{"gif":{"url":"https://x.com/a.gif","width":1,"height":1,"size":1}}}} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn normalize_asset_fallback_order() { + // No md.gif, has hd.gif — should pick hd.gif as cdn_url. + let raw = r#"{"result":true,"data":{"data":[ + {"type":"gif","slug":"fallback","title":"F","file":{ + "hd":{"gif":{"url":"https://x.com/hd.gif","width":640,"height":360,"size":100}}, + "sm":{"webp":{"url":"https://x.com/sm.webp","width":160,"height":90,"size":10}} + }} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].cdn_url, "https://x.com/hd.gif"); + } + + #[test] + fn normalize_skips_items_with_no_usable_original() { + // Only a preview asset, no gif asset at any size. + let raw = r#"{"result":true,"data":{"data":[ + {"type":"gif","slug":"broken","title":"B","file":{ + "sm":{"webp":{"url":"https://x.com/sm.webp","width":160,"height":90,"size":10}} + }} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn normalize_rejects_malformed_envelope() { + // Missing the data.data wrapper — must error, not silently return []. + let bad = r#"{"result":true,"gifs":[]}"#; + assert!(normalize_gif_response(bad).is_err()); + } + + #[test] + fn normalize_empty_data_array_is_ok() { + let raw = r#"{"result":true,"data":{"data":[]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert!(entries.is_empty()); + } + + // ----------------------------------------------------------------------- + // HTTP integration tests: real client seam via axum fake server + // ----------------------------------------------------------------------- + + use crate::client::BuzzClient; + use axum::body::Bytes; + use axum::extract::State; + use axum::http::{HeaderMap, StatusCode}; + use axum::routing::post; + use axum::Router; + use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; + use nostr::{JsonUtil, Keys, Tag}; + use std::net::SocketAddr; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + /// Captured request data from the fake server. + #[derive(Clone, Default)] + struct Captured { + path: String, + auth_header: String, + auth_tag_header: String, + body: String, + } + + /// NIP-11 JSON that advertises non-default search/share paths. + /// + /// Production code must read the advertised paths from NIP-11 and POST to + /// them. Using non-default paths here means hardcoded "/gifs/search" / + /// "/gifs/share" in production would target 404 routes and the tests would + /// fail — proving that the relay-advertised path is actually used. + const ALT_SEARCH_PATH: &str = "/x/search-alt"; + const ALT_SHARE_PATH: &str = "/x/share-alt"; + + fn alt_nip11() -> &'static str { + // Embedded as a literal so there is no run-time allocation in the const. + r#"{"supported_extensions":["buzz-gif"],"gif":{"provider":"klipy","search":"/x/search-alt","share":"/x/share-alt"}}"# + } + + /// A simple fake relay: serves NIP-11 at `/info` advertising non-default + /// paths, then captures POST bodies at those paths. + async fn fake_server( + search_status: StatusCode, + search_body: String, + share_status: StatusCode, + ) -> (String, Arc>>) { + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + + type S = (Arc>>, StatusCode, String, StatusCode); + let state: S = (captured.clone(), search_status, search_body, share_status); + + let app = + Router::new() + .route( + "/info", + axum::routing::get(|| async { + ( + StatusCode::OK, + [("content-type", "application/nostr+json")], + alt_nip11(), + ) + }), + ) + .route( + ALT_SEARCH_PATH, + post( + |State((cap, search_st, search_bd, _)): State, + headers: HeaderMap, + body: Bytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + cap.lock().unwrap().push(Captured { + path: ALT_SEARCH_PATH.to_string(), + auth_header: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + auth_tag_header: headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + body: body_str, + }); + axum::response::Response::builder() + .status(search_st) + .header("content-type", "application/json") + .body(axum::body::Body::from(search_bd.clone())) + .unwrap() + }, + ), + ) + .route( + ALT_SHARE_PATH, + post( + |State((cap, _, _, share_st)): State, + headers: HeaderMap, + body: Bytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + cap.lock().unwrap().push(Captured { + path: ALT_SHARE_PATH.to_string(), + auth_header: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + auth_tag_header: headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + body: body_str, + }); + axum::response::Response::builder() + .status(share_st) + .body(axum::body::Body::empty()) + .unwrap() + }, + ), + ) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), captured) + } + + /// Client without an auth tag — used for basic NIP-98 / body / path tests. + fn test_client(base_url: &str) -> BuzzClient { + let keys = Keys::generate(); + BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() + } + + /// Client with a synthetic `x-auth-tag` — used to assert that the header + /// is forwarded verbatim and that its value is the raw JSON of the tag. + fn test_client_with_tag(base_url: &str) -> (BuzzClient, String) { + let keys = Keys::generate(); + // Construct a minimal auth tag: ["auth", , "conditions", ] + let owner_hex = "a".repeat(64); + let sig_hex = "b".repeat(128); + let tag_vec = vec![ + "auth".to_string(), + owner_hex, + "conditions".to_string(), + sig_hex, + ]; + let tag_json = serde_json::to_string(&tag_vec).unwrap(); + let tag = Tag::parse(tag_vec).unwrap(); + let client = BuzzClient::new( + base_url.to_string(), + keys, + Some(tag), + Some(tag_json.clone()), + ) + .unwrap(); + (client, tag_json) + } + + fn one_gif_response() -> String { + serde_json::json!({"result":true,"data":{"data":[ + {"type":"gif","slug":"test-slug","title":"Test","file":{ + "md":{"gif":{"url":"https://cdn.klipy.com/test.gif","width":320,"height":180,"size":50}} + }} + ]}}) + .to_string() + } + + // ── item 1: relay-advertised path binding ────────────────────────────── + + #[tokio::test] + async fn search_posts_to_relay_advertised_path_not_hardcoded() { + // Fake advertises ALT_SEARCH_PATH; hardcoded "/gifs/search" would 404. + let (url, captured) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + cmd_search(&client, "hello", Some("en_US")).await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SEARCH_PATH) + .expect("POST must arrive at the NIP-11-advertised path"); + assert!( + call.auth_header.starts_with("Nostr "), + "Authorization must be a NIP-98 Nostr token, got: {:?}", + call.auth_header + ); + let body: serde_json::Value = serde_json::from_str(&call.body).unwrap(); + assert_eq!(body["query"], "hello"); + assert_eq!(body["locale"], "en_US"); + assert!( + body["customer_id"] + .as_str() + .map(|s| s.len() == 32) + .unwrap_or(false), + "customer_id must be 32 hex chars" + ); + } + + #[tokio::test] + async fn share_posts_to_relay_advertised_path_not_hardcoded() { + // Fake advertises ALT_SHARE_PATH; hardcoded "/gifs/share" would 404. + let (url, captured) = + fake_server(StatusCode::OK, "[]".to_string(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + cmd_share(&client, "my-gif-slug").await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SHARE_PATH) + .expect("POST must arrive at the NIP-11-advertised share path"); + assert!( + call.auth_header.starts_with("Nostr "), + "Authorization must be a NIP-98 Nostr token" + ); + let body: serde_json::Value = serde_json::from_str(&call.body).unwrap(); + assert_eq!(body["slug"], "my-gif-slug"); + assert!( + body["customer_id"] + .as_str() + .map(|s| s.len() == 32) + .unwrap_or(false), + "customer_id must be 32 hex chars" + ); + } + + // ── item 2: x-auth-tag forwarded + NIP-98 deep assertions ───────────── + + #[tokio::test] + async fn search_forwards_x_auth_tag_header() { + let (url, captured) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let (client, expected_tag_json) = test_client_with_tag(&url); + + cmd_search(&client, "", None).await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SEARCH_PATH) + .expect("search POST must arrive"); + assert_eq!( + call.auth_tag_header, expected_tag_json, + "x-auth-tag must equal the exact JSON of the auth tag" + ); + } + + #[tokio::test] + async fn search_nip98_token_has_correct_u_method_and_payload_hash() { + let (url, captured) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + cmd_search(&client, "cats", Some("en_US")).await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SEARCH_PATH) + .expect("search POST must arrive"); + + // Decode "Nostr " → JSON event + let token = call + .auth_header + .strip_prefix("Nostr ") + .expect("must start with Nostr "); + let json_bytes = B64.decode(token).expect("must be valid base64"); + let event: nostr::Event = + nostr::Event::from_json(std::str::from_utf8(&json_bytes).unwrap()).unwrap(); + + // kind:27235 (NIP-98) + assert_eq!(event.kind.as_u16(), 27235); + + // `u` tag must be the exact POST URL + let expected_url = format!("{url}{ALT_SEARCH_PATH}"); + let u_tag = event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("u")) + .expect("NIP-98 event must have a u tag"); + assert_eq!( + u_tag.as_slice().get(1).map(|s| s.as_str()).unwrap_or(""), + expected_url + ); + + // `method` tag must be "POST" + let method_tag = event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("method")) + .expect("NIP-98 event must have a method tag"); + assert_eq!( + method_tag + .as_slice() + .get(1) + .map(|s| s.as_str()) + .unwrap_or(""), + "POST" + ); + + // `payload` tag must equal SHA-256 of the request body + use sha2::{Digest, Sha256}; + let body_bytes = call.body.as_bytes(); + let expected_hash = hex::encode(Sha256::digest(body_bytes)); + let payload_tag = event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("payload")) + .expect("NIP-98 event must have a payload tag for POST with body"); + assert_eq!( + payload_tag + .as_slice() + .get(1) + .map(|s| s.as_str()) + .unwrap_or(""), + expected_hash, + "payload tag must be SHA-256 of the request body" + ); + } + + // ── item 3: search_output_contains_cdn_url asserts typed result ──────── + + #[tokio::test] + async fn search_entries_returns_top_level_cdn_url() { + // Tests that cmd_search delegates to search_entries() which returns + // typed output with cdn_url at the top level. A raw-passthrough + // regression (no normalize_gif_response) would produce a different + // struct shape and cdn_url would be absent. + let (url, _) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + let entries = search_entries(&client, "", None).await.unwrap(); + + assert!(!entries.is_empty(), "must return at least one entry"); + assert_eq!( + entries[0].cdn_url, "https://cdn.klipy.com/test.gif", + "cdn_url must be the normalized top-level URL from md.gif" + ); + assert_eq!(entries[0].slug, "test-slug"); + } + + // ── existing negative gate ───────────────────────────────────────────── + + #[tokio::test] + async fn share_returns_accepted_true_on_204() { + let (url, _) = fake_server(StatusCode::OK, "[]".to_string(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + cmd_share(&client, "slug-abc").await.unwrap(); + } + + #[tokio::test] + async fn search_rejects_missing_extension_in_nip11() { + // Serve NIP-11 without buzz-gif. + let app = Router::new().route( + "/info", + axum::routing::get(|| async { + ( + StatusCode::OK, + [("content-type", "application/nostr+json")], + r#"{"supported_extensions":[],"gif":{"provider":"klipy","search":"/x/search-alt","share":"/x/share-alt"}}"#, + ) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let url = format!("http://{addr}"); + let client = test_client(&url); + + let err = cmd_search(&client, "test", None).await.unwrap_err(); + assert!( + err.to_string().contains("buzz-gif"), + "error must mention buzz-gif, got: {err}" + ); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751..f80f928d31 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -698,15 +698,43 @@ pub async fn cmd_send_message( ) .map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))? } - None | Some(9) => buzz_sdk::build_message( - channel_uuid, - &final_content, - thread_ref.as_ref(), - &mention_refs, - p.broadcast, - &media_tags, - ) - .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?, + None | Some(9) => { + // Scan final_content for `:shortcode:` patterns and attach NIP-30 + // emoji tags for any that resolve in the workspace palette. + // Palette resolution is scoped to kind 9: forum builders (45001, + // 45003) do not accept emoji_tags, so resolving early would pay + // the relay query and immediately discard the result. + // The fetch is skipped entirely when content has no `:`, keeping + // plain sends at zero extra RTTs. Palette resolution is + // decorative enrichment — a fetch or parse failure must not block + // delivery of a valid message; on error, degrade to no emoji tags + // and log a diagnostic to stderr. + let emoji_tags = if final_content.contains(':') { + match crate::commands::emoji::resolve_emoji_tags_for_content(client, &final_content) + .await + { + Ok(tags) => tags, + Err(e) => { + eprintln!( + "warning: emoji palette fetch failed ({e}); sending without emoji tags" + ); + Vec::new() + } + } + } else { + Vec::new() + }; + buzz_sdk::build_message( + channel_uuid, + &final_content, + thread_ref.as_ref(), + &mention_refs, + p.broadcast, + &media_tags, + &emoji_tags, + ) + .map_err(|e| CliError::Other(format!("build_message failed: {e}")))? + } Some(k) => { return Err(CliError::Usage(format!( "--kind {k} is not supported (use 9, 45001, or 45003)" @@ -1056,11 +1084,11 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags, - format_events, match_profiles_by_name, merge_message_mentions, missing_members, - normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, - resolve_thread_target, thread_ref_from_event, thread_ref_from_parent_tags, BuzzClient, - CliError, Uuid, + channel_id_from_event, cmd_get_thread, cmd_send_message, event_mention_pubkeys, + find_root_from_tags, format_events, match_profiles_by_name, merge_message_mentions, + missing_members, normalize_explicit_mentions, parse_member_pubkeys, + resolve_names_to_pubkeys, resolve_thread_target, thread_ref_from_event, + thread_ref_from_parent_tags, BuzzClient, CliError, Uuid, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1570,4 +1598,294 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + // ── cmd_send_message — emoji-tag binding seam ───────────────────────── + // + // These tests drive `cmd_send_message` through a minimal fake relay + // serving `/query` (emoji palette) and `/events` (event submission). + // + // Content with no `@` and no explicit mentions bypasses member-resolution + // relay calls, so the only relay traffic is: + // 1. POST /query — emoji palette fetch (when content has `:`) + // 2. POST /events — signed event submission + // + // Removing the resolver call at messages.rs:687-691 or passing &[] at + // :718 would cause the emoji-tag assertions below to fail. + + use axum::body::Bytes as AxumBytes; + use axum::extract::State as AxumState; + use axum::http::{HeaderMap as AxumHeaderMap, StatusCode as AxumStatusCode}; + use axum::routing::post as axum_post; + use axum::Router as AxumRouter; + use std::net::SocketAddr as StdSocketAddr; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc as StdArc; + use tokio::net::TcpListener as TokioTcpListener; + + /// Captured body of a POST /events call. + #[derive(Clone, Default)] + struct CapturedEvent { + body: String, + } + + /// Minimal fake relay for send-path tests. + /// + /// - `/query` returns the given `query_body` on every call and increments + /// `query_count`. + /// - `/events` returns `{"event_id":"fake","accepted":true}` and records + /// the raw event JSON in `captured_event`. + async fn fake_send_relay( + query_body: String, + ) -> ( + String, + StdArc, + StdArc>>, + ) { + let query_count = StdArc::new(AtomicU32::new(0)); + let captured_event: StdArc>> = + StdArc::new(std::sync::Mutex::new(None)); + + type S = ( + StdArc, + String, + StdArc>>, + ); + let state: S = (query_count.clone(), query_body, captured_event.clone()); + + let app = AxumRouter::new() + .route( + "/query", + axum_post( + |AxumState((count, body, _)): AxumState, + _headers: AxumHeaderMap, + _req: AxumBytes| async move { + count.fetch_add(1, Ordering::Relaxed); + ( + AxumStatusCode::OK, + [("content-type", "application/json")], + body, + ) + }, + ), + ) + .route( + "/events", + axum_post( + |AxumState((_, _, cap)): AxumState, + _headers: AxumHeaderMap, + body: AxumBytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + *cap.lock().unwrap() = Some(CapturedEvent { body: body_str }); + ( + AxumStatusCode::OK, + [("content-type", "application/json")], + r#"{"event_id":"fake0000","accepted":true}"#, + ) + }, + ), + ) + .with_state(state); + + let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: StdSocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), query_count, captured_event) + } + + /// Palette JSON with one emoji: `wave` → some URL. + fn send_palette_response() -> String { + serde_json::json!([{ + "created_at": 100, + "tags": [ + ["d", "buzz:custom-emoji"], + ["emoji", "wave", "https://cdn.example.com/wave.png"], + ["emoji", "sweatblob", "https://cdn.example.com/sweatblob.gif"] + ] + }]) + .to_string() + } + + /// A valid channel UUID used across send-path tests. + const SEND_TEST_CHANNEL: &str = "123e4567-e89b-12d3-a456-426614174000"; + + fn send_params(content: &str) -> super::SendMessageParams { + super::SendMessageParams { + channel_id: SEND_TEST_CHANNEL.to_string(), + content: content.to_string(), + kind: None, + reply_to: None, + broadcast: false, + files: vec![], + mentions: vec![], + } + } + + #[tokio::test] + async fn cmd_send_message_attaches_emoji_tags_for_known_shortcodes() { + // Content contains `:wave:` which resolves in the palette. + // The submitted event must carry an `emoji` tag for `wave`. + let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + + cmd_send_message(&client, send_params("hello :wave: everyone")) + .await + .unwrap(); + + // Palette was queried at least once (short-circuit was NOT triggered). + assert!( + query_count.load(Ordering::Relaxed) >= 1, + "palette must be queried when content has a colon" + ); + + // Submitted event must contain an emoji tag for `wave`. + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + let tags: Vec> = event["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| { + t.as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .collect(); + let emoji_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("emoji")) + .collect(); + assert!( + emoji_tags + .iter() + .any(|t| t.get(1).map(|s| s.as_str()) == Some("wave")), + "submitted event must have an emoji tag for `wave`, got tags: {tags:?}" + ); + // Unknown shortcodes must not produce tags. + assert!( + !emoji_tags + .iter() + .any(|t| t.get(1).map(|s| s.as_str()) == Some("notreal")), + "unknown shortcodes must not produce emoji tags" + ); + } + + #[tokio::test] + async fn cmd_send_message_skips_palette_query_when_no_colon_in_content() { + // Content has no `:` at all — the palette query must be skipped + // entirely (zero RTTs), and the submitted event must have no emoji tags. + let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + + cmd_send_message(&client, send_params("plain message no colons")) + .await + .unwrap(); + + assert_eq!( + query_count.load(Ordering::Relaxed), + 0, + "palette must NOT be queried when content has no colon" + ); + + // Submitted event must have no emoji tags. + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + let tags: Vec> = event["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| { + t.as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .collect(); + let emoji_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("emoji")) + .collect(); + assert!( + emoji_tags.is_empty(), + "no-colon content must produce no emoji tags, got: {emoji_tags:?}" + ); + } + + #[tokio::test] + async fn cmd_send_message_succeeds_when_palette_query_errors() { + // Palette enrichment is decorative — a 500 from the `/query` endpoint + // must not abort delivery; the message must still be sent with zero + // emoji tags, and a diagnostic must be emitted to stderr. + + // Fake relay: `/query` returns 500, `/events` accepts and captures. + let captured_event: StdArc>> = + StdArc::new(std::sync::Mutex::new(None)); + let cap = captured_event.clone(); + let app = AxumRouter::new() + .route( + "/query", + axum_post(|_headers: AxumHeaderMap, _req: AxumBytes| async move { + ( + AxumStatusCode::INTERNAL_SERVER_ERROR, + [("content-type", "application/json")], + r#"{"error":"unavailable"}"#, + ) + }), + ) + .route( + "/events", + axum_post(move |_headers: AxumHeaderMap, body: AxumBytes| { + let cap = cap.clone(); + async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + *cap.lock().unwrap() = Some(CapturedEvent { body: body_str }); + ( + AxumStatusCode::OK, + [("content-type", "application/json")], + r#"{"event_id":"fake0001","accepted":true}"#, + ) + } + }), + ); + + let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: StdSocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let url = format!("http://{addr}"); + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + + // Must not return Err — a palette failure is a soft warning. + cmd_send_message(&client, send_params(":wave: message with emoji candidate")) + .await + .expect("send must succeed even when palette query returns 500"); + + // Submitted event must have zero emoji tags (fallback to empty). + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + let tags: Vec> = event["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| { + t.as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .collect(); + let emoji_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("emoji")) + .collect(); + assert!( + emoji_tags.is_empty(), + "palette-error fallback must produce no emoji tags, got: {emoji_tags:?}" + ); + } } diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8bb24218eb..7ed03f9d06 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod channels; pub mod dms; pub mod emoji; pub mod feed; +pub mod gifs; pub mod issues; pub mod mem; pub mod messages; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa..3f2bea7397 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -192,6 +192,9 @@ enum Cmd { /// Manage your custom emoji set (workspace palette is the union of all members' sets) #[command(subcommand)] Emoji(EmojiCmd), + /// Search and share GIFs via the relay's KLIPY proxy + #[command(subcommand)] + Gifs(GifsCmd), /// List, open, and manage direct messages #[command(subcommand)] Dms(DmsCmd), @@ -806,6 +809,31 @@ pub enum EmojiCmd { }, } +#[derive(Subcommand)] +pub enum GifsCmd { + /// Search or browse trending GIFs via the relay's KLIPY proxy. + /// + /// Omitting --query returns trending GIFs. The output is a JSON array of + /// GIF objects; paste the `cdn_url` field directly into + /// `buzz messages send --content` to share a GIF. + Search { + /// Search text; omit or leave empty for trending + #[arg(long)] + query: Option, + /// BCP 47 locale for provider results (default: $LANG or en_US) + #[arg(long)] + locale: Option, + }, + /// Report a selected GIF to the provider so it enters your Recents. + /// + /// The slug is the provider identifier in the search result objects. + Share { + /// Provider GIF slug from a search result + #[arg(long)] + slug: String, + }, +} + #[derive(Subcommand)] pub enum DmsCmd { /// List direct message conversations @@ -2080,6 +2108,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, + Cmd::Gifs(sub) => commands::gifs::dispatch(sub, &client).await, Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await, Cmd::Users(sub) => commands::users::dispatch(sub, &client, &cli.format).await, Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await, @@ -2229,6 +2258,7 @@ mod tests { "dms", "emoji", "feed", + "gifs", "issues", "media", "mem", diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 30c46e2fd9..6f8d0ffb3d 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -34,6 +34,14 @@ pub use runtime::{ insert_mentions, migration, replica_fence, Db, DbConfig, DbPoolStats, DbReadinessOutcome, ReadSession, }; + +/// Valid low-cardinality `(pool_role, operation)` pairs for pool-acquisition telemetry. +pub const DB_POOL_ACQUIRE_VALID_PAIRS: [(&str, &str); 11] = + runtime::observability::POOL_ACQUIRE_VALID_PAIRS; + +/// Raw Prometheus series ceiling per relay pod for the operation-aware contract. +pub const DB_POOL_ACQUIRE_RAW_SERIES_PER_POD: usize = + runtime::observability::POOL_ACQUIRE_RAW_SERIES_PER_POD; pub(crate) use runtime::{ insert_mentions_in_transaction, observability, route_proof, ReadSessionInner, RouteDecision, RoutePredicate, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 68071e0ed2..5901512504 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -82,9 +82,12 @@ where F: FnOnce(PgConnection) -> Fut, Fut: Future)>, { - let mut lock_conn = crate::observability::acquire(pool, crate::observability::PoolRole::Writer) - .await? - .detach(); + let mut lock_conn = crate::observability::acquire_writer_with_legacy_metrics( + pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await? + .detach(); // This dedicated connection intentionally waits for the current migration // or schema-destruction owner and may then run long DDL. Exempt those two // phases from runtime lock/statement budgets. Keep the idle-in-transaction @@ -699,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 43); + assert_eq!(migrations.len(), 44); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1242,44 +1245,15 @@ mod postgres_tests { "migration 39 must register relay_operator_audit in _operator_global_tables" ); - // NIP-FI core identity + base-lifecycle foundation (migration 0041) and - // final-admission foundation (0042). Both widen the single SQL source of - // truth `community_write_fence_excluded_table` so their durable, - // immutable ledger relations are never fence-attached, purged, or - // counted as tenant-scoped drift. schema.sql keeps one consolidated - // definition of that function whose body must match 0042's exactly. assert_eq!(migrations[40].version, 41); let identity_foundation = migrations[40].sql.as_str(); assert!(identity_foundation.contains("CREATE TABLE identity_bindings")); assert!(identity_foundation.contains("CREATE TABLE identity_lifecycle_history")); - assert!(identity_foundation - .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); - assert!(identity_foundation.contains("'identity_bindings'")); assert_eq!(migrations[41].version, 42); let authorization_foundation = migrations[41].sql.as_str(); assert!(authorization_foundation.contains("CREATE TABLE authorization_events")); assert!(authorization_foundation.contains("CREATE TABLE protected_object_authority")); - assert!(authorization_foundation.contains("CREATE TABLE authorization_admission_results")); - - // The consolidated desired-state exclusion function must byte-match - // migration 0042's CREATE OR REPLACE body, or a future schema - // consolidation would silently drop NIP-FI relations from the ledger. - fn extract_excluded_table_array(sql: &str) -> &str { - let anchor = "community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN"; - let start = sql.find(anchor).expect("exclusion function definition"); - let array_start = sql[start..].find("ARRAY[").expect("exclusion array") + start; - let array_end = sql[array_start..] - .find("]::TEXT[]") - .expect("exclusion array end") - + array_start; - &sql[array_start..array_end] - } - assert_eq!( - extract_excluded_table_array(authorization_foundation), - extract_excluded_table_array(desired_schema), - "schema.sql exclusion list drifted from migration 0042" - ); // Brownfield relay databases created through SQLx still carry the // production/sandbox constraint from 0015. Converge them to the same @@ -1292,6 +1266,27 @@ mod postgres_tests { .contains("DROP CONSTRAINT push_gateway_installations_app_profile_check")); assert!(dogfood_profile.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); assert!(desired_schema.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); + + // Drop the Phase-A NIP-FI relay-side authority ledger (0041 + 0042). + // OSS Buzz is stateless for identity (spec v2, PR #7214); the durable + // ledger tables are dead code. Restores community_write_fence_excluded_table + // to its pre-0041 body so the deletion catalog no longer includes the + // removed relations. + assert_eq!(migrations[43].version, 44); + let ledger_removal = migrations[43].sql.as_str(); + assert!(ledger_removal.contains("DROP TABLE authorization_operation_receipts")); + assert!(ledger_removal.contains("DROP TABLE identity_bindings")); + assert!(ledger_removal.contains("DROP TABLE authorization_events")); + assert!(ledger_removal + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + // The restored exclusion function must NOT list any NIP-FI relation. + assert!(!ledger_removal.contains("'authorization_operation_receipts'")); + assert!(!ledger_removal.contains("'identity_bindings'")); + // schema.sql exclusion list must match the restored (pre-0041) body. + assert!( + desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"), + "schema.sql exclusion list must match the pre-0041 body after ledger removal" + ); } #[test] @@ -2670,104 +2665,13 @@ mod postgres_tests { .expect("drop late-table fixtures"); } - /// NIP-FI intermediate state: migration 0041 (identity + base lifecycle) - /// alone must present a coherent catalog. Its five community-scoped ledger - /// relations are immutable and durable, so they are registered in the - /// write-fence exclusion — never counted as tenant-scoped drift, never - /// fence-attached — and the exact deletion catalog must still validate. + /// Verify migration 0044 applies cleanly against a DB that has rows in + /// the NIP-FI 0041+0042 tables. The immutability guards (no_delete, + /// no_truncate) are enforced via triggers; DROP TABLE bypasses them and + /// must succeed even when rows are present. #[tokio::test] #[ignore = "requires Postgres"] - async fn migration_0041_identity_foundation_is_durable_ledger_after_migration_a() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(41, &pool) - .await - .expect("apply migrations 1-41"); - - // The five identity relations exist. - let identity_tables = [ - "authorization_operation_receipts", - "identity_enrollment_policies", - "identity_bindings", - "identity_lifecycle_history", - "identity_lifecycle_selectors", - ]; - for table in identity_tables { - let exists = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ - WHERE table_schema = 'public' AND table_name = $1)", - ) - .bind(table) - .fetch_one(&pool) - .await - .unwrap_or_else(|err| panic!("check table {table}: {err}")); - assert!(exists, "migration 0041 must create {table}"); - } - - // Migration B's relations must NOT exist yet. - for table in ["authorization_events", "protected_object_authority"] { - let exists = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ - WHERE table_schema = 'public' AND table_name = $1)", - ) - .bind(table) - .fetch_one(&pool) - .await - .unwrap_or_else(|err| panic!("check table {table}: {err}")); - assert!(!exists, "{table} belongs to migration 0042, not 0041"); - } - - // Every identity relation is excluded from the write fence: none may - // appear as tenant-scoped drift or carry the fence trigger. - let scoped_or_fenced: Vec = sqlx::query_scalar( - "WITH scoped AS ( \ - SELECT c.relname FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - JOIN pg_attribute a ON a.attrelid = c.oid \ - WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ - AND NOT c.relispartition AND a.attname = 'community_id' \ - AND NOT a.attisdropped \ - AND NOT community_write_fence_excluded_table(c.relname) \ - ) \ - SELECT relname FROM scoped \ - WHERE relname = ANY($1) ORDER BY relname", - ) - .bind(&identity_tables[..]) - .fetch_all(&pool) - .await - .expect("read scoped identity relations"); - assert!( - scoped_or_fenced.is_empty(), - "identity ledger relations must be write-fence excluded, not scoped: {scoped_or_fenced:?}" - ); - - // The exact deletion catalog validates: the excluded ledger relations - // do not perturb the scoped-table/fence equality check. - crate::deletion::DeletionStore::new(pool.clone()) - .validate_catalog() - .await - .expect("deletion catalog validates after migration 0041"); - - // The immutability contract is enforced, not merely declared. TRUNCATE - // fires the statement-level guard unconditionally, so this proves the - // rejection without constructing a fully valid ledger row. - let rejected = sqlx::query("TRUNCATE identity_lifecycle_selectors") - .execute(&pool) - .await - .expect_err("identity_lifecycle_selectors truncation must be rejected"); - assert!( - rejected.to_string().contains("cannot be truncated"), - "expected immutability rejection, got: {rejected}" - ); - } - - /// NIP-FI full state: migrations 0041 + 0042 together must present a - /// coherent 15-relation catalog with zero dangling foreign keys, all - /// relations write-fence excluded, and an intact exact deletion catalog. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn nip_fi_foundation_is_a_closed_durable_ledger_after_migrations_a_and_b() { + async fn migration_0044_drops_populated_nip_fi_ledger_cleanly() { let pool = connect_test_pool().await; reset_public_schema(&pool).await; MIGRATOR @@ -2775,6 +2679,49 @@ mod postgres_tests { .await .expect("apply migrations 1-42"); + // Seed a community and minimal rows in a selection of 0041+0042 tables. + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("drop-test-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("seed community"); + + // Seed a receipt (used as FK anchor for several 0042 tables). + let operation_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 1, $5)", + ) + .bind(community_id) + .bind(operation_id) + .bind(vec![0xAA_u8; 32]) + .bind(vec![0xBB_u8; 32]) + .bind(vec![0xCC_u8; 32]) + .execute(&pool) + .await + .expect("seed operation receipt"); + + // Seed an invalidation domain (0042 table with no FK to receipts). + sqlx::query( + "INSERT INTO authorization_invalidation_domains \ + (community_id, current_generation) VALUES ($1, 0)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("seed invalidation domain"); + + // Apply migration 0043 (dogfood profile) and 0044 (ledger removal). + MIGRATOR + .run_to(44, &pool) + .await + .expect("migration 0044 must apply cleanly against a populated NIP-FI DB"); + + // All NIP-FI tables must be gone. let nip_fi_tables = [ "authorization_admission_results", "authorization_authentication_denial_attempts", @@ -2792,2455 +2739,23 @@ mod postgres_tests { "identity_lifecycle_selectors", "protected_object_authority", ]; - - // All fifteen relations exist. let present: Vec = sqlx::query_scalar( "SELECT table_name FROM information_schema.tables \ - WHERE table_schema = 'public' AND table_name = ANY($1) ORDER BY table_name", + WHERE table_schema = 'public' AND table_name = ANY($1)", ) .bind(&nip_fi_tables[..]) .fetch_all(&pool) .await - .expect("read NIP-FI table catalog"); - let mut expected: Vec = nip_fi_tables.iter().map(|t| t.to_string()).collect(); - expected.sort(); - assert_eq!( - present, expected, - "all NIP-FI relations must exist after 0042" - ); - - // Zero dangling foreign keys: every FK target is a live relation. - let invalid_fks: i64 = sqlx::query_scalar( - "SELECT count(*)::BIGINT FROM pg_constraint \ - WHERE contype = 'f' AND NOT convalidated", - ) - .fetch_one(&pool) - .await - .expect("read FK validity"); - assert_eq!( - invalid_fks, 0, - "no NIP-FI foreign key may be left unvalidated" - ); - - // None of the fifteen appear as tenant-scoped drift; all are excluded. - let scoped: Vec = sqlx::query_scalar( - "SELECT c.relname FROM pg_class c \ - JOIN pg_namespace n ON n.oid = c.relnamespace \ - JOIN pg_attribute a ON a.attrelid = c.oid \ - WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ - AND NOT c.relispartition AND a.attname = 'community_id' \ - AND NOT a.attisdropped \ - AND NOT community_write_fence_excluded_table(c.relname) \ - AND c.relname = ANY($1) ORDER BY c.relname", - ) - .bind(&nip_fi_tables[..]) - .fetch_all(&pool) - .await - .expect("read scoped NIP-FI relations"); + .expect("catalog check after ledger removal"); assert!( - scoped.is_empty(), - "all NIP-FI ledger relations must be write-fence excluded: {scoped:?}" + present.is_empty(), + "all NIP-FI tables must be absent after migration 0044: {present:?}" ); - // The exact deletion catalog validates with the full ledger present. + // The deletion catalog must validate with ledger relations gone. crate::deletion::DeletionStore::new(pool.clone()) .validate_catalog() .await - .expect("deletion catalog validates after migrations 0041 + 0042"); - - // A migration-B relation is immutable too. TRUNCATE fires the - // statement-level guard unconditionally. - let rejected = sqlx::query("TRUNCATE authorization_admission_results") - .execute(&pool) - .await - .expect_err("authorization_admission_results truncation must be rejected"); - assert!( - rejected.to_string().contains("cannot be truncated"), - "expected immutability rejection, got: {rejected}" - ); - } - - /// NIP-FI monotonic invalidation-floor advancement must actually run - /// through the `BEFORE UPDATE` guard. PL/pgSQL defers record-field - /// resolution to execution, so a guard that references a column absent from - /// its Phase-A table passes every catalog/parity test yet aborts the first - /// real advancement. This test exercises live UPDATEs: legitimate forward - /// moves on `floor_generation` and `binding_version_floor` must commit, and - /// equal/regressive moves must be rejected. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authorization_invalidation_floor_advances_through_guard() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("floor-guard-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Each floor state points at an operation receipt via - // (community_id, operation_id, request_fingerprint). Seed one receipt - // per operation the test advances through. - let operations: [(uuid::Uuid, u8); 4] = [ - (uuid::Uuid::new_v4(), 0x11), - (uuid::Uuid::new_v4(), 0x22), - (uuid::Uuid::new_v4(), 0x33), - (uuid::Uuid::new_v4(), 0x44), - ]; - for (operation_id, fp_byte) in operations { - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 12, $4, 1, $5)", - ) - .bind(community_id) - .bind(operation_id) - .bind(vec![fp_byte; 32]) - .bind(vec![0xAA_u8; 32]) - .bind(vec![0xBB_u8; 32]) - .execute(&pool) - .await - .expect("seed operation receipt"); - } - - // selector_kind 3 requires binding_version_floor, so this row exercises - // both monotonic dimensions the guard still governs. - let selector_fingerprint = vec![0xCC_u8; 32]; - sqlx::query( - "INSERT INTO authorization_invalidation_floors \ - (community_id, selector_kind, selector_fingerprint, floor_generation, \ - binding_version_floor, operation_id, request_fingerprint, updated_at) \ - VALUES ($1, 3, $2, 1, 1, $3, $4, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(&selector_fingerprint) - .bind(operations[0].0) - .bind(vec![operations[0].1; 32]) - .execute(&pool) - .await - .expect("insert initial invalidation floor"); - - let advance = - |generation: i64, binding_floor: i64, op_index: usize, updated_at: &'static str| { - sqlx::query( - "UPDATE authorization_invalidation_floors \ - SET floor_generation = $1, binding_version_floor = $2, \ - operation_id = $3, request_fingerprint = $4, updated_at = $5::timestamptz \ - WHERE community_id = $6 AND selector_kind = 3 AND selector_fingerprint = $7", - ) - .bind(generation) - .bind(binding_floor) - .bind(operations[op_index].0) - .bind(vec![operations[op_index].1; 32]) - .bind(updated_at) - .bind(community_id) - .bind(selector_fingerprint.clone()) - .execute(&pool) - }; - - // Forward generation advance commits. - advance(2, 1, 1, "2026-01-01T00:01:00Z") - .await - .expect("forward floor_generation advance must pass the guard"); - - // Forward binding_version_floor advance commits (generation unchanged). - advance(2, 2, 2, "2026-01-01T00:02:00Z") - .await - .expect("forward binding_version_floor advance must pass the guard"); - - // Regressive generation is rejected. - let regressive = advance(1, 2, 3, "2026-01-01T00:03:00Z") - .await - .expect_err("regressive floor_generation must be rejected"); - assert!( - regressive.to_string().contains("cannot move backward"), - "expected monotonic rejection, got: {regressive}" - ); - - // Equal floors with only a new operation is a rejected no-op advance. - let no_op = advance(2, 2, 3, "2026-01-01T00:03:00Z") - .await - .expect_err("equal-floor no-op advance must be rejected"); - assert!( - no_op.to_string().contains("cannot move backward"), - "expected no-op rejection, got: {no_op}" - ); - - // The committed state reflects only the two accepted advances. - let (generation, binding_floor): (i64, i64) = sqlx::query_as( - "SELECT floor_generation, binding_version_floor \ - FROM authorization_invalidation_floors \ - WHERE community_id = $1 AND selector_kind = 3 AND selector_fingerprint = $2", - ) - .bind(community_id) - .bind(&selector_fingerprint) - .fetch_one(&pool) - .await - .expect("read final floor state"); - assert_eq!( - (generation, binding_floor), - (2, 2), - "only the accepted forward advances may persist" - ); - } - - /// NIP-FI identity FK contract: a binding's provenance is determined from - /// operation evidence and is independent of the enrollment policy's mode. - /// The corrected FK references only `(community_id, policy_revision)`; - /// the original composite FK `(community_id, policy_revision, - /// binding_provenance) → (community_id, policy_revision, enrollment_mode)` - /// would have rejected valid admissions such as TOFU policy + - /// attested-key provenance (NIP-FI.md §352, §424). - #[tokio::test] - #[ignore = "requires Postgres"] - async fn identity_binding_provenance_is_independent_of_enrollment_mode() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(41, &pool) - .await - .expect("apply migrations 1-41"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("provenance-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Enrollment policy: mode 3 (TOFU). - let policy_revision: i64 = 1; - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(policy_revision) - .bind(vec![0xA0_u8; 32]) // policy_digest - .execute(&pool) - .await - .expect("insert TOFU enrollment policy"); - - // Insert a binding with provenance 1 (attested-key) under the TOFU - // policy. The circular deferred FK between identity_bindings and - // identity_lifecycle_history requires both to be committed in one - // transaction; all cross-table FKs in this pair are DEFERRABLE - // INITIALLY DEFERRED. A pinned connection is required so that BEGIN - // and each subsequent statement share the same session/transaction. - let binding_id = uuid::Uuid::new_v4(); - let history_id = uuid::Uuid::new_v4(); - let operation_id = uuid::Uuid::new_v4(); - let request_fingerprint = vec![0xAB_u8; 32]; - - let mut conn = pool.acquire().await.expect("acquire connection"); - - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - // Enrollment history must be inserted BEFORE the operation receipt: - // authorization_operation_receipt_history_guard_v1 fires AFTER INSERT - // on authorization_operation_receipts and checks that lifecycle receipts - // already have exactly one history row. The history → receipt FK is - // DEFERRABLE INITIALLY DEFERRED, so this order is safe. - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", - ) - .bind(community_id) - .bind(history_id) - .bind(binding_id) - .bind(operation_id) - .bind(&request_fingerprint) - .bind(vec![0xAE_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert lifecycle history"); - - // Operation receipt: kind 1 (enroll), outcome 1 (applied). - // The receipt_history_cardinality trigger fires here and validates the - // history row inserted above. - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(operation_id) - .bind(&request_fingerprint) - .bind(vec![0xAC_u8; 32]) - .bind(vec![0xAD_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert operation receipt"); - - // Binding: provenance 1 (attested-key) under TOFU-mode policy. - // Before the FK fix this INSERT would fail at commit with a FK - // violation because 1 (attested-key) ≠ 3 (TOFU mode). - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-01', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(binding_id) - .bind(vec![0xAF_u8; 32]) // principal_fingerprint - .bind(vec![0xB0_u8; 32]) // event_author_pubkey - .bind(policy_revision) - .bind(vec![0xB1_u8; 32]) // enrollment_evidence_digest - .bind(history_id) - .bind(operation_id) - .bind(&request_fingerprint) - .execute(&mut *conn) - .await - .expect("insert binding"); - - sqlx::query("COMMIT") - .execute(&mut *conn) - .await - .expect("attested-key binding under TOFU policy must commit — FK is on (community_id, policy_revision) only"); - - // Confirm the binding persisted with provenance 1, policy mode 3. - let (stored_provenance, stored_mode): (i16, i16) = sqlx::query_as( - "SELECT b.binding_provenance, p.enrollment_mode \ - FROM identity_bindings b \ - JOIN identity_enrollment_policies p \ - ON p.community_id = b.community_id AND p.policy_revision = b.policy_revision \ - WHERE b.community_id = $1 AND b.binding_id = $2", - ) - .bind(community_id) - .bind(binding_id) - .fetch_one(&pool) - .await - .expect("read persisted binding"); - assert_eq!(stored_provenance, 1, "provenance must be attested-key (1)"); - assert_eq!(stored_mode, 3, "enrollment mode must be TOFU (3)"); - assert_ne!( - stored_provenance, stored_mode, - "provenance and mode are independent: they must differ here" - ); - - // --- Negative half: absent policy revision --- - // - // Two-sided mutation sensitivity requires that a FK dropped or neutered - // entirely is also detected. A second otherwise-valid deferred - // transaction uses a nonexistent policy_revision (999) and must fail - // with SQLSTATE 23503 — the narrowed FK - // identity_bindings(community_id, policy_revision) - // → identity_enrollment_policies(community_id, policy_revision) - // rejects the row. This FK is not deferred, so it fires at INSERT - // time; a `COMMIT` is unnecessary and not reached. If the FK were - // absent the INSERT would succeed and this assertion would catch the - // regression. - let absent_binding_id = uuid::Uuid::new_v4(); - let absent_history_id = uuid::Uuid::new_v4(); - let absent_operation_id = uuid::Uuid::new_v4(); - let absent_fp = vec![0xC0_u8; 32]; - let nonexistent_policy_revision: i64 = 999; - - let mut conn2 = pool.acquire().await.expect("acquire second connection"); - - sqlx::query("BEGIN") - .execute(&mut *conn2) - .await - .expect("begin absent-policy transaction"); - - // History first (receipt_history_cardinality guard fires on receipt - // insert and requires the history row to already exist). - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", - ) - .bind(community_id) - .bind(absent_history_id) - .bind(absent_binding_id) - .bind(absent_operation_id) - .bind(&absent_fp) - .bind(vec![0xC1_u8; 32]) - .execute(&mut *conn2) - .await - .expect("insert absent-policy lifecycle history"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(absent_operation_id) - .bind(&absent_fp) - .bind(vec![0xC2_u8; 32]) - .bind(vec![0xC3_u8; 32]) - .execute(&mut *conn2) - .await - .expect("insert absent-policy operation receipt"); - - // The policy FK is not deferred; it fires at INSERT, not COMMIT. - let absent_policy_err = sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-02', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(absent_binding_id) - .bind(vec![0xC4_u8; 32]) // principal_fingerprint (unique, different from first binding) - .bind(vec![0xC5_u8; 32]) // event_author_pubkey (unique, different from first binding) - .bind(nonexistent_policy_revision) - .bind(vec![0xC6_u8; 32]) // enrollment_evidence_digest - .bind(absent_history_id) - .bind(absent_operation_id) - .bind(&absent_fp) - .execute(&mut *conn2) - .await - .expect_err("binding with nonexistent policy_revision must be rejected by the FK"); - assert!( - absent_policy_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23503")) - .unwrap_or(false), - "expected FK violation (23503) for absent policy_revision, got: {absent_policy_err}" - ); - - sqlx::query("ROLLBACK") - .execute(&mut *conn2) - .await - .expect("rollback absent-policy transaction"); - } - - /// NIP-FI policy-revision monotonicity: each new policy revision for a - /// community must strictly exceed the current maximum revision - /// (FI-INV-06 — stable assertion policy). `effective_at` ordering is - /// deliberately not enforced — the downstream constructor stamps every - /// immediately-effective revision with Unix epoch. - /// - /// Mutation sensitivity is two-sided: - /// - neutering the guard lets a replayed or backfilled revision through - /// (the positive half detects insertion into a guarded table); - /// - leaving the guard intact rejects equal/regressive inserts (negative - /// halves detect that each rejection fires). - #[tokio::test] - #[ignore = "requires Postgres"] - async fn identity_enrollment_policy_revision_is_monotonic() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(41, &pool) - .await - .expect("apply migrations 1-41"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("policy-mono-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // First insertion: no prior rows — should always succeed. - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 1, 1, $2, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA1_u8; 32]) - .execute(&pool) - .await - .expect("first policy insertion (revision 1) must succeed"); - - // Forward advance: revision 2. - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 2, 1, $2, '2026-06-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA2_u8; 32]) - .execute(&pool) - .await - .expect("forward advance to revision 2 must succeed"); - - // Seed a gap: skip from 2 to 100, then advance to 101. - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 100, 1, $2, '2027-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA3_u8; 32]) - .execute(&pool) - .await - .expect("jump to revision 100 must succeed"); - - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 101, 1, $2, '2027-06-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA4_u8; 32]) - .execute(&pool) - .await - .expect("advance to revision 101 must succeed"); - - // Negative: unused lower revision 99 — not a PK duplicate (never inserted), - // but the guard must reject it because 99 < MAX(100, 101). This is the - // case a plain PK constraint cannot catch; the named guard must fire. - let backfill_err = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 99, 1, $2, '2028-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA5_u8; 32]) - .execute(&pool) - .await - .expect_err("unused lower revision 99 must be rejected by the guard"); - assert!( - backfill_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from identity_enrollment_policy_revision_monotonic \ - guard for backfilled revision 99, got: {backfill_err}" - ); - - // Negative: equal revision (101 <= 101). The PK is (community_id, policy_revision) - // so this is a PK duplicate regardless of policy_digest; either 23505 from the PK - // or 23514 from the guard fires first. This case is secondary — the load-bearing - // proof is the unused-99 case above, which is not a PK duplicate. - let replay_err = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 101, 2, $2, '2028-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xA6_u8; 32]) - .execute(&pool) - .await - .expect_err("equal revision must be rejected"); - // PK (23505) or guard (23514) — either proves the insert cannot commit. - assert!( - replay_err - .as_database_error() - .map(|e| { - let code = e.code(); - let c = code.as_deref().unwrap_or(""); - c == "23514" || c == "23505" - }) - .unwrap_or(false), - "expected check_violation (23514) or unique_violation (23505) for replayed revision, \ - got: {replay_err}" - ); - - // Concurrency regression: prove the advisory lock is load-bearing. The - // test uses a controlled two-connection schedule: - // - // 1. tx1 opens a transaction and inserts revision 102. The BEFORE INSERT - // trigger acquires `pg_advisory_xact_lock(lock_key)` and completes the - // INSERT — tx1 now holds the advisory lock until it commits. - // 2. tx2 opens a transaction on a second backend and issues INSERT for - // revision 103. The trigger fires and blocks inside - // `pg_advisory_xact_lock(lock_key)` waiting for tx1 to release. - // 3. We observe tx2's backend entering a Lock-wait state via - // pg_stat_activity (wait_event_type='Lock', wait_event='advisory'), - // with a bounded timeout — not a sleep. If the advisory-lock call is - // removed from the guard, the trigger returns immediately; tx2 never - // enters the advisory wait, and the poll times out, failing the test. - // This is the mutation-sensitivity guarantee. - // 4. tx1 commits, releasing the advisory lock. tx2 unblocks, its trigger - // reads the fresh MAX=102, and the INSERT succeeds (103 > 102). - // 5. tx2 commits. Both revisions 102 and 103 are present. - use std::time::Instant; - - // tx1: open a transaction and insert revision 102. The INSERT returns after - // the trigger acquires the lock and succeeds; the advisory lock stays held - // until the transaction commits. - let mut conn1 = pool.acquire().await.expect("acquire conn1"); - sqlx::query("BEGIN") - .execute(&mut *conn1) - .await - .expect("begin tx1"); - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xB1_u8; 32]) - .execute(&mut *conn1) - .await - .expect("tx1 INSERT revision 102 must succeed"); - // tx1 holds the advisory lock. Do NOT commit yet. - - // tx2: acquire a separate backend, record its PID, then issue the INSERT. - // The trigger will block on the advisory lock held by tx1. - let pool2 = pool.clone(); - let pool3 = pool.clone(); - let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::(); - let tx2_task = tokio::spawn(async move { - let mut conn2 = pool2.acquire().await.expect("acquire conn2"); - // Report this backend's PID so the observer can poll pg_stat_activity. - let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") - .fetch_one(&mut *conn2) - .await - .expect("get conn2 backend pid"); - let _ = pid_tx.send(backend_pid); - sqlx::query("BEGIN") - .execute(&mut *conn2) - .await - .expect("begin tx2"); - // This INSERT will block inside the trigger waiting for tx1's advisory lock. - let insert_r = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 103, 1, $2, '2029-06-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xB2_u8; 32]) - .execute(&mut *conn2) - .await; - let commit_r = sqlx::query("COMMIT").execute(&mut *conn2).await; - (insert_r, commit_r) - }); - - // Receive tx2's backend PID and wait until it enters an advisory-lock wait. - // Mutation proof: without pg_advisory_xact_lock in the guard, the trigger - // returns immediately; tx2 never parks on an advisory lock; the poll below - // times out and panics, making this test deterministically red. - let tx2_pid = pid_rx.await.expect("tx2 reports its backend pid"); - let deadline = Instant::now() + std::time::Duration::from_secs(10); - loop { - let waiting: bool = sqlx::query_scalar( - "SELECT EXISTS (\ - SELECT 1 FROM pg_stat_activity \ - WHERE pid = $1 \ - AND wait_event_type = 'Lock' \ - AND wait_event = 'advisory'\ - )", - ) - .bind(tx2_pid) - .fetch_one(&pool3) - .await - .expect("poll tx2 advisory-lock wait"); - if waiting { - break; - } - assert!( - Instant::now() < deadline, - "tx2 never entered advisory-lock wait — pg_advisory_xact_lock \ - must be present in the guard for the lock to serialize writers" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - // tx2 is observably blocked. Commit tx1, releasing the advisory lock. - sqlx::query("COMMIT") - .execute(&mut *conn1) - .await - .expect("tx1 COMMIT must succeed"); - - // tx2 unblocks: the trigger re-runs its SELECT MAX, sees committed 102, - // and INSERT 103 succeeds. Both the INSERT and COMMIT must complete. - let (insert2, commit2) = tx2_task.await.expect("tx2 task completed"); - insert2.expect("tx2 INSERT revision 103 must succeed after tx1 commits"); - commit2.expect("tx2 COMMIT must succeed"); - - // Both revisions 102 and 103 must be present (total: 1, 2, 100, 101, 102, 103). - let count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1", - ) - .bind(community_id) - .fetch_one(&pool) - .await - .expect("count persisted policy revisions"); - assert_eq!( - count, 6, - "exactly six revisions must persist after the controlled concurrency sequence" - ); - } - - /// NIP-FI admission-result ↔ kind-11 receipt cardinality: a kind-11 - /// (protected-mutation) receipt must commit with exactly one admission - /// result; an admission result must commit against a kind-11 receipt. - /// - /// Mutation sensitivity is two-sided: - /// - the guard is load-bearing when a kind-11 receipt has no result row - /// (negative A) — without the guard this commits silently; - /// - the guard is load-bearing when a result attaches to a non-kind-11 - /// receipt (negative B) — without the guard this commits silently. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authorization_admission_result_requires_kind_11_receipt_bidirectional() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("adm-result-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Capacity must exist for authorization_events inserts; admission-result - // tests exercise only authorization_operation_receipts and - // authorization_admission_results — no authorization_events rows are - // needed here, but insert capacity anyway to satisfy any trigger - // that reads the policy row defensively. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - let mut conn = pool.acquire().await.expect("acquire connection"); - - // --- Positive: kind-11 receipt + admission result in one transaction --- - let op1 = uuid::Uuid::new_v4(); - let fp1 = vec![0xB1_u8; 32]; - - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 11, $4, 1, $5)", - ) - .bind(community_id) - .bind(op1) - .bind(&fp1) - .bind(vec![0xB2_u8; 32]) - .bind(vec![0xB3_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert kind-11 receipt"); - - sqlx::query( - "INSERT INTO authorization_admission_results \ - (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ - object_kind, object_key) \ - VALUES ($1, $2, $3, $4, 1, $5)", - ) - .bind(community_id) - .bind(op1) - .bind(&fp1) - .bind(vec![0xB4_u8; 32]) // semantic_fingerprint - .bind(vec![0xB5_u8; 32]) // object_key - .execute(&mut *conn) - .await - .expect("insert admission result"); - - sqlx::query("COMMIT") - .execute(&mut *conn) - .await - .expect("kind-11 receipt + result must commit"); - drop(conn); - - // --- Negative A: kind-11 receipt without result must be rejected --- - let op2 = uuid::Uuid::new_v4(); - let fp2 = vec![0xC1_u8; 32]; - - let mut conn_a = pool.acquire().await.expect("acquire connection A"); - sqlx::query("BEGIN") - .execute(&mut *conn_a) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 11, $4, 1, $5)", - ) - .bind(community_id) - .bind(op2) - .bind(&fp2) - .bind(vec![0xC2_u8; 32]) - .bind(vec![0xC3_u8; 32]) - .execute(&mut *conn_a) - .await - .expect("insert kind-11 receipt for negative A"); - - let no_result_err = sqlx::query("COMMIT") - .execute(&mut *conn_a) - .await - .expect_err("kind-11 receipt without result must be rejected at commit"); - assert!( - no_result_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) for kind-11 without result, got: {no_result_err}" - ); - drop(conn_a); - - // --- Negative B: admission result against non-kind-11 receipt --- - // Use operation_kind 12 (invalidation) — no admission result should - // ever attach to it. The guard fires at COMMIT (deferred trigger). - let op3 = uuid::Uuid::new_v4(); - let fp3 = vec![0xD1_u8; 32]; - - let mut conn_b = pool.acquire().await.expect("acquire connection B"); - sqlx::query("BEGIN") - .execute(&mut *conn_b) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 12, $4, 1, $5)", - ) - .bind(community_id) - .bind(op3) - .bind(&fp3) - .bind(vec![0xD2_u8; 32]) - .bind(vec![0xD3_u8; 32]) - .execute(&mut *conn_b) - .await - .expect("insert kind-12 receipt"); - - // The guard is deferred: the INSERT succeeds; the violation surfaces - // at COMMIT when the guard checks that the receipt is kind-11. - sqlx::query( - "INSERT INTO authorization_admission_results \ - (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ - object_kind, object_key) \ - VALUES ($1, $2, $3, $4, 1, $5)", - ) - .bind(community_id) - .bind(op3) - .bind(&fp3) - .bind(vec![0xD4_u8; 32]) - .bind(vec![0xD5_u8; 32]) - .execute(&mut *conn_b) - .await - .expect("result insert must pass — deferred guard fires at commit, not here"); - - let wrong_kind_err = sqlx::query("COMMIT") - .execute(&mut *conn_b) - .await - .expect_err("result against non-kind-11 receipt must be rejected at commit"); - assert!( - wrong_kind_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) for result against non-kind-11 receipt, got: {wrong_kind_err}" - ); - drop(conn_b); - - // --- Negative C: mismatched request_fingerprint rejected by composite FK --- - // The admission result table has an immediate composite FK - // (community_id, operation_id, request_fingerprint) - // REFERENCES authorization_operation_receipts(...) - // A result referencing a receipt that exists but with a different - // request_fingerprint must be rejected. This exercises the semantic half - // of Carl finding 2 — cardinality is handled by the deferred trigger; - // coordinate binding is handled by the structural FK. - let op4 = uuid::Uuid::new_v4(); - let fp4_receipt = vec![0xE1_u8; 32]; // fingerprint stored in the receipt - let fp4_wrong = vec![0xE2_u8; 32]; // wrong fingerprint used in the result - - let mut conn_c = pool.acquire().await.expect("acquire connection C"); - sqlx::query("BEGIN") - .execute(&mut *conn_c) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 11, $4, 1, $5)", - ) - .bind(community_id) - .bind(op4) - .bind(&fp4_receipt) - .bind(vec![0xE3_u8; 32]) - .bind(vec![0xE4_u8; 32]) - .execute(&mut *conn_c) - .await - .expect("insert kind-11 receipt for negative C"); - - // The admission result FK is immediate (not deferred), so the INSERT - // itself rejects a fingerprint with no matching receipt row. - let wrong_fp_err = sqlx::query( - "INSERT INTO authorization_admission_results \ - (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ - object_kind, object_key) \ - VALUES ($1, $2, $3, $4, 1, $5)", - ) - .bind(community_id) - .bind(op4) - .bind(&fp4_wrong) // wrong fingerprint — no matching receipt row - .bind(vec![0xE5_u8; 32]) - .bind(vec![0xE6_u8; 32]) - .execute(&mut *conn_c) - .await - .expect_err("result with mismatched request_fingerprint must be rejected at INSERT"); - // Immediate composite FK fires as foreign_key_violation (23503). - assert!( - wrong_fp_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23503")) - .unwrap_or(false), - "expected foreign_key_violation (23503) for mismatched request_fingerprint, got: {wrong_fp_err}" - ); - sqlx::query("ROLLBACK").execute(&mut *conn_c).await.ok(); - } - - /// NIP-FI denial-attempt ↔ kind-9 event cardinality: a kind-9 - /// (pre-authentication denial) audit event must commit with exactly one - /// denial attempt; a denial attempt must commit with a matching kind-9 - /// audit event. - /// - /// Mutation sensitivity is two-sided: - /// - the event-side guard is load-bearing when a kind-9 event has no - /// attempt row (negative A) — without it this commits silently, making - /// replay reconstruction impossible; - /// - the attempt-side guard is load-bearing for semantic mismatches (negatives - /// B1–B3) — the old deferred FK only checks event existence/kind and would - /// not catch a correlation, reason_code, or attempt_id mismatch. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authorization_denial_attempt_requires_kind_9_event_bidirectional() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("denial-attempt-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Capacity is required by the authorization_events BEFORE INSERT trigger. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - let mut conn = pool.acquire().await.expect("acquire connection"); - - // --- Positive: kind-9 event + denial attempt in one transaction --- - let op1 = uuid::Uuid::new_v4(); - let event1 = uuid::Uuid::new_v4(); - let corr1 = uuid::Uuid::new_v4(); - let attempt1_id = uuid::Uuid::new_v4(); - - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - // Insert denial attempt first (FKs are deferred). - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op1) - .bind(corr1) - .bind(vec![0xE1_u8; 32]) // semantic_fingerprint - .bind(attempt1_id) - .bind(event1) - .execute(&mut *conn) - .await - .expect("insert denial attempt before event"); - - // Insert the kind-9 event (actor_kind 4, no request_fingerprint). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event1) - .bind(op1) - .bind(corr1) - .bind(attempt1_id) - .bind(vec![0xE1_u8; 32]) // semantic_fingerprint matches denial attempt - .bind(vec![0xE2_u8; 64]) // canonical_envelope (≤16384 bytes) - .bind(vec![0xE3_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("insert kind-9 event"); - - sqlx::query("COMMIT") - .execute(&mut *conn) - .await - .expect("kind-9 event + denial attempt must commit"); - drop(conn); - - // --- Negative A: kind-9 event alone must be rejected at commit --- - let op2 = uuid::Uuid::new_v4(); - let event2 = uuid::Uuid::new_v4(); - let corr2 = uuid::Uuid::new_v4(); - let attempt2_id = uuid::Uuid::new_v4(); - - let mut conn_a = pool.acquire().await.expect("acquire connection A"); - sqlx::query("BEGIN") - .execute(&mut *conn_a) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event2) - .bind(op2) - .bind(corr2) - .bind(attempt2_id) - .bind(vec![0xF0_u8; 32]) // semantic_fingerprint (non-zero) - .bind(vec![0xF1_u8; 64]) - .bind(vec![0xF2_u8; 32]) - .execute(&mut *conn_a) - .await - .expect("insert kind-9 event without attempt"); - - let no_attempt_err = sqlx::query("COMMIT") - .execute(&mut *conn_a) - .await - .expect_err("kind-9 event without denial attempt must be rejected at commit"); - assert!( - no_attempt_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) for kind-9 event without attempt, got: {no_attempt_err}" - ); - drop(conn_a); - - // --- Negatives B1-B3: semantic coordinate mismatches, each attributed to - // the named guard (23514), not the old deferred FK (23503). Each case - // inserts a valid event then a denial attempt that matches everywhere - // except one coordinate; the guard must fire for that mismatch. - - // B1: correlation_id mismatch — attempt carries a different correlation - // than the event it references. - let op_b1 = uuid::Uuid::new_v4(); - let event_b1 = uuid::Uuid::new_v4(); - let corr_b1_event = uuid::Uuid::new_v4(); - let corr_b1_wrong = uuid::Uuid::new_v4(); // different from corr_b1_event - let attempt_b1 = uuid::Uuid::new_v4(); - - let mut conn_b1 = pool.acquire().await.expect("acquire connection B1"); - sqlx::query("BEGIN") - .execute(&mut *conn_b1) - .await - .expect("begin B1"); - - // Insert the event first (deferred FK allows this ordering). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b1) - .bind(op_b1) - .bind(corr_b1_event) - .bind(attempt_b1) - .bind(vec![0xB3_u8; 32]) // semantic_fingerprint matches denial attempt - .bind(vec![0xB1_u8; 64]) - .bind(vec![0xB2_u8; 32]) - .execute(&mut *conn_b1) - .await - .expect("insert kind-9 event for B1"); - - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_b1) - .bind(corr_b1_wrong) // wrong correlation_id - .bind(vec![0xB3_u8; 32]) - .bind(attempt_b1) - .bind(event_b1) - .execute(&mut *conn_b1) - .await - .expect("insert denial attempt with wrong correlation_id (guard deferred)"); - - let corr_err = sqlx::query("COMMIT") - .execute(&mut *conn_b1) - .await - .expect_err("mismatched correlation_id must be rejected at commit"); - assert!( - corr_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ - for correlation_id mismatch, got: {corr_err}" - ); - drop(conn_b1); - - // B2: reason_code mismatch — attempt carries denial_reason=1 (MissingCredential, - // requires reason_code=2 per canonical mapping), but event carries reason_code=1. - // The attempt INSERT passes (denial_reason=1↔reason_code=2 is a valid mapping pair), - // then the deferred guard fires at commit because event reason_code=1 ≠ attempt - // reason_code=2. - let op_b2 = uuid::Uuid::new_v4(); - let event_b2 = uuid::Uuid::new_v4(); - let corr_b2 = uuid::Uuid::new_v4(); - let attempt_b2 = uuid::Uuid::new_v4(); - - let mut conn_b2 = pool.acquire().await.expect("acquire connection B2"); - sqlx::query("BEGIN") - .execute(&mut *conn_b2) - .await - .expect("begin B2"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b2) - .bind(op_b2) - .bind(corr_b2) - .bind(attempt_b2) - .bind(vec![0xC3_u8; 32]) // semantic_fingerprint matches denial attempt - .bind(vec![0xC1_u8; 64]) - .bind(vec![0xC2_u8; 32]) - .execute(&mut *conn_b2) - .await - .expect("insert kind-9 event for B2 (reason_code=1)"); - - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - // reason_code = 2 but event has reason_code = 1 - ) - .bind(community_id) - .bind(op_b2) - .bind(corr_b2) - .bind(vec![0xC3_u8; 32]) - .bind(attempt_b2) - .bind(event_b2) - .execute(&mut *conn_b2) - .await - .expect( - "insert denial attempt with wrong reason_code (deferred guard will fire at commit)", - ); - - let reason_err = sqlx::query("COMMIT") - .execute(&mut *conn_b2) - .await - .expect_err("mismatched reason_code must be rejected at commit"); - assert!( - reason_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ - for reason_code mismatch, got: {reason_err}" - ); - drop(conn_b2); - - // B3: attempt_id mismatch — the denial attempt's attempt_id FK references - // a different event (attempt_b3_wrong) than the one being paired (event_b3). - // The attempt_id FK on the denial attempt table binds - // (community_id, operation_id, audit_event_kind, attempt_id) - // -> authorization_events(community_id, operation_id, event_kind, attempt_id) - // so using a different attempt_id that doesn't exist for this operation - // will be caught as a FK violation (23503) at commit. - let op_b3 = uuid::Uuid::new_v4(); - let event_b3 = uuid::Uuid::new_v4(); - let corr_b3 = uuid::Uuid::new_v4(); - let attempt_b3_correct = uuid::Uuid::new_v4(); - let attempt_b3_wrong = uuid::Uuid::new_v4(); // not registered for this operation - - let mut conn_b3 = pool.acquire().await.expect("acquire connection B3"); - sqlx::query("BEGIN") - .execute(&mut *conn_b3) - .await - .expect("begin B3"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b3) - .bind(op_b3) - .bind(corr_b3) - .bind(attempt_b3_correct) - .bind(vec![0xD3_u8; 32]) // semantic_fingerprint (matches denial attempt) - .bind(vec![0xD1_u8; 64]) - .bind(vec![0xD2_u8; 32]) - .execute(&mut *conn_b3) - .await - .expect("insert kind-9 event for B3"); - - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_b3) - .bind(corr_b3) - .bind(vec![0xD3_u8; 32]) - .bind(attempt_b3_wrong) // wrong attempt_id — no matching UNIQUE row on events - .bind(event_b3) - .execute(&mut *conn_b3) - .await - .expect("insert denial attempt with wrong attempt_id (FK is deferred)"); - - let attempt_err = sqlx::query("COMMIT") - .execute(&mut *conn_b3) - .await - .expect_err("mismatched attempt_id must be rejected at commit"); - // The attempt_id FK is deferred and fires as foreign_key_violation (23503). - assert!( - attempt_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23503")) - .unwrap_or(false), - "expected foreign_key_violation (23503) for attempt_id mismatch \ - (deferred FK on denial attempt), got: {attempt_err}" - ); - - // B4: denial_reason ↔ reason_code mapping violation — the denial attempt - // row carries denial_reason=2 (InvalidCredential) but reason_code=2 - // (Missing). The canonical mapping requires InvalidCredential(2)↔Invalid(3); - // reason_code=2 is only valid for MissingCredential(denial_reason=1). - // The immediate CHECK constraint authorization_denial_reason_reason_code_binding - // fires at INSERT, not commit. Mutation-sensitive: removing the CHECK lets - // this INSERT succeed (the guard does not compare denial_reason; only the - // paired event's reason_code is checked at commit). - let op_b4 = uuid::Uuid::new_v4(); - let event_b4 = uuid::Uuid::new_v4(); - let corr_b4 = uuid::Uuid::new_v4(); - let attempt_b4 = uuid::Uuid::new_v4(); - - let mut conn_b4 = pool.acquire().await.expect("acquire connection B4"); - sqlx::query("BEGIN") - .execute(&mut *conn_b4) - .await - .expect("begin B4"); - - // Insert the matching kind-9 event first. - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b4) - .bind(op_b4) - .bind(corr_b4) - .bind(attempt_b4) - .bind(vec![0xE4_u8; 32]) // semantic_fingerprint - .bind(vec![0xE5_u8; 64]) - .bind(vec![0xE6_u8; 32]) - .execute(&mut *conn_b4) - .await - .expect("insert kind-9 event for B4"); - - // Insert denial attempt with denial_reason=2 (InvalidCredential) but - // reason_code=2 (Missing) — violates the canonical mapping (requires reason_code=3). - let denial_reason_err = sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, $6, 9)", - // denial_reason=2 (InvalidCredential) requires reason_code=3; reason_code=2 is wrong - ) - .bind(community_id) - .bind(op_b4) - .bind(corr_b4) - .bind(vec![0xE4_u8; 32]) - .bind(attempt_b4) - .bind(event_b4) - .execute(&mut *conn_b4) - .await - .expect_err("denial_reason/reason_code mapping violation must be rejected at INSERT"); - - assert!( - denial_reason_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from \ - authorization_denial_reason_reason_code_binding for denial_reason mismatch, \ - got: {denial_reason_err}" - ); - - // B5: semantic_fingerprint mismatch — the event carries semantic_fingerprint - // 0xB5…B5 while the denial attempt carries 0xB6…B6. correlation_id, reason_code, - // and attempt_id all match; only the fingerprint differs. The deferred guard - // authorization_denial_attempt_guard_v1 fires at COMMIT on the denial-attempt - // side, compares found_semantic_fingerprint (from the event) with - // NEW.semantic_fingerprint (from the attempt), and raises 23514 with named - // constraint authorization_denial_attempt_semantic_binding. - // Mutation-sensitive: removing the semantic_fingerprint comparison block from - // the guard function lets this transaction commit. - let op_b5 = uuid::Uuid::new_v4(); - let event_b5 = uuid::Uuid::new_v4(); - let corr_b5 = uuid::Uuid::new_v4(); - let attempt_b5 = uuid::Uuid::new_v4(); - let fp_event_b5 = vec![0xB5_u8; 32]; // event semantic_fingerprint - let fp_attempt_b5 = vec![0xB6_u8; 32]; // mismatched attempt semantic_fingerprint - - let mut conn_b5 = pool.acquire().await.expect("acquire connection B5"); - sqlx::query("BEGIN") - .execute(&mut *conn_b5) - .await - .expect("begin B5"); - - // Insert the kind-9 event with fingerprint 0xB5…B5. - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, operation_id, correlation_id, attempt_id, \ - semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ - $6, '2026-01-01T00:00:00Z', $7, $8)", - ) - .bind(community_id) - .bind(event_b5) - .bind(op_b5) - .bind(corr_b5) - .bind(attempt_b5) - .bind(fp_event_b5) - .bind(vec![0xB7_u8; 64]) // canonical_envelope - .bind(vec![0xB8_u8; 32]) // envelope_digest - .execute(&mut *conn_b5) - .await - .expect("insert kind-9 event for B5"); - - // Insert denial attempt with the WRONG semantic_fingerprint (0xB6…B6). - // correlation_id, reason_code=2, denial_reason=1 (MissingCredential↔Missing), - // and attempt_id all match the event — only semantic_fingerprint differs. - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_b5) - .bind(corr_b5) - .bind(fp_attempt_b5) // 0xB6…B6 ≠ event's 0xB5…B5 - .bind(attempt_b5) - .bind(event_b5) - .execute(&mut *conn_b5) - .await - .expect( - "insert denial attempt with mismatched fingerprint (deferred guard fires at commit)", - ); - - let fp_mismatch_err = sqlx::query("COMMIT") - .execute(&mut *conn_b5) - .await - .expect_err("commit with mismatched semantic_fingerprint must be rejected"); - - assert!( - fp_mismatch_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) from \ - authorization_denial_attempt_semantic_binding for semantic_fingerprint mismatch, \ - got: {fp_mismatch_err}" - ); - } - - /// NIP-FI authenticated kind-9 OperatorDenied denial: an authenticated - /// kind-9 event (actor_kind 1–3, non-null request_fingerprint) must commit - /// without an authorization_authentication_denial_attempts row and must - /// reject any attempt to attach one. - /// - /// Mutation sensitivity: - /// - Removing the `actor_kind <> 4` guard from the event-side trigger makes - /// positive A red: the COMMIT fails because the guard now requires a - /// denial-attempt row for the authenticated event and none is present. - /// - Removing the `actor_kind <> 4` shape guard from the attempt-side - /// trigger makes negative B red: the COMMIT is rejected by the pre-existing - /// `authorization_denial_attempt_semantic_binding` guard instead (non-null - /// attempt `semantic_fingerprint` vs. null on the authenticated event), so - /// `assert_eq!` on the constraint name fails. The exact constraint name - /// assertion is therefore the load-bearing proof that the new shape guard — - /// not the pre-existing semantic-binding check — is what fires. - /// - /// The unresolved pre-auth positive path (actor_kind 4) is exercised in - /// `authorization_denial_attempt_requires_kind_9_event_bidirectional` and - /// is unchanged by this fix. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn authenticated_kind_9_denial_commits_without_denial_attempt() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("auth-denial-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - // Seed an operation receipt for the authenticated denial. Use - // operation_kind = 12 (invalidation) with outcome_code = 2 (denied): - // this satisfies the receipt CHECK constraints without triggering the - // lifecycle history guard (expected_count = 0 for non-lifecycle kinds) - // and without requiring a lifecycle event (expected_event_kind = NULL). - // The authorization_events FK on (community_id, operation_id, - // request_fingerprint) requires a receipt row. - let op_auth = uuid::Uuid::new_v4(); - let fp_auth = vec![0xA1_u8; 32]; - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 12, $4, 2, $5)", - // operation_kind 12 (invalidation), outcome_code 2 (denied) - ) - .bind(community_id) - .bind(op_auth) - .bind(&fp_auth) - .bind(vec![0xA2_u8; 32]) // actor_fingerprint - .bind(vec![0xA3_u8; 32]) // result_digest - .execute(&pool) - .await - .expect("seed authenticated denial receipt"); - - let event_auth = uuid::Uuid::new_v4(); - let corr_auth = uuid::Uuid::new_v4(); - let attempt_auth = uuid::Uuid::new_v4(); - - // --- Positive A: authenticated kind-9 denial (actor_kind = 1) commits - // without any denial-attempt row. The semantic_fingerprint must be NULL - // per the corrected shape CHECK. The deferred cardinality guard must - // skip this event because actor_kind ≠ 4. - let mut conn = pool.acquire().await.expect("acquire connection"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - ) - .bind(community_id) - .bind(event_auth) - .bind(vec![0xA4_u8; 32]) // actor_fingerprint (required for actor_kind 1) - .bind(op_auth) - .bind(&fp_auth) // non-null request_fingerprint (authenticated shape) - .bind(corr_auth) - .bind(attempt_auth) - // semantic_fingerprint = NULL: authenticated kind-9 must not carry one - .bind(vec![0xA5_u8; 64]) // canonical_envelope - .bind(vec![0xA6_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("insert authenticated kind-9 event"); - - sqlx::query("COMMIT").execute(&mut *conn).await.expect( - "authenticated kind-9 denial must commit without a denial-attempt row \ - — the event-side cardinality guard must skip actor_kind 1", - ); - drop(conn); - - // Confirm no denial attempt was needed: the table must have zero rows - // for this event. - let attempt_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM authorization_authentication_denial_attempts \ - WHERE community_id = $1 AND audit_event_id = $2", - ) - .bind(community_id) - .bind(event_auth) - .fetch_one(&pool) - .await - .expect("count denial attempts for authenticated event"); - assert_eq!( - attempt_count, 0, - "no denial-attempt row should exist for an authenticated kind-9 event" - ); - - // --- Negative B: a denial attempt cannot bind to the authenticated kind-9 - // event. The attempt-side shape guard must reject this at commit because - // the referenced event has actor_kind = 1 (not 4). The rejection must - // name the exact shape constraint (authorization_denial_attempt_event_kind) - // rather than merely returning 23514, proving the new actor/request-fingerprint - // guard fires — not the pre-existing semantic_fingerprint equality check - // (which would fire as authorization_denial_attempt_semantic_binding if - // the shape guard were absent, because the attempt carries a non-null - // semantic_fingerprint while the authenticated event has null). - // - // Reuse attempt_auth from the committed event so the deferred attempt_id - // FK resolves (wrong attempt_id would activate that FK first and make the - // negative non-isolated to the new guard). - let mut conn_b = pool.acquire().await.expect("acquire connection B"); - sqlx::query("BEGIN") - .execute(&mut *conn_b) - .await - .expect("begin B"); - - // Insert the denial attempt referencing the authenticated event. - // The attempt table FKs are deferred, so this INSERT succeeds; - // the shape guard fires at COMMIT. - sqlx::query( - "INSERT INTO authorization_authentication_denial_attempts \ - (community_id, operation_id, correlation_id, semantic_fingerprint, \ - denial_reason, expected_revision, action, reason_code, \ - attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", - ) - .bind(community_id) - .bind(op_auth) - .bind(corr_auth) - .bind(vec![0xA7_u8; 32]) // semantic_fingerprint on the attempt (non-null) - .bind(attempt_auth) // reuse the event's attempt_id — FK isolation - .bind(event_auth) // references the authenticated event (actor_kind = 1) - .execute(&mut *conn_b) - .await - .expect("attempt INSERT must pass — shape guard is deferred"); - - let cross_shape_err = sqlx::query("COMMIT") - .execute(&mut *conn_b) - .await - .expect_err( - "denial attempt binding to authenticated kind-9 event must be rejected at commit", - ); - // The exact constraint name must be authorization_denial_attempt_event_kind — - // the new actor/request_fingerprint shape guard. If the shape guard were - // removed, the pre-existing semantic_fingerprint equality check would fire - // instead, named authorization_denial_attempt_semantic_binding. Requiring - // the exact name makes the mutation reliably red. - assert_eq!( - cross_shape_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denial_attempt_event_kind"), - "rejection must be attributed to authorization_denial_attempt_event_kind \ - shape guard (not an incidental FK or semantic-binding check), \ - got: {cross_shape_err}" - ); - } - - /// NIP-FI denied lifecycle receipt: a denied core lifecycle receipt - /// (outcome_code = 2) must commit without a paired audit event. Requiring - /// one would falsely record that the lifecycle transition occurred. - /// - /// The denied branch forbids any event from the complete core - /// success-transition class (kinds 1, 2, 3, 6). This test uses the mapped - /// kind (kind 1 for enroll). Cross-kind rejection — a wrong success-transition - /// kind on a denied receipt — is exercised by - /// `denied_lifecycle_receipt_wrong_kind_receipt_side` (receipt-side trigger) - /// and `denied_lifecycle_receipt_wrong_kind_event_side` (event-side trigger). - /// - /// Mutation sensitivity: - /// - Removing the `outcome_code IN (1, 3)` branch entirely (or replacing it with a - /// blanket early-return) makes the positive case red — the denied enroll receipt - /// cannot commit alone because the guard then demands a paired enroll audit event - /// (expected_event_kind = 1) that is absent. - /// - Removing the `ELSIF outcome_code = 2` zero-event branch makes the - /// receipt-then-event negative below green (COMMIT succeeds when it must not), - /// failing `expect_err`. The event-side isolation in - /// `denied_lifecycle_receipt_event_side_trigger_isolated` independently confirms - /// the same branch using only the `authorization_event_receipt_cardinality` - /// trigger direction. - /// Applied/no-op lifecycle cardinality is exercised by - /// `applied_lifecycle_receipt_requires_exactly_one_event`. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_commits_without_audit_event() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!( - "denied-lifecycle-{}.example", - community_id.simple() - )) - .execute(&pool) - .await - .expect("insert community"); - - // Denied enroll receipt (operation_kind = 1, outcome_code = 2) must commit - // without any paired authorization_events row. The guard must skip it - // because outcome_code = 2 is not in (1, 3). - // - // The receipt history guard (migration 0041) uses `outcome_code IN (1, 3)` - // for lifecycle receipts, so a denied enroll receipt (outcome_code = 2) - // expects zero lifecycle history rows — no history setup is needed. - let op_denied = uuid::Uuid::new_v4(); - let fp_denied = vec![0xB1_u8; 32]; - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - // operation_kind 1 (enroll), outcome_code 2 (denied) - ) - .bind(community_id) - .bind(op_denied) - .bind(&fp_denied) - .bind(vec![0xB2_u8; 32]) - .bind(vec![0xB3_u8; 32]) - .execute(&pool) - .await - .expect("denied enroll receipt must commit without a paired audit event"); - - // No audit event for this operation; confirm the table is empty for it. - let event_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM authorization_events \ - WHERE community_id = $1 AND operation_id = $2", - ) - .bind(community_id) - .bind(op_denied) - .fetch_one(&pool) - .await - .expect("count events for denied receipt"); - assert_eq!( - event_count, 0, - "no audit event should be required or present for a denied lifecycle receipt" - ); - - // --- Negative: denied enroll receipt paired with its mapped success- - // transition event (event_kind = 1, enrolled) must be rejected at COMMIT. - // The receipt-side deferred trigger fires here (receipt was inserted in - // this same transaction). The event-side trigger direction is isolated in - // `denied_lifecycle_receipt_event_side_trigger_isolated`. - // - // Seed event capacity; the authorization_events BEFORE INSERT trigger - // requires a capacity row. No lifecycle history is needed: denied receipts - // (outcome_code = 2) expect zero history rows per the history guard. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - let op_neg = uuid::Uuid::new_v4(); - let fp_neg = vec![0xD1_u8; 32]; - let event_neg = uuid::Uuid::new_v4(); - let corr_neg = uuid::Uuid::new_v4(); - let attempt_neg = uuid::Uuid::new_v4(); - - let mut conn_neg = pool.acquire().await.expect("acquire connection neg"); - sqlx::query("BEGIN") - .execute(&mut *conn_neg) - .await - .expect("begin neg"); - - // Denied enroll receipt — no history row needed (outcome_code = 2). - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_neg) - .bind(&fp_neg) - .bind(vec![0xD2_u8; 32]) - .bind(vec![0xD3_u8; 32]) - .execute(&mut *conn_neg) - .await - .expect("insert denied receipt — event guard is deferred"); - - // Insert the mapped success-transition event (event_kind = 1, enrolled). - // actor_kind = 1 requires a non-null actor_fingerprint and a matching - // receipt FK (satisfied by the denied receipt above, which shares the - // same (community_id, operation_id, request_fingerprint)). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - // event_kind 1 (enrolled) — the mapped success transition for enroll - ) - .bind(community_id) - .bind(event_neg) - .bind(vec![0xD4_u8; 32]) // actor_fingerprint - .bind(op_neg) - .bind(&fp_neg) - .bind(corr_neg) - .bind(attempt_neg) - .bind(vec![0xD5_u8; 64]) // canonical_envelope - .bind(vec![0xD6_u8; 32]) // envelope_digest - .execute(&mut *conn_neg) - .await - .expect("event INSERT must pass — deferred guard fires at COMMIT"); - - let contradiction_err = sqlx::query("COMMIT") - .execute(&mut *conn_neg) - .await - .expect_err( - "denied receipt + mapped success event must be rejected at COMMIT \ - — contradictory durable facts must not be permitted", - ); - assert_eq!( - contradiction_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "expected authorization_denied_lifecycle_receipt_no_success_event constraint \ - rejection for denied receipt + success event, got: {contradiction_err}" - ); - } - - /// NIP-FI event-side trigger isolation: when a denied enroll receipt is already - /// committed (auto-commit via pool), a new independent transaction that inserts - /// only the mapped success-transition event must be rejected at COMMIT by - /// `authorization_event_receipt_cardinality` (the event-side deferred trigger). - /// - /// This isolates the `authorization_event_receipt_cardinality` trigger path. - /// In `denied_lifecycle_receipt_commits_without_audit_event`'s receipt-then-event - /// negative, the receipt-side trigger (`authorization_operation_receipt_event_cardinality`) - /// also fires. Here the committed receipt produces no deferred trigger, so rejection - /// can only come from the event-side trigger. Uses the mapped kind (kind 1 for - /// enroll). Wrong-kind event-side isolation is in - /// `denied_lifecycle_receipt_wrong_kind_event_side`. - /// - /// Mutation sensitivity: disabling the - /// `authorization_event_receipt_cardinality` trigger (DROP or ALTER TABLE - /// DISABLE TRIGGER) makes this negative green — the COMMIT succeeds when it - /// must not, so `expect_err` panics. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_event_side_trigger_isolated() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("evt-side-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - // Seed event capacity before any event insert. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - // Commit a denied enroll receipt in auto-commit mode (no explicit BEGIN). - // This receipt produces no deferred trigger — the receipt-side deferred - // trigger only fires within the transaction that inserts the receipt row. - let op_id = uuid::Uuid::new_v4(); - let fp = vec![0xE1_u8; 32]; - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xE2_u8; 32]) - .bind(vec![0xE3_u8; 32]) - .execute(&pool) - .await - .expect("denied receipt must commit alone in auto-commit mode"); - - // Now open a NEW transaction and insert only the mapped success-transition - // event (event_kind = 1, enrolled). The receipt is already committed and - // its deferred trigger is no longer active. Rejection at COMMIT must come - // from authorization_event_receipt_cardinality (the event-side trigger). - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for event-side test"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin event-side transaction"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xE4_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xE5_u8; 64]) // canonical_envelope - .bind(vec![0xE6_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); - - let event_side_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( - "mapping a success-transition event to a committed denied receipt \ - must be rejected at COMMIT by the event-side trigger", - ); - assert_eq!( - event_side_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event, \ - got: {event_side_err}" - ); - } - - /// NIP-FI applied lifecycle receipt: an applied core lifecycle enroll receipt - /// (outcome_code = 1) requires exactly one mapped success-transition event - /// (event_kind = 1, enrolled). This exercises the `outcome_code IN (1, 3)` - /// branch of `authorization_operation_receipt_event_guard_v1` at migration 42. - /// - /// Mutation sensitivity: - /// - Removing/bypassing the applied/no-op branch (replacing it with a blanket - /// RETURN NULL) makes the positive transaction commit without an event, leaving - /// the contract silently unenforced. The negative below requires the cardinality - /// constraint to fire when the event is absent. - /// - Removing the negative assertion: the absent-event case would commit when it - /// must not. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn applied_lifecycle_receipt_requires_exactly_one_event() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!( - "applied-lifecycle-{}.example", - community_id.simple() - )) - .execute(&pool) - .await - .expect("insert community"); - - // Enrollment policy (TOFU, mode 3). - let policy_revision: i64 = 1; - sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(policy_revision) - .bind(vec![0xF0_u8; 32]) - .execute(&pool) - .await - .expect("insert enrollment policy"); - - // Event capacity — required by authorization_event_capacity_before_insert_v1. - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - // --- Positive: applied enroll commits with exactly one mapped event --- - // - // All cross-table FKs between identity_lifecycle_history, identity_bindings, - // authorization_operation_receipts, and authorization_events are - // DEFERRABLE INITIALLY DEFERRED — insert order within the transaction is - // flexible, but a pinned connection is required for BEGIN/COMMIT to share - // the same session. The receipt_history_cardinality trigger (migration 0041) - // fires at COMMIT and requires exactly one history row for applied enroll. - let op_id = uuid::Uuid::new_v4(); - let binding_id = uuid::Uuid::new_v4(); - let history_id = uuid::Uuid::new_v4(); - let fp = vec![0xF1_u8; 32]; - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for positive case"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin positive transaction"); - - // History first: the receipt_history_cardinality AFTER INSERT trigger - // on authorization_operation_receipts is DEFERRED and checks at COMMIT - // time, but inserting history before receipt is idiomatic. - // successor_binding_version = 1 because binding_version is an identity - // sequence starting at 1 per community; this is the first binding. - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", - ) - .bind(community_id) - .bind(history_id) - .bind(binding_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xF2_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert lifecycle history"); - - // Applied enroll receipt (operation_kind = 1, outcome_code = 1). - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xF3_u8; 32]) - .bind(vec![0xF4_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert applied enroll receipt"); - - // Binding — birth_history_id FK is deferred; binding_version is generated. - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-applied', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(binding_id) - .bind(vec![0xF5_u8; 32]) // principal_fingerprint - .bind(vec![0xF6_u8; 32]) // event_author_pubkey - .bind(policy_revision) - .bind(vec![0xF7_u8; 32]) // enrollment_evidence_digest - .bind(history_id) - .bind(op_id) - .bind(&fp) - .execute(&mut *conn) - .await - .expect("insert identity binding"); - - // Mapped success-transition event (event_kind = 1, enrolled). - // actor_kind = 1 requires non-null actor_fingerprint and matching receipt FK. - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xF8_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xF9_u8; 64]) // canonical_envelope - .bind(vec![0xFA_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("insert mapped success-transition event"); - - sqlx::query("COMMIT").execute(&mut *conn).await.expect( - "applied enroll receipt + exactly one mapped event must commit — \ - authorization_operation_receipt_event_guard_v1 applied/no-op branch", - ); - - // Confirm exactly one event committed for this operation. - let event_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM authorization_events \ - WHERE community_id = $1 AND operation_id = $2", - ) - .bind(community_id) - .bind(op_id) - .fetch_one(&pool) - .await - .expect("count events for applied receipt"); - assert_eq!( - event_count, 1, - "exactly one audit event must be present for an applied enroll receipt" - ); - - // --- Negative: applied enroll receipt without a mapped event must reject --- - // - // A second applied enroll transaction that commits receipt + history + binding - // but no event must be rejected with authorization_operation_receipt_event_cardinality. - let op_neg = uuid::Uuid::new_v4(); - let binding_neg = uuid::Uuid::new_v4(); - let history_neg = uuid::Uuid::new_v4(); - let fp_neg = vec![0xFB_u8; 32]; - - let mut conn_neg = pool - .acquire() - .await - .expect("acquire connection for negative case"); - sqlx::query("BEGIN") - .execute(&mut *conn_neg) - .await - .expect("begin negative transaction"); - - sqlx::query( - "INSERT INTO identity_lifecycle_history \ - (community_id, history_id, transition_kind, outcome_code, \ - successor_binding_id, successor_binding_version, \ - successor_lifecycle_revision, successor_state, \ - operation_id, request_fingerprint, transition_digest) \ - VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", - // successor_binding_version = 2: second binding in this community - ) - .bind(community_id) - .bind(history_neg) - .bind(binding_neg) - .bind(op_neg) - .bind(&fp_neg) - .bind(vec![0xFC_u8; 32]) - .execute(&mut *conn_neg) - .await - .expect("insert negative lifecycle history"); - - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 1, $5)", - ) - .bind(community_id) - .bind(op_neg) - .bind(&fp_neg) - .bind(vec![0xFD_u8; 32]) - .bind(vec![0xFE_u8; 32]) - .execute(&mut *conn_neg) - .await - .expect("insert negative applied receipt"); - - sqlx::query( - "INSERT INTO identity_bindings \ - (community_id, binding_id, issuer, subject, \ - principal_fingerprint, event_author_pubkey, \ - binding_state, lifecycle_revision, binding_provenance, \ - policy_revision, enrollment_evidence_digest, \ - birth_history_id, creation_operation_id, \ - creation_request_fingerprint) \ - VALUES ($1, $2, 'https://issuer.example', 'sub-applied-neg', \ - $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", - ) - .bind(community_id) - .bind(binding_neg) - .bind(vec![0xE7_u8; 32]) // principal_fingerprint (distinct from positive) - .bind(vec![0xE8_u8; 32]) // event_author_pubkey (distinct from positive) - .bind(policy_revision) - .bind(vec![0xE9_u8; 32]) - .bind(history_neg) - .bind(op_neg) - .bind(&fp_neg) - .execute(&mut *conn_neg) - .await - .expect("insert negative binding — no event inserted"); - - // Commit without the mapped event — guard must reject. - let absent_event_err = sqlx::query("COMMIT") - .execute(&mut *conn_neg) - .await - .expect_err( - "applied enroll receipt without a mapped success-transition event \ - must be rejected at COMMIT", - ); - assert_eq!( - absent_event_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_operation_receipt_event_cardinality"), - "expected authorization_operation_receipt_event_cardinality rejection \ - for applied receipt without event, got: {absent_event_err}" - ); - } - - /// NIP-FI cross-kind denied lifecycle: a wrong success-transition kind paired - /// with a denied lifecycle receipt must be rejected through the receipt-side - /// deferred trigger. Uses a denied enroll receipt (operation_kind = 1, mapped - /// kind = 1) with a kind-6 (retired) event — a different success-transition - /// kind that is equally forbidden by the class-based guard (kinds 1, 2, 3, 6). - /// - /// Both the receipt and the wrong-kind event are inserted in the same - /// transaction, so the receipt-side deferred trigger - /// (`authorization_operation_receipt_event_cardinality`) fires at COMMIT. - /// - /// Mutation sensitivity: narrowing the denied filter back to - /// `event_kind = expected_event_kind` (the mapped kind, 1) removes kind 6 - /// from the forbidden set, causing this negative to turn green — COMMIT - /// succeeds when it must not, failing `expect_err`. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_wrong_kind_receipt_side() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("wrong-kind-rcpt-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - let op_id = uuid::Uuid::new_v4(); - let fp = vec![0xA0_u8; 32]; - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for wrong-kind receipt-side test"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin"); - - // Denied enroll receipt (operation_kind = 1, outcome_code = 2). - // No history row needed: outcome_code = 2 expects zero history rows. - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xA1_u8; 32]) - .bind(vec![0xA2_u8; 32]) - .execute(&mut *conn) - .await - .expect("insert denied enroll receipt — deferred guard"); - - // Wrong success-transition kind: event_kind = 6 (retired), not the mapped - // kind 1 (enrolled). Both are in the forbidden class (1, 2, 3, 6). - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - // event_kind 6 (retired) — wrong kind for a denied enroll receipt - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xA3_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xA4_u8; 64]) // canonical_envelope - .bind(vec![0xA5_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("event INSERT must pass — deferred guard fires at COMMIT"); - - let wrong_kind_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( - "denied receipt + wrong success-transition kind (6) must be rejected at COMMIT \ - — class-based guard forbids all of kinds 1, 2, 3, 6", - ); - assert_eq!( - wrong_kind_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "expected authorization_denied_lifecycle_receipt_no_success_event for \ - denied receipt + wrong kind (6), got: {wrong_kind_err}" - ); - } - - /// NIP-FI cross-kind denied lifecycle event-side: after a denied enroll - /// receipt is committed alone (auto-commit), a new transaction that inserts - /// only a wrong success-transition kind (kind 6, retired) must be rejected at - /// COMMIT by `authorization_event_receipt_cardinality` (event-side trigger). - /// - /// This isolates the event-side trigger path for the cross-kind case. - /// The committed receipt produces no active deferred trigger, so rejection - /// can only come from the event-side trigger. - /// - /// Mutation sensitivity: narrowing the denied filter to - /// `event_kind = expected_event_kind` (kind 1) removes kind 6 from the - /// forbidden set, making this negative green — COMMIT succeeds when it must - /// not, failing `expect_err`. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn denied_lifecycle_receipt_wrong_kind_event_side() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - MIGRATOR - .run_to(42, &pool) - .await - .expect("apply migrations 1-42"); - - let community_id = uuid::Uuid::new_v4(); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community_id) - .bind(format!("wrong-kind-evt-{}.example", community_id.simple())) - .execute(&pool) - .await - .expect("insert community"); - - sqlx::query( - "INSERT INTO authorization_event_capacity \ - (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ - VALUES ($1, 1000, 16777216, 16384)", - ) - .bind(community_id) - .execute(&pool) - .await - .expect("insert event capacity"); - - // Commit a denied enroll receipt in auto-commit mode. No deferred trigger - // is active after this commit; the receipt-side trigger fires only within - // the transaction that inserts the receipt. - let op_id = uuid::Uuid::new_v4(); - let fp = vec![0xB0_u8; 32]; - sqlx::query( - "INSERT INTO authorization_operation_receipts \ - (community_id, operation_id, request_fingerprint, operation_kind, \ - actor_fingerprint, outcome_code, result_digest) \ - VALUES ($1, $2, $3, 1, $4, 2, $5)", - ) - .bind(community_id) - .bind(op_id) - .bind(&fp) - .bind(vec![0xB1_u8; 32]) - .bind(vec![0xB2_u8; 32]) - .execute(&pool) - .await - .expect("denied receipt must commit alone in auto-commit mode"); - - // New transaction: insert only a kind-6 (retired) event for the same - // operation. The event-side trigger is the only active deferred trigger. - let event_id = uuid::Uuid::new_v4(); - let corr_id = uuid::Uuid::new_v4(); - let attempt_id = uuid::Uuid::new_v4(); - - let mut conn = pool - .acquire() - .await - .expect("acquire connection for wrong-kind event-side test"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin event-side wrong-kind transaction"); - - sqlx::query( - "INSERT INTO authorization_events \ - (community_id, event_id, event_kind, outcome_code, reason_code, \ - actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ - correlation_id, attempt_id, semantic_fingerprint, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ - '2026-01-01T00:00:00Z', $8, $9)", - // event_kind 6 (retired) — wrong kind for the denied enroll receipt - ) - .bind(community_id) - .bind(event_id) - .bind(vec![0xB3_u8; 32]) // actor_fingerprint - .bind(op_id) - .bind(&fp) - .bind(corr_id) - .bind(attempt_id) - .bind(vec![0xB4_u8; 64]) // canonical_envelope - .bind(vec![0xB5_u8; 32]) // envelope_digest - .execute(&mut *conn) - .await - .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); - - let wrong_kind_evt_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( - "kind-6 event paired with a committed denied enroll receipt must be \ - rejected at COMMIT by the event-side trigger", - ); - assert_eq!( - wrong_kind_evt_err - .as_database_error() - .and_then(|e| e.constraint()), - Some("authorization_denied_lifecycle_receipt_no_success_event"), - "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event \ - for wrong kind (6) against denied receipt, got: {wrong_kind_evt_err}" - ); + .expect("deletion catalog validates after migration 0044"); } } diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 214cc4bca6..5f608cb78f 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -24,7 +24,9 @@ pub async fn insert_mentions( event: &nostr::Event, channel_id: Option, ) -> Result<()> { - let mut tx = pool.begin().await?; + let connection = + observability::acquire_writer(pool, observability::WriterOperation::EventWrite).await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; insert_mentions_in_transaction(&mut tx, community_id, event, channel_id).await?; tx.commit().await?; Ok(()) @@ -687,25 +689,43 @@ impl Db { return; }; let aurora_identity = self.reader_aurora_identity.clone(); - tokio::spawn(async move { - match observability::acquire(&read_pool, observability::PoolRole::Reader).await { - Ok(mut conn) => { - tracing::info!("read replica reachable at boot"); - match replica_fence::reader_supports_aurora_identity(&mut conn).await { - Ok(supported) => { - let _ = aurora_identity.set(supported); - } - Err(e) => tracing::debug!( - error = %e, - "aurora identity boot prime failed; first routed read will probe" - ), + tokio::spawn(Self::read_pool_boot_ping_once(read_pool, aurora_identity)); + } + + async fn read_pool_boot_ping_once( + read_pool: PgPool, + aurora_identity: std::sync::Arc>, + ) { + match observability::acquire_reader_with_legacy_metrics( + &read_pool, + observability::ReaderOperation::Bootstrap, + ) + .await + { + Ok(mut conn) => { + tracing::info!("read replica reachable at boot"); + match replica_fence::reader_supports_aurora_identity(&mut conn).await { + Ok(supported) => { + let _ = aurora_identity.set(supported); } + Err(e) => tracing::debug!( + error = %e, + "aurora identity boot prime failed; first routed read will probe" + ), } - Err(e) => tracing::warn!( - "read replica unreachable at boot; serving all-writer until it recovers: {e}" - ), } - }); + Err(e) => tracing::warn!( + "read replica unreachable at boot; serving all-writer until it recovers: {e}" + ), + } + } + + #[cfg(test)] + pub(crate) async fn read_pool_boot_ping_for_tests(&self) { + let Some(read_pool) = self.read_pool.clone() else { + return; + }; + Self::read_pool_boot_ping_once(read_pool, self.reader_aurora_identity.clone()).await; } /// Creates a `Db` from an existing `PgPool` (useful in tests). @@ -771,8 +791,7 @@ impl Db { if self.read_pool.is_none() { return Ok(false); } - replica_fence::verify_floor_guard_catalog(&self.pool).await?; - replica_fence::verify_floor_guard_behavior(&self.pool).await?; + self.verify_replica_fence_at_boot().await?; tokio::spawn(replica_fence::run_probe( self.pool.clone(), std::sync::Arc::clone(&self.fence), @@ -780,6 +799,17 @@ impl Db { Ok(true) } + /// Verify replica-fence catalog shape and behavior through attributed + /// writer/bootstrap acquisitions without starting the recurring probe. + pub(crate) async fn verify_replica_fence_at_boot(&self) -> Result<()> { + let mut connection = + observability::acquire_writer(&self.pool, observability::WriterOperation::Bootstrap) + .await?; + replica_fence::verify_floor_guard_catalog(&mut *connection).await?; + drop(connection); + replica_fence::verify_floor_guard_behavior(&self.pool).await + } + /// The pool for lag-tolerant reads: the read replica when configured, /// otherwise the writer pool. /// @@ -817,6 +847,7 @@ impl Db { async fn proved_reader( &self, read_pool: &PgPool, + operation: observability::ReaderOperation, ) -> std::result::Result< ( sqlx::Transaction<'static, sqlx::Postgres>, @@ -830,7 +861,9 @@ impl Db { // `read_pool` separately would spend a second budget whenever the // capability is uncached — i.e. after a failed boot ping, which is // precisely the reader-unavailable case the bound must hold for. - let conn = match observability::acquire(read_pool, observability::PoolRole::Reader).await { + let conn = match observability::acquire_reader_with_legacy_metrics(read_pool, operation) + .await + { Ok(conn) => conn, Err(sqlx::Error::PoolTimedOut) => { tracing::warn!("reader pool acquire timed out; routing to writer"); @@ -953,7 +986,16 @@ impl Db { /// Returns `true` if the database is reachable. pub async fn ping(&self) -> bool { - sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() + let Ok(mut connection) = + observability::acquire_writer(&self.pool, observability::WriterOperation::Readiness) + .await + else { + return false; + }; + sqlx::query("SELECT 1") + .execute(&mut *connection) + .await + .is_ok() } /// Checks writer-pool acquisition and query execution against one deadline. @@ -974,14 +1016,19 @@ impl Db { deadline: tokio::time::Instant, query: &'static str, ) -> DbReadinessOutcome { - let mut connection = match tokio::time::timeout_at(deadline, self.pool.acquire()).await { - Err(_) => return DbReadinessOutcome::PoolTimeout, - Ok(Err(sqlx::Error::PoolTimedOut)) => return DbReadinessOutcome::PoolTimeout, - Ok(Err(error)) => { + let mut connection = match observability::acquire_writer_until( + &self.pool, + observability::WriterOperation::Readiness, + deadline, + ) + .await + { + Err(sqlx::Error::PoolTimedOut) => return DbReadinessOutcome::PoolTimeout, + Err(error) => { tracing::debug!(error = %error, "Postgres readiness pool acquisition failed"); return DbReadinessOutcome::PoolError; } - Ok(Ok(connection)) => connection, + Ok(connection) => connection, }; match tokio::time::timeout_at(deadline, sqlx::query(query).execute(&mut *connection)).await @@ -1008,6 +1055,15 @@ impl Db { } } + /// Refresh all expected operation-specific waiter gauges, including zero. + /// + /// The relay pool sampler calls this periodically so an exporter idle + /// timeout cannot make a healthy zero indistinguishable from missing + /// telemetry. + pub fn refresh_pool_waiter_metrics(&self) { + observability::refresh_pool_waiters(self.read_pool.is_some()); + } + /// Pool utilisation stats for the read-replica pool, when configured. /// /// `max` is the **reader's** ceiling ([`Db::read_max_connections`]), not @@ -1028,14 +1084,29 @@ impl Db { /// /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. /// The transaction holds an owned pool handle, not a borrow. - pub async fn begin_transaction(&self) -> Result> { - let connection = - observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + pub async fn begin_event_write_transaction( + &self, + ) -> Result> { + let connection = observability::acquire_writer_with_legacy_metrics( + &self.pool, + observability::WriterOperation::EventWrite, + ) + .await?; sqlx::Transaction::begin(connection, None) .await .map_err(Into::into) } + /// Begin an event-write transaction through the pre-operation API name. + /// + /// New callers should use [`Self::begin_event_write_transaction`] so the + /// semantic intent is explicit. This alias preserves the crate's public + /// API while emitting the same operation-aware and compatibility metrics. + #[deprecated(note = "use Db::begin_event_write_transaction")] + pub async fn begin_transaction(&self) -> Result> { + self.begin_event_write_transaction().await + } + /// Insert an event while holding and validating an admitted serving-write /// lease under the community ordering lock through commit. /// @@ -1058,7 +1129,10 @@ impl Db { return Err(DbError::EphemeralEventRejected(kind_u16)); } - let mut tx = self.pool.begin().await?; + let connection = + observability::acquire_writer(&self.pool, observability::WriterOperation::EventWrite) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; self.deletion_store() .guard_transaction_with_serving_lease(&mut tx, lease) .await?; @@ -1086,6 +1160,7 @@ impl Db { &self, path: &'static str, predicate: RoutePredicate, + operation: observability::ReaderOperation, ) -> RouteDecision { let Some(read_pool) = &self.read_pool else { Self::record_route(path, "writer", "disabled"); @@ -1128,7 +1203,7 @@ impl Db { Self::record_route(path, "writer", reason); return RouteDecision::Writer; } - match self.proved_reader(read_pool).await { + match self.proved_reader(read_pool, operation).await { Ok((tx, entry)) => { // Re-evaluate against the entry the session actually proved // (it may be older than the shared newest). diff --git a/crates/buzz-db/src/runtime/observability.rs b/crates/buzz-db/src/runtime/observability.rs index 0d4774ccad..4d2e5f7ad6 100644 --- a/crates/buzz-db/src/runtime/observability.rs +++ b/crates/buzz-db/src/runtime/observability.rs @@ -4,26 +4,156 @@ //! never derive labels from tenant data, events, SQL text, or query identifiers. use std::future::Future; +use std::sync::Mutex; use std::time::{Duration, Instant}; +/// One valid pool/operation acquisition family. +/// +/// Keeping role and operation in one enum makes invalid combinations +/// unrepresentable at call sites and gives the series budget one exhaustive +/// source of truth. +#[repr(usize)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum PoolRole { - Writer, - Reader, +enum PoolOperation { + WriterBootstrap, + ReaderBootstrap, + WriterReadiness, + WriterTenantResolution, + WriterAuthentication, + WriterAuthorization, + ReaderAuthorization, + WriterSubscriptionHistory, + ReaderSubscriptionHistory, + WriterEventWrite, + WriterMaintenance, } -impl PoolRole { - #[cfg(test)] - pub(crate) const ALL: [Self; 2] = [Self::Writer, Self::Reader]; +/// Writer-pool operations. Reader-only combinations cannot be constructed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WriterOperation { + Bootstrap, + Readiness, + TenantResolution, + Authentication, + Authorization, + SubscriptionHistory, + EventWrite, + Maintenance, +} - pub(crate) const fn as_str(self) -> &'static str { +impl WriterOperation { + #[cfg(test)] + const ALL: [Self; 8] = [ + Self::Bootstrap, + Self::Readiness, + Self::TenantResolution, + Self::Authentication, + Self::Authorization, + Self::SubscriptionHistory, + Self::EventWrite, + Self::Maintenance, + ]; + + const fn pair(self) -> PoolOperation { match self { - Self::Writer => "writer", - Self::Reader => "reader", + Self::Bootstrap => PoolOperation::WriterBootstrap, + Self::Readiness => PoolOperation::WriterReadiness, + Self::TenantResolution => PoolOperation::WriterTenantResolution, + Self::Authentication => PoolOperation::WriterAuthentication, + Self::Authorization => PoolOperation::WriterAuthorization, + Self::SubscriptionHistory => PoolOperation::WriterSubscriptionHistory, + Self::EventWrite => PoolOperation::WriterEventWrite, + Self::Maintenance => PoolOperation::WriterMaintenance, } } } +/// Reader-pool operations. Writer-only combinations cannot be constructed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ReaderOperation { + Bootstrap, + Authorization, + SubscriptionHistory, +} + +impl ReaderOperation { + #[cfg(test)] + const ALL: [Self; 3] = [ + Self::Bootstrap, + Self::Authorization, + Self::SubscriptionHistory, + ]; + + const fn pair(self) -> PoolOperation { + match self { + Self::Bootstrap => PoolOperation::ReaderBootstrap, + Self::Authorization => PoolOperation::ReaderAuthorization, + Self::SubscriptionHistory => PoolOperation::ReaderSubscriptionHistory, + } + } +} + +impl PoolOperation { + pub(crate) const ALL: [Self; 11] = [ + Self::WriterBootstrap, + Self::ReaderBootstrap, + Self::WriterReadiness, + Self::WriterTenantResolution, + Self::WriterAuthentication, + Self::WriterAuthorization, + Self::ReaderAuthorization, + Self::WriterSubscriptionHistory, + Self::ReaderSubscriptionHistory, + Self::WriterEventWrite, + Self::WriterMaintenance, + ]; + + pub(crate) const fn pool_role(self) -> &'static str { + match self { + Self::ReaderBootstrap | Self::ReaderAuthorization | Self::ReaderSubscriptionHistory => { + "reader" + } + _ => "writer", + } + } + + pub(crate) const fn operation(self) -> &'static str { + match self { + Self::WriterBootstrap | Self::ReaderBootstrap => "bootstrap", + Self::WriterReadiness => "readiness", + Self::WriterTenantResolution => "tenant_resolution", + Self::WriterAuthentication => "authentication", + Self::WriterAuthorization | Self::ReaderAuthorization => "authorization", + Self::WriterSubscriptionHistory | Self::ReaderSubscriptionHistory => { + "subscription_history" + } + Self::WriterEventWrite => "event_write", + Self::WriterMaintenance => "maintenance", + } + } + + const fn index(self) -> usize { + self as usize + } +} + +pub(crate) const POOL_ACQUIRE_VALID_PAIRS: [(&str, &str); 11] = [ + ("writer", "bootstrap"), + ("reader", "bootstrap"), + ("writer", "readiness"), + ("writer", "tenant_resolution"), + ("writer", "authentication"), + ("writer", "authorization"), + ("reader", "authorization"), + ("writer", "subscription_history"), + ("reader", "subscription_history"), + ("writer", "event_write"), + ("writer", "maintenance"), +]; + +/// Eleven valid pairs × (12 histogram series + 4 outcome counters + 1 gauge). +pub(crate) const POOL_ACQUIRE_RAW_SERIES_PER_POD: usize = POOL_ACQUIRE_VALID_PAIRS.len() * 17; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum LockType { Replacement, @@ -59,17 +189,19 @@ pub(crate) enum Outcome { Success, Error, Timeout, + Cancelled, } impl Outcome { #[cfg(test)] - pub(crate) const ALL: [Self; 3] = [Self::Success, Self::Error, Self::Timeout]; + pub(crate) const ALL: [Self; 4] = [Self::Success, Self::Error, Self::Timeout, Self::Cancelled]; pub(crate) const fn as_str(self) -> &'static str { match self { Self::Success => "success", Self::Error => "error", Self::Timeout => "timeout", + Self::Cancelled => "cancelled", } } @@ -115,42 +247,256 @@ impl TransactionOperation { Self::FenceCommunityDeletion => "fence_community_deletion", } } + + const fn writer_operation(self) -> WriterOperation { + match self { + Self::ReplaceParameterizedEvent + | Self::ReplaceAddressableEvent + | Self::PublishNip43MembershipLocked + | Self::AcceptPushLeaseEvent => WriterOperation::EventWrite, + Self::BeginCommunityDeletionQuiescing | Self::FenceCommunityDeletion => { + WriterOperation::Maintenance + } + } + } } -pub(crate) fn record_pool_acquire(role: PoolRole, outcome: Outcome, elapsed: Duration) { +fn record_pool_acquire( + pair: PoolOperation, + outcome: Outcome, + elapsed: Duration, + emit_legacy: bool, +) { + // Preserve the original observed population for existing dashboards. + // Newly instrumented raw-pool seams must not create a deployment-time + // discontinuity in these compatibility families. + if emit_legacy && outcome != Outcome::Cancelled { + metrics::histogram!( + "buzz_db_pool_acquire_wait_seconds", + "pool_role" => pair.pool_role(), + "outcome" => outcome.as_str(), + ) + .record(elapsed.as_secs_f64()); + metrics::counter!( + "buzz_db_pool_acquisitions_total", + "pool_role" => pair.pool_role(), + "outcome" => outcome.as_str(), + ) + .increment(1); + } + metrics::histogram!( - "buzz_db_pool_acquire_wait_seconds", - "pool_role" => role.as_str(), - "outcome" => outcome.as_str(), + "buzz_db_pool_acquire_duration_seconds", + "pool_role" => pair.pool_role(), + "operation" => pair.operation(), ) .record(elapsed.as_secs_f64()); metrics::counter!( - "buzz_db_pool_acquisitions_total", - "pool_role" => role.as_str(), + "buzz_db_pool_acquire_attempts_total", + "pool_role" => pair.pool_role(), + "operation" => pair.operation(), "outcome" => outcome.as_str(), ) .increment(1); } -pub(crate) async fn acquire( +static POOL_WAITERS: [Mutex; PoolOperation::ALL.len()] = + [const { Mutex::new(0) }; PoolOperation::ALL.len()]; + +#[cfg(test)] +static POOL_METRICS_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +#[cfg(test)] +#[derive(Clone)] +struct WaiterPublishTestHook { + pair: PoolOperation, + value: u64, + entered: std::sync::Arc, + release: std::sync::Arc, + armed: std::sync::Arc, +} + +#[cfg(test)] +static WAITER_PUBLISH_TEST_HOOK: Mutex> = Mutex::new(None); + +#[cfg(test)] +static WAITER_LAST_PUBLISHED: [Mutex; PoolOperation::ALL.len()] = + [const { Mutex::new(u64::MAX) }; PoolOperation::ALL.len()]; + +fn publish_waiters(pair: PoolOperation, value: u64) { + #[cfg(test)] + { + let hook = WAITER_PUBLISH_TEST_HOOK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if let Some(hook) = hook { + if hook.pair == pair + && hook.value == value + && hook.armed.swap(false, std::sync::atomic::Ordering::SeqCst) + { + hook.entered.wait(); + hook.release.wait(); + } + } + *WAITER_LAST_PUBLISHED[pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = value; + } + metrics::gauge!( + "buzz_db_pool_waiters", + "pool_role" => pair.pool_role(), + "operation" => pair.operation(), + ) + .set(value as f64); +} + +/// Re-publish every valid waiter pair, including healthy zero, so exporter +/// idle eviction cannot turn an expected zero into ambiguous missing data. +pub(crate) fn refresh_pool_waiters(include_reader: bool) { + for pair in PoolOperation::ALL { + if pair.pool_role() == "reader" && !include_reader { + continue; + } + let waiters = POOL_WAITERS[pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + publish_waiters(pair, *waiters); + } +} + +/// Owns one polled connection acquisition until exactly one terminal. +/// +/// Because async function bodies do not run until first poll, a future that is +/// constructed and immediately dropped emits nothing. Once armed, dropping it +/// while awaiting SQLx records `cancelled`, duration, and the balanced waiter +/// decrement. +struct PoolAcquireAttempt { + pair: PoolOperation, + started: Instant, + emit_legacy: bool, + terminal: bool, +} + +impl PoolAcquireAttempt { + fn start(pair: PoolOperation, emit_legacy: bool) -> Self { + { + let mut waiters = POOL_WAITERS[pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *waiters += 1; + publish_waiters(pair, *waiters); + } + Self { + pair, + started: Instant::now(), + emit_legacy, + terminal: false, + } + } + + fn finish(mut self, outcome: Outcome) { + self.terminal = true; + record_pool_acquire(self.pair, outcome, self.started.elapsed(), self.emit_legacy); + self.release_waiter(); + } + + fn release_waiter(&self) { + let mut waiters = POOL_WAITERS[self.pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(*waiters > 0, "pool waiter balance underflow"); + *waiters = waiters.saturating_sub(1); + publish_waiters(self.pair, *waiters); + } +} + +impl Drop for PoolAcquireAttempt { + fn drop(&mut self) { + if !self.terminal { + record_pool_acquire( + self.pair, + Outcome::Cancelled, + self.started.elapsed(), + self.emit_legacy, + ); + self.release_waiter(); + self.terminal = true; + } + } +} + +async fn acquire( pool: &sqlx::PgPool, - role: PoolRole, + pair: PoolOperation, + emit_legacy: bool, ) -> sqlx::Result> { - let started = Instant::now(); + let attempt = PoolAcquireAttempt::start(pair, emit_legacy); let result = pool.acquire().await; let outcome = result .as_ref() .map(|_| Outcome::Success) .unwrap_or_else(Outcome::from_sqlx_error); - record_pool_acquire(role, outcome, started.elapsed()); + attempt.finish(outcome); result } +/// Acquire from an authoritative writer pool for one valid writer operation. +pub(crate) async fn acquire_writer( + pool: &sqlx::PgPool, + operation: WriterOperation, +) -> sqlx::Result> { + acquire(pool, operation.pair(), false).await +} + +/// Acquire from a writer seam already covered by the pre-operation metric. +pub(crate) async fn acquire_writer_with_legacy_metrics( + pool: &sqlx::PgPool, + operation: WriterOperation, +) -> sqlx::Result> { + acquire(pool, operation.pair(), true).await +} + +/// Acquire from a reader seam already covered by the pre-operation metric. +pub(super) async fn acquire_reader_with_legacy_metrics( + pool: &sqlx::PgPool, + operation: ReaderOperation, +) -> sqlx::Result> { + acquire(pool, operation.pair(), true).await +} + +/// Acquire within an operation-owned absolute deadline. +/// +/// A deadline expiry is a timeout terminal. Dropping the enclosing future +/// before that deadline remains a cancellation terminal. +pub(crate) async fn acquire_writer_until( + pool: &sqlx::PgPool, + operation: WriterOperation, + deadline: tokio::time::Instant, +) -> sqlx::Result> { + let pair = operation.pair(); + let attempt = PoolAcquireAttempt::start(pair, false); + match tokio::time::timeout_at(deadline, pool.acquire()).await { + Err(_) => { + attempt.finish(Outcome::Timeout); + Err(sqlx::Error::PoolTimedOut) + } + Ok(result) => { + let outcome = result + .as_ref() + .map(|_| Outcome::Success) + .unwrap_or_else(Outcome::from_sqlx_error); + attempt.finish(outcome); + result + } + } +} + pub(crate) async fn begin_transaction( pool: &sqlx::PgPool, operation: TransactionOperation, ) -> sqlx::Result<(sqlx::Transaction<'static, sqlx::Postgres>, TransactionTimer)> { - let connection = acquire(pool, PoolRole::Writer).await?; + let connection = acquire_writer_with_legacy_metrics(pool, operation.writer_operation()).await?; let transaction = sqlx::Transaction::begin(connection, None).await?; Ok((transaction, TransactionTimer::start(operation))) } @@ -221,16 +567,44 @@ impl Drop for TransactionTimer { #[cfg(test)] mod tests { use super::{ - acquire, observe_advisory_lock, record_pool_acquire, LockType, Outcome, PoolRole, - TransactionOperation, TransactionTimer, + acquire_reader_with_legacy_metrics, acquire_writer, acquire_writer_with_legacy_metrics, + observe_advisory_lock, record_pool_acquire, refresh_pool_waiters, LockType, Outcome, + PoolAcquireAttempt, PoolOperation, ReaderOperation, TransactionOperation, TransactionTimer, + WriterOperation, }; use metrics_util::debugging::{DebugValue, DebuggingRecorder}; use std::collections::{BTreeMap, BTreeSet}; + use std::sync::{Arc, Barrier}; use std::time::Duration; #[test] fn label_vocabularies_are_closed_and_documented() { - assert_eq!(PoolRole::ALL.map(PoolRole::as_str), ["writer", "reader"]); + assert_eq!( + PoolOperation::ALL.map(|pair| (pair.pool_role(), pair.operation())), + super::POOL_ACQUIRE_VALID_PAIRS + ); + assert_eq!( + WriterOperation::ALL.map(WriterOperation::pair), + [ + PoolOperation::WriterBootstrap, + PoolOperation::WriterReadiness, + PoolOperation::WriterTenantResolution, + PoolOperation::WriterAuthentication, + PoolOperation::WriterAuthorization, + PoolOperation::WriterSubscriptionHistory, + PoolOperation::WriterEventWrite, + PoolOperation::WriterMaintenance, + ] + ); + assert_eq!( + ReaderOperation::ALL.map(ReaderOperation::pair), + [ + PoolOperation::ReaderBootstrap, + PoolOperation::ReaderAuthorization, + PoolOperation::ReaderSubscriptionHistory, + ] + ); + assert_eq!(super::POOL_ACQUIRE_RAW_SERIES_PER_POD, 187); assert_eq!( LockType::ALL.map(LockType::as_str), [ @@ -243,7 +617,7 @@ mod tests { ); assert_eq!( Outcome::ALL.map(Outcome::as_str), - ["success", "error", "timeout"] + ["success", "error", "timeout", "cancelled"] ); assert_eq!( TransactionOperation::ALL.map(TransactionOperation::as_str), @@ -311,14 +685,16 @@ mod tests { let _guard = metrics::set_default_local_recorder(&recorder); record_pool_acquire( - PoolRole::Writer, + PoolOperation::WriterReadiness, Outcome::Success, Duration::from_millis(12), + false, ); record_pool_acquire( - PoolRole::Reader, + PoolOperation::ReaderSubscriptionHistory, Outcome::Timeout, Duration::from_millis(34), + true, ); let lock_ok: sqlx::Result<()> = observe_advisory_lock(LockType::Replacement, async { Ok(()) }).await; @@ -353,18 +729,10 @@ mod tests { .collect::>(); for expected in [ - ( - "buzz_db_pool_acquire_wait_seconds", - [("outcome", "success"), ("pool_role", "writer")], - ), ( "buzz_db_pool_acquire_wait_seconds", [("outcome", "timeout"), ("pool_role", "reader")], ), - ( - "buzz_db_pool_acquisitions_total", - [("outcome", "success"), ("pool_role", "writer")], - ), ( "buzz_db_pool_acquisitions_total", [("outcome", "timeout"), ("pool_role", "reader")], @@ -410,6 +778,65 @@ mod tests { "missing metric series {expected:?}; got {keys:?}" ); } + for name in [ + "buzz_db_pool_acquire_wait_seconds", + "buzz_db_pool_acquisitions_total", + ] { + assert!( + !keys.contains(&( + name.to_owned(), + [ + ("outcome".to_owned(), "success".to_owned()), + ("pool_role".to_owned(), "writer".to_owned()), + ] + .into_iter() + .collect(), + )), + "newly instrumented seams must not expand legacy metric population" + ); + } + + for (name, labels) in [ + ( + "buzz_db_pool_acquire_duration_seconds", + [("operation", "readiness"), ("pool_role", "writer")], + ), + ( + "buzz_db_pool_acquire_duration_seconds", + [ + ("operation", "subscription_history"), + ("pool_role", "reader"), + ], + ), + ] { + let labels = labels + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect(); + assert!( + keys.contains(&(name.to_owned(), labels)), + "missing operation-aware pool duration for {name}" + ); + } + for (pool_role, operation, outcome) in [ + ("writer", "readiness", "success"), + ("reader", "subscription_history", "timeout"), + ] { + assert!(keys.contains(&( + "buzz_db_pool_acquire_attempts_total".to_owned(), + [ + ("operation".to_owned(), operation.to_owned()), + ("outcome".to_owned(), outcome.to_owned()), + ("pool_role".to_owned(), pool_role.to_owned()), + ] + .into_iter() + .collect(), + ))); + } + assert!(keys.iter().all(|(name, labels)| { + name != "buzz_db_pool_acquire_duration_seconds" + || (!labels.contains_key("outcome") && !labels.contains_key("result")) + })); for (key, _, _, value) in snapshot { if key.key().name().ends_with("_seconds") { @@ -426,44 +853,391 @@ mod tests { } } - async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { - // This timeout also bounds the pool's initial connection. Leave enough - // headroom for a cold PostgreSQL start under the lane's eight workers; - // the assertion below cares about classification, not a 75 ms budget. - let database_url = std::env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials - let pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(1) - .acquire_timeout(Duration::from_secs(1)) - .connect(&database_url) - .await - .expect("connect size-one test pool"); + #[test] + fn cancelled_attempt_records_terminal_and_refreshes_zero() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.blocking_lock(); let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); let _guard = metrics::set_default_local_recorder(&recorder); - let held = acquire(&pool, PoolRole::Writer) + let attempt = PoolAcquireAttempt::start(PoolOperation::WriterTenantResolution, false); + drop(attempt); + refresh_pool_waiters(true); + + let mut saw_cancelled = false; + let mut saw_zero = false; + for (key, _, _, value) in snapshotter.snapshot().into_vec() { + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + if labels.get("operation") != Some(&"tenant_resolution") { + continue; + } + match key.key().name() { + "buzz_db_pool_acquire_attempts_total" => { + let DebugValue::Counter(value) = value else { + panic!("attempt terminals must be a counter"); + }; + saw_cancelled = labels.get("outcome") == Some(&"cancelled") && value == 1; + } + "buzz_db_pool_waiters" => { + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be a gauge"); + }; + saw_zero = value.into_inner() == 0.0; + } + _ => {} + } + } + assert!( + saw_cancelled, + "dropped armed attempt must terminalize cancellation" + ); + assert!(saw_zero, "periodic refresh must publish a healthy zero"); + } + + #[test] + fn waiter_refresh_omits_reader_pairs_when_no_reader_pool_is_configured() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.blocking_lock(); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + refresh_pool_waiters(false); + + let published = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_waiters" { + return None; + } + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be gauges"); + }; + assert_eq!(value.into_inner(), 0.0); + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + Some((labels["pool_role"].clone(), labels["operation"].clone())) + }) + .collect::>(); + let expected = WriterOperation::ALL + .into_iter() + .map(|operation| { + let pair = operation.pair(); + (pair.pool_role().to_owned(), pair.operation().to_owned()) + }) + .collect::>(); + + assert_eq!(published, expected); + assert!(published.iter().all(|(pool_role, _)| pool_role == "writer")); + } + + #[tokio::test(flavor = "current_thread")] + async fn compatibility_metrics_only_cover_preexisting_acquisition_seams() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.lock().await; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy(&crate::test_support::database_url()) + .expect("construct lazy compatibility test pool"); + pool.close().await; + + let error = acquire_writer(&pool, WriterOperation::EventWrite) + .await + .expect_err("closed newly instrumented seam errors"); + assert!(matches!(error, sqlx::Error::PoolClosed)); + assert_eq!( + legacy_acquisition_count(&snapshotter.snapshot().into_vec()), + 0 + ); + + let error = acquire_writer_with_legacy_metrics(&pool, WriterOperation::EventWrite) + .await + .expect_err("closed legacy seam errors"); + assert!(matches!(error, sqlx::Error::PoolClosed)); + assert_eq!( + legacy_acquisition_count(&snapshotter.snapshot().into_vec()), + 1 + ); + } + + fn legacy_acquisition_count( + snapshot: &[( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )], + ) -> u64 { + snapshot + .iter() + .filter_map(|(key, _, _, value)| { + (key.key().name() == "buzz_db_pool_acquisitions_total") + .then_some(value) + .map(|value| match value { + DebugValue::Counter(value) => *value, + _ => panic!("legacy acquisitions must be a counter"), + }) + }) + .sum() + } + + #[test] + fn concurrent_attempts_publish_an_exact_balanced_waiter_count() { + const ATTEMPTS: usize = 8; + + let _test_guard = super::POOL_METRICS_TEST_LOCK.blocking_lock(); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let armed = Arc::new(Barrier::new(ATTEMPTS + 1)); + let release = Arc::new(Barrier::new(ATTEMPTS + 1)); + let threads = (0..ATTEMPTS) + .map(|_| { + let armed = Arc::clone(&armed); + let release = Arc::clone(&release); + std::thread::spawn(move || { + let attempt = + PoolAcquireAttempt::start(PoolOperation::WriterTenantResolution, false); + armed.wait(); + release.wait(); + drop(attempt); + }) + }) + .collect::>(); + + armed.wait(); + refresh_pool_waiters(true); + let live = waiter_value( + &snapshotter.snapshot().into_vec(), + "writer", + "tenant_resolution", + ); + assert_eq!(live, Some(ATTEMPTS as f64)); + + release.wait(); + for thread in threads { + thread.join().expect("waiter thread completes"); + } + refresh_pool_waiters(true); + let balanced = waiter_value( + &snapshotter.snapshot().into_vec(), + "writer", + "tenant_resolution", + ); + assert_eq!(balanced, Some(0.0)); + } + + #[test] + fn waiter_publication_is_serialized_with_state_mutation() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.blocking_lock(); + let pair = PoolOperation::WriterTenantResolution; + let first = PoolAcquireAttempt::start(pair, false); + let second = PoolAcquireAttempt::start(pair, false); + let entered = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + *super::WAITER_PUBLISH_TEST_HOOK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(super::WaiterPublishTestHook { + pair, + value: 1, + entered: Arc::clone(&entered), + release: Arc::clone(&release), + armed: Arc::new(std::sync::atomic::AtomicBool::new(true)), + }); + + let first_drop = std::thread::spawn(move || drop(first)); + entered.wait(); + let mutation_lock_held = super::POOL_WAITERS[pair.index()].try_lock().is_err(); + release.wait(); + first_drop.join().expect("first drop completes"); + drop(second); + *super::WAITER_PUBLISH_TEST_HOOK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + + assert!( + mutation_lock_held, + "waiter state mutation must remain locked until its publication completes" + ); + assert_eq!( + *super::WAITER_LAST_PUBLISHED[pair.index()] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + 0, + "the final directly published waiter value must be balanced without a refresh" + ); + } + + fn waiter_value( + snapshot: &[( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )], + pool_role: &str, + operation: &str, + ) -> Option { + snapshot.iter().find_map(|(key, _, _, value)| { + let labels = key.key().labels().collect::>(); + if key.key().name() != "buzz_db_pool_waiters" + || !labels + .iter() + .any(|label| label.key() == "pool_role" && label.value() == pool_role) + || !labels + .iter() + .any(|label| label.key() == "operation" && label.value() == operation) + { + return None; + } + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be gauges"); + }; + Some(value.into_inner()) + }) + } + + async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { + // This timeout also bounds the pool's initial connection. Leave enough + // headroom for a cold PostgreSQL start under the lane's eight workers; + // the assertion below cares about classification, not a sub-second + // synthetic timeout budget. + let database_url = crate::test_support::database_url(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .connect(&database_url) + .await + .expect("connect size-one test pool"); + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .connect(&database_url) + .await + .expect("connect size-one reader test pool"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let held = acquire_writer_with_legacy_metrics(&pool, WriterOperation::EventWrite) .await .expect("writer acquire succeeds"); - let timeout = acquire(&pool, PoolRole::Reader) + let mut cancelled = Box::pin(acquire_writer_with_legacy_metrics( + &pool, + WriterOperation::Authentication, + )); + tokio::select! { + result = &mut cancelled => panic!("blocked acquisition unexpectedly completed: {result:?}"), + () = tokio::time::sleep(Duration::from_millis(40)) => {} + } + let before_cancel = snapshotter.snapshot().into_vec(); + let live_waiter = before_cancel.iter().find_map(|(key, _, _, value)| { + let labels = key.key().labels().collect::>(); + if key.key().name() != "buzz_db_pool_waiters" + || !labels + .iter() + .any(|label| label.key() == "pool_role" && label.value() == "writer") + || !labels + .iter() + .any(|label| label.key() == "operation" && label.value() == "authentication") + { + return None; + } + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be a gauge"); + }; + Some(value.into_inner()) + }); + assert_eq!(live_waiter, Some(1.0)); + let legacy_before_cancel = legacy_acquisition_count(&before_cancel); + assert_eq!( + legacy_before_cancel, 1, + "the completed legacy acquisition must be counted exactly once" + ); + let writer_success = before_cancel.iter().any(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_acquire_wait_seconds" { + return false; + } + let labels = key.key().labels().collect::>(); + let DebugValue::Histogram(samples) = value else { + panic!("pool wait must be a histogram"); + }; + labels + .iter() + .any(|label| label.key() == "pool_role" && label.value() == "writer") + && labels + .iter() + .any(|label| label.key() == "outcome" && label.value() == "success") + && !samples.is_empty() + }); + drop(cancelled); + let after_cancel = snapshotter.snapshot().into_vec(); + let legacy_after_cancel = legacy_acquisition_count(&after_cancel); + let mut cancelled_terminal = None; + let mut balanced_waiter = None; + for (key, _, _, value) in after_cancel { + let labels = key.key().labels().collect::>(); + let label = |name: &str| { + labels + .iter() + .find(|label| label.key() == name) + .map(|label| label.value()) + }; + if label("pool_role") != Some("writer") || label("operation") != Some("authentication") + { + continue; + } + match key.key().name() { + "buzz_db_pool_waiters" => { + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be a gauge"); + }; + balanced_waiter = Some(value.into_inner()); + } + "buzz_db_pool_acquire_attempts_total" if label("outcome") == Some("cancelled") => { + let DebugValue::Counter(value) = value else { + panic!("cancelled acquisition terminal must be a counter"); + }; + cancelled_terminal = Some(value); + } + _ => {} + } + } + assert_eq!(balanced_waiter, Some(0.0)); + assert_eq!(cancelled_terminal, Some(1)); + assert_eq!( + legacy_after_cancel, 0, + "cancelling a legacy seam must not expand its historical population" + ); + let held_reader = reader_pool + .acquire() .await - .expect_err("reader-labeled checkout times out while pool is saturated"); + .expect("hold the reader test connection"); + let timeout = + acquire_reader_with_legacy_metrics(&reader_pool, ReaderOperation::SubscriptionHistory) + .await + .expect_err("reader-labeled checkout times out while pool is saturated"); assert!(matches!(timeout, sqlx::Error::PoolTimedOut)); + drop(held_reader); drop(held); pool.close().await; - let closed = acquire(&pool, PoolRole::Writer) + let closed = acquire_writer_with_legacy_metrics(&pool, WriterOperation::Readiness) .await .expect_err("closed pool acquire errors"); assert!(matches!(closed, sqlx::Error::PoolClosed)); let mut outcomes = BTreeMap::<(String, String), Vec>::new(); for (key, _, _, value) in snapshotter.snapshot().into_vec() { - if key.key().name() != "buzz_db_pool_acquire_wait_seconds" { - continue; - } - let DebugValue::Histogram(samples) = value else { - panic!("pool wait must be a histogram"); - }; let labels = key.key().labels().collect::>(); let label = |name: &str| { labels @@ -472,15 +1246,27 @@ mod tests { .map(|label| label.value().to_owned()) .unwrap_or_default() }; - outcomes.insert( - (label("pool_role"), label("outcome")), - samples - .into_iter() - .map(|sample| sample.into_inner()) - .collect(), - ); + if key.key().name() == "buzz_db_pool_acquire_wait_seconds" { + let DebugValue::Histogram(samples) = value else { + panic!("pool wait must be a histogram"); + }; + if samples.is_empty() { + continue; + } + outcomes.insert( + (label("pool_role"), label("outcome")), + samples + .into_iter() + .map(|sample| sample.into_inner()) + .collect(), + ); + } } - assert!(outcomes.contains_key(&("writer".to_owned(), "success".to_owned()))); + assert!(writer_success); + assert!( + !outcomes.contains_key(&("writer".to_owned(), "cancelled".to_owned())), + "legacy compatibility families must not add a cancellation population" + ); assert!(outcomes.contains_key(&("writer".to_owned(), "error".to_owned()))); let timeout_samples = outcomes .get(&("reader".to_owned(), "timeout".to_owned())) @@ -491,9 +1277,395 @@ mod tests { ); } + async fn deletion_catalog_readiness_records_timeout_and_recovers() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.lock().await; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .connect(&crate::test_support::database_url()) + .await + .expect("connect size-one deletion readiness pool"); + let db = crate::Db::from_pool(pool.clone()); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let held = pool.acquire().await.expect("hold the only pool connection"); + let timeout = db + .validate_deletion_serving_catalog_for_readiness( + tokio::time::Instant::now() + Duration::from_millis(40), + ) + .await + .expect_err("saturated deletion catalog checkout must time out"); + assert!(matches!( + timeout, + crate::DbError::Sqlx(sqlx::Error::PoolTimedOut) + )); + drop(held); + db.validate_deletion_serving_catalog_for_readiness( + tokio::time::Instant::now() + Duration::from_secs(2), + ) + .await + .expect("deletion catalog readiness must recover after pool release"); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!( + waiter_value(&snapshot, "writer", "readiness"), + Some(0.0), + "deadline terminal must directly balance the readiness waiter" + ); + for outcome in ["timeout", "success"] { + assert!( + snapshot.iter().any(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_acquire_attempts_total" { + return false; + } + let labels = key.key().labels().collect::>(); + let has = |name: &str, expected: &str| { + labels + .iter() + .any(|label| label.key() == name && label.value() == expected) + }; + has("pool_role", "writer") + && has("operation", "readiness") + && has("outcome", outcome) + && matches!(value, DebugValue::Counter(1)) + }), + "missing writer/readiness/{outcome} acquisition terminal" + ); + } + } + + async fn production_db_methods_emit_exact_pool_operation_labels() { + use buzz_core::CommunityId; + use chrono::Utc; + use uuid::Uuid; + + let database_url = crate::test_support::database_url(); + let writer_pool = crate::Db::connect_writer_pool(&crate::DbConfig { + database_url: database_url.clone(), + max_connections: 4, + min_connections: 0, + acquire_timeout_secs: 5, + ..crate::DbConfig::default() + }) + .await + .expect("connect production-method writer pool"); + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(4) + .acquire_timeout(Duration::from_secs(5)) + .connect(&database_url) + .await + .expect("connect production-method reader pool"); + let writer_db = crate::Db::from_pool(writer_pool.clone()); + let mut routed_db = crate::Db::from_pools(writer_pool.clone(), reader_pool); + routed_db.set_replica_read_max_age_for_tests(Some(Duration::from_secs(5))); + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let test_scope = CommunityId::from_uuid(Uuid::new_v4()); + let query = crate::EventQuery::for_community(test_scope); + + routed_db.read_pool_boot_ping_for_tests().await; + routed_db + .verify_replica_fence_at_boot() + .await + .expect("real startup fence verification succeeds"); + let _ = crate::replica_fence::probe_once(&writer_pool, routed_db.fence()).await; + routed_db.fence().force_open_for_tests(Utc::now()); + assert_eq!( + writer_db + .readiness_check(tokio::time::Instant::now() + Duration::from_secs(1)) + .await, + crate::DbReadinessOutcome::Success + ); + let _ = writer_db + .lookup_community_by_host("pool-operation-matrix.invalid") + .await; + let _ = writer_db + .lookup_community_by_host_for_management("pool-operation-matrix.invalid") + .await; + let _ = writer_db.list_communities_owned_by(&"a".repeat(64)).await; + let _ = writer_db.lookup_community_host(test_scope).await; + let _ = writer_db + .set_community_icon(test_scope, Some("pool-operation-matrix")) + .await; + let _ = writer_db + .create_community_with_owner( + &format!("pool-operation-matrix-{}.invalid", Uuid::new_v4().simple()), + &"b".repeat(64), + ) + .await; + let _ = writer_db + .archive_community_owned_by( + "pool-operation-matrix.invalid", + &"c".repeat(64), + "protected.invalid", + ) + .await; + let _ = writer_db + .unarchive_community_owned_by("pool-operation-matrix.invalid", &"c".repeat(64)) + .await; + let _ = writer_db.community_of_channel(Uuid::new_v4()).await; + let _ = writer_db.communities_of_channels(&[Uuid::new_v4()]).await; + let _ = writer_db + .ensure_user_for_authorization(test_scope, &[17; 32]) + .await; + let _ = writer_db + .set_agent_owner_for_authorization(test_scope, &[18; 32], &[19; 32]) + .await; + let _ = writer_db.is_pubkey_allowed(test_scope, &[7; 32]).await; + let _ = writer_db + .is_agent_owner(test_scope, &[8; 32], &[9; 32]) + .await; + let _ = writer_db + .moderation_restriction_state(test_scope, &[14; 32]) + .await; + let _ = writer_db + .get_agent_channel_policy(test_scope, &[15; 32]) + .await; + let _ = writer_db + .get_thread_metadata_by_event(test_scope, &[10; 32]) + .await; + let _ = writer_db.get_thread_summary(test_scope, &[16; 32]).await; + let _ = writer_db + .get_channel_for_event_write(test_scope, Uuid::new_v4()) + .await; + let _ = writer_db + .get_members_for_event_write(test_scope, Uuid::new_v4()) + .await; + let _ = writer_db + .get_users_bulk_for_event_write(test_scope, &[vec![11; 32]]) + .await; + let _ = writer_db + .huddle_started_link_exists_for_event_write( + test_scope, + Uuid::new_v4(), + Uuid::new_v4(), + &[12; 32], + ) + .await; + let _ = writer_db + .huddle_started_link_exists(test_scope, Uuid::new_v4(), Uuid::new_v4(), &[13; 32]) + .await; + let _ = writer_db.list_archived(test_scope).await; + let _ = writer_db + .query_events_routed("pool_operation_matrix_writer", &query) + .await; + let write_tx = writer_db + .begin_event_write_transaction() + .await + .expect("event-write semantic entry point begins a real transaction"); + write_tx + .rollback() + .await + .expect("rollback operation-label fixture"); + let _ = writer_db + .is_community_active_for_maintenance(test_scope) + .await; + let _ = writer_db.usage_community_count().await; + let _ = writer_db.reap_expired_ephemeral_channels().await; + let deletion_store = writer_db.deletion_store(); + let _ = deletion_store.reap_expired_serving_write_leases(1).await; + let _ = deletion_store.serving_lease_stats().await; + let _ = routed_db.is_relay_member(test_scope, &"a".repeat(64)).await; + let _ = routed_db + .query_events_routed("pool_operation_matrix_reader", &query) + .await; + routed_db.refresh_pool_waiter_metrics(); + + let snapshot = snapshotter.snapshot().into_vec(); + let attempt_labels = snapshot + .iter() + .filter_map(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_acquire_attempts_total" { + return None; + } + let DebugValue::Counter(value) = value else { + panic!("acquisition attempts must be counters"); + }; + if *value == 0 { + return None; + } + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + assert_eq!(labels.get("outcome").map(String::as_str), Some("success")); + Some((labels["pool_role"].clone(), labels["operation"].clone())) + }) + .collect::>(); + let expected = super::POOL_ACQUIRE_VALID_PAIRS + .into_iter() + .map(|(pool_role, operation)| (pool_role.to_owned(), operation.to_owned())) + .collect::>(); + assert_eq!( + attempt_labels, expected, + "real production Db/store methods must emit every exact valid operation pair" + ); + + let duration_labels = snapshot + .iter() + .filter_map(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_acquire_duration_seconds" { + return None; + } + let DebugValue::Histogram(samples) = value else { + panic!("acquisition duration must be a histogram"); + }; + assert!(!samples.is_empty()); + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + assert!(!labels.contains_key("outcome")); + Some(( + labels["pool_role"].to_owned(), + labels["operation"].to_owned(), + )) + }) + .collect::>(); + assert_eq!(duration_labels, expected); + + let waiter_labels = snapshot + .iter() + .filter_map(|(key, _, _, value)| { + if key.key().name() != "buzz_db_pool_waiters" { + return None; + } + let DebugValue::Gauge(value) = value else { + panic!("pool waiters must be gauges"); + }; + assert_eq!(value.into_inner(), 0.0); + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + Some(( + labels["pool_role"].to_owned(), + labels["operation"].to_owned(), + )) + }) + .collect::>(); + assert_eq!(waiter_labels, expected); + } + + async fn serving_write_gate_records_cancel_timeout_success_and_recovery() { + let _test_guard = super::POOL_METRICS_TEST_LOCK.lock().await; + let database_url = crate::test_support::database_url(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + // The same budget also covers the pool's initial physical + // connection. Keep enough headroom for a cold CI database; the + // held size-one connection below still deterministically drives + // the checkout timeout terminal. + .acquire_timeout(Duration::from_secs(1)) + .connect(&database_url) + .await + .expect("connect size-one serving-write test pool"); + let db = crate::Db::from_pool(pool.clone()); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate serving-write test DB"); + } + let test_scope = db + .ensure_configured_community(&format!( + "pool-observability-{}.example", + uuid::Uuid::new_v4().simple() + )) + .await + .expect("create serving-write test community") + .id; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let held = pool.acquire().await.expect("hold sole writer connection"); + + let store = db.deletion_store(); + let mut cancelled = Box::pin(store.is_serving_active(test_scope)); + tokio::select! { + result = &mut cancelled => panic!("blocked serving-write gate unexpectedly completed: {result:?}"), + () = tokio::time::sleep(Duration::from_millis(25)) => {} + } + assert_eq!( + waiter_value(&snapshotter.snapshot().into_vec(), "writer", "event_write"), + Some(1.0) + ); + drop(cancelled); + + let timeout = store + .is_serving_active(test_scope) + .await + .expect_err("saturated serving-write gate times out"); + assert!(matches!( + timeout, + crate::DbError::Sqlx(sqlx::Error::PoolTimedOut) + )); + drop(held); + + assert!(store + .is_serving_active(test_scope) + .await + .expect("serving-write gate recovers after release")); + let lease = store + .acquire_serving_write_lease( + test_scope, + "pool_observability", + "pool-observability-test", + Duration::from_secs(5), + ) + .await + .expect("serving-write lease acquires through event-write seam"); + assert!(store + .release_serving_write_lease(&lease) + .await + .expect("serving-write lease release")); + refresh_pool_waiters(false); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!(waiter_value(&snapshot, "writer", "event_write"), Some(0.0)); + assert_eq!(attempt_count(&snapshot, "event_write", "cancelled"), 1); + assert_eq!(attempt_count(&snapshot, "event_write", "timeout"), 1); + assert!( + attempt_count(&snapshot, "event_write", "success") >= 3, + "gate recovery plus lease acquire/release must emit successes" + ); + } + + fn attempt_count( + snapshot: &[( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )], + operation: &str, + outcome: &str, + ) -> u64 { + snapshot + .iter() + .find_map(|(key, _, _, value)| { + let labels = key.key().labels().collect::>(); + (key.key().name() == "buzz_db_pool_acquire_attempts_total" + && labels + .iter() + .any(|label| label.key() == "operation" && label.value() == operation) + && labels + .iter() + .any(|label| label.key() == "outcome" && label.value() == outcome)) + .then(|| match value { + DebugValue::Counter(value) => *value, + _ => panic!("pool attempts must be a counter"), + }) + }) + .unwrap_or(0) + } + async fn advisory_lock_records_success_contention_timeout_and_error() { - let database_url = std::env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials + let database_url = crate::test_support::database_url(); let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(4) .connect(&database_url) @@ -640,6 +1812,24 @@ mod tests { super::pool_acquire_records_success_timeout_and_error_with_wait_time().await; } + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn production_db_methods_emit_exact_pool_operation_labels() { + super::production_db_methods_emit_exact_pool_operation_labels().await; + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn deletion_catalog_readiness_records_timeout_and_recovers() { + super::deletion_catalog_readiness_records_timeout_and_recovers().await; + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn serving_write_gate_records_cancel_timeout_success_and_recovery() { + super::serving_write_gate_records_cancel_timeout_success_and_recovery().await; + } + #[tokio::test(flavor = "current_thread")] #[ignore = "requires Postgres"] async fn advisory_lock_records_success_contention_timeout_and_error() { diff --git a/crates/buzz-db/src/runtime/replica_fence.rs b/crates/buzz-db/src/runtime/replica_fence.rs index 2194c8bb30..044dc3a58c 100644 --- a/crates/buzz-db/src/runtime/replica_fence.rs +++ b/crates/buzz-db/src/runtime/replica_fence.rs @@ -395,7 +395,12 @@ pub async fn verify_floor_guard_behavior(pool: &PgPool) -> crate::Result<()> { } }; - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // 1. Pool arming (Perci: assert the effective value, not the intent). let armed: String = sqlx::query_scalar("SHOW buzz.created_at_floor") @@ -552,7 +557,11 @@ pub enum ProbeError { /// a single SELECT would not guarantee evaluation order across the /// subexpressions, reopening the race this ordering exists to close. async fn sample_writer(writer: &PgPool) -> Result { - let mut conn = writer.acquire().await?; + let mut conn = crate::observability::acquire_writer( + writer, + crate::observability::WriterOperation::Maintenance, + ) + .await?; // 1. S first. let sampled_at: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") @@ -671,11 +680,16 @@ pub async fn probe_once(writer: &PgPool, fence: &ReplicaFence) -> Result Db { Db::from_pool(pool) } +#[tokio::test] +async fn begin_transaction_compatibility_alias_is_preserved() { + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy(&crate::test_support::database_url()) + .expect("construct lazy compatibility pool"); + pool.close().await; + let db = Db::from_pool(pool); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + #[allow(deprecated)] + let result = db.begin_transaction().await; + assert!(matches!( + result, + Err(DbError::Sqlx(sqlx::Error::PoolClosed)) + )); + + let counters = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| { + let name = key.key().name(); + if ![ + "buzz_db_pool_acquire_attempts_total", + "buzz_db_pool_acquisitions_total", + ] + .contains(&name) + { + return None; + } + let DebugValue::Counter(value) = value else { + panic!("pool acquisition terminals must be counters"); + }; + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + Some(((name.to_owned(), labels), value)) + }) + .collect::>(); + let expected = [ + ( + ( + "buzz_db_pool_acquire_attempts_total".to_owned(), + [ + ("operation".to_owned(), "event_write".to_owned()), + ("outcome".to_owned(), "error".to_owned()), + ("pool_role".to_owned(), "writer".to_owned()), + ] + .into_iter() + .collect(), + ), + 1, + ), + ( + ( + "buzz_db_pool_acquisitions_total".to_owned(), + [ + ("outcome".to_owned(), "error".to_owned()), + ("pool_role".to_owned(), "writer".to_owned()), + ] + .into_iter() + .collect(), + ), + 1, + ), + ] + .into_iter() + .collect::>(); + assert_eq!(counters, expected); +} + +#[test] +fn nip43_reconciliation_compatibility_alias_is_preserved() { + #[allow(deprecated)] + async fn call( + db: &Db, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + ) -> crate::Result { + db.nip43_membership_snapshot_needs_reconciliation(community_id, relay_pubkey) + .await + } + + let _ = call; +} + #[tokio::test] #[ignore = "requires Postgres"] async fn readiness_check_distinguishes_pool_exhaustion_from_success() { diff --git a/crates/buzz-db/src/store/allowlist.rs b/crates/buzz-db/src/store/allowlist.rs index d2780a498c..2b72780a62 100644 --- a/crates/buzz-db/src/store/allowlist.rs +++ b/crates/buzz-db/src/store/allowlist.rs @@ -28,12 +28,17 @@ impl Db { /// Check if a pubkey is in the allowlist for `community`. #[datastore_span(name = "is_pubkey_allowed", system = "postgresql")] pub async fn is_pubkey_allowed(&self, community: CommunityId, pubkey: &[u8]) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authentication, + ) + .await?; let row = sqlx::query( "SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2", ) .bind(community.as_uuid()) .bind(pubkey) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -42,10 +47,15 @@ impl Db { /// Check if the community allowlist has any entries (i.e. is enforcement active). #[datastore_span(name = "has_allowlist_entries", system = "postgresql")] pub async fn has_allowlist_entries(&self, community: CommunityId) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authentication, + ) + .await?; let row = sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1") .bind(community.as_uuid()) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -60,6 +70,11 @@ impl Db { added_by: &[u8], note: Option<&str>, ) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( "INSERT INTO pubkey_allowlist (community_id, pubkey, added_by, note) VALUES ($1, $2, $3, $4) \ ON CONFLICT DO NOTHING", @@ -68,7 +83,7 @@ impl Db { .bind(pubkey) .bind(added_by) .bind(note) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) } @@ -80,11 +95,16 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) } @@ -92,11 +112,16 @@ impl Db { /// List all pubkeys in the community allowlist. #[datastore_span(name = "list_allowlist", system = "postgresql")] pub async fn list_allowlist(&self, community: CommunityId) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist WHERE community_id = $1 ORDER BY added_at DESC", ) .bind(community.as_uuid()) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await?; let mut out = Vec::with_capacity(rows.len()); diff --git a/crates/buzz-db/src/store/archived_identities.rs b/crates/buzz-db/src/store/archived_identities.rs index 102ca44f96..b1636de31a 100644 --- a/crates/buzz-db/src/store/archived_identities.rs +++ b/crates/buzz-db/src/store/archived_identities.rs @@ -34,11 +34,16 @@ pub struct ArchivedIdentity { /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. pub async fn is_archived(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query("SELECT 1 FROM archived_identities WHERE community_id = $1 AND pubkey = $2") .bind(community_id.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; Ok(row.is_some()) } @@ -59,6 +64,11 @@ pub async fn archive( replaced_by: Option<&str>, request_event_id: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "INSERT INTO archived_identities \ (community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ @@ -72,7 +82,7 @@ pub async fn archive( .bind(reason) .bind(replaced_by) .bind(request_event_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -83,11 +93,16 @@ pub async fn archive( /// Returns `true` if a row was deleted, `false` if the identity was not archived /// in that community. pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") .bind(community_id.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -98,12 +113,17 @@ pub async fn list_archived( pool: &PgPool, community_id: CommunityId, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let rows = sqlx::query( "SELECT pubkey, consent_path, actor, reason, replaced_by, request_event_id, archived_at \ FROM archived_identities WHERE community_id = $1 ORDER BY archived_at ASC", ) .bind(community_id.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index c581f529f7..5e89b1101b 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -29,6 +29,27 @@ pub use crate::channel_members::{ LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, }; +async fn begin_event_write_transaction( + pool: &PgPool, +) -> Result> { + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + Ok(sqlx::Transaction::begin(connection, None).await?) +} + +async fn acquire_event_write_connection( + pool: &PgPool, +) -> Result> { + Ok(crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?) +} + /// A channel row as returned from the database. #[derive(Debug, Clone)] pub struct ChannelRecord { @@ -104,7 +125,7 @@ pub async fn create_channel( let id = Uuid::new_v4(); - let mut tx = pool.begin().await?; + let mut tx = begin_event_write_transaction(pool).await?; sqlx::query( r#" @@ -197,7 +218,7 @@ pub async fn create_channel_with_id( return Err(DbError::InvalidData("channel name is required".into())); } - let mut tx = pool.begin().await?; + let mut tx = begin_event_write_transaction(pool).await?; let rows_affected = sqlx::query( r#" @@ -269,6 +290,22 @@ pub async fn get_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result { + get_channel_with_operation( + pool, + community_id, + channel_id, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +async fn get_channel_with_operation( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + operation: crate::observability::WriterOperation, +) -> Result { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let row = sqlx::query( r#" SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, @@ -283,7 +320,7 @@ pub async fn get_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await? .ok_or(DbError::ChannelNotFound(channel_id))?; @@ -334,6 +371,22 @@ pub async fn list_channels( community_id: CommunityId, visibility: Option<&str>, ) -> Result> { + list_channels_with_operation( + pool, + community_id, + visibility, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +async fn list_channels_with_operation( + pool: &PgPool, + community_id: CommunityId, + visibility: Option<&str>, + operation: crate::observability::WriterOperation, +) -> Result> { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let rows = if let Some(vis) = visibility { sqlx::query( r#" @@ -352,7 +405,7 @@ pub async fn list_channels( ) .bind(community_id.as_uuid()) .bind(vis) - .fetch_all(pool) + .fetch_all(&mut *connection) .await? } else { sqlx::query( @@ -371,7 +424,7 @@ pub async fn list_channels( "#, ) .bind(community_id.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await? }; @@ -530,7 +583,7 @@ pub async fn update_channel( // this transition — whose own deadline reset is then the latest word. // Non-TTL updates don't touch the fast path and skip the lock. if updates.ttl_seconds.is_some() { - let mut tx = pool.begin().await?; + let mut tx = begin_event_write_transaction(pool).await?; sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") .bind(format!( "buzz_channel_ttl:{}:{}", @@ -545,13 +598,20 @@ pub async fn update_channel( } tx.commit().await?; } else { - let result = q.execute(pool).await?; + let mut connection = acquire_event_write_connection(pool).await?; + let result = q.execute(&mut *connection).await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } } - get_channel(pool, community_id, channel_id).await + get_channel_with_operation( + pool, + community_id, + channel_id, + crate::observability::WriterOperation::EventWrite, + ) + .await } /// Sets the topic for a channel, recording who set it and when. @@ -562,6 +622,7 @@ pub async fn set_topic( topic: &str, set_by: &[u8], ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; let result = sqlx::query( "UPDATE channels SET topic = $1, topic_set_by = $2, topic_set_at = NOW() \ WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", @@ -570,7 +631,7 @@ pub async fn set_topic( .bind(set_by) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); @@ -586,6 +647,7 @@ pub async fn set_purpose( purpose: &str, set_by: &[u8], ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; let result = sqlx::query( "UPDATE channels SET purpose = $1, purpose_set_by = $2, purpose_set_at = NOW() \ WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", @@ -594,7 +656,7 @@ pub async fn set_purpose( .bind(set_by) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); @@ -611,13 +673,14 @@ pub async fn archive_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; // First check: does the channel exist and what is its state? let row = sqlx::query( "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -638,7 +701,7 @@ pub async fn archive_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(()) @@ -653,13 +716,14 @@ pub async fn unarchive_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; // First check: does the channel exist and what is its state? let row = sqlx::query( "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -682,7 +746,7 @@ pub async fn unarchive_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(()) @@ -697,12 +761,13 @@ pub async fn soft_delete_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result { + let mut connection = acquire_event_write_connection(pool).await?; let result = sqlx::query( "UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -714,6 +779,11 @@ pub async fn soft_delete_channel( /// `archived_at IS NULL` guard prevents double-archiving even if called /// concurrently from multiple relay pods. pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let rows = sqlx::query( "UPDATE channels AS ch SET archived_at = NOW() \ FROM communities AS c \ @@ -726,7 +796,7 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result Result { + get_channel_with_operation( + &self.pool, + community_id, + channel_id, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Returns the canvas content for a channel, if any. #[datastore_span(name = "get_canvas", system = "postgresql")] pub async fn get_canvas( @@ -841,6 +928,22 @@ impl Db { list_channels(&self.pool, community_id, visibility).await } + /// Lists channels during startup reconciliation. + #[datastore_span(name = "list_channels_for_bootstrap", system = "postgresql")] + pub async fn list_channels_for_bootstrap( + &self, + community_id: CommunityId, + visibility: Option<&str>, + ) -> Result> { + list_channels_with_operation( + &self.pool, + community_id, + visibility, + crate::observability::WriterOperation::Bootstrap, + ) + .await + } + /// Updates a channel's name and/or description. #[datastore_span(name = "update_channel", system = "postgresql")] pub async fn update_channel( diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index dd526a0a46..8280ca01f8 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -100,7 +100,12 @@ pub async fn verify_channel_roster_fence_catalog<'e>( /// function. This rolled-back probe verifies that a canonical empty roster is /// accepted while a stale roster member is rejected with `check_violation`. pub async fn verify_channel_roster_fence_behavior(pool: &sqlx::PgPool) -> Result<()> { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let community_id = Uuid::new_v4(); let channel_id = Uuid::new_v4(); sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") @@ -323,7 +328,12 @@ pub async fn lock_member_snapshot( channel_id: Uuid, relay_pubkey: &[u8], ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // Match the canonical replacement writer's lock order. Old binaries take // this key before INSERT; migration 0032 then takes the membership key in // the INSERT trigger. Taking both in that order avoids mixed-version @@ -396,7 +406,12 @@ pub async fn add_member( ))); } - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // First statement: serialize the whole role-check / owner-count / upsert // sequence against concurrent membership writes on this channel. @@ -577,7 +592,12 @@ pub async fn remove_member( crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? }; - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // First statement: serialize the actor-role check, the last-owner count and // the UPDATE against concurrent membership writes on this channel (same key @@ -648,6 +668,11 @@ pub async fn is_member( channel_id: Uuid, pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members cm \ JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ @@ -656,7 +681,7 @@ pub async fn is_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) @@ -674,6 +699,11 @@ pub async fn membership_pairs( if channel_ids.is_empty() || pubkeys.is_empty() { return Ok(Vec::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( "SELECT cm.channel_id, cm.pubkey FROM channel_members cm \ JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ @@ -682,7 +712,7 @@ pub async fn membership_pairs( .bind(community_id.as_uuid()) .bind(channel_ids) .bind(pubkeys) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() .map(|row| Ok((row.try_get("channel_id")?, row.try_get("pubkey")?))) @@ -702,6 +732,22 @@ pub async fn get_members( community_id: CommunityId, channel_id: Uuid, ) -> Result> { + get_members_with_operation( + pool, + community_id, + channel_id, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +async fn get_members_with_operation( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + operation: crate::observability::WriterOperation, +) -> Result> { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let rows = sqlx::query( r#" SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at @@ -713,7 +759,7 @@ pub async fn get_members( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter().map(row_to_member_record).collect() } @@ -733,6 +779,11 @@ pub async fn get_members_bulk( if channel_ids.is_empty() { return Ok(Vec::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( r#" SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at @@ -744,7 +795,7 @@ pub async fn get_members_bulk( ) .bind(community_id.as_uuid()) .bind(channel_ids) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter().map(row_to_member_record).collect() } @@ -758,6 +809,11 @@ pub async fn get_accessible_channel_ids( community_id: CommunityId, pubkey: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( r#" SELECT cm.channel_id @@ -772,7 +828,7 @@ pub async fn get_accessible_channel_ids( ) .bind(community_id.as_uuid()) .bind(pubkey) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -806,6 +862,11 @@ pub async fn list_large_channel_rosters_needing_reconciliation( minimum_members: i64, relay_pubkey: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let rows = sqlx::query( r#" WITH large_rosters AS ( @@ -843,7 +904,7 @@ pub async fn list_large_channel_rosters_needing_reconciliation( ) .bind(minimum_members) .bind(relay_pubkey) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -964,6 +1025,11 @@ pub async fn get_accessible_channels( visibility_filter: Option<&str>, member_only: Option, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; // When `member_only` is `Some(true)`, restrict to channels where the user // has an active membership (cm.channel_id IS NOT NULL). This is a strict // subset of the default result set and is pushed into SQL so the LIMIT 1000 @@ -1008,7 +1074,7 @@ pub async fn get_accessible_channels( query }; - let rows = query.fetch_all(pool).await?; + let rows = query.fetch_all(&mut *connection).await?; rows.into_iter() .map(|row| { let is_member: bool = row.try_get("is_member").unwrap_or(false); @@ -1027,6 +1093,11 @@ pub async fn get_bot_members( pool: &PgPool, community_id: CommunityId, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( r#" SELECT cm.pubkey, u.display_name, u.agent_type, u.capabilities, @@ -1040,7 +1111,7 @@ pub async fn get_bot_members( "#, ) .bind(community_id.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let mut out = Vec::with_capacity(rows.len()); @@ -1071,10 +1142,26 @@ pub async fn get_users_bulk( pool: &PgPool, community_id: CommunityId, pubkeys: &[Vec], +) -> Result> { + get_users_bulk_with_operation( + pool, + community_id, + pubkeys, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await +} + +async fn get_users_bulk_with_operation( + pool: &PgPool, + community_id: CommunityId, + pubkeys: &[Vec], + operation: crate::observability::WriterOperation, ) -> Result> { if pubkeys.is_empty() { return Ok(Vec::new()); } + let mut connection = crate::observability::acquire_writer(pool, operation).await?; // Build a parameterised IN clause: ($2, $3, ...); $1 is community_id. let placeholders = (2..(pubkeys.len() + 2)) @@ -1091,7 +1178,7 @@ pub async fn get_users_bulk( q = q.bind(pk); } - let rows = q.fetch_all(pool).await?; + let rows = q.fetch_all(&mut *connection).await?; let mut out = Vec::with_capacity(rows.len()); for row in rows { @@ -1124,12 +1211,17 @@ pub async fn get_member_count( community_id: CommunityId, channel_id: Uuid, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members WHERE community_id = $1 AND channel_id = $2 AND removed_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; Ok(row.try_get("cnt")?) } @@ -1146,6 +1238,11 @@ pub async fn get_member_counts_bulk( if channel_ids.is_empty() { return Ok(std::collections::HashMap::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( "SELECT channel_id, COUNT(*) as cnt FROM channel_members \ @@ -1159,7 +1256,7 @@ pub async fn get_member_counts_bulk( } qb.push(") GROUP BY channel_id"); - let rows = qb.build().fetch_all(pool).await?; + let rows = qb.build().fetch_all(&mut *connection).await?; let mut map = std::collections::HashMap::with_capacity(rows.len()); for row in rows { @@ -1179,6 +1276,11 @@ pub async fn get_member_role( channel_id: Uuid, pubkey: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT cm.role::text AS role FROM channel_members cm \ JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ @@ -1187,7 +1289,7 @@ pub async fn get_member_role( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; Ok(row.map(|r| r.try_get("role")).transpose()?) } @@ -1196,7 +1298,14 @@ impl Db { /// Verify the mixed-version channel-roster database fence end to end. #[datastore_span(name = "verify_channel_roster_fence", system = "postgresql")] pub async fn verify_channel_roster_fence(&self) -> Result<()> { - verify_channel_roster_fence_catalog(&self.pool).await?; + { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + verify_channel_roster_fence_catalog(&mut *connection).await?; + } verify_channel_roster_fence_behavior(&self.pool).await } @@ -1277,6 +1386,22 @@ impl Db { get_members(&self.pool, community_id, channel_id).await } + /// Return a channel roster used to build or validate an event mutation. + #[datastore_span(name = "get_members_for_event_write", system = "postgresql")] + pub async fn get_members_for_event_write( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result> { + get_members_with_operation( + &self.pool, + community_id, + channel_id, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Returns active members for multiple channels in a single query. #[datastore_span(name = "get_members_bulk", system = "postgresql")] pub async fn get_members_bulk( @@ -1346,6 +1471,22 @@ impl Db { get_users_bulk(&self.pool, community_id, pubkeys).await } + /// Bulk-fetch user names while constructing an event and its mention tags. + #[datastore_span(name = "get_users_bulk_for_event_write", system = "postgresql")] + pub async fn get_users_bulk_for_event_write( + &self, + community_id: CommunityId, + pubkeys: &[Vec], + ) -> Result> { + get_users_bulk_with_operation( + &self.pool, + community_id, + pubkeys, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Returns the count of active members in a channel. #[datastore_span(name = "get_member_count", system = "postgresql")] pub async fn get_member_count( @@ -2944,6 +3085,37 @@ mod postgres_tests { drop_scratch_db(&admin, pool, &scratch_name).await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_roster_fence_verification_supports_size_one_pool() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, scratch_name) = create_scratch_db(&admin, "roster_fence_size_one").await; + seed_pool.close().await; + + let base_url = admin_url().await; + let path = base_url.rfind('/').expect("database URL path"); + let scratch_url = format!("{}/{}", &base_url[..path], scratch_name); + let pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect(&scratch_url) + .await + .expect("connect size-one writer pool"); + let db = Db::from_pool(pool.clone()); + + tokio::time::timeout( + std::time::Duration::from_secs(2), + db.verify_channel_roster_fence(), + ) + .await + .expect("roster verification must not self-deadlock on its second checkout") + .expect("migrated roster fence verifies on a size-one pool"); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn desired_schema_rejects_stale_legacy_roster_role() { diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index dd8a2e58bc..bcd38f4e5c 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -91,6 +91,11 @@ impl Db { &self, normalized_host: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::TenantResolution, + ) + .await?; let row = sqlx::query( r#" SELECT id, host @@ -102,7 +107,7 @@ impl Db { "#, ) .bind(normalized_host) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { @@ -120,11 +125,37 @@ impl Db { /// Returns whether a community id still exists in the active lifecycle state. #[datastore_span(name = "is_community_active", system = "postgresql")] pub async fn is_community_active(&self, community_id: CommunityId) -> Result { + self.is_community_active_with_operation( + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await + } + + /// Background lifecycle revalidation variant of [`Self::is_community_active`]. + #[datastore_span(name = "is_community_active_for_maintenance", system = "postgresql")] + pub async fn is_community_active_for_maintenance( + &self, + community_id: CommunityId, + ) -> Result { + self.is_community_active_with_operation( + community_id, + crate::observability::WriterOperation::Maintenance, + ) + .await + } + + async fn is_community_active_with_operation( + &self, + community_id: CommunityId, + operation: crate::observability::WriterOperation, + ) -> Result { + let mut connection = crate::observability::acquire_writer(&self.pool, operation).await?; let active = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active')", ) .bind(community_id.as_uuid()) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await?; Ok(active) } @@ -138,9 +169,14 @@ impl Db { &self, normalized_host: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") .bind(normalized_host) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { Ok(CommunityRecord { @@ -161,6 +197,11 @@ impl Db { owner_pubkey: &str, ) -> Result> { let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( r#" SELECT c.id, c.host, c.created_at, c.archived_at @@ -172,7 +213,7 @@ impl Db { "#, ) .bind(owner_pubkey) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -203,6 +244,11 @@ impl Db { /// is never used to re-derive the community. #[datastore_span(name = "lookup_community_host", system = "postgresql")] pub async fn lookup_community_host(&self, community_id: CommunityId) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::TenantResolution, + ) + .await?; let row = sqlx::query( r#" SELECT host @@ -214,7 +260,7 @@ impl Db { "#, ) .bind(community_id.as_uuid()) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { @@ -255,6 +301,11 @@ impl Db { community_id: CommunityId, icon: Option<&str>, ) -> Result<()> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query( r#" UPDATE communities @@ -264,7 +315,7 @@ impl Db { ) .bind(community_id.as_uuid()) .bind(icon) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(()) } @@ -279,6 +330,35 @@ impl Db { &self, normalized_host: &str, ) -> Result { + self.ensure_configured_community_with_operation( + normalized_host, + crate::observability::WriterOperation::Authorization, + ) + .await + } + + /// Ensure the deployment-configured community during process bootstrap. + #[datastore_span( + name = "ensure_configured_community_for_bootstrap", + system = "postgresql" + )] + pub async fn ensure_configured_community_for_bootstrap( + &self, + normalized_host: &str, + ) -> Result { + self.ensure_configured_community_with_operation( + normalized_host, + crate::observability::WriterOperation::Bootstrap, + ) + .await + } + + async fn ensure_configured_community_with_operation( + &self, + normalized_host: &str, + operation: crate::observability::WriterOperation, + ) -> Result { + let mut connection = crate::observability::acquire_writer(&self.pool, operation).await?; let row = sqlx::query( r#" INSERT INTO communities (host) @@ -290,7 +370,7 @@ impl Db { "#, ) .bind(normalized_host) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await? .ok_or_else(|| { DbError::AccessDenied(format!( @@ -321,7 +401,12 @@ impl Db { owner_pubkey: &str, ) -> Result { let owner_pubkey = owner_pubkey.to_ascii_lowercase(); - let mut tx = self.pool.begin().await?; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // Serialize on the owner pubkey so concurrent creates to the same // owner cannot both pass the ownership count check. @@ -412,6 +497,11 @@ impl Db { owner_pubkey: &str, protected_deployment_host: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#"UPDATE communities c SET archived_at = COALESCE(c.archived_at, now()) @@ -428,7 +518,7 @@ impl Db { .bind(normalized_host) .bind(owner_pubkey) .bind(protected_deployment_host) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { Ok(ArchivedCommunityRecord { @@ -447,6 +537,11 @@ impl Db { normalized_host: &str, owner_pubkey: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#"UPDATE communities c SET archived_at = NULL @@ -461,7 +556,7 @@ impl Db { ) .bind(normalized_host) .bind(owner_pubkey) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { Ok(UnarchivedCommunityRecord { @@ -478,6 +573,11 @@ impl Db { /// they are acting on, rather than falling back to an implicit default. #[datastore_span(name = "community_of_channel", system = "postgresql")] pub async fn community_of_channel(&self, channel_id: Uuid) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::TenantResolution, + ) + .await?; let row = sqlx::query( r#" SELECT community_id @@ -487,7 +587,7 @@ impl Db { "#, ) .bind(channel_id) - .fetch_optional(&self.pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| { @@ -523,6 +623,11 @@ impl Db { if channel_ids.is_empty() { return Ok(std::collections::HashMap::new()); } + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; let rows = sqlx::query( r#" SELECT id, community_id @@ -532,7 +637,7 @@ impl Db { "#, ) .bind(channel_ids) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await?; let mut out = std::collections::HashMap::with_capacity(rows.len()); diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index d34b39f14b..0e184e00d8 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -631,7 +631,38 @@ pub struct DeletionStore { impl Db { /// Validate the minimum deletion fence catalog required by serving paths. pub async fn validate_deletion_serving_catalog(&self) -> Result<()> { - self.deletion_store().validate_serving_catalog().await + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + self.deletion_store() + .validate_serving_catalog_on(&mut connection) + .await + } + + /// Validate the serving catalog inside the readiness request's absolute + /// deadline, attributing only the one real writer checkout to readiness. + pub async fn validate_deletion_serving_catalog_for_readiness( + &self, + deadline: tokio::time::Instant, + ) -> Result<()> { + let mut connection = crate::observability::acquire_writer_until( + &self.pool, + crate::observability::WriterOperation::Readiness, + deadline, + ) + .await?; + match tokio::time::timeout_at( + deadline, + self.deletion_store() + .validate_serving_catalog_on(&mut connection), + ) + .await + { + Err(_) => Err(sqlx::Error::PoolTimedOut.into()), + Ok(result) => result, + } } /// Validate the exact live community-deletion tenant catalog for destruction. @@ -784,13 +815,22 @@ impl DeletionStore { /// Validate the deletion catalog contract required by relay serving. pub async fn validate_serving_catalog(&self) -> Result<()> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + self.validate_serving_catalog_on(&mut connection).await + } + + async fn validate_serving_catalog_on(&self, conn: &mut PgConnection) -> Result<()> { let runtime_columns = sqlx::query( "SELECT attname, format_type(atttypid, atttypmod) AS type_name, attnotnull \ FROM pg_attribute WHERE attrelid = 'communities'::regclass \ AND attname IN ('deletion_state', 'deletion_fence_generation', 'deleted_at') \ AND NOT attisdropped ORDER BY attname", ) - .fetch_all(&self.pool) + .fetch_all(&mut *conn) .await?; let column_contract = runtime_columns .iter() @@ -836,7 +876,7 @@ impl DeletionStore { ORDER BY table_name", ) .bind(&required_table_names) - .fetch_all(&self.pool) + .fetch_all(&mut *conn) .await? .into_iter() .collect(); @@ -856,7 +896,7 @@ impl DeletionStore { .copied() .map(str::to_owned) .collect::>(); - let live_fences = self.live_fenced_tables().await?; + let live_fences = live_fenced_tables_on(&mut *conn).await?; let missing_fences = required_fences .difference(&live_fences) .cloned() @@ -882,7 +922,7 @@ impl DeletionStore { AND p.proname = 'enforce_community_tombstone' \ AND NOT t.tgisinternal AND t.tgenabled = 'O')", ) - .fetch_one(&self.pool) + .fetch_one(&mut *conn) .await?; if !required_objects_present { return Err(DbError::DeletionSafety( @@ -2361,7 +2401,12 @@ impl DeletionStore { lease_duration: Duration, ) -> Result { let lease_seconds = i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX); - let mut tx = self.pool.begin().await?; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // The assertion owns both the shared ordering lock and the supported // READ COMMITTED check. The lease table is trigger-excluded, so this // explicit admission is its database-enforced write fence. @@ -2425,7 +2470,12 @@ impl DeletionStore { lease_duration: Duration, ) -> Result<()> { let lease_seconds = i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX); - let mut tx = self.pool.begin().await?; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; lock_community_deletion_shared(&mut tx, lease.community_id).await?; let lease_until: Option> = sqlx::query_scalar( "UPDATE community_serving_write_leases lease \ @@ -2458,6 +2508,11 @@ impl DeletionStore { /// Release a serving side-effect lease. A stale release is harmless. pub async fn release_serving_write_lease(&self, lease: &ServingWriteLease) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let deleted = sqlx::query( "DELETE FROM community_serving_write_leases \ WHERE id = $1 AND community_id = $2 AND owner = $3 AND generation = $4 \ @@ -2468,7 +2523,7 @@ impl DeletionStore { .bind(&lease.owner) .bind(lease.generation) .bind(lease.fence_generation) - .execute(&self.pool) + .execute(&mut *connection) .await? .rows_affected(); Ok(deleted == 1) @@ -2480,7 +2535,12 @@ impl DeletionStore { /// work remains blocked, preserving an accurate drain without abandoning an /// admitted remote effect. pub async fn verify_serving_write_lease(&self, lease: &ServingWriteLease) -> Result<()> { - let mut tx = self.pool.begin().await?; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; lock_community_deletion_shared(&mut tx, lease.community_id).await?; let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ @@ -2512,6 +2572,11 @@ impl DeletionStore { /// Delete expired serving leases in a bounded batch. pub async fn reap_expired_serving_write_leases(&self, limit: i64) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let affected = sqlx::query( "WITH expired AS ( \ SELECT id FROM community_serving_write_leases \ @@ -2521,7 +2586,7 @@ impl DeletionStore { USING expired WHERE lease.id = expired.id", ) .bind(limit.clamp(1, 10_000)) - .execute(&self.pool) + .execute(&mut *connection) .await? .rows_affected(); Ok(affected) @@ -2529,6 +2594,11 @@ impl DeletionStore { /// Return serving-lease counts and dead-tuple estimate for observability. pub async fn serving_lease_stats(&self) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let row = sqlx::query( "SELECT count(*) FILTER (WHERE lease_until >= now())::BIGINT AS active, \ count(*) FILTER (WHERE lease_until < now())::BIGINT AS expired, \ @@ -2536,7 +2606,7 @@ impl DeletionStore { WHERE relname = 'community_serving_write_leases'), 0) AS dead_tuples \ FROM community_serving_write_leases", ) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await?; Ok(ServingLeaseStats { active: row.try_get("active")?, @@ -2547,12 +2617,17 @@ impl DeletionStore { /// Whether a community remains active and serving-write eligible. pub async fn is_serving_active(&self, community: CommunityId) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 \ AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active')", ) .bind(community.as_uuid()) - .fetch_one(&self.pool) + .fetch_one(&mut *connection) .await .map_err(Into::into) } @@ -4016,7 +4091,7 @@ mod postgres_tests { .expect("won claim"); let mut open_write = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("open write transaction"); sqlx::query("INSERT INTO pubkey_allowlist (community_id, pubkey) VALUES ($1, $2)") diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index d685e44485..d3c0340e07 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -234,6 +234,26 @@ pub async fn huddle_started_link_exists( ephemeral_channel_id: Uuid, creator_pubkey: &[u8], ) -> Result { + huddle_started_link_exists_with_operation( + pool, + community_id, + parent_channel_id, + ephemeral_channel_id, + creator_pubkey, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +async fn huddle_started_link_exists_with_operation( + pool: &PgPool, + community_id: CommunityId, + parent_channel_id: Uuid, + ephemeral_channel_id: Uuid, + creator_pubkey: &[u8], + operation: crate::observability::WriterOperation, +) -> Result { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let uuid_needle = format!("%{}%", ephemeral_channel_id); let candidates: Vec = sqlx::query_scalar( r#" @@ -257,7 +277,7 @@ pub async fn huddle_started_link_exists( .bind(HUDDLE_LINK_CONTENT_MAX_BYTES) .bind(uuid_needle) .bind(HUDDLE_LINK_CANDIDATE_LIMIT) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(candidates @@ -274,7 +294,11 @@ pub async fn insert_event( event: &Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let mut connection = pool.acquire().await?; + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; insert_event_on(&mut connection, community_id, event, channel_id).await } @@ -355,7 +379,20 @@ async fn insert_event_on( /// Uses `QueryBuilder` for dynamic filter composition — avoids string concatenation /// while keeping all user values in bind parameters. pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result> { - let mut conn = pool.acquire().await?; + query_events_with_operation( + pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await +} + +pub(crate) async fn query_events_with_operation( + pool: &PgPool, + q: &EventQuery, + operation: crate::observability::WriterOperation, +) -> Result> { + let mut conn = crate::observability::acquire_writer(pool, operation).await?; query_events_on(&mut conn, q).await } @@ -659,7 +696,11 @@ pub(crate) fn row_to_stored_event(row: sqlx::postgres::PgRow) -> Result Result { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; count_events_on(&mut conn, q).await } @@ -823,12 +864,17 @@ pub async fn soft_delete_event( community_id: CommunityId, event_id: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(event_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -873,6 +919,11 @@ pub async fn soft_delete_by_coordinate( ) -> Result { let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0) .ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?; + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ @@ -883,7 +934,7 @@ pub async fn soft_delete_by_coordinate( .bind(pubkey) .bind(d_tag) .bind(deletion_created_at) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -901,7 +952,12 @@ pub async fn soft_delete_event_and_update_thread( parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", @@ -949,6 +1005,11 @@ pub async fn get_last_message_at( community_id: CommunityId, channel_id: uuid::Uuid, ) -> Result>> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; let row = sqlx::query( "SELECT created_at FROM events \ WHERE community_id = $1 AND channel_id = $2 AND deleted_at IS NULL \ @@ -956,7 +1017,7 @@ pub async fn get_last_message_at( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -977,6 +1038,11 @@ pub async fn get_last_message_at_bulk( if channel_ids.is_empty() { return Ok(std::collections::HashMap::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; let mut qb: QueryBuilder = QueryBuilder::new( "SELECT channel_id, MAX(created_at) as last_at FROM events \ @@ -990,7 +1056,7 @@ pub async fn get_last_message_at_bulk( } qb.push(") GROUP BY channel_id"); - let rows = qb.build().fetch_all(pool).await?; + let rows = qb.build().fetch_all(&mut *connection).await?; let mut map = std::collections::HashMap::with_capacity(rows.len()); for row in rows { @@ -1011,13 +1077,29 @@ pub async fn get_event_by_id( community_id: CommunityId, id_bytes: &[u8], ) -> Result> { + get_event_by_id_with_operation( + pool, + community_id, + id_bytes, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +pub(crate) async fn get_event_by_id_with_operation( + pool: &PgPool, + community_id: CommunityId, + id_bytes: &[u8], + operation: crate::observability::WriterOperation, +) -> Result> { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", ) .bind(community_id.as_uuid()) .bind(id_bytes) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -1038,6 +1120,11 @@ pub async fn get_latest_global_replaceable( kind: i32, pubkey_bytes: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events \ @@ -1048,7 +1135,7 @@ pub async fn get_latest_global_replaceable( .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey_bytes) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -1067,13 +1154,29 @@ pub async fn get_event_by_id_including_deleted( community_id: CommunityId, id_bytes: &[u8], ) -> Result> { + get_event_by_id_including_deleted_with_operation( + pool, + community_id, + id_bytes, + crate::observability::WriterOperation::Authorization, + ) + .await +} + +pub(crate) async fn get_event_by_id_including_deleted_with_operation( + pool: &PgPool, + community_id: CommunityId, + id_bytes: &[u8], + operation: crate::observability::WriterOperation, +) -> Result> { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events WHERE community_id = $1 AND id = $2 ORDER BY created_at DESC LIMIT 1", ) .bind(community_id.as_uuid()) .bind(id_bytes) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -1090,11 +1193,26 @@ pub async fn get_events_by_ids( pool: &PgPool, community_id: CommunityId, ids: &[&[u8]], +) -> Result> { + get_events_by_ids_with_operation( + pool, + community_id, + ids, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await +} + +pub(crate) async fn get_events_by_ids_with_operation( + pool: &PgPool, + community_id: CommunityId, + ids: &[&[u8]], + operation: crate::observability::WriterOperation, ) -> Result> { if ids.is_empty() { return Ok(vec![]); } - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer(pool, operation).await?; get_events_by_ids_on(&mut conn, community_id, ids).await } @@ -1340,7 +1458,12 @@ pub async fn insert_event_with_thread_metadata( channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let result = insert_event_with_thread_metadata_tx(&mut tx, community_id, event, channel_id, thread_meta) .await?; @@ -1378,7 +1501,46 @@ impl Db { /// explicit, per-callsite decision, never a change to this method. #[datastore_span(name = "query_events", system = "postgresql")] pub async fn query_events(&self, q: &EventQuery) -> Result> { - crate::event::query_events(&self.pool, q).await + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::Authorization, + ) + .await + } + + /// Query authoritative event state that directly controls a durable event + /// mutation or its post-commit side effects. + #[datastore_span(name = "query_events_for_event_write", system = "postgresql")] + pub async fn query_events_for_event_write(&self, q: &EventQuery) -> Result> { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + + /// Query authoritative event state for startup reconciliation. + #[datastore_span(name = "query_events_for_bootstrap", system = "postgresql")] + pub async fn query_events_for_bootstrap(&self, q: &EventQuery) -> Result> { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::Bootstrap, + ) + .await + } + + /// Query authoritative event state for background reconciliation or repair. + #[datastore_span(name = "query_events_for_maintenance", system = "postgresql")] + pub async fn query_events_for_maintenance(&self, q: &EventQuery) -> Result> { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::Maintenance, + ) + .await } /// [`Db::query_events`] with replica routing — the opt-in fast path for @@ -1404,7 +1566,14 @@ impl Db { q: &EventQuery, ) -> Result> { let predicate = crate::RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); - match self.route_read(path, predicate).await { + match self + .route_read( + path, + predicate, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { crate::RouteDecision::Replica(mut tx, _entry, reason) => { match crate::event::query_events_on(&mut tx, q).await { Ok(events) => { @@ -1416,11 +1585,23 @@ impl Db { // writer rather than surfacing a routed error. tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - crate::event::query_events(&self.pool, q).await + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await } } } - crate::RouteDecision::Writer => crate::event::query_events(&self.pool, q).await, + crate::RouteDecision::Writer => { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await + } } } @@ -1438,7 +1619,14 @@ impl Db { path: &'static str, q: &EventQuery, ) -> Result> { - match self.route_read(path, crate::RoutePredicate::Bounded).await { + match self + .route_read( + path, + crate::RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { crate::RouteDecision::Replica(mut tx, _entry, reason) => { match crate::event::query_events_on(&mut tx, q).await { Ok(events) => { @@ -1448,11 +1636,23 @@ impl Db { Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - crate::event::query_events(&self.pool, q).await + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await } } } - crate::RouteDecision::Writer => crate::event::query_events(&self.pool, q).await, + crate::RouteDecision::Writer => { + crate::event::query_events_with_operation( + &self.pool, + q, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await + } } } @@ -1477,7 +1677,14 @@ impl Db { /// the error to the accepted budget `B`. #[datastore_span(name = "count_events_routed", system = "postgresql")] pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result { - match self.route_read(path, crate::RoutePredicate::Bounded).await { + match self + .route_read( + path, + crate::RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { crate::RouteDecision::Replica(mut tx, _entry, reason) => { match crate::event::count_events_on(&mut tx, q).await { Ok(count) => { @@ -1515,6 +1722,29 @@ impl Db { .await } + /// Validate a huddle link while admitting a huddle event for persistence. + #[datastore_span( + name = "huddle_started_link_exists_for_event_write", + system = "postgresql" + )] + pub async fn huddle_started_link_exists_for_event_write( + &self, + community_id: CommunityId, + parent_channel_id: Uuid, + ephemeral_channel_id: Uuid, + creator_pubkey: &[u8], + ) -> Result { + crate::event::huddle_started_link_exists_with_operation( + &self.pool, + community_id, + parent_channel_id, + ephemeral_channel_id, + creator_pubkey, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Fetch the latest replaceable event for a (kind, pubkey) pair. /// /// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`. @@ -1543,6 +1773,23 @@ impl Db { crate::event::get_event_by_id(&self.pool, community_id, id_bytes).await } + /// Fetch an event as a prerequisite of an event write or durable + /// post-write side effect. + #[datastore_span(name = "get_event_by_id_for_event_write", system = "postgresql")] + pub async fn get_event_by_id_for_event_write( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + crate::event::get_event_by_id_with_operation( + &self.pool, + community_id, + id_bytes, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. #[datastore_span(name = "get_event_by_id_including_deleted", system = "postgresql")] pub async fn get_event_by_id_including_deleted( @@ -1553,6 +1800,26 @@ impl Db { crate::event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await } + /// Fetch an event including tombstones as a prerequisite of an event + /// write or durable post-write side effect. + #[datastore_span( + name = "get_event_by_id_including_deleted_for_event_write", + system = "postgresql" + )] + pub async fn get_event_by_id_including_deleted_for_event_write( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + crate::event::get_event_by_id_including_deleted_with_operation( + &self.pool, + community_id, + id_bytes, + crate::observability::WriterOperation::EventWrite, + ) + .await + } + /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. #[datastore_span(name = "soft_delete_event", system = "postgresql")] pub async fn soft_delete_event( @@ -1633,7 +1900,13 @@ impl Db { community_id: CommunityId, ids: &[&[u8]], ) -> Result> { - crate::event::get_events_by_ids(&self.pool, community_id, ids).await + crate::event::get_events_by_ids_with_operation( + &self.pool, + community_id, + ids, + crate::observability::WriterOperation::Authorization, + ) + .await } /// [`Db::get_events_by_ids`] with replica routing — same contract and @@ -1650,7 +1923,14 @@ impl Db { community_id: CommunityId, ids: &[&[u8]], ) -> Result> { - match self.route_read(path, crate::RoutePredicate::Bounded).await { + match self + .route_read( + path, + crate::RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { crate::RouteDecision::Replica(mut tx, _entry, reason) => { match crate::event::get_events_by_ids_on(&mut tx, community_id, ids).await { Ok(events) => { @@ -1660,12 +1940,24 @@ impl Db { Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); - crate::event::get_events_by_ids(&self.pool, community_id, ids).await + crate::event::get_events_by_ids_with_operation( + &self.pool, + community_id, + ids, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await } } } crate::RouteDecision::Writer => { - crate::event::get_events_by_ids(&self.pool, community_id, ids).await + crate::event::get_events_by_ids_with_operation( + &self.pool, + community_id, + ids, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await } } } @@ -1703,6 +1995,11 @@ impl Db { /// Runs a single UPDATE touching only NIP-33 rows with NULL d_tag. #[datastore_span(name = "backfill_d_tags", system = "postgresql")] pub async fn backfill_d_tags(&self) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; let result = sqlx::query( "UPDATE events \ SET d_tag = COALESCE( \ @@ -1713,7 +2010,7 @@ impl Db { WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL \ AND community_write_allowed(community_id)", ) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) } @@ -1726,6 +2023,11 @@ impl Db { channel_id: Uuid, relay_pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", @@ -1733,7 +2035,7 @@ impl Db { .bind(community_id.as_uuid()) .bind(channel_id) .bind(relay_pubkey) - .execute(&self.pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) } diff --git a/crates/buzz-db/src/store/feed.rs b/crates/buzz-db/src/store/feed.rs index 7047bbdd9b..5819136fe6 100644 --- a/crates/buzz-db/src/store/feed.rs +++ b/crates/buzz-db/src/store/feed.rs @@ -134,7 +134,11 @@ pub async fn query_mentions( since: Option>, limit: i64, ) -> Result> { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; query_mentions_on( &mut conn, community, @@ -218,7 +222,11 @@ pub async fn query_needs_action( since: Option>, limit: i64, ) -> Result> { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; query_needs_action_on( &mut conn, community, @@ -287,7 +295,11 @@ pub async fn query_activity( since: Option>, limit: i64, ) -> Result> { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; query_activity_on(&mut conn, community, accessible_channel_ids, since, limit).await } @@ -345,7 +357,14 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { + match self + .route_read( + path, + RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { RouteDecision::Replica(mut tx, _entry, reason) => match crate::feed::query_mentions_on( &mut tx, community, @@ -422,7 +441,14 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { + match self + .route_read( + path, + RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { RouteDecision::Replica(mut tx, _entry, reason) => { match crate::feed::query_needs_action_on( &mut tx, @@ -492,7 +518,14 @@ impl Db { since: Option>, limit: i64, ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { + match self + .route_read( + path, + RoutePredicate::Bounded, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await + { RouteDecision::Replica(mut tx, _entry, reason) => match crate::feed::query_activity_on( &mut tx, community, diff --git a/crates/buzz-db/src/store/git_repo.rs b/crates/buzz-db/src/store/git_repo.rs index bc4e70e151..fac10c3f61 100644 --- a/crates/buzz-db/src/store/git_repo.rs +++ b/crates/buzz-db/src/store/git_repo.rs @@ -50,13 +50,18 @@ pub async fn repo_name_owner( community: CommunityId, repo_id: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT owner_pubkey FROM git_repo_names \ WHERE community_id = $1 AND repo_id = $2", ) .bind(community.as_uuid()) .bind(repo_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(|r| r.try_get("owner_pubkey")) .transpose() @@ -85,6 +90,11 @@ pub async fn reserve_repo_name( repo_id: &str, owner_pubkey: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Atomic claim: insert only if the (community, repo) is free. RETURNING is // non-empty exactly when *this* statement inserted the row, so it cleanly // distinguishes "I claimed it" from "someone already holds it" without a @@ -98,7 +108,7 @@ pub async fn reserve_repo_name( .bind(community.as_uuid()) .bind(repo_id) .bind(owner_pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; if inserted.is_some() { @@ -113,7 +123,7 @@ pub async fn reserve_repo_name( ) .bind(community.as_uuid()) .bind(repo_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match existing { @@ -145,13 +155,18 @@ pub async fn count_repos_for_owner( community: CommunityId, owner_pubkey: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT COUNT(*) AS n FROM git_repo_names \ WHERE community_id = $1 AND owner_pubkey = $2", ) .bind(community.as_uuid()) .bind(owner_pubkey) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; row.try_get("n").map_err(crate::error::DbError::from) } @@ -168,6 +183,11 @@ pub async fn release_repo_name( repo_id: &str, owner_pubkey: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( "DELETE FROM git_repo_names \ WHERE community_id = $1 AND repo_id = $2 AND owner_pubkey = $3", @@ -175,7 +195,7 @@ pub async fn release_repo_name( .bind(community.as_uuid()) .bind(repo_id) .bind(owner_pubkey) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) } diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index 94e550185d..b5cc4d3093 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -464,6 +464,11 @@ pub async fn restriction_state( community: CommunityId, pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#" SELECT @@ -475,7 +480,7 @@ pub async fn restriction_state( ) .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { diff --git a/crates/buzz-db/src/store/partition.rs b/crates/buzz-db/src/store/partition.rs index ba252f71f4..179ba60b78 100644 --- a/crates/buzz-db/src/store/partition.rs +++ b/crates/buzz-db/src/store/partition.rs @@ -16,6 +16,11 @@ const PARTITIONED_TABLES: &[&str] = &["events", "delivery_log"]; /// Ensures monthly partition tables exist for the next `months_ahead` months. pub async fn ensure_future_partitions(pool: &PgPool, months_ahead: u32) -> Result<()> { let now = Utc::now(); + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; for i in 0..=(months_ahead as i32) { let year = now.year(); @@ -50,7 +55,7 @@ pub async fn ensure_future_partitions(pool: &PgPool, months_ahead: u32) -> Resul let end_str = end.format("%Y-%m-%d").to_string(); for table in PARTITIONED_TABLES { - ensure_partition(pool, table, &start_str, &end_str, &suffix).await?; + ensure_partition(&mut connection, table, &start_str, &end_str, &suffix).await?; } } @@ -82,7 +87,7 @@ fn validate_date_str(s: &str) -> bool { } async fn ensure_partition( - pool: &PgPool, + connection: &mut sqlx::PgConnection, table_name: &str, start_date_str: &str, end_date_str: &str, @@ -123,7 +128,7 @@ async fn ensure_partition( "#, ) .bind(&partition_name) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; let cnt: i64 = row.try_get("cnt")?; @@ -137,7 +142,10 @@ async fn ensure_partition( FOR VALUES FROM ('{start_date_str}') TO ('{end_date_str}')" ); - match sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await { + match sqlx::query(sqlx::AssertSqlSafe(sql)) + .execute(&mut *connection) + .await + { Ok(_) => { info!("added partition {partition_name}"); Ok(()) diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index 710ffb931c..59c44fc5a8 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -14,6 +14,21 @@ use crate::error::Result; use crate::Db; use buzz_datastore_tracing::datastore_span; +async fn acquire_operation_connection( + pool: &PgPool, + operation: crate::observability::WriterOperation, +) -> Result> { + Ok(crate::observability::acquire_writer(pool, operation).await?) +} + +async fn begin_operation_transaction( + pool: &PgPool, + operation: crate::observability::WriterOperation, +) -> Result> { + let connection = acquire_operation_connection(pool, operation).await?; + Ok(sqlx::Transaction::begin(connection, None).await?) +} + /// Namespace for the per-community push-gate advisory lock. Must match the /// key built inside the `enqueue_push_match_job` trigger (migration 0023): /// event inserts take it SHARED there; every lease transition that can make @@ -498,7 +513,9 @@ async fn replace_lease( // lease" to "eligible"; serialize it against the trigger's shared gate // lock (gate → lease row, matching accept_lease_event's global order). // Revocations (is_active = false) never make eligibility true and skip it. - let mut tx = pool.begin().await?; + let mut tx = + begin_operation_transaction(pool, crate::observability::WriterOperation::EventWrite) + .await?; if is_active { acquire_push_gate_lock(&mut tx, community).await?; } @@ -651,7 +668,9 @@ pub async fn enqueue_wakes( if requests.is_empty() { return Ok(Vec::new()); } - let mut tx = pool.begin().await?; + let mut tx = + begin_operation_transaction(pool, crate::observability::WriterOperation::Maintenance) + .await?; // 1. Lock and read the current lease row for every distinct requested // (author, installation), in deterministic order. @@ -854,7 +873,13 @@ pub async fn claim_due_match_batch( lease_until, |pool, community, ids| async move { let refs: Vec<&[u8]> = ids.iter().map(Vec::as_slice).collect(); - crate::event::get_events_by_ids(&pool, community, &refs).await + crate::event::get_events_by_ids_with_operation( + &pool, + community, + &refs, + crate::observability::WriterOperation::Maintenance, + ) + .await }, ) .await @@ -871,6 +896,9 @@ where Fut: std::future::Future>>, { let claim_id = Uuid::new_v4(); + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let rows = sqlx::query( r#" WITH target AS ( @@ -905,11 +933,16 @@ where .bind(lease_until) .bind(MAX_MATCH_ATTEMPTS) .bind(limit) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; if rows.is_empty() { return Ok(None); } + // The claim query is a single autocommitted statement. Release its pool + // slot before loading source events, because the production loader owns a + // separately attributed acquisition. Holding this connection across the + // load would self-starve a supported size-one writer pool. + drop(connection); let community = CommunityId::from_uuid(rows[0].try_get("community_id")?); let mut attempts = std::collections::HashMap::with_capacity(rows.len()); for row in &rows { @@ -932,6 +965,9 @@ where // recoverable after their claim lease expires. let gone: Vec> = attempts.into_keys().collect(); if !gone.is_empty() { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; sqlx::query( "DELETE FROM push_match_queue \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", @@ -939,7 +975,7 @@ where .bind(community.as_uuid()) .bind(claim_id) .bind(&gone) - .execute(pool) + .execute(&mut *connection) .await?; } if jobs.is_empty() { @@ -959,26 +995,32 @@ where /// served by the due partial index, so putting it in every claim made claims /// slower exactly when a backlog needed them fastest. pub async fn reap_exhausted_matches(pool: &PgPool) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; Ok(sqlx::query( "DELETE FROM push_match_queue WHERE attempts >= $1 \ AND (state='pending' OR (state='matching' AND lease_until < now())) \ AND community_write_allowed(community_id)", ) .bind(MAX_MATCH_ATTEMPTS) - .execute(pool) + .execute(&mut *connection) .await? .rows_affected()) } /// Load active endpoint-enabled leases for one tenant. pub async fn active_match_leases(pool: &PgPool, community: CommunityId) -> Result> { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let rows = sqlx::query( "SELECT author, installation_id, generation, subscriptions, expires_at \ FROM push_leases WHERE community_id=$1 AND active AND endpoint_enabled \ AND expires_at > EXTRACT(EPOCH FROM now())::bigint", ) .bind(community.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() .map(|row| { @@ -1005,6 +1047,9 @@ pub async fn complete_match_batch( if event_ids.is_empty() { return Ok(0); } + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; Ok(sqlx::query( "DELETE FROM push_match_queue \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", @@ -1012,7 +1057,7 @@ pub async fn complete_match_batch( .bind(community.as_uuid()) .bind(claim_id) .bind(event_ids) - .execute(pool) + .execute(&mut *connection) .await? .rows_affected()) } @@ -1029,6 +1074,9 @@ pub async fn retry_match_batch( if event_ids.is_empty() { return Ok(0); } + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; Ok(sqlx::query( "UPDATE push_match_queue \ SET state='pending', claim_id=NULL, lease_until=NULL, next_attempt_at=$4 \ @@ -1038,7 +1086,7 @@ pub async fn retry_match_batch( .bind(claim_id) .bind(event_ids) .bind(next) - .execute(pool) + .execute(&mut *connection) .await? .rows_affected()) } @@ -1054,6 +1102,9 @@ pub async fn claim_due_wakes( lease_until: DateTime, ) -> Result> { let claim_id = Uuid::new_v4(); + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let rows = sqlx::query( r#" WITH candidates AS ( @@ -1101,7 +1152,7 @@ pub async fn claim_due_wakes( .bind(limit) .bind(claim_id) .bind(lease_until) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter().map(row_to_claimed_wake).collect() @@ -1118,6 +1169,9 @@ pub async fn revalidate_wake_for_send( id: Uuid, claim_id: Uuid, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let row = sqlx::query( r#" SELECT o.community_id, o.id, o.claim_id, o.event_id, e.channel_id, @@ -1149,7 +1203,7 @@ pub async fn revalidate_wake_for_send( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(row_to_claimed_wake) @@ -1166,6 +1220,9 @@ pub async fn complete_wake( id: Uuid, claim_id: Uuid, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'delivered', claim_id = NULL, lease_until = NULL \ @@ -1174,7 +1231,7 @@ pub async fn complete_wake( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -1187,6 +1244,9 @@ pub async fn retry_wake( claim_id: Uuid, next_attempt_at: DateTime, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'pending', next_attempt_at = $4, claim_id = NULL, lease_until = NULL \ @@ -1196,7 +1256,7 @@ pub async fn retry_wake( .bind(id) .bind(claim_id) .bind(next_attempt_at) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -1208,6 +1268,9 @@ pub async fn fail_wake( id: Uuid, claim_id: Uuid, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'failed', claim_id = NULL, lease_until = NULL \ @@ -1216,7 +1279,7 @@ pub async fn fail_wake( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -1232,6 +1295,9 @@ pub async fn disable_endpoint_generation( installation_id: &str, generation: i64, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "UPDATE push_leases SET endpoint_enabled = false, updated_at = now() \ WHERE community_id = $1 AND author = $2 AND installation_id = $3 \ @@ -1241,7 +1307,7 @@ pub async fn disable_endpoint_generation( .bind(author) .bind(installation_id) .bind(generation) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -1256,6 +1322,9 @@ pub async fn prune_wake_outbox( community: CommunityId, before: DateTime, ) -> Result { + let mut connection = + acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) + .await?; let result = sqlx::query( "DELETE FROM push_wake_outbox o \ WHERE o.community_id = $1 AND o.created_at < $2 \ @@ -1268,7 +1337,7 @@ pub async fn prune_wake_outbox( ) .bind(community.as_uuid()) .bind(before) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) } @@ -1466,7 +1535,9 @@ impl Db { mod postgres_tests { use super::*; use crate::migration; + use sqlx::postgres::PgPoolOptions; use std::sync::Arc; + use std::time::Duration; use tokio::sync::Barrier; async fn setup_pool() -> PgPool { @@ -2160,6 +2231,51 @@ mod postgres_tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn matcher_claim_and_load_support_size_one_pool() { + let setup = setup_pool().await; + sqlx::query("DELETE FROM push_match_queue") + .execute(&setup) + .await + .expect("drain matcher queue"); + let community = make_community(&setup).await; + activate(&setup, community, &[83; 32], "install", &[84; 32], 1).await; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "size one") + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign event"); + crate::event::insert_event(&setup, community, &event, None) + .await + .expect("insert event"); + setup.close().await; + + let pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_millis(250)) + .connect(&crate::test_support::database_url()) + .await + .expect("connect size-one matcher pool"); + let batch = tokio::time::timeout( + Duration::from_secs(2), + claim_due_match_batch(&pool, 16, Utc::now() + chrono::Duration::minutes(1)), + ) + .await + .expect("matcher must not self-starve on a size-one pool") + .expect("claim and source load must succeed") + .expect("seeded matcher job must be claimed"); + assert_eq!(batch.community, community); + assert_eq!(batch.jobs.len(), 1); + assert_eq!(batch.jobs[0].event.event.id, event.id); + + let ids = vec![event.id.as_bytes().to_vec()]; + assert_eq!( + complete_match_batch(&pool, community, batch.claim_id, &ids) + .await + .expect("complete size-one matcher batch"), + 1 + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn matcher_claim_is_exclusive_across_workers() { diff --git a/crates/buzz-db/src/store/reaction.rs b/crates/buzz-db/src/store/reaction.rs index 6494d85663..1c4ee19ead 100644 --- a/crates/buzz-db/src/store/reaction.rs +++ b/crates/buzz-db/src/store/reaction.rs @@ -111,6 +111,11 @@ pub async fn add_reaction( emoji: &str, reaction_event_id: Option<&[u8]>, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query(ADD_REACTION_SQL) .bind(community.as_uuid()) .bind(event_created_at) @@ -118,7 +123,7 @@ pub async fn add_reaction( .bind(pubkey) .bind(emoji) .bind(reaction_event_id) - .execute(pool) + .execute(&mut *connection) .await?; // Three cases: @@ -173,7 +178,12 @@ pub async fn insert_reaction_event_with_thread_metadata( actor_pubkey: &[u8], emoji: &str, ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let target_row = sqlx::query( "SELECT created_at FROM events \ @@ -236,6 +246,11 @@ pub async fn remove_reaction( pubkey: &[u8], emoji: &str, ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE reactions @@ -253,7 +268,7 @@ pub async fn remove_reaction( .bind(event_id) .bind(pubkey) .bind(emoji) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -267,6 +282,11 @@ pub async fn remove_reaction_by_source_event_id( community: CommunityId, reaction_event_id: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE reactions @@ -278,7 +298,7 @@ pub async fn remove_reaction_by_source_event_id( ) .bind(community.as_uuid()) .bind(reaction_event_id) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -293,6 +313,11 @@ pub async fn get_active_reaction_record( pubkey: &[u8], emoji: &str, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" SELECT reaction_event_id @@ -311,7 +336,7 @@ pub async fn get_active_reaction_record( .bind(event_created_at) .bind(pubkey) .bind(emoji) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(|row| -> Result { @@ -335,6 +360,11 @@ pub async fn set_reaction_event_id( emoji: &str, reaction_event_id: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE reactions @@ -353,7 +383,7 @@ pub async fn set_reaction_event_id( .bind(event_id) .bind(pubkey) .bind(emoji) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) @@ -376,6 +406,11 @@ pub async fn get_reactions( limit: u32, _cursor: Option<&str>, ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; // Two-step query: first get the limited set of distinct emoji groups, // then fetch all rows for those groups. This ensures `limit` applies to // emoji groups (the API contract), not raw rows — so one busy emoji @@ -405,7 +440,7 @@ pub async fn get_reactions( .bind(event_id) .bind(event_created_at) .bind(limit as i64) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; // Group individual rows by emoji in Rust. @@ -466,6 +501,11 @@ pub async fn get_reactions_bulk( // Run one query per event. For typical message-list sizes (<=100 events) // this is acceptable; a single-query approach with dynamic IN clauses over // composite keys can be added later if needed. + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; let mut entries = Vec::new(); for (event_id, event_created_at) in event_ids { @@ -484,7 +524,7 @@ pub async fn get_reactions_bulk( .bind(community.as_uuid()) .bind(*event_id) .bind(event_created_at) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; if rows.is_empty() { diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 6c90c31b94..4bb48e121c 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -116,7 +116,12 @@ pub async fn mint_relay_invite( // community-scoped database write. The trigger remains the final backstop, // but this typed guard keeps a quiescing community from surfacing as an // opaque SQLSTATE/HTTP 500 at the API boundary. - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; crate::deletion::DeletionStore::new(pool.clone()) .guard_transaction(&mut tx, community) .await?; @@ -172,6 +177,11 @@ const RETENTION_SWEEP_BATCH_SIZE: i64 = 1_000; /// expiry index makes old rows drain first without turning cleanup into an /// unbounded transaction. pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "DELETE FROM relay_invites \ WHERE (community_id, id) IN (\ @@ -184,7 +194,7 @@ pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> ) .bind(cutoff) .bind(RETENTION_SWEEP_BATCH_SIZE) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) @@ -216,7 +226,12 @@ pub async fn claim_relay_invite( claimer_pubkey: &str, policy_version: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. let row = sqlx::query( diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 9a5b6f91a2..ecde3924fe 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -32,7 +32,8 @@ pub struct RelayMember { /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. pub async fn is_relay_member(pool: &PgPool, community: CommunityId, pubkey: &str) -> Result { - let mut conn = pool.acquire().await?; + let mut conn = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; is_relay_member_on(&mut conn, community, pubkey).await } @@ -57,12 +58,14 @@ pub(crate) async fn is_relay_member_on( /// (`bootstrap_owner`) and operator provisioning still populate it — this is /// how the workspace-profile gate detects whether a steward exists. pub async fn has_admin_or_owner(pool: &PgPool, community: CommunityId) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let row = sqlx::query( "SELECT 1 FROM relay_members \ WHERE community_id = $1 AND role IN ('admin', 'owner') LIMIT 1", ) .bind(community.as_uuid()) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; Ok(row.is_some()) } @@ -73,13 +76,15 @@ pub async fn get_relay_member( community: CommunityId, pubkey: &str, ) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let row = sqlx::query( "SELECT pubkey, role, added_by, created_at, updated_at \ FROM relay_members WHERE community_id = $1 AND pubkey = $2", ) .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(|r| -> std::result::Result { @@ -97,12 +102,26 @@ pub async fn get_relay_member( /// Returns all relay members of `community` ordered by `created_at` ascending. pub async fn list_relay_members(pool: &PgPool, community: CommunityId) -> Result> { + list_relay_members_with_operation( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await +} + +async fn list_relay_members_with_operation( + pool: &PgPool, + community: CommunityId, + operation: observability::WriterOperation, +) -> Result> { + let mut connection = observability::acquire_writer(pool, operation).await?; let rows = sqlx::query( "SELECT pubkey, role, added_by, created_at, updated_at \ FROM relay_members WHERE community_id = $1 ORDER BY created_at ASC", ) .bind(community.as_uuid()) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -131,6 +150,8 @@ pub async fn add_relay_member( role: &str, added_by: Option<&str>, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let result = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING", @@ -139,7 +160,7 @@ pub async fn add_relay_member( .bind(pubkey) .bind(role) .bind(added_by) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) } @@ -156,7 +177,9 @@ pub async fn claim_relay_membership( role: &str, policy_version: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; let inserted = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, 'invite') \ @@ -193,6 +216,8 @@ pub async fn has_join_policy_acceptance( pubkey: &str, policy_version: &str, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let row = sqlx::query( "SELECT 1 FROM join_policy_acceptances \ WHERE community_id = $1 AND pubkey = $2 AND policy_version = $3", @@ -200,7 +225,7 @@ pub async fn has_join_policy_acceptance( .bind(community.as_uuid()) .bind(pubkey) .bind(policy_version) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; Ok(row.is_some()) } @@ -228,13 +253,15 @@ pub async fn remove_relay_member( community: CommunityId, pubkey: &str, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let result = sqlx::query( "DELETE FROM relay_members \ WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'", ) .bind(community.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() > 0 { @@ -246,7 +273,7 @@ pub async fn remove_relay_member( let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; if exists.is_some() { @@ -275,13 +302,15 @@ pub async fn remove_relay_member_if_role( pubkey: &str, expected_role: &str, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let result = sqlx::query( "DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3", ) .bind(community.as_uuid()) .bind(pubkey) .bind(expected_role) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() > 0 { @@ -293,7 +322,7 @@ pub async fn remove_relay_member_if_role( let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; match row { @@ -320,6 +349,8 @@ pub async fn update_relay_member_role( pubkey: &str, new_role: &str, ) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let result = sqlx::query( "UPDATE relay_members SET role = $1, updated_at = now() \ WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'", @@ -327,7 +358,7 @@ pub async fn update_relay_member_role( .bind(new_role) .bind(community.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() > 0) } @@ -351,9 +382,25 @@ pub async fn bootstrap_owner( pool: &PgPool, community: CommunityId, owner_pubkey: &str, +) -> Result<()> { + bootstrap_owner_with_operation( + pool, + community, + owner_pubkey, + observability::WriterOperation::Bootstrap, + ) + .await +} + +async fn bootstrap_owner_with_operation( + pool: &PgPool, + community: CommunityId, + owner_pubkey: &str, + operation: observability::WriterOperation, ) -> Result<()> { let pubkey = owner_pubkey.to_ascii_lowercase(); - let mut tx = pool.begin().await?; + let connection = observability::acquire_writer(pool, operation).await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // 1. Upsert the configured owner for this community. sqlx::query( @@ -472,7 +519,9 @@ pub async fn transfer_ownership( ) -> Result { let pubkey = new_owner_pubkey.to_ascii_lowercase(); let expected_owner = expected_owner_pubkey.to_ascii_lowercase(); - let mut tx = pool.begin().await?; + let connection = + observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // 1. Serialize on the transferee so concurrent transfers to the same // recipient cannot both pass the ownership count check. @@ -573,12 +622,14 @@ pub async fn transfer_ownership( /// The empty-table guard prevents re-adding members that were intentionally /// removed by an admin after the initial backfill. pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Bootstrap).await?; // Check if pubkey_allowlist table exists. let exists: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ WHERE table_schema = 'public' AND table_name = 'pubkey_allowlist')", ) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; if !exists { @@ -591,7 +642,7 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)") .bind(community.as_uuid()) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; if has_members { @@ -606,7 +657,7 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R ON CONFLICT (community_id, pubkey) DO NOTHING", ) .bind(community.as_uuid()) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected()) @@ -624,7 +675,14 @@ impl Db { #[datastore_span(name = "is_relay_member", system = "postgresql")] pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { let path = "relay_membership"; - match self.route_read(path, RoutePredicate::Bounded).await { + match self + .route_read( + path, + RoutePredicate::Bounded, + crate::observability::ReaderOperation::Authorization, + ) + .await + { RouteDecision::Replica(mut tx, _entry, reason) => { match is_relay_member_on(&mut tx, community, pubkey).await { Ok(is_member) => { @@ -738,6 +796,18 @@ impl Db { bootstrap_owner(&self.pool, community, owner_pubkey).await } + /// Ensure an owner during operator-driven community provisioning. + #[datastore_span(name = "provision_owner", system = "postgresql")] + pub async fn provision_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { + bootstrap_owner_with_operation( + &self.pool, + community, + owner_pubkey, + observability::WriterOperation::Authorization, + ) + .await + } + /// Returns `true` if any member of `community` holds the `admin` or /// `owner` role. #[datastore_span(name = "has_admin_or_owner", system = "postgresql")] @@ -784,23 +854,80 @@ impl Db { name = "nip43_membership_snapshot_needs_reconciliation", system = "postgresql" )] + #[deprecated( + note = "use nip43_membership_snapshot_needs_reconciliation_for_bootstrap or nip43_membership_snapshot_needs_reconciliation_for_maintenance" + )] pub async fn nip43_membership_snapshot_needs_reconciliation( &self, community_id: CommunityId, relay_pubkey: &nostr::PublicKey, ) -> Result { - let snapshot = self - .query_events(&crate::event::EventQuery { + self.nip43_membership_snapshot_needs_reconciliation_with_operation( + community_id, + relay_pubkey, + observability::WriterOperation::Maintenance, + ) + .await + } + + /// Startup-attributed variant of the NIP-43 snapshot comparison. + #[datastore_span( + name = "nip43_membership_snapshot_needs_reconciliation_for_bootstrap", + system = "postgresql" + )] + pub async fn nip43_membership_snapshot_needs_reconciliation_for_bootstrap( + &self, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + ) -> Result { + self.nip43_membership_snapshot_needs_reconciliation_with_operation( + community_id, + relay_pubkey, + observability::WriterOperation::Bootstrap, + ) + .await + } + + /// Periodic maintenance variant of the NIP-43 snapshot comparison. + #[datastore_span( + name = "nip43_membership_snapshot_needs_reconciliation_for_maintenance", + system = "postgresql" + )] + pub async fn nip43_membership_snapshot_needs_reconciliation_for_maintenance( + &self, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + ) -> Result { + self.nip43_membership_snapshot_needs_reconciliation_with_operation( + community_id, + relay_pubkey, + observability::WriterOperation::Maintenance, + ) + .await + } + + async fn nip43_membership_snapshot_needs_reconciliation_with_operation( + &self, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + operation: observability::WriterOperation, + ) -> Result { + let snapshot = crate::event::query_events_with_operation( + &self.pool, + &crate::event::EventQuery { kinds: Some(vec![buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32]), pubkey: Some(relay_pubkey.to_bytes().to_vec()), global_only: true, limit: Some(1), ..crate::event::EventQuery::for_community(community_id) - }) - .await? - .into_iter() - .next(); - let members = self.list_relay_members(community_id).await?; + }, + operation, + ) + .await? + .into_iter() + .next(); + let members = + list_relay_members_with_operation(&self.pool, community_id, operation).await?; let Some(snapshot) = snapshot else { return Ok(true); diff --git a/crates/buzz-db/src/store/relay_operators.rs b/crates/buzz-db/src/store/relay_operators.rs index 204e88fd95..41b45a9ac6 100644 --- a/crates/buzz-db/src/store/relay_operators.rs +++ b/crates/buzz-db/src/store/relay_operators.rs @@ -97,7 +97,12 @@ pub async fn upsert( added_by: &[u8], config_operator_exists: bool, ) -> Result<()> { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // A demotion to moderator can drop the effective-operator count; serialize // it against every other operator-removing mutation via the roster-wide @@ -193,7 +198,12 @@ pub async fn remove( actor: &[u8], config_operator_exists: bool, ) -> Result { - let mut tx = pool.begin().await?; + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; // Serialize against every other operator-removing mutation before the // delete so the post-delete count reflects a stable roster. @@ -235,11 +245,16 @@ pub async fn remove( /// Fetch one relay operator/moderator row by pubkey. pub async fn get(pool: &PgPool, pubkey: &[u8]) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( "SELECT pubkey, role, added_by, created_at FROM relay_operators WHERE pubkey = $1", ) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map( @@ -258,10 +273,15 @@ pub async fn get(pool: &PgPool, pubkey: &[u8]) -> Result Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let rows = sqlx::query( "SELECT pubkey, role, added_by, created_at FROM relay_operators ORDER BY created_at ASC", ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() diff --git a/crates/buzz-db/src/store/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs index 19f0d2d008..5985d22b6d 100644 --- a/crates/buzz-db/src/store/replaceable.rs +++ b/crates/buzz-db/src/store/replaceable.rs @@ -1039,7 +1039,7 @@ mod postgres_tests { ); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin caller transaction"); let result = db @@ -1109,7 +1109,10 @@ mod postgres_tests { .1 ); - let mut tx = db.begin_transaction().await.expect("begin replacement tx"); + let mut tx = db + .begin_event_write_transaction() + .await + .expect("begin replacement tx"); let outcome = db .replace_parameterized_event_in_transaction( &mut tx, @@ -1143,7 +1146,7 @@ mod postgres_tests { assert_eq!(live_id, old.id.as_bytes().to_vec()); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin stale revision tx"); let mismatch = db @@ -1177,7 +1180,7 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign missing project"); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin missing revision tx"); let missing_result = db @@ -1255,7 +1258,7 @@ mod postgres_tests { .expect("install failure injection"); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin caller transaction"); let error = db @@ -1336,7 +1339,7 @@ mod postgres_tests { .expect("soft-delete duplicate row"); let mut seed_tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin seed transaction"); let (_, was_inserted) = @@ -1347,7 +1350,7 @@ mod postgres_tests { seed_tx.commit().await.expect("commit older live head"); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .expect("begin caller transaction"); let result = db diff --git a/crates/buzz-db/src/store/thread.rs b/crates/buzz-db/src/store/thread.rs index 0cf4e91f34..a38ac2b038 100644 --- a/crates/buzz-db/src/store/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -11,6 +11,23 @@ use uuid::Uuid; use buzz_datastore_tracing::datastore_span; +async fn acquire_event_write_connection( + pool: &PgPool, +) -> Result> { + Ok(crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?) +} + +async fn begin_event_write_transaction( + pool: &PgPool, +) -> Result> { + let connection = acquire_event_write_connection(pool).await?; + Ok(sqlx::Transaction::begin(connection, None).await?) +} + use buzz_core::CommunityId; use crate::{ @@ -131,7 +148,7 @@ pub async fn insert_thread_metadata( depth: i32, broadcast: bool, ) -> Result<()> { - let mut tx = pool.begin().await?; + let mut tx = begin_event_write_transaction(pool).await?; let result = sqlx::query( r#" @@ -259,6 +276,7 @@ pub async fn increment_reply_count( parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; // Always bump the parent's direct reply count and last-reply timestamp. sqlx::query( r#" @@ -270,7 +288,7 @@ pub async fn increment_reply_count( ) .bind(community_id.as_uuid()) .bind(parent_event_id) - .execute(pool) + .execute(&mut *connection) .await?; // Always bump root's descendant_count, regardless of whether root == parent. @@ -284,7 +302,7 @@ pub async fn increment_reply_count( ) .bind(community_id.as_uuid()) .bind(root_id) - .execute(pool) + .execute(&mut *connection) .await?; } @@ -300,6 +318,7 @@ pub async fn decrement_reply_count( parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { + let mut connection = acquire_event_write_connection(pool).await?; // Always decrement the parent's direct reply count (floor at 0). sqlx::query( r#" @@ -310,7 +329,7 @@ pub async fn decrement_reply_count( ) .bind(community_id.as_uuid()) .bind(parent_event_id) - .execute(pool) + .execute(&mut *connection) .await?; // Always decrement root's descendant_count, regardless of whether root == parent. @@ -324,7 +343,7 @@ pub async fn decrement_reply_count( ) .bind(community_id.as_uuid()) .bind(root_id) - .execute(pool) + .execute(&mut *connection) .await?; } @@ -355,7 +374,11 @@ pub async fn get_thread_replies( limit: u32, cursor: Option<&[u8]>, ) -> Result> { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; get_thread_replies_on( &mut conn, community_id, @@ -520,6 +543,11 @@ pub async fn get_thread_summary( community_id: CommunityId, event_id: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" SELECT reply_count, descendant_count, last_reply_at @@ -530,7 +558,7 @@ pub async fn get_thread_summary( ) .bind(community_id.as_uuid()) .bind(event_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; let row = match row { @@ -563,7 +591,7 @@ pub async fn get_thread_summary( ) .bind(community_id.as_uuid()) .bind(event_id) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let participants: Vec> = participant_rows @@ -599,7 +627,11 @@ pub async fn get_channel_window( cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, ) -> Result { - let mut conn = pool.acquire().await?; + let mut conn = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; get_channel_window_on( &mut conn, community_id, @@ -811,6 +843,11 @@ pub async fn get_thread_metadata_by_event( community_id: CommunityId, event_id: &[u8], ) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" SELECT @@ -830,7 +867,7 @@ pub async fn get_thread_metadata_by_event( ) .bind(community_id.as_uuid()) .bind(event_id) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; let row = match row { @@ -935,8 +972,13 @@ impl Db { ), None => ("thread_head", RoutePredicate::Bounded), }; - if let RouteDecision::Replica(mut tx, entry, reason) = - self.route_read(path, predicate).await + if let RouteDecision::Replica(mut tx, entry, reason) = self + .route_read( + path, + predicate, + crate::observability::ReaderOperation::SubscriptionHistory, + ) + .await { match crate::thread::get_thread_replies_on( &mut tx, @@ -1062,6 +1104,7 @@ impl Db { .route_read( path, RoutePredicate::from_channel_cursor(channel_id, &cursor), + crate::observability::ReaderOperation::SubscriptionHistory, ) .await { diff --git a/crates/buzz-db/src/store/usage.rs b/crates/buzz-db/src/store/usage.rs index ce581561bd..9d557c8b18 100644 --- a/crates/buzz-db/src/store/usage.rs +++ b/crates/buzz-db/src/store/usage.rs @@ -43,8 +43,10 @@ impl UsageMetricsLeader { /// Total number of communities registered on this relay. pub async fn community_count(pool: &PgPool) -> Result { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM communities") - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; Ok(row) } @@ -64,6 +66,8 @@ pub struct CommunityUserCounts { /// /// Agent discriminator: `agent_owner_pubkey IS NOT NULL`. pub async fn user_counts(pool: &PgPool) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; // Single GROUP BY query; two conditional SUMs avoid two round-trips. let rows = sqlx::query_as::<_, (Uuid, i64, i64)>( r#" @@ -76,7 +80,7 @@ pub async fn user_counts(pool: &PgPool) -> Result> { GROUP BY community_id "#, ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -102,6 +106,8 @@ pub struct CommunityChannelCount { /// Return non-deleted channel counts per community per type. pub async fn channel_counts(pool: &PgPool) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, String, i64)>( r#" SELECT community_id, channel_type::text, COUNT(*) AS count @@ -110,7 +116,7 @@ pub async fn channel_counts(pool: &PgPool) -> Result> GROUP BY community_id, channel_type "#, ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -136,6 +142,8 @@ pub struct CommunityMessageCount { /// Return non-deleted kind=9 event counts per community. pub async fn message_counts(pool: &PgPool) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, i64)>( r#" SELECT community_id, COUNT(*) AS count @@ -144,7 +152,7 @@ pub async fn message_counts(pool: &PgPool) -> Result> GROUP BY community_id "#, ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -169,6 +177,8 @@ pub struct CommunityMemberCount { /// Return relay-member counts per community per role. pub async fn relay_member_counts(pool: &PgPool) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, String, i64)>( r#" SELECT community_id, role::text, COUNT(*) AS count @@ -176,7 +186,7 @@ pub async fn relay_member_counts(pool: &PgPool) -> Result Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, String, i64)>( r#" SELECT community_id, status::text, COUNT(*) AS count @@ -209,7 +221,7 @@ pub async fn workflow_counts(pool: &PgPool) -> Result Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let rows = sqlx::query_as::<_, (Uuid, i64)>( r#" SELECT community_id, COUNT(*) AS count @@ -240,7 +254,7 @@ pub async fn git_repo_counts(pool: &PgPool) -> Result GROUP BY community_id "#, ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -280,6 +294,8 @@ pub async fn active_user_counts( pool: &PgPool, interval_sql: &'static str, ) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; // LEFT JOIN users: pubkeys with no row have u.* = NULL. // Three-way classification: // human — row exists (u.pubkey IS NOT NULL) and agent_owner_pubkey IS NULL @@ -304,7 +320,7 @@ pub async fn active_user_counts( "# ); let rows = sqlx::query_as::<_, (Uuid, i64, i64, i64)>(sqlx::AssertSqlSafe(sql)) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -334,6 +350,8 @@ pub async fn active_channel_counts( pool: &PgPool, interval_sql: &'static str, ) -> Result> { + let mut connection = + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await?; let sql = format!( r#" SELECT community_id, COUNT(DISTINCT channel_id) AS count @@ -346,7 +364,7 @@ pub async fn active_channel_counts( "# ); let rows = sqlx::query_as::<_, (Uuid, i64)>(sqlx::AssertSqlSafe(sql)) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows @@ -370,8 +388,16 @@ pub struct CommunityHost { /// Fetch all community id → host mappings in one query. pub async fn community_hosts(pool: &PgPool) -> Result> { + community_hosts_with_operation(pool, observability::WriterOperation::Maintenance).await +} + +async fn community_hosts_with_operation( + pool: &PgPool, + operation: observability::WriterOperation, +) -> Result> { + let mut connection = observability::acquire_writer(pool, operation).await?; let rows = sqlx::query_as::<_, (Uuid, String)>("SELECT id, host FROM communities") - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; Ok(rows .into_iter() @@ -391,8 +417,11 @@ impl Db { &self, lock_key: i64, ) -> Result> { - let mut connection = - observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + let mut connection = observability::acquire_writer_with_legacy_metrics( + &self.pool, + observability::WriterOperation::Maintenance, + ) + .await?; let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") .bind(lock_key) .fetch_one(&mut *connection) @@ -473,6 +502,12 @@ impl Db { pub async fn usage_community_hosts(&self) -> Result> { community_hosts(&self.pool).await } + + /// Return community host mappings during startup bootstrap work. + #[datastore_span(name = "bootstrap_community_hosts", system = "postgresql")] + pub async fn bootstrap_community_hosts(&self) -> Result> { + community_hosts_with_operation(&self.pool, observability::WriterOperation::Bootstrap).await + } } #[cfg(test)] diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 67f9cb341d..759e916e4b 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -42,6 +42,22 @@ pub struct UserSearchProfile { /// The `true` case is the reliable signal for "user was just registered" — used /// by callers to increment `buzz_users_created_total`. pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8]) -> Result { + ensure_user_with_operation( + pool, + community_id, + pubkey, + crate::observability::WriterOperation::EventWrite, + ) + .await +} + +async fn ensure_user_with_operation( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], + operation: crate::observability::WriterOperation, +) -> Result { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; let result = sqlx::query( r#" INSERT INTO users (community_id, pubkey) @@ -51,7 +67,7 @@ pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8] ) .bind(community_id.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(&mut *connection) .await?; Ok(result.rows_affected() == 1) } @@ -296,6 +312,24 @@ pub async fn set_agent_owner( agent_pubkey: &[u8], owner_pubkey: &[u8], ) -> Result { + set_agent_owner_with_operation( + pool, + community_id, + agent_pubkey, + owner_pubkey, + crate::observability::WriterOperation::EventWrite, + ) + .await +} + +async fn set_agent_owner_with_operation( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], + operation: crate::observability::WriterOperation, +) -> Result { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; // Conditional UPDATE: only set owner if currently NULL. This makes // "first mint wins" atomic — no TOCTOU race between concurrent mints. let result = sqlx::query( @@ -304,7 +338,7 @@ pub async fn set_agent_owner( .bind(owner_pubkey) .bind(community_id.as_uuid()) .bind(agent_pubkey) - .execute(pool) + .execute(&mut *connection) .await?; if result.rows_affected() == 0 { @@ -313,7 +347,7 @@ pub async fn set_agent_owner( let exists = sqlx::query(r#"SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2"#) .bind(community_id.as_uuid()) .bind(agent_pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; if exists.is_none() { return Err(crate::error::DbError::NotFound( @@ -334,12 +368,17 @@ pub async fn get_agent_channel_policy( community_id: CommunityId, pubkey: &[u8], ) -> Result>)>> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#"SELECT channel_add_policy::text AS channel_add_policy, agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2"#, ) .bind(community_id.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; row.map(|r| -> Result<(String, Option>)> { @@ -359,13 +398,18 @@ pub async fn is_agent_owner( target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query_scalar::<_, bool>( "SELECT agent_owner_pubkey = $3 FROM users WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL", ) .bind(community_id.as_uuid()) .bind(target_pubkey) .bind(actor_pubkey) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; Ok(row.unwrap_or(false)) } @@ -411,6 +455,23 @@ impl Db { crate::user::ensure_user(&self.pool, community_id, pubkey).await } + /// Ensure a principal while materializing an authenticated NIP-OA + /// authorization relationship. + #[datastore_span(name = "ensure_user_for_authorization", system = "postgresql")] + pub async fn ensure_user_for_authorization( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result { + ensure_user_with_operation( + &self.pool, + community_id, + pubkey, + crate::observability::WriterOperation::Authorization, + ) + .await + } + /// Get a single user record by pubkey. #[datastore_span(name = "get_user", system = "postgresql")] pub async fn get_user( @@ -478,6 +539,25 @@ impl Db { crate::user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await } + /// Materialize an authenticated NIP-OA agent-owner relationship under + /// authorization attribution. + #[datastore_span(name = "set_agent_owner_for_authorization", system = "postgresql")] + pub async fn set_agent_owner_for_authorization( + &self, + community_id: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], + ) -> Result { + set_agent_owner_with_operation( + &self.pool, + community_id, + agent_pubkey, + owner_pubkey, + crate::observability::WriterOperation::Authorization, + ) + .await + } + /// Get the channel_add_policy and agent_owner_pubkey for a user. #[datastore_span(name = "get_agent_channel_policy", system = "postgresql")] pub async fn get_agent_channel_policy( diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 9e37186009..832b05c56f 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -79,3 +79,549 @@ fn relay_admin_db_wrappers_have_exactly_one_datastore_span() { ); } } + +#[test] +fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() { + let observability = include_str!("../src/runtime/observability.rs"); + assert!(observability.contains("enum PoolOperation")); + assert!(observability.contains("pub(crate) enum WriterOperation")); + assert!(observability.contains("pub(crate) enum ReaderOperation")); + assert!(observability.contains("Self::WriterAuthentication")); + assert!(observability.contains("Self::ReaderSubscriptionHistory")); + assert!(observability.contains("pub(crate) async fn acquire_writer(")); + assert!(observability.contains("pub(super) async fn acquire_reader_with_legacy_metrics(")); + assert!(observability.contains("static POOL_WAITERS: [Mutex")); + assert!(!observability.contains("AtomicU64")); + assert!(!observability.contains("DbOperation::Other")); + assert!(!observability.contains("\"other\"")); + assert!(!observability.contains("buzz_db_pool_acquire_timeouts_total")); + assert!(!observability.contains("\"result\" =>")); + let legacy_transaction = observability + .split_once("pub(crate) async fn begin_transaction(") + .expect("observability must expose attributed transaction acquisition") + .1 + .split_once("pub(crate) async fn observe_advisory_lock") + .expect("transaction acquisition must precede advisory-lock observation") + .0; + assert!(legacy_transaction.contains("acquire_writer_with_legacy_metrics(")); + + let runtime = include_str!("../src/runtime/mod.rs"); + assert!(runtime.contains("observability::acquire_writer_until(")); + assert!(runtime.contains("WriterOperation::Readiness")); + assert!(runtime.contains("WriterOperation::EventWrite")); + assert!(runtime.contains("ReaderOperation::Bootstrap")); + assert!(runtime.contains("pub async fn begin_event_write_transaction")); + let reader_boot = runtime + .split_once("async fn read_pool_boot_ping_once(") + .expect("runtime must expose the reader boot probe") + .1 + .split_once("#[cfg(test)]") + .expect("reader boot probe must precede its test seam") + .0; + assert!(reader_boot.contains("acquire_reader_with_legacy_metrics(")); + let routed_reader = runtime + .split_once("async fn proved_reader(") + .expect("runtime must expose the routed-reader checkout") + .1 + .split_once("async fn reader_aurora_capability_on(") + .expect("routed-reader checkout must precede capability probing") + .0; + assert!(routed_reader.contains("acquire_reader_with_legacy_metrics(read_pool, operation)")); + let event_write_transaction = runtime + .split_once("pub async fn begin_event_write_transaction(") + .expect("runtime must expose the legacy event-write transaction seam") + .1 + .split_once("pub async fn insert_event_with_serving_write_guard(") + .expect("legacy event-write transaction must precede guarded writes") + .0; + assert!(event_write_transaction.contains("acquire_writer_with_legacy_metrics(")); + + let migration = include_str!("../src/runtime/migration.rs"); + let migration_lock = migration + .split_once("pub(crate) async fn with_exclusive_schema_destruction_lock") + .expect("migration must expose the schema-safety acquisition seam") + .1 + .split_once("async fn reject_legacy_nip_rs_cardinality_ambiguity") + .expect("schema-safety acquisition must precede migration validation") + .0; + assert!(migration_lock.contains("acquire_writer_with_legacy_metrics(")); + + let allowlist = include_str!("../src/store/allowlist.rs"); + assert!(allowlist.contains("WriterOperation::Authentication")); + assert!(allowlist.contains("WriterOperation::Authorization")); + assert!(!allowlist.contains("fetch_one(&self.pool)")); + + let event = include_str!("../src/store/event.rs"); + assert!(event.contains("query_events_with_operation")); + assert!(event.contains("WriterOperation::Authorization")); + assert!(event.contains("WriterOperation::SubscriptionHistory")); + assert!(event.contains("ReaderOperation::SubscriptionHistory")); + let backfill_d_tags = event + .split_once("pub async fn backfill_d_tags") + .expect("event store must expose the startup d-tag backfill") + .1 + .split_once("/// Soft-delete NIP-29 discovery events") + .expect("d-tag backfill must precede discovery deletion") + .0; + assert!(backfill_d_tags.contains("WriterOperation::Bootstrap")); + assert!(backfill_d_tags.contains("execute(&mut *connection)")); + let soft_delete_discovery = event + .split_once("pub async fn soft_delete_discovery_events") + .expect("event store must expose discovery-event deletion") + .1 + .split_once("\n}\n\n#[cfg(test)]") + .expect("discovery deletion must end the production Db implementation") + .0; + assert!(soft_delete_discovery.contains("WriterOperation::EventWrite")); + assert!(soft_delete_discovery.contains("execute(&mut *connection)")); + + let side_effects = include_str!("../../buzz-relay/src/handlers/side_effects.rs"); + assert!(side_effects.contains("query_events_for_event_write")); + assert!(side_effects.contains("query_events_for_bootstrap")); + assert!(side_effects.contains(".list_channels_for_bootstrap(")); + + let deletion = include_str!("../src/store/deletion.rs"); + let public_serving_catalog = deletion + .split_once("pub async fn validate_serving_catalog(&self)") + .expect("deletion store must preserve its public serving-catalog API") + .1 + .split_once("async fn validate_serving_catalog_on") + .expect("public serving-catalog validation must delegate to its connection helper") + .0; + assert!(public_serving_catalog.contains("WriterOperation::Bootstrap")); + assert!(public_serving_catalog.contains("observability::acquire_writer(")); + assert!(public_serving_catalog.contains("validate_serving_catalog_on")); + assert!(!public_serving_catalog.contains("self.pool.acquire()")); + + let thread = include_str!("../src/store/thread.rs"); + let thread_metadata = thread + .split_once("pub async fn get_thread_metadata_by_event(") + .expect("thread store must expose metadata lookup") + .1 + .split_once("// -- Db API") + .expect("metadata lookup must precede the Db wrapper section") + .0; + assert!(thread_metadata.contains("WriterOperation::EventWrite")); + assert!(thread_metadata.contains("fetch_optional(&mut *connection)")); + assert!(!thread_metadata.contains("fetch_optional(pool)")); + + let channel = include_str!("../src/store/channel.rs"); + assert!(channel.contains("async fn begin_event_write_transaction(")); + assert!(channel.contains("async fn acquire_event_write_connection(")); + for (start, end, expected) in [ + ( + "pub async fn create_channel(\n", + "/// Creates a channel with a client-supplied UUID", + "begin_event_write_transaction(pool)", + ), + ( + "pub async fn create_channel_with_id(\n", + "/// Fetches a channel record by `(community_id, id)`", + "begin_event_write_transaction(pool)", + ), + ( + "pub async fn update_channel(\n", + "/// Sets the topic for a channel", + "begin_event_write_transaction(pool)", + ), + ( + "pub async fn set_topic(\n", + "/// Sets the purpose for a channel", + "acquire_event_write_connection(pool)", + ), + ( + "pub async fn set_purpose(\n", + "/// Archives a channel", + "acquire_event_write_connection(pool)", + ), + ( + "pub async fn archive_channel(\n", + "/// Unarchives a channel", + "acquire_event_write_connection(pool)", + ), + ( + "pub async fn unarchive_channel(\n", + "/// Soft-delete a channel", + "acquire_event_write_connection(pool)", + ), + ( + "pub async fn soft_delete_channel(\n", + "/// Archive ephemeral channels", + "acquire_event_write_connection(pool)", + ), + ] { + let function = channel + .split_once(start) + .unwrap_or_else(|| panic!("missing channel seam {start}")) + .1 + .split_once(end) + .unwrap_or_else(|| panic!("channel seam {start} must precede {end}")) + .0; + assert!( + function.contains(expected), + "channel seam {start} must use {expected}" + ); + assert!(!function.contains("pool.begin().await")); + assert!(!function.contains(".execute(pool)")); + assert!(!function.contains(".fetch_optional(pool)")); + } + let get_channel = channel + .split_once("async fn get_channel_with_operation(") + .expect("channel store must route shared lookups through caller-owned intent") + .1 + .split_once("/// Returns the canvas content") + .expect("channel lookup helper must precede canvas reads") + .0; + assert!(get_channel.contains("acquire_writer(pool, operation)")); + assert!(get_channel.contains("fetch_optional(&mut *connection)")); + assert!(!get_channel.contains("fetch_optional(pool)")); + assert!(channel.contains("pub async fn get_channel_for_event_write(")); + let list_channels = channel + .split_once("async fn list_channels_with_operation(") + .expect("channel listing must accept caller-owned intent") + .1 + .split_once("/// A channel archived by the ephemeral-channel reaper") + .expect("channel listing must precede ephemeral-channel types") + .0; + assert!(list_channels.contains("acquire_writer(pool, operation)")); + assert!(list_channels.contains("fetch_all(&mut *connection)")); + assert!(!list_channels.contains("fetch_all(pool)")); + assert!(channel.contains("pub async fn list_channels_for_bootstrap(")); + + let channel_members = include_str!("../src/store/channel_members.rs"); + assert!(channel_members.contains("async fn get_members_with_operation(")); + assert!(channel_members.contains("pub async fn get_members_for_event_write(")); + assert!(channel_members.contains("async fn get_users_bulk_with_operation(")); + assert!(channel_members.contains("pub async fn get_users_bulk_for_event_write(")); + + let huddle_link = event + .split_once("async fn huddle_started_link_exists_with_operation(") + .expect("huddle link lookup must accept caller-owned intent") + .1 + .split_once("/// Insert a Nostr event") + .expect("huddle link lookup must precede event insertion") + .0; + assert!(huddle_link.contains("acquire_writer(pool, operation)")); + assert!(event.contains("pub async fn huddle_started_link_exists_for_event_write(")); + let ingest = include_str!("../../buzz-relay/src/handlers/ingest.rs"); + assert!(ingest.contains(".huddle_started_link_exists_for_event_write(")); + let audio = include_str!("../../buzz-relay/src/audio/handler.rs"); + assert!(audio.contains(".huddle_started_link_exists(")); + + let workflow_sink = include_str!("../../buzz-relay/src/workflow_sink.rs"); + assert!(workflow_sink.contains(".get_members_for_event_write(")); + assert!(workflow_sink.contains(".get_users_bulk_for_event_write(")); + + for write_caller in [ + include_str!("../../buzz-relay/src/handlers/side_effects.rs"), + include_str!("../../buzz-relay/src/handlers/ingest.rs"), + include_str!("../../buzz-relay/src/handlers/command_executor.rs"), + workflow_sink, + ] { + assert!(!write_caller.contains(".get_channel(")); + assert!(write_caller.contains(".get_channel_for_event_write(")); + } + + let user = include_str!("../src/store/user.rs"); + let agent_channel_policy = user + .split_once("pub async fn get_agent_channel_policy(") + .expect("user store must expose get_agent_channel_policy") + .1 + .split_once("/// Check whether `actor_pubkey`") + .expect("agent policy lookup must precede owner lookup") + .0; + assert!(agent_channel_policy.contains("WriterOperation::Authorization")); + assert!(agent_channel_policy.contains("fetch_optional(&mut *connection)")); + assert!(!agent_channel_policy.contains("fetch_optional(pool)")); + let is_agent_owner = user + .split_once("pub async fn is_agent_owner(") + .expect("user store must expose is_agent_owner") + .1 + .split_once("/// Set the channel_add_policy") + .expect("is_agent_owner must precede set_agent_channel_policy") + .0; + assert!(is_agent_owner.contains("WriterOperation::Authorization")); + assert!(is_agent_owner.contains("acquire_writer(")); + assert!(is_agent_owner.contains("fetch_optional(&mut *connection)")); + assert!(!is_agent_owner.contains("fetch_optional(pool)")); + + let moderation = include_str!("../src/store/moderation.rs"); + let restriction_state = moderation + .split_once("pub async fn restriction_state(") + .expect("moderation store must expose restriction_state") + .1 + .split_once("/// Fetch the full ban/timeout row") + .expect("restriction state must precede full ban reads") + .0; + assert!(restriction_state.contains("WriterOperation::Authorization")); + assert!(restriction_state.contains("fetch_optional(&mut *connection)")); + assert!(!restriction_state.contains("fetch_optional(pool)")); + + let community_store = include_str!("../src/store/community.rs"); + let ensure_community = community_store + .split_once("pub async fn ensure_configured_community(") + .expect("community store must expose ensure_configured_community") + .1 + .split_once("/// Atomically creates a community") + .expect("configured-community helpers must precede community creation") + .0; + assert!(ensure_community.contains("WriterOperation::Authorization")); + assert!(ensure_community.contains("WriterOperation::Bootstrap")); + assert!(ensure_community.contains("ensure_configured_community_with_operation")); + assert!(ensure_community.contains("acquire_writer(&self.pool, operation)")); + assert!(ensure_community.contains("fetch_optional(&mut *connection)")); + let management_lookup = community_store + .split_once("pub async fn lookup_community_by_host_for_management(") + .expect("community store must expose management host lookup") + .1 + .split_once("/// Lists communities where") + .expect("management lookup must precede owner listing") + .0; + assert!(management_lookup.contains("WriterOperation::Authorization")); + assert!(management_lookup.contains("fetch_optional(&mut *connection)")); + assert!(!management_lookup.contains("fetch_optional(&self.pool)")); + let community_production = community_store + .split("\n#[cfg(test)]") + .next() + .expect("community production source"); + for required in [ + "WriterOperation::TenantResolution", + "WriterOperation::Authorization", + "WriterOperation::SubscriptionHistory", + "WriterOperation::EventWrite", + ] { + assert!( + community_production.contains(required), + "community P0 paths must include {required} attribution" + ); + } + assert!(!community_production.contains("self.pool.begin().await")); + assert!(!community_production.contains(".fetch_one(&self.pool)")); + assert!(!community_production.contains(".fetch_all(&self.pool)")); + assert!(!community_production.contains(".execute(&self.pool)")); + assert_eq!( + community_production + .matches(".fetch_optional(&self.pool)") + .count(), + 1, + "only the out-of-scope NIP-11 metadata read may retain a raw pool checkout" + ); + + let thread_summary = thread + .split_once("pub async fn get_thread_summary(") + .expect("thread store must expose get_thread_summary") + .1 + .split_once("/// Fetch one channel window") + .expect("thread summary must precede channel-window reads") + .0; + assert!(thread_summary.contains("WriterOperation::EventWrite")); + assert!(thread_summary.contains("fetch_optional(&mut *connection)")); + assert!(thread_summary.contains("fetch_all(&mut *connection)")); + assert!(!thread_summary.contains("fetch_optional(pool)")); + assert!(!thread_summary.contains("fetch_all(pool)")); + + let archived_identities = include_str!("../src/store/archived_identities.rs"); + let archived_identity_production = archived_identities + .split("\n#[cfg(test)]") + .next() + .expect("archived identity production source"); + assert_eq!( + archived_identity_production + .matches("WriterOperation::EventWrite") + .count(), + 4, + "all four archived identity operations must be attributed to event writes" + ); + assert!(!archived_identity_production.contains("fetch_optional(pool)")); + assert!(!archived_identity_production.contains("fetch_all(pool)")); + assert!(!archived_identity_production.contains("execute(pool)")); + + let relay_main = include_str!("../../buzz-relay/src/main.rs"); + assert!(relay_main.contains("pool_state.db.refresh_pool_waiter_metrics();")); + assert!(relay_main.contains(".ensure_configured_community_for_bootstrap(")); + + let runtime = include_str!("../src/runtime/mod.rs"); + assert!(runtime.contains("observability::refresh_pool_waiters(self.read_pool.is_some())")); + assert!(runtime.contains("self.verify_replica_fence_at_boot().await?")); + let fence_boot = runtime + .split_once("pub(crate) async fn verify_replica_fence_at_boot") + .expect("runtime must expose attributed boot fence verification") + .1 + .split_once("/// The pool for lag-tolerant reads") + .expect("boot fence verification must precede routed-read plumbing") + .0; + assert!(fence_boot.contains("WriterOperation::Bootstrap")); + + let replica_fence = include_str!("../src/runtime/replica_fence.rs"); + let replica_fence_production = replica_fence + .split("\n#[cfg(test)]") + .next() + .expect("replica-fence production source"); + assert!(replica_fence_production.contains("WriterOperation::Bootstrap")); + assert!(replica_fence_production.contains("WriterOperation::Maintenance")); + assert!(!replica_fence_production.contains("pool.begin().await")); + assert!(!replica_fence_production.contains("writer.acquire().await")); + assert!(!replica_fence_production.contains("fetch_optional(writer)")); + + let usage = include_str!("../src/store/usage.rs"); + let usage_production = usage + .split("\n#[cfg(test)]") + .next() + .expect("usage production source"); + let usage_leader_lock = usage_production + .split_once("pub async fn try_lock_usage_metrics(") + .expect("usage store must expose the legacy leader-lock acquisition") + .1 + .split_once("pub async fn usage_community_count(") + .expect("usage leader lock must precede counter reads") + .0; + assert!(usage_leader_lock.contains("acquire_writer_with_legacy_metrics(")); + assert!( + usage_production + .matches("WriterOperation::Maintenance") + .count() + >= 11, + "every periodic usage checkout must be maintenance-attributed" + ); + for bypass in [ + ".fetch_one(pool)", + ".fetch_all(pool)", + ".fetch_optional(pool)", + ".execute(pool)", + ] { + assert!( + !usage_production.contains(bypass), + "usage production path bypasses operation attribution with {bypass}" + ); + } + + let channel_reaper = channel + .split_once("pub async fn reap_expired_ephemeral_channels(pool:") + .expect("channel store must expose ephemeral reaper") + .1 + .split_once("\nimpl Db {") + .expect("ephemeral reaper must precede Db wrappers") + .0; + assert!(channel_reaper.contains("WriterOperation::Maintenance")); + assert!(channel_reaper.contains("fetch_all(&mut *connection)")); + + let deletion = include_str!("../src/store/deletion.rs"); + let lease_reaper = deletion + .split_once("pub async fn reap_expired_serving_write_leases") + .expect("deletion store must expose serving-lease reaper") + .1 + .split_once("/// Return serving-lease counts") + .expect("serving-lease reaper must precede stats") + .0; + assert!(lease_reaper.contains("WriterOperation::Maintenance")); + assert!(lease_reaper.contains("execute(&mut *connection)")); + let lease_stats = deletion + .split_once("pub async fn serving_lease_stats") + .expect("deletion store must expose serving-lease stats") + .1 + .split_once("/// Whether a community remains active") + .expect("serving-lease stats must precede serving-state reads") + .0; + assert!(lease_stats.contains("WriterOperation::Maintenance")); + assert!(lease_stats.contains("fetch_one(&mut *connection)")); + for (start, end) in [ + ( + "pub async fn acquire_serving_write_lease", + "/// Renew an already-admitted external side-effect lease", + ), + ( + "pub async fn renew_serving_write_lease", + "/// Release a serving side-effect lease", + ), + ( + "pub async fn release_serving_write_lease", + "/// Check that an external side-effect lease remains current", + ), + ( + "pub async fn verify_serving_write_lease", + "/// Delete expired serving leases", + ), + ( + "pub async fn is_serving_active", + "async fn advance_with_checkpoint", + ), + ] { + let function = deletion + .split_once(start) + .unwrap_or_else(|| panic!("missing serving-write seam {start}")) + .1 + .split_once(end) + .unwrap_or_else(|| panic!("serving-write seam {start} must precede {end}")) + .0; + assert!( + function.contains("WriterOperation::EventWrite"), + "serving-write seam {start} must be event-write attributed" + ); + assert!(!function.contains("self.pool.begin().await")); + assert!(!function.contains(".execute(&self.pool)")); + assert!(!function.contains(".fetch_one(&self.pool)")); + } + + let ensure_authorization = user + .split_once("pub async fn ensure_user_for_authorization(") + .expect("user store must expose NIP-OA authorization ensure") + .1 + .split_once("/// Get a single user record") + .expect("authorization ensure must precede generic user reads") + .0; + assert!(ensure_authorization.contains("WriterOperation::Authorization")); + let set_owner_authorization = user + .split_once("pub async fn set_agent_owner_for_authorization(") + .expect("user store must expose NIP-OA authorization owner write") + .1 + .split_once("/// Get the channel_add_policy") + .expect("authorization owner write must precede policy reads") + .0; + assert!(set_owner_authorization.contains("WriterOperation::Authorization")); + let relay_api = include_str!("../../buzz-relay/src/api/mod.rs"); + assert!(relay_api.contains(".ensure_user_for_authorization(")); + assert!(relay_api.contains(".set_agent_owner_for_authorization(")); + + for (domain, source) in [ + ( + "channel_members", + include_str!("../src/store/channel_members.rs"), + ), + ("archived_identities", archived_identities), + ("event", event), + ("git_repo", include_str!("../src/store/git_repo.rs")), + ("push", include_str!("../src/store/push.rs")), + ("replica_fence", replica_fence), + ("reaction", include_str!("../src/store/reaction.rs")), + ("relay_invite", include_str!("../src/store/relay_invite.rs")), + ( + "relay_members", + include_str!("../src/store/relay_members.rs"), + ), + ("thread", thread), + ( + "relay_operators", + include_str!("../src/store/relay_operators.rs"), + ), + ("usage", usage), + ] { + let production = source.split("\n#[cfg(test)]").next().unwrap_or(source); + for bypass in [ + "pool.begin().await", + "self.pool.begin().await", + ".fetch_one(pool)", + ".fetch_one(&self.pool)", + ".fetch_all(pool)", + ".fetch_all(&self.pool)", + ".fetch_optional(pool)", + ".fetch_optional(&self.pool)", + ".execute(pool)", + ".execute(&self.pool)", + ] { + assert!( + !production.contains(bypass), + "{domain} production path bypasses operation attribution with {bypass}" + ); + } + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index b7a0458f5a..5745b8d4e5 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -223,7 +223,7 @@ pub mod relay_members { for (role, pubkey) in [("agent", agent), ("owner", owner)] { match state .db - .ensure_user(tenant.community(), pubkey.as_bytes()) + .ensure_user_for_authorization(tenant.community(), pubkey.as_bytes()) .await { Ok(true) => { @@ -243,7 +243,11 @@ pub mod relay_members { let materialized = match state .db - .set_agent_owner(tenant.community(), agent.as_bytes(), owner.as_bytes()) + .set_agent_owner_for_authorization( + tenant.community(), + agent.as_bytes(), + owner.as_bytes(), + ) .await { Ok(true) => true, diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 41d7900b7d..074f6b391d 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -109,7 +109,7 @@ async fn persist_command_event( let channel_id = channel_id_override.or_else(|| extract_channel_id(event)); let mut tx = db - .begin_transaction() + .begin_event_write_transaction() .await .map_err(|e| IngestError::Internal(format!("error: begin transaction: {e}")))?; buzz_deletion::store(db) @@ -463,7 +463,7 @@ async fn handle_dm_add_member( // 3. Validate channel is type "dm" let existing_channel = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map_err(|_| IngestError::Rejected("invalid: DM not found".into()))?; if existing_channel.channel_type != "dm" { @@ -473,7 +473,7 @@ async fn handle_dm_add_member( // 4. Get existing members, merge with new let existing_members = state .db - .get_members(tenant.community(), channel_id) + .get_members_for_event_write(tenant.community(), channel_id) .await .map_err(|e| IngestError::Internal(format!("error: get members: {e}")))?; @@ -593,7 +593,7 @@ async fn handle_dm_hide( // 3. Validate channel is type "dm" let channel = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map_err(|_| IngestError::Rejected("invalid: DM not found".into()))?; if channel.channel_type != "dm" { @@ -763,7 +763,7 @@ async fn handle_workflow_def( let community_id = tenant.community(); state .db - .get_channel(community_id, channel_id) + .get_channel_for_event_write(community_id, channel_id) .await .map_err(|_| IngestError::Rejected("invalid: workflow channel not found".into()))?; @@ -1595,7 +1595,10 @@ mod postgres_tests { "legacy-malformed", ); - let mut tx = db.begin_transaction().await.expect("begin legacy seed"); + let mut tx = db + .begin_event_write_transaction() + .await + .expect("begin legacy seed"); let (_, was_inserted) = buzz_db::event::insert_event_in_transaction( &mut tx, tenant.community(), diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 3185af8bea..229b5f3716 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -327,7 +327,7 @@ pub async fn provision_community( if let Some(owner_hex) = &initial_owner { state .db - .bootstrap_owner(record.id, owner_hex) + .provision_owner(record.id, owner_hex) .await .map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))?; publish_membership_snapshot_if_required(state, record.id, &record.host).await; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index b4a3e24f8f..ee1d0312be 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -91,7 +91,7 @@ async fn validate_huddle_lifecycle_event( let backing_channel_id = huddle_backing_channel_id(event)?; let backing = state .db - .get_channel(tenant.community(), backing_channel_id) + .get_channel_for_event_write(tenant.community(), backing_channel_id) .await .map_err(map_huddle_backing_channel_error)?; let signer = event.pubkey.to_bytes(); @@ -122,7 +122,7 @@ async fn validate_huddle_lifecycle_event( })?; let linked = state .db - .huddle_started_link_exists( + .huddle_started_link_exists_for_event_write( tenant.community(), parent_channel_id, backing_channel_id, @@ -597,7 +597,10 @@ pub(crate) async fn derive_reaction_channel( _ => return ReactionChannelResult::NoTarget, }; - match db.get_event_by_id(community_id, &id_bytes).await { + match db + .get_event_by_id_for_event_write(community_id, &id_bytes) + .await + { Ok(Some(target)) => match target.channel_id { Some(ch_id) => ReactionChannelResult::Channel(ch_id), None => ReactionChannelResult::NoChannel, @@ -759,7 +762,7 @@ pub(crate) async fn check_channel_membership( Some(ch) => ch.visibility == "open", None => state .db - .get_channel(tenant.community(), ch_id) + .get_channel_for_event_write(tenant.community(), ch_id) .await .map(|ch| ch.visibility == "open") .unwrap_or(false), @@ -827,7 +830,9 @@ pub(crate) async fn resolve_nip10_thread_meta( hex::decode(&parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?; let (parent_event_result, parent_meta_result) = tokio::join!( - state.db.get_event_by_id(community_id, &parent_bytes), + state + .db + .get_event_by_id_for_event_write(community_id, &parent_bytes), state .db .get_thread_metadata_by_event(community_id, &parent_bytes), @@ -863,7 +868,7 @@ pub(crate) async fn resolve_nip10_thread_meta( } let root_ts = if let Ok(Some(root_ev)) = state .db - .get_event_by_id(community_id, &effective_root) + .get_event_by_id_for_event_write(community_id, &effective_root) .await { chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) @@ -947,13 +952,16 @@ async fn derive_ancestry_from_parent_tags( if parent_root.as_slice() == parent_bytes { (parent_root, parent_created, 1) } else { - let root_created = - if let Ok(Some(root_ev)) = state.db.get_event_by_id(community_id, &parent_root).await { - chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) - .unwrap_or(parent_created) - } else { - parent_created - }; + let root_created = if let Ok(Some(root_ev)) = state + .db + .get_event_by_id_for_event_write(community_id, &parent_root) + .await + { + chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) + .unwrap_or(parent_created) + } else { + parent_created + }; (parent_root, root_created, 2) } } @@ -1019,7 +1027,9 @@ pub(crate) async fn resolve_relay_reply_thread_meta( hex::decode(parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?; let (parent_event_result, parent_meta_result) = tokio::join!( - state.db.get_event_by_id(community_id, &parent_bytes), + state + .db + .get_event_by_id_for_event_write(community_id, &parent_bytes), state .db .get_thread_metadata_by_event(community_id, &parent_bytes), @@ -1053,7 +1063,7 @@ pub(crate) async fn resolve_relay_reply_thread_meta( parent_created } else if let Ok(Some(root_ev)) = state .db - .get_event_by_id(community_id, &effective_root) + .get_event_by_id_for_event_write(community_id, &effective_root) .await { chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) @@ -1163,7 +1173,7 @@ async fn validate_edit_ownership( hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; let target_event = state .db - .get_event_by_id(community_id, &target_bytes) + .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? .ok_or_else(|| "edit target event not found".to_string())?; @@ -1193,7 +1203,7 @@ async fn validate_edit_ownership( if !is_member { let is_open = state .db - .get_channel(community_id, ch_id) + .get_channel_for_event_write(community_id, ch_id) .await .map(|ch| ch.visibility == "open") .unwrap_or(false); @@ -1244,7 +1254,7 @@ async fn validate_forum_vote_target( hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; let target_event = state .db - .get_event_by_id(community_id, &target_bytes) + .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? .ok_or_else(|| "vote target event not found".to_string())?; @@ -2435,7 +2445,7 @@ async fn ingest_event_inner( })?; match state .db - .get_event_by_id(tenant.community(), &target_bytes) + .get_event_by_id_for_event_write(tenant.community(), &target_bytes) .await { Ok(Some(target)) => target.channel_id, @@ -2483,7 +2493,11 @@ async fn ingest_event_inner( // it later in this request); each gate keeps its existing missing-row // behavior. let channel_row = match channel_id { - Some(ch_id) => state.db.get_channel(tenant.community(), ch_id).await.ok(), + Some(ch_id) => state + .db + .get_channel_for_event_write(tenant.community(), ch_id) + .await + .ok(), None => None, }; // E1 phase-2 (§4.8 phase-2 addendum): resolve the fan-out visibility once, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index d3416d673c..8183fe98d8 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -147,7 +147,10 @@ async fn evict_non_member_channel_subscriptions( state: &Arc, channel_id: Uuid, ) -> anyhow::Result<()> { - let members = state.db.get_members(tenant.community(), channel_id).await?; + let members = state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await?; let member_pubkeys: std::collections::HashSet> = members.into_iter().map(|m| m.pubkey).collect(); @@ -262,7 +265,7 @@ pub async fn validate_standard_deletion_event( for target_id in target_ids { let target_event = state .db - .get_event_by_id_including_deleted(tenant.community(), &target_id) + .get_event_by_id_including_deleted_for_event_write(tenant.community(), &target_id) .await? .ok_or_else(|| anyhow::anyhow!("target event not found"))?; @@ -327,7 +330,7 @@ pub async fn validate_admin_event( // (unarchive), which must be allowed through so the channel can be restored. let channel = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map_err(|_| anyhow::anyhow!("channel not found"))?; let is_unarchive_request = kind == 9002 @@ -655,7 +658,7 @@ pub async fn validate_admin_event( // BEFORE storage. Fail closed: missing target → reject. let target_event = state .db - .get_event_by_id(tenant.community(), &target_id) + .get_event_by_id_for_event_write(tenant.community(), &target_id) .await .map_err(|e| anyhow::anyhow!("db error looking up target: {e}"))? .ok_or_else(|| anyhow::anyhow!("target event not found"))?; @@ -687,7 +690,7 @@ pub async fn validate_admin_event( } let is_open = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map(|ch| ch.visibility == "open") .unwrap_or(false); @@ -1015,7 +1018,7 @@ async fn emit_addressable_discovery_event( let min_ts = { let existing = state .db - .query_events(&buzz_db::event::EventQuery { + .query_events_for_event_write(&buzz_db::event::EventQuery { kinds: Some(vec![kind as i32]), channel_id: Some(channel_id), limit: Some(1), @@ -1129,8 +1132,14 @@ pub async fn emit_group_discovery_events( state: &Arc, channel_id: Uuid, ) -> anyhow::Result<()> { - let channel = state.db.get_channel(tenant.community(), channel_id).await?; - let members = state.db.get_members(tenant.community(), channel_id).await?; + let channel = state + .db + .get_channel_for_event_write(tenant.community(), channel_id) + .await?; + let members = state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await?; let relay_pubkey_hex = hex::encode(state.relay_keypair.public_key().to_bytes()); let group_id = channel_id.to_string(); @@ -1376,7 +1385,7 @@ async fn handle_put_user( .map_err(|_| anyhow::anyhow!("invalid role: {role_str}"))?, None => state .db - .get_members(tenant.community(), channel_id) + .get_members_for_event_write(tenant.community(), channel_id) .await? .iter() .find(|m| m.pubkey == target_pubkey) @@ -1446,7 +1455,10 @@ async fn handle_remove_user( // Guard: prevent last-owner orphaning on self-removal (kind 9001). if target_pubkey == actor_bytes { - let members = state.db.get_members(tenant.community(), channel_id).await?; + let members = state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await?; let owner_count = members.iter().filter(|m| m.role == "owner").count(); let actor_is_owner = members .iter() @@ -1581,7 +1593,7 @@ async fn handle_edit_metadata( "visibility" => { let was_open = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map(|c| c.visibility == "open") .unwrap_or(false); @@ -1701,8 +1713,10 @@ async fn handle_edit_metadata( // same channel by the same actor could collide ids and skip a fan-out. // Not reachable in practice — unarchive has a single human-driven caller; // the reaper only auto-archives — so we don't engineer around it. - for member in - state.db.get_members(tenant.community(), channel_id).await? + for member in state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await? { if let Err(e) = emit_membership_notification( tenant, @@ -1770,7 +1784,7 @@ async fn handle_delete_event_side_effect( // by sending h=A, e=. if let Some(target_event) = state .db - .get_event_by_id_including_deleted(tenant.community(), &target_id) + .get_event_by_id_including_deleted_for_event_write(tenant.community(), &target_id) .await .map_err(|e| anyhow::anyhow!("get_event_by_id failed: {e}"))? { @@ -1872,7 +1886,11 @@ async fn handle_create_group( // no-h-tag path, ingest never creates the channel, so this is the sole // increment. let channel = if let Some(client_uuid) = extract_h_tag_channel(event) { - match state.db.get_channel(tenant.community(), client_uuid).await { + match state + .db + .get_channel_for_event_write(tenant.community(), client_uuid) + .await + { Ok(ch) => ch, Err(_) => { // Channel not found — shouldn't happen (ingest_event pre-created it), @@ -2026,7 +2044,7 @@ async fn handle_join_request( // Only open channels allow self-join via kind:9021. let channel = state .db - .get_channel(tenant.community(), channel_id) + .get_channel_for_event_write(tenant.community(), channel_id) .await .map_err(|_| anyhow::anyhow!("channel not found"))?; if channel.visibility != "open" { @@ -2104,7 +2122,10 @@ async fn handle_leave_request( let actor_bytes = event.pubkey.to_bytes().to_vec(); // Guard: prevent last-owner orphaning on leave. - let members = state.db.get_members(tenant.community(), channel_id).await?; + let members = state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await?; let owner_count = members.iter().filter(|m| m.role == "owner").count(); let actor_is_owner = members .iter() @@ -2315,7 +2336,7 @@ async fn handle_standard_deletion_event( for target_id in target_ids { let target_event = match state .db - .get_event_by_id_including_deleted(tenant.community(), &target_id) + .get_event_by_id_including_deleted_for_event_write(tenant.community(), &target_id) .await? { Some(target) => target, @@ -2393,7 +2414,7 @@ async fn handle_standard_deletion_event( if let Ok(react_target_id) = hex::decode(&react_target_hex) { if let Ok(Some(react_target_event)) = state .db - .get_event_by_id(tenant.community(), &react_target_id) + .get_event_by_id_for_event_write(tenant.community(), &react_target_id) .await { let react_target_ts = chrono::DateTime::from_timestamp( @@ -3030,22 +3051,60 @@ async fn emit_initial_ref_state( /// safe to run at startup and periodically without producing an event stream /// when nothing changed. A failure in one community is logged and counted but /// does not prevent the remaining communities from being repaired. +#[derive(Clone, Copy)] +pub enum Nip43ReconciliationPurpose { + /// Before listener admission opens. + Bootstrap, + /// Periodic background repair after startup. + Maintenance, +} + +/// Preserve the original maintenance reconciliation API for downstream callers. +#[deprecated(note = "use reconcile_nip43_membership_snapshots_with_purpose")] pub async fn reconcile_nip43_membership_snapshots(state: &Arc) -> anyhow::Result { - let communities = state.db.usage_community_hosts().await?; + reconcile_nip43_membership_snapshots_with_purpose( + state, + Nip43ReconciliationPurpose::Maintenance, + ) + .await +} + +/// Reconcile NIP-43 snapshots with explicit startup or maintenance attribution. +pub async fn reconcile_nip43_membership_snapshots_with_purpose( + state: &Arc, + purpose: Nip43ReconciliationPurpose, +) -> anyhow::Result { + let communities = match purpose { + Nip43ReconciliationPurpose::Bootstrap => state.db.bootstrap_community_hosts().await?, + Nip43ReconciliationPurpose::Maintenance => state.db.usage_community_hosts().await?, + }; let mut reconciled = 0usize; for community in communities { let community_id = buzz_core::CommunityId::from_uuid(community.id); let host = community.host; let result = async { - if !state - .db - .nip43_membership_snapshot_needs_reconciliation( - community_id, - &state.relay_keypair.public_key(), - ) - .await? - { + let needs_reconciliation = match purpose { + Nip43ReconciliationPurpose::Bootstrap => { + state + .db + .nip43_membership_snapshot_needs_reconciliation_for_bootstrap( + community_id, + &state.relay_keypair.public_key(), + ) + .await? + } + Nip43ReconciliationPurpose::Maintenance => { + state + .db + .nip43_membership_snapshot_needs_reconciliation_for_maintenance( + community_id, + &state.relay_keypair.public_key(), + ) + .await? + } + }; + if !needs_reconciliation { return Ok::(false); } @@ -3270,7 +3329,10 @@ pub async fn reconcile_channel_events( ) -> anyhow::Result<()> { use buzz_db::event::EventQuery; - let channels = state.db.list_channels(tenant.community(), None).await?; + let channels = state + .db + .list_channels_for_bootstrap(tenant.community(), None) + .await?; if channels.is_empty() { return Ok(()); } @@ -3281,7 +3343,7 @@ pub async fn reconcile_channel_events( let channel_id_str = channel.id.to_string(); let existing = match state .db - .query_events(&EventQuery { + .query_events_for_bootstrap(&EventQuery { kinds: Some(vec![39000]), d_tag: Some(channel_id_str.clone()), limit: Some(1), @@ -3414,7 +3476,7 @@ pub async fn publish_nipia_archival_list( let now = nostr::Timestamp::now().as_secs(); let previous = state .db - .query_events(&buzz_db::event::EventQuery { + .query_events_for_event_write(&buzz_db::event::EventQuery { kinds: Some(vec![KIND_IA_ARCHIVED_LIST as i32]), pubkey: Some(relay_pubkey.to_bytes().to_vec()), limit: Some(1), @@ -3517,7 +3579,7 @@ pub async fn publish_dm_visibility_snapshot( let ts = { let existing = state .db - .query_events(&buzz_db::event::EventQuery { + .query_events_for_event_write(&buzz_db::event::EventQuery { kinds: Some(vec![KIND_DM_VISIBILITY as i32]), pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), d_tag: Some(viewer_hex.clone()), @@ -3684,6 +3746,16 @@ pub async fn publish_nipia_unarchived( mod tests { use super::*; + #[test] + fn nip43_reconciliation_compatibility_alias_is_preserved() { + #[allow(deprecated)] + async fn call(state: &Arc) -> anyhow::Result { + reconcile_nip43_membership_snapshots(state).await + } + + let _ = call; + } + #[test] fn group_members_snapshot_keeps_members_past_one_thousand() { let channel_id = Uuid::new_v4(); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 260dfaed68..933756aa10 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -302,7 +302,7 @@ async fn main() -> anyhow::Result<()> { ); None } else { - match db.ensure_configured_community(&host).await { + match db.ensure_configured_community_for_bootstrap(&host).await { Ok(record) => { info!(host = %record.host, community = %record.id, "Deployment community ensured"); Some(record.id) @@ -563,7 +563,11 @@ async fn main() -> anyhow::Result<()> { // this repairs pre-snapshot communities and any publication that failed // after a membership transaction committed. if config.require_relay_membership { - match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots(&state).await + match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots_with_purpose( + &state, + buzz_relay::handlers::side_effects::Nip43ReconciliationPurpose::Bootstrap, + ) + .await { Ok(count) => info!(count, "NIP-43 membership snapshots reconciled on startup"), Err(error) => { @@ -582,8 +586,9 @@ async fn main() -> anyhow::Result<()> { interval.tick().await; loop { interval.tick().await; - match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots( + match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots_with_purpose( &reconcile_state, + buzz_relay::handlers::side_effects::Nip43ReconciliationPurpose::Maintenance, ) .await { @@ -1041,6 +1046,7 @@ async fn main() -> anyhow::Result<()> { metrics::gauge!("buzz_db_pool_idle").set(db_stats.idle as f64); metrics::gauge!("buzz_db_pool_active").set(active as f64); metrics::gauge!("buzz_db_pool_max").set(db_stats.max as f64); + pool_state.db.refresh_pool_waiter_metrics(); if let Some(read_stats) = pool_state.db.read_pool_stats() { let read_active = read_stats.size.saturating_sub(read_stats.idle); diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index fb484b0174..0c4bfd2c31 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -37,6 +37,12 @@ const READINESS_DURATION_BUCKETS_S: [f64; 15] = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, ]; +/// Pool checkout buckets: dense around normal sub-100ms waits, with explicit +/// coverage of the reader's 150ms and writer's default three-second budgets. +const DB_POOL_ACQUIRE_DURATION_BUCKETS_S: [f64; 9] = + [0.001, 0.005, 0.01, 0.025, 0.05, 0.15, 0.5, 1.0, 3.0]; +const DB_POOL_ACQUIRE_DURATION_UNIT: metrics::Unit = metrics::Unit::Seconds; + /// Seconds-scale buckets for Git hydration and pack streams. const GIT_DURATION_BUCKETS_S: [f64; 13] = [ 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, @@ -104,6 +110,11 @@ fn configured_prometheus_builder(gauge_idle_timeout_secs: u64) -> PrometheusBuil &READINESS_DURATION_BUCKETS_S, ) .expect("valid readiness duration bucket boundaries") + .set_buckets_for_metric( + Matcher::Full("buzz_db_pool_acquire_duration_seconds".to_owned()), + &DB_POOL_ACQUIRE_DURATION_BUCKETS_S, + ) + .expect("valid DB pool acquisition duration bucket boundaries") .set_buckets_for_metric( Matcher::Full("buzz_git_hydrate_bytes".to_owned()), &GIT_BYTES_BUCKETS, @@ -158,6 +169,7 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) { metrics::set_global_recorder(recorder).expect("global recorder must be set exactly once"); describe_readiness_metrics(); + describe_db_pool_metrics(); tokio::spawn(exporter); } @@ -182,6 +194,23 @@ pub(crate) fn describe_readiness_metrics() { ); } +/// Register the frozen operation-aware pool-acquisition contract. +pub(crate) fn describe_db_pool_metrics() { + metrics::describe_histogram!( + "buzz_db_pool_acquire_duration_seconds", + DB_POOL_ACQUIRE_DURATION_UNIT, + "Database pool checkout duration by valid pool role and operation" + ); + metrics::describe_counter!( + "buzz_db_pool_acquire_attempts_total", + "Database pool checkout terminals by valid pool role, operation, and outcome" + ); + metrics::describe_gauge!( + "buzz_db_pool_waiters", + "Current tracked-operation database pool checkout attempts in progress by valid pool role and operation" + ); +} + #[cfg(test)] pub(crate) fn readiness_test_recorder() -> ( metrics_exporter_prometheus::PrometheusRecorder, @@ -251,3 +280,110 @@ pub async fn track_metrics(req: Request, next: Next) -> Response { response } + +#[cfg(test)] +mod contract_tests { + use std::collections::BTreeSet; + + const OUTCOMES: [&str; 4] = ["success", "timeout", "error", "cancelled"]; + + fn label_keys(line: &str) -> BTreeSet<&str> { + line.split_once('{') + .and_then(|(_, rest)| rest.split_once('}')) + .map(|(labels, _)| { + labels + .split(',') + .filter_map(|label| label.split_once('=').map(|(key, _)| key)) + .collect() + }) + .unwrap_or_default() + } + + #[test] + fn production_builder_exports_frozen_db_pool_contract_and_187_series_budget() { + let (recorder, handle) = super::readiness_test_recorder(); + metrics::with_local_recorder(&recorder, || { + super::describe_db_pool_metrics(); + for (pool_role, operation) in buzz_db::DB_POOL_ACQUIRE_VALID_PAIRS { + metrics::histogram!( + "buzz_db_pool_acquire_duration_seconds", + "pool_role" => pool_role, + "operation" => operation, + ) + .record(0.02); + metrics::gauge!( + "buzz_db_pool_waiters", + "pool_role" => pool_role, + "operation" => operation, + ) + .set(0.0); + for outcome in OUTCOMES { + metrics::counter!( + "buzz_db_pool_acquire_attempts_total", + "pool_role" => pool_role, + "operation" => operation, + "outcome" => outcome, + ) + .increment(1); + } + } + }); + + let scrape = handle.render(); + assert!(scrape.contains("# TYPE buzz_db_pool_acquire_duration_seconds histogram")); + assert!(scrape.contains("# TYPE buzz_db_pool_acquire_attempts_total counter")); + assert!(scrape.contains("# TYPE buzz_db_pool_waiters gauge")); + assert!(scrape.contains("# HELP buzz_db_pool_acquire_duration_seconds Database pool checkout duration by valid pool role and operation")); + assert!(scrape.contains("# HELP buzz_db_pool_acquire_attempts_total Database pool checkout terminals by valid pool role, operation, and outcome")); + assert!(scrape.contains("# HELP buzz_db_pool_waiters Current tracked-operation database pool checkout attempts in progress by valid pool role and operation")); + assert_eq!(super::DB_POOL_ACQUIRE_DURATION_UNIT, metrics::Unit::Seconds); + let readiness_buckets = scrape + .lines() + .filter(|line| { + line.starts_with("buzz_db_pool_acquire_duration_seconds_bucket{") + && line.contains("pool_role=\"writer\"") + && line.contains("operation=\"readiness\"") + }) + .map(|line| { + line.split(",le=\"") + .nth(1) + .and_then(|rest| rest.split_once('"').map(|(bucket, _)| bucket)) + .expect("duration bucket carries le label") + }) + .collect::>(); + assert_eq!( + readiness_buckets, + ["0.001", "0.005", "0.01", "0.025", "0.05", "0.15", "0.5", "1", "3", "+Inf",], + "duration bucket contract drifted:\n{scrape}" + ); + + let raw_series = scrape + .lines() + .filter(|line| { + line.starts_with("buzz_db_pool_acquire_duration_seconds") + || line.starts_with("buzz_db_pool_acquire_attempts_total") + || line.starts_with("buzz_db_pool_waiters{") + }) + .collect::>(); + assert_eq!( + raw_series.len(), + buzz_db::DB_POOL_ACQUIRE_RAW_SERIES_PER_POD, + "unexpected raw scrape:\n{scrape}" + ); + + for line in raw_series { + let keys = label_keys(line); + if line.starts_with("buzz_db_pool_acquire_duration_seconds_bucket") { + assert_eq!(keys, BTreeSet::from(["le", "operation", "pool_role"])); + } else if line.starts_with("buzz_db_pool_acquire_duration_seconds") { + assert_eq!(keys, BTreeSet::from(["operation", "pool_role"])); + } else if line.starts_with("buzz_db_pool_acquire_attempts_total") { + assert_eq!(keys, BTreeSet::from(["operation", "outcome", "pool_role"])); + } else { + assert_eq!(keys, BTreeSet::from(["operation", "pool_role"])); + } + assert!(!line.contains("operation=\"other\"")); + assert!(!line.contains("result=")); + } + } +} diff --git a/crates/buzz-relay/src/readiness.rs b/crates/buzz-relay/src/readiness.rs index 36a36c228b..79a1a98570 100644 --- a/crates/buzz-relay/src/readiness.rs +++ b/crates/buzz-relay/src/readiness.rs @@ -8,7 +8,7 @@ use std::future::Future; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::Duration; -use buzz_db::{Db, DbReadinessOutcome}; +use buzz_db::{Db, DbError, DbReadinessOutcome}; use tokio::time::Instant; const READINESS_TIMEOUT: Duration = Duration::from_secs(2); @@ -327,13 +327,20 @@ async fn redis_check(pool: &deadpool_redis::Pool, deadline: Instant) -> RedisOut } async fn deletion_catalog_check(db: &Db, deadline: Instant) -> DeletionCatalogOutcome { - match tokio::time::timeout_at(deadline, db.validate_deletion_serving_catalog()).await { - Err(_) => DeletionCatalogOutcome::OperationTimeout, - Ok(Err(error)) => { + classify_deletion_catalog_result( + db.validate_deletion_serving_catalog_for_readiness(deadline) + .await, + ) +} + +fn classify_deletion_catalog_result(result: buzz_db::Result<()>) -> DeletionCatalogOutcome { + match result { + Err(DbError::Sqlx(sqlx::Error::PoolTimedOut)) => DeletionCatalogOutcome::OperationTimeout, + Err(error) => { tracing::debug!(error = %error, "Deletion catalog readiness validation failed"); DeletionCatalogOutcome::OperationError } - Ok(Ok(())) => DeletionCatalogOutcome::Success, + Ok(()) => DeletionCatalogOutcome::Success, } } @@ -692,6 +699,18 @@ mod tests { assert_eq!(READINESS_RAW_SERIES_PER_POD, 99); } + #[test] + fn deletion_catalog_deadline_is_a_timeout_not_an_operation_error() { + assert_eq!( + classify_deletion_catalog_result(Err(DbError::Sqlx(sqlx::Error::PoolTimedOut))), + DeletionCatalogOutcome::OperationTimeout + ); + assert_eq!( + classify_deletion_catalog_result(Err(DbError::InvalidData("catalog".into()))), + DeletionCatalogOutcome::OperationError + ); + } + #[test] fn slow_older_failure_cannot_overwrite_newer_success_gauges() { let coordinator = ReadinessCoordinator::default(); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 47665db677..bf51a2ff3a 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1244,7 +1244,7 @@ impl AppState { pub async fn revalidate_live_communities(&self) -> usize { let (closed, failures) = revalidate_registered_communities(&self.community_connections, |community_id| { - self.db.is_community_active(community_id) + self.db.is_community_active_for_maintenance(community_id) }) .await; for (community_id, error) in failures { diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 17016ca0d8..6450b15b28 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -257,7 +257,7 @@ impl ActionSink for RelayActionSink { let channel = state .db - .get_channel(tenant.community(), channel_uuid) + .get_channel_for_event_write(tenant.community(), channel_uuid) .await .map_err(|e| match &e { buzz_db::DbError::ChannelNotFound(_) | buzz_db::DbError::NotFound(_) => { @@ -364,13 +364,13 @@ impl ActionSink for RelayActionSink { // must not drop the message, so log and proceed with the base tags. let members = state .db - .get_members(tenant.community(), channel_uuid) + .get_members_for_event_write(tenant.community(), channel_uuid) .await .map_err(|e| ActionSinkError::Database(e.to_string()))?; let member_pubkeys: Vec> = members.iter().map(|m| m.pubkey.clone()).collect(); let users = state .db - .get_users_bulk(tenant.community(), &member_pubkeys) + .get_users_bulk_for_event_write(tenant.community(), &member_pubkeys) .await .map_err(|e| ActionSinkError::Database(e.to_string()))?; let named_members: Vec<(String, String)> = users @@ -994,7 +994,7 @@ mod postgres_tests { .to_vec(); state .db - .get_event_by_id(community, &id_bytes) + .get_event_by_id_for_event_write(community, &id_bytes) .await .expect("query event") .expect("event persisted") @@ -1117,7 +1117,7 @@ mod postgres_tests { .to_vec(); let stored = state .db - .get_event_by_id(community, &reply_id_bytes) + .get_event_by_id_for_event_write(community, &reply_id_bytes) .await .expect("query reply") .expect("reply persisted"); @@ -1286,7 +1286,7 @@ mod postgres_tests { // reply→the immediate parent (matching the ingest resolver). let stored = state .db - .get_event_by_id(community, &reply_id_bytes) + .get_event_by_id_for_event_write(community, &reply_id_bytes) .await .expect("query reply") .expect("reply persisted"); diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 71c0f1e73d..f43887b65b 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -213,6 +213,21 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk Ok(()) } +/// Attach NIP-30 `["emoji", shortcode, url]` tags. +/// +/// Each element of `emoji_tags` must be a three-element vector whose first +/// entry is `"emoji"`. Entries that don't match this shape are silently +/// skipped so an unknown future shape never blocks a message send. +fn nip30_emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), SdkError> { + for et in emoji_tags { + if et.len() == 3 && et[0] == "emoji" { + let parts: Vec<&str> = et.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| SdkError::InvalidTag(e.to_string()))?); + } + } + Ok(()) +} + /// Build a stream message (kind 9). /// /// - `channel_id`: target channel UUID @@ -221,6 +236,7 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk /// - `mentions`: pubkey hex strings to p-tag (deduped, max 50) /// - `broadcast`: if true, adds `["broadcast", "1"]` tag /// - `media_tags`: raw imeta tag vectors +/// - `emoji_tags`: NIP-30 `["emoji", shortcode, url]` tag vectors pub fn build_message( channel_id: Uuid, content: &str, @@ -228,6 +244,7 @@ pub fn build_message( mentions: &[&str], broadcast: bool, media_tags: &[Vec], + emoji_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; @@ -239,6 +256,7 @@ pub fn build_message( tags.push(tag(&["broadcast", "1"])?); } imeta_tags(media_tags, &mut tags)?; + nip30_emoji_tags(emoji_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content) .tags(tags) .allow_self_tagging()) @@ -2380,7 +2398,7 @@ mod tests { #[test] fn message_happy_path() { let cid = uuid(); - let ev = sign(build_message(cid, "hello", None, &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hello", None, &[], false, &[], &[]).unwrap()); assert_eq!(ev.kind.as_u16(), 9); assert_eq!(ev.content, "hello"); assert!(has_tag(&ev, "h", &cid.to_string())); @@ -2394,7 +2412,8 @@ mod tests { let cid = uuid(); let sender = keys(); let self_pk = sender.public_key().to_hex(); - let builder = build_message(cid, "self-canary", None, &[&self_pk], false, &[]).unwrap(); + let builder = + build_message(cid, "self-canary", None, &[&self_pk], false, &[], &[]).unwrap(); let ev = builder.sign_with_keys(&sender).expect("sign"); assert!( has_tag(&ev, "p", &self_pk), @@ -2485,7 +2504,7 @@ mod tests { root_event_id: eid, parent_event_id: eid, }; - let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[], &[]).unwrap()); // Direct reply: only one e-tag with "reply" marker let e_tags: Vec<_> = ev .tags @@ -2508,7 +2527,7 @@ mod tests { root_event_id: root, parent_event_id: parent, }; - let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[], &[]).unwrap()); let e_tags: Vec<_> = ev .tags .iter() @@ -2526,7 +2545,7 @@ mod tests { #[test] fn message_broadcast_flag() { let cid = uuid(); - let ev = sign(build_message(cid, "hi", None, &[], true, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[], true, &[], &[]).unwrap()); assert!(has_tag(&ev, "broadcast", "1")); } @@ -2534,7 +2553,7 @@ mod tests { fn message_mentions_deduped() { let cid = uuid(); let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; - let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[], &[]).unwrap()); let p_tags = tag_values(&ev, "p"); assert_eq!(p_tags.len(), 1); } @@ -2555,7 +2574,7 @@ mod tests { }) .collect(); let refs: Vec<&str> = hexes.iter().map(|s| s.as_str()).collect(); - let result = build_message(cid, "hi", None, &refs, false, &[]); + let result = build_message(cid, "hi", None, &refs, false, &[], &[]); assert!(matches!(result, Err(SdkError::TooManyMentions))); } @@ -2563,7 +2582,7 @@ mod tests { fn message_content_too_large() { let cid = uuid(); let big = "x".repeat(64 * 1024 + 1); - let result = build_message(cid, &big, None, &[], false, &[]); + let result = build_message(cid, &big, None, &[], false, &[], &[]); assert!(matches!(result, Err(SdkError::ContentTooLarge { .. }))); } @@ -2571,7 +2590,91 @@ mod tests { fn message_max_content_ok() { let cid = uuid(); let max = "x".repeat(64 * 1024); - assert!(build_message(cid, &max, None, &[], false, &[]).is_ok()); + assert!(build_message(cid, &max, None, &[], false, &[], &[]).is_ok()); + } + + #[test] + fn message_emoji_tags_attached() { + let cid = uuid(); + let emoji_tags = vec![ + vec![ + "emoji".to_string(), + "wave".to_string(), + "https://example.com/wave.gif".to_string(), + ], + vec![ + "emoji".to_string(), + "party".to_string(), + "https://example.com/party.gif".to_string(), + ], + ]; + let ev = sign( + build_message( + cid, + ":wave: hey :party:", + None, + &[], + false, + &[], + &emoji_tags, + ) + .unwrap(), + ); + // Both emoji tags present + assert!(ev + .tags + .iter() + .any(|t| t.as_slice() == ["emoji", "wave", "https://example.com/wave.gif"])); + assert!(ev + .tags + .iter() + .any(|t| t.as_slice() == ["emoji", "party", "https://example.com/party.gif"])); + // kind 9 + assert_eq!(ev.kind.as_u16(), 9); + } + + #[test] + fn message_malformed_emoji_tag_silently_skipped() { + let cid = uuid(); + let emoji_tags = vec![ + // only 2 elements — invalid, must be skipped + vec!["emoji".to_string(), "wave".to_string()], + // wrong kind — must be skipped + vec![ + "imeta".to_string(), + "wave".to_string(), + "https://example.com/wave.gif".to_string(), + ], + // valid + vec![ + "emoji".to_string(), + "ok".to_string(), + "https://example.com/ok.gif".to_string(), + ], + ]; + let ev = sign(build_message(cid, "hi", None, &[], false, &[], &emoji_tags).unwrap()); + let emoji_count = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(String::as_str) == Some("emoji")) + .count(); + assert_eq!(emoji_count, 1); + assert!(ev + .tags + .iter() + .any(|t| t.as_slice() == ["emoji", "ok", "https://example.com/ok.gif"])); + } + + #[test] + fn message_empty_emoji_tags_slice_ok() { + let cid = uuid(); + let ev = sign(build_message(cid, "hello", None, &[], false, &[], &[]).unwrap()); + let emoji_count = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(String::as_str) == Some("emoji")) + .count(); + assert_eq!(emoji_count, 0); } #[test] diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index b022dd8a27..a4545827bc 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -136,6 +136,48 @@ Shutdown without dependency evaluation increments only `buzz_readiness_checks_total{reason="shutting_down"}` and sets the overall state to zero; it does not fabricate dependency failures or latency samples. +### Operation-aware database pool acquisition contract + +The operation-aware families separate three questions: who is waiting now, +how completed/abandoned attempts ended, and how long checkout waits took. +Outcome remains on the terminal counter for historical deployment comparison; +it is intentionally absent from the expensive duration histogram. + +These families cover the explicitly routed deployment-critical operations +listed below; they are not a count of every SQLx checkout in Buzz. In +particular, a zero operation waiter does not prove that the shared SQLx pool +has no uninstrumented waiter. Interpret it beside the pool active, idle, and +maximum gauges when diagnosing total capacity pressure. + +| Metric | Type | Labels | +|--------|------|--------| +| `buzz_db_pool_acquire_duration_seconds` | histogram | `pool_role`, `operation` | +| `buzz_db_pool_acquire_attempts_total` | counter | `pool_role`, `operation`, `outcome` | +| `buzz_db_pool_waiters` | gauge | `pool_role`, `operation`; tracked operations only, periodically refreshed including zero | + +Outcomes are `success`, `timeout`, `error`, and `cancelled`. Operations are +`bootstrap`, `readiness`, `tenant_resolution`, `authentication`, +`authorization`, `subscription_history`, `event_write`, and `maintenance`. +Only the following eleven pairs are valid: + +```text +writer/bootstrap reader/bootstrap +writer/readiness +writer/tenant_resolution +writer/authentication +writer/authorization reader/authorization +writer/subscription_history reader/subscription_history +writer/event_write +writer/maintenance +``` + +Nine finite checkout buckets plus `+Inf`, sum, and count yield 12 histogram +series per valid pair. The new contract therefore has a hard ceiling of 187 +raw Prometheus series per pod: `11 × (12 + 4 + 1)`. The two legacy acquisition +families remain temporarily for dashboard compatibility and are not part of +that new-family budget. No `other` operation or request-controlled/sensitive +label is valid. + ## Relay Pod extensions The chart exposes narrow extension points for init containers, volumes, relay diff --git a/desktop/.env.e2e b/desktop/.env.e2e new file mode 100644 index 0000000000..a323e18f6c --- /dev/null +++ b/desktop/.env.e2e @@ -0,0 +1 @@ +VITE_BUZZ_BESTIE=1 diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 701c767b32..14cab5c63b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,12 +20,14 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", "**/tooltip-semantics.spec.ts", "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", + "**/exact-key-profile.spec.ts", "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", @@ -39,6 +41,7 @@ export default defineConfig({ "**/hosted-communities-settings-screenshots.spec.ts", "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", + "**/bestie.spec.ts", "**/message-feedback-snapshots.spec.ts", "**/message-copy-link.spec.ts", "**/custom-emoji.spec.ts", diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 4df24e6e9b..16b4c93b3e 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -14,9 +14,8 @@ use crate::{ }, current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key, known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env, - save_managed_agents, sync_managed_agent_processes, AgentDefinition, BackendKind, - GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, - MAX_ENV_VALUE_BYTES, + save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, + KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, }, }; @@ -535,42 +534,15 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< (models, current_model) } -/// Persist the canonical startup effort level for a local managed agent. -/// -/// B5 (v4 direct-write): the panel's EffortPicker calls this directly to set the -/// effort a spawn will apply at next session start. The value is stored on the -/// record; at spawn `runtime.rs` injects it as `BUZZ_ACP_EFFORT_LEVEL` and the -/// harness applies it via `session/set_config_option` against the adapter's -/// advertised `thought_level` configId. Pass `None` to clear (adapter default). -/// -/// Rejects non-local backends: remote agents receive effort through `policy_env` -/// at deploy time (see `agents_deploy.rs`), never this local persistence path — -/// so an effort edit against a deployed agent is a caller error, not a silent -/// no-op that leaves the panel and the running agent disagreeing. -#[tauri::command] -pub fn persist_agent_effort_level( - pubkey: String, +/// Atomically set the record's canonical effort column and strip every stale +/// record-scope effort env alias. Split from the Tauri command so the invariant +/// — no leftover alias can outrank the just-set column — is directly testable. +pub(crate) fn apply_picker_effort_level( + record: &mut ManagedAgentRecord, effort_level: Option, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let record = records - .iter_mut() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - if record.backend != BackendKind::Local { - return Err(format!( - "agent {pubkey} is not a local agent; remote effort is set at deploy time" - )); - } +) { record.effort_level = effort_level; - record.updated_at = crate::util::now_iso(); - save_managed_agents(&app, &records) + crate::managed_agents::remove_record_effort_aliases(&mut record.env_vars); } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 596d9d3629..093e925f18 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -29,7 +29,7 @@ fn with_no_goose_config(body: impl FnOnce() -> T) -> T { } fn goose_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { + static RUNTIME: KnownAcpRuntime = KnownAcpRuntime { id: "goose", label: "Goose", commands: &["goose"], @@ -55,13 +55,16 @@ fn goose_runtime() -> &'static KnownAcpRuntime { config_file_format: Some("yaml"), supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&crate::managed_agents::GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, - } + }; + &RUNTIME } fn agent_record() -> ManagedAgentRecord { @@ -630,6 +633,58 @@ fn baked_env_mixed_keys_correct_masking() { assert!(token.masked); } +/// F1 picker direct-write invariant: a stale record-native `GOOSE_THINKING_EFFORT` +/// (launch-projection tier 1, ABOVE the canonical column) must not survive a +/// picker write. Setting effort `high` through the picker path both writes the +/// column and sweeps the stale alias, so the reader and the launch projection +/// both resolve `high` — not the stale `low`. Deleting the sweep in +/// `apply_picker_effort_level` re-breaks this: the projection would emit `low`. +#[test] +fn picker_write_sweeps_stale_record_native_effort_alias() { + let mut record = agent_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + super::apply_picker_effort_level(&mut record, Some("high".to_string())); + + // The stale record-native alias is gone; only the column carries the value. + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "stale record-native effort alias must be swept by the picker write" + ); + assert_eq!(record.effort_level.as_deref(), Some("high")); + + // Reader: the panel resolves the just-set value, not the stale alias. + let surface = with_no_goose_config(|| { + resolve_config_surface( + record.clone(), + &[], + Some(goose_runtime()), + None, + &Default::default(), + None, + ) + }); + let effort = surface + .normalized + .thinking_effort + .expect("picker-set effort must resolve"); + assert_eq!(effort.value.as_deref(), Some("high")); + + // Launch projection: the spawned child receives the picker value. + let launch = crate::managed_agents::config_bridge::effort::effort_launch_projection( + &record, + Some(goose_runtime()), + &[], + None, + &std::collections::BTreeMap::new(), + None, + &std::collections::BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + #[test] fn baked_env_thinking_effort_is_unmasked() { // BUZZ_AGENT_THINKING_EFFORT is a non-secret enum — must not be masked. diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 95e9759f10..ccca7c4abf 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -17,7 +17,6 @@ fn active_installs() -> &'static std::sync::Mutex = member_agent_channel_ids.keys().cloned().collect(); + let candidate_pubkeys: Vec = member_agent_channel_ids + .keys() + .cloned() + .chain(owned_candidates) + .collect::>() + .into_iter() + .collect(); if candidate_pubkeys.is_empty() { return Ok(Vec::new()); } @@ -206,7 +231,10 @@ async fn list_relay_agents_for_selection( &mut agents, crate::managed_agents::owner_only_access_build(), ); - agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey)); + agents.retain(|agent| { + member_agent_channel_ids.contains_key(&agent.pubkey) + || agent.owner_pubkey.as_deref() == Some(viewer_pubkey.as_str()) + }); for agent in &mut agents { agent.channel_ids = member_agent_channel_ids .get(&agent.pubkey) @@ -569,3 +597,6 @@ mod real_relay_tests { assert_eq!(emitted_mentions, vec![agent.public_key().to_hex()]); } } + +#[cfg(test)] +mod owned_tests; diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs new file mode 100644 index 0000000000..bb42b3e6d2 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs @@ -0,0 +1,192 @@ +//! Exercise the production query plan against a loopback relay with signed fixtures. +use super::*; +use axum::{ + routing::{get, post}, + Json, Router, +}; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use std::sync::{Arc, Mutex}; + +#[tokio::test] +async fn remote_owned_discovery_and_membership_do_not_require_local_records() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + let relay = Keys::generate(); + let owner = Keys::generate(); + let agent = Keys::generate(); + let stranger = Keys::generate(); + let agent_key = agent.public_key().to_hex(); + let owner_key = owner.public_key().to_hex(); + let relay_key = relay.public_key().to_hex(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let auth: Vec = serde_json::from_str(&auth).unwrap(); + let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Remote Scout"}"#) + .tags([Tag::parse(auth).unwrap()]) + .sign_with_keys(&agent) + .unwrap(); + let policy = |key: &str| { + EventBuilder::new( + Kind::Custom(30177), + r#"{"name":"Remote Scout","parallelism":1,"respond_to":"owner-only"}"#, + ) + .tags([Tag::parse(["d", key]).unwrap()]) + .sign_with_keys(&owner) + .unwrap() + }; + // An owner-authored coordinate is a discovery hint, not ownership proof. + let forged = policy(&stranger.public_key().to_hex()); + let stranger_profile = EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&stranger) + .unwrap(); + let events = Arc::new(Mutex::new(vec![ + profile, + policy(&agent_key), + forged, + stranger_profile, + ])); + let queries = Arc::new(Mutex::new(Vec::::new())); + let query_events = events.clone(); + let query_log = queries.clone(); + let router = Router::new() + .route( + "/", + get(move || { + let key = relay_key.clone(); + async move { Json(serde_json::json!({"self": key})) } + }), + ) + .route( + "/query", + post(move |Json(filters): Json>| { + let events = query_events.clone(); + let queries = query_log.clone(); + async move { + queries.lock().unwrap().extend(filters.clone()); + let events = events.lock().unwrap(); + let result: Vec<_> = events + .iter() + .filter(|event| { + filters.iter().any(|filter| { + filter["kinds"] + .as_array() + .unwrap() + .contains(&serde_json::json!(event.kind.as_u16())) + && filter.get("authors").is_none_or(|authors| { + authors + .as_array() + .unwrap() + .contains(&serde_json::json!(event.pubkey.to_hex())) + }) + && ["d", "p"].iter().all(|tag| { + filter.get(format!("#{tag}")).is_none_or(|values| { + event.tags.iter().any(|t| { + t.as_slice().first().map(String::as_str) + == Some(*tag) + && t.as_slice().get(1).is_some_and(|value| { + values + .as_array() + .unwrap() + .contains(&serde_json::json!(value)) + }) + }) + }) + }) + }) + }) + .cloned() + .collect(); + Json(result) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let state = crate::app_state::build_app_state(); + *state.keys.lock().unwrap() = owner.clone(); + *state.relay_url_override.lock().unwrap() = Some(format!("ws://{address}")); + + let discovered = list_relay_agents_for_state(&state).await.unwrap(); + assert_eq!(discovered.len(), 1, "forged ownership must not be admitted"); + assert_eq!(discovered[0].pubkey, agent_key); + assert_eq!( + discovered[0].owner_pubkey.as_deref(), + Some(owner_key.as_str()) + ); + assert!( + discovered[0].channel_ids.is_empty(), + "discovery is not membership" + ); + + let membership = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "general"]).unwrap(), + Tag::parse(["p", &owner_key, "", "member"]).unwrap(), + Tag::parse(["p", &agent_key, "", "member"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + events.lock().unwrap().push(membership); + let requested = std::collections::HashSet::from([agent_key.clone()]); + let admitted = list_relay_agents_for_selection(&state, Some(&requested), Some("general")) + .await + .unwrap(); + assert_eq!(admitted.len(), 1); + assert_eq!(admitted[0].channel_ids, vec!["general".to_string()]); + let outside = list_relay_agents_for_selection(&state, Some(&requested), Some("private-other")) + .await + .unwrap(); + assert_eq!(outside.len(), 1); + assert!( + outside[0].channel_ids.is_empty(), + "ownership cannot fabricate destination membership" + ); + // A newer signed snapshot revokes membership, even if an old snapshot + // is also returned. The owned identity remains discoverable, not admitted. + let removed = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "general"]).unwrap(), + Tag::parse(["p", &owner_key, "", "member"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs() + 1, + )) + .sign_with_keys(&relay) + .unwrap(); + events.lock().unwrap().push(removed); + let revoked = list_relay_agents_for_selection(&state, Some(&requested), Some("general")) + .await + .unwrap(); + assert!(revoked[0].channel_ids.is_empty()); + + let deny = EventBuilder::new( + Kind::Custom(30177), + r#"{"name":"Remote Scout","parallelism":1,"respond_to":"nobody"}"#, + ) + .tags([Tag::parse(["d", &agent_key]).unwrap()]) + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs() + 2, + )) + .sign_with_keys(&owner) + .unwrap(); + events.lock().unwrap().push(deny); + let denied = list_relay_agents_for_selection(&state, Some(&requested), Some("general")) + .await + .unwrap(); + assert!( + denied.is_empty(), + "latest unsupported policy cannot fall back to an older allow" + ); + + assert!(queries + .lock() + .unwrap() + .iter() + .any(|filter| filter["kinds"] == serde_json::json!([30177]) + && filter["authors"] == serde_json::json!([owner_key]) + && filter.get("#d").is_none())); + server.abort(); + crate::relay_admission::reset_rate_limit_gate(); +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 05b1abad90..c887b25148 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -26,7 +26,6 @@ use crate::{ UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, - util::now_iso, }; /// Query available models from an agent via `buzz-acp models --json`. diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index 68f54f58ad..2ef014d795 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -35,6 +35,88 @@ fn ensure_access_policy_change_supported( Ok(()) } +/// Reject an effort mutation for a non-local record. Remote effort is +/// deployment-owned (set via `policy_env` at deploy time); persisting locally +/// would make the canonical column diverge from the deployed runtime's actual +/// effort. +fn ensure_effort_change_supported( + record: &ManagedAgentRecord, + effort_level: &Option>, +) -> Result<(), String> { + if effort_level.is_some() && record.backend != crate::managed_agents::BackendKind::Local { + return Err(format!( + "agent {} is not a local agent; remote effort is set at deploy time", + record.pubkey + )); + } + Ok(()) +} + +/// Guard/apply seam for the effort step inside `apply_record_field_updates`. +fn apply_effort_update( + record: &mut ManagedAgentRecord, + effort_level: Option>, +) -> Result<(), String> { + ensure_effort_change_supported(record, &effort_level)?; + if let Some(effort_override) = effort_level { + crate::commands::agent_config::apply_picker_effort_level(record, effort_override); + } + Ok(()) +} + +/// Proof token returned by `apply_record_field_updates`. Zero-size and +/// `#[must_use]`; consumed by `stamp_record_updated_at`, so removing the +/// `apply_record_field_updates` call from `update_managed_agent` leaves +/// `applied` undefined at the timestamp site — a compile error. +#[derive(Debug)] +#[must_use] +pub(crate) struct RecordFieldsApplied(()); + +/// Apply the env-vars and effort steps of `update_managed_agent` to a record +/// in the correct order: env_vars FIRST (so the same-request map cannot +/// reintroduce a stale alias), then the canonical effort column write. +/// +/// Returns a `RecordFieldsApplied` token that must be passed to +/// `stamp_record_updated_at`. Removing this call from `update_managed_agent` +/// leaves `applied` undefined at the timestamp site — a compile error. +/// +/// Called by `update_managed_agent` inside its locked transaction and by tests. +/// Any step deleted from inside this function is directly caught by the +/// corresponding test assertion. +/// +/// Mutation proofs (see `agent_models_update_tests.rs`): +/// - Deleting the `apply_effort_update` call leaves `effort_level` unchanged. +/// - Deleting `ensure_effort_change_supported` inside `apply_effort_update` +/// lets non-local writes pass `Ok(())` without mutating the column. +/// - Deleting `apply_picker_effort_level` inside `apply_effort_update` +/// leaves `effort_level == None` on a local-set request. +pub(crate) fn apply_record_field_updates( + record: &mut ManagedAgentRecord, + env_vars: Option<&std::collections::BTreeMap>, + inherit_transition: bool, + effort_level: Option>, +) -> Result { + // Order is load-bearing: env_vars before effort so a same-request + // env_vars map cannot reintroduce a stale alias after the column write. + crate::managed_agents::apply_env_vars_then_effort_transition( + record, + env_vars.cloned(), + inherit_transition, + ); + apply_effort_update(record, effort_level)?; + Ok(RecordFieldsApplied(())) +} + +/// Stamp `record.updated_at` with the current ISO timestamp, consuming the +/// `RecordFieldsApplied` proof token. Removing `apply_record_field_updates` +/// from `update_managed_agent` leaves `applied` undefined here — a compile error. +pub(crate) fn stamp_record_updated_at( + record: &mut ManagedAgentRecord, + _applied: RecordFieldsApplied, +) { + record.updated_at = crate::util::now_iso(); +} + /// Flush a retained managed-agent policy, preserving any earlier profile error. pub(crate) async fn flush_managed_agent_policy( app: &AppHandle, @@ -115,15 +197,17 @@ pub async fn update_managed_agent( // Harness edit: the persona's runtime is authoritative, so an explicit // `agent_command_override` is persisted ONLY when the user picks a // command that diverges from the persona, and the empty/whitespace - // "Inherit from persona" sentinel clears both the pin and the - // materialized record runtime. A name-only edit + // "Inherit from persona" sentinel clears the pin, the materialized + // record runtime, AND the per-instance effort override (column here, + // env aliases after `env_vars` is applied below). A name-only edit // (`agent_command == None`) leaves the pin intact. `harness_override` // threads the user's explicit intent — see `apply_agent_command_update` // and `update_time_agent_command_override` for the full resolution // rules. + let mut inherit_transition = false; if let Some(agent_command) = input.agent_command { let personas = load_personas(&app).unwrap_or_default(); - crate::managed_agents::apply_agent_command_update( + inherit_transition = crate::managed_agents::apply_agent_command_update( record, &personas, &agent_command, @@ -136,9 +220,16 @@ pub async fn update_managed_agent( // mcp_command is intentionally not applied here — the effective MCP // command is always catalog-derived (known_acp_runtime at spawn time) // and the per-record field is never read by the runtime. - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - record.env_vars = env_vars; + // + // Apply the caller-supplied `env_vars` (validated first), then — only on + // the pin→inherit transition — strip the record effort env aliases. The + // order is load-bearing: stripping AFTER the env replacement is what + // stops a same-request `env_vars` map from reintroducing a stale effort + // alias while the instance inherits its harness. The column was already + // cleared inside `apply_agent_command_update`. See + // `apply_env_vars_then_effort_transition` for the pinned invariant. + if let Some(ref env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(env_vars)?; } // Native provider/model fields are authoritative. Keep the typed marker @@ -211,7 +302,23 @@ pub async fn update_managed_agent( record.respond_to_allowlist = prospective_allowlist; } - record.updated_at = now_iso(); + // Effort + env_vars: applied together inside `apply_record_field_updates` to + // enforce the ordering invariant (env_vars before effort column write) and + // provide a directly-testable production seam. Effort persists inside the + // locked transaction so an access-policy restart above snapshots and + // launches the new effort value. Present+Some(v)=set; Present+None=clear; + // Absent=don't touch (the dialog sends it only when effortTouched). + // The returned token is consumed by `stamp_record_updated_at`; removing + // this call from `update_managed_agent` leaves `applied` undefined there + // — a compile error (the sole outer-seam proof for this call site). + let applied = apply_record_field_updates( + record, + input.env_vars.as_ref(), + inherit_transition, + input.effort_level, + )?; + + stamp_record_updated_at(record, applied); save_managed_agents(&app, &records)?; @@ -365,5 +472,6 @@ pub async fn update_managed_agent( } #[cfg(test)] +#[allow(unused_must_use)] #[path = "agent_models_update_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agent_models_update_tests.rs b/desktop/src-tauri/src/commands/agent_models_update_tests.rs index b9fd0bd183..28a50e7b15 100644 --- a/desktop/src-tauri/src/commands/agent_models_update_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_update_tests.rs @@ -1,4 +1,9 @@ use super::*; +// The tests call `apply_record_field_updates(...)` and consume the return value +// via `.expect(...)`, discarding `RecordFieldsApplied`. The tests verify column +// writes (side effects), not the token itself. The lint is suppressed here so +// callers remain readable. Production code (update_managed_agent) must never +// suppress it — the token IS the outer-seam compile-time proof. fn provider_record(deployed: bool) -> ManagedAgentRecord { let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ @@ -29,3 +34,340 @@ fn undeployed_provider_accepts_access_edits() { ensure_access_policy_change_supported(&provider_record(false), true) .expect("no running provider deployment can retain stale access"); } + +fn local_record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "local", "name": "Local Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap() + // BackendKind deserializes as Local when the field is absent (the json! above). +} + +// ── Production-entered seam tests (apply_record_field_updates) ────────────── +// +// These tests call `apply_record_field_updates`, the same function production +// calls inside `update_managed_agent` for the env_vars+effort ordered write. +// They verify: +// - non-local records are rejected AND the column is NOT mutated; +// - local set writes to the column and sweeps stale env aliases; +// - local clear zeroes the column and sweeps stale env aliases; +// - env_vars applied before effort so no same-request alias re-pins the column. +// +// Deletion proof for the effort guard: removing `ensure_effort_change_supported` +// inside `apply_record_field_updates` makes reject tests return `Ok(())` instead +// of `Err`, and the "record not mutated" assertions fail. +// +// Deletion proof for the apply call: removing the `apply_effort_update` call +// inside `apply_record_field_updates` leaves `effort_level == None` on local-set. +// +// Deletion proof for the env_vars step: removing `apply_env_vars_then_effort_transition` +// inside `apply_record_field_updates` leaves the env alias in `env_vars` on local-set. +// +// Ordering proof: `env_vars` with a stale alias is applied BEFORE effort so the +// alias is stripped; reversing the order leaves both the alias and the new column. +// +// Outer-seam proof (compile-error): removing `apply_record_field_updates` from +// `update_managed_agent` leaves `applied` undefined at `stamp_record_updated_at` +// — a compile error enforced by the `#[must_use] RecordFieldsApplied` token. +// `record_field_updates_persist_effort_to_disk` below proves the +// disk-persistence contract of `apply_record_field_updates` itself (calls it +// directly); it does not independently gate the production invocation. + +#[test] +fn non_local_set_is_rejected_and_record_not_mutated() { + let mut record = provider_record(false); + let err = apply_record_field_updates(&mut record, None, false, Some(Some("high".to_string()))) + .expect_err("non-local record must reject effort writes"); + assert!( + err.contains("remote effort is set at deploy time"), + "error must explain why non-local effort writes are rejected: {err}" + ); + // Column must not be touched — the rejection is before mutation. + assert_eq!( + record.effort_level, None, + "non-local record column must be unchanged after a rejected set" + ); +} + +#[test] +fn non_local_clear_is_rejected_and_record_not_mutated() { + // Clear (None inner value) is also rejected for non-local records — the + // outer Some signals presence; the inner None is the clear sentinel. + let mut record = provider_record(false); + let err = apply_record_field_updates(&mut record, None, false, Some(None)) + .expect_err("non-local record effort clear must also be rejected"); + assert!(err.contains("remote effort is set at deploy time")); + assert_eq!( + record.effort_level, None, + "non-local record column must be unchanged after a rejected clear" + ); +} + +#[test] +fn local_set_writes_column_and_sweeps_stale_alias() { + // `apply_record_field_updates` must write `effort_level` for a local record + // and strip any stale record-scope effort alias. Deleting the + // `apply_effort_update` call inside leaves `effort_level == None`. + let mut record = local_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + let _ = apply_record_field_updates(&mut record, None, false, Some(Some("high".to_string()))) + .expect("local record must accept effort set"); + + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "local set must write the canonical column" + ); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "local set must sweep the stale record-native alias" + ); +} + +#[test] +fn local_clear_zeroes_column_and_sweeps_alias() { + let mut record = local_record(); + record.effort_level = Some("high".to_string()); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); + + apply_record_field_updates(&mut record, None, false, Some(None)) + .expect("local record must accept effort clear"); + + assert_eq!( + record.effort_level, None, + "local clear must zero the canonical column" + ); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "local clear must sweep the stale record-native alias" + ); +} + +#[test] +fn absent_effort_is_noop_for_any_backend() { + // A missing effortLevel field (the common case) must never be rejected and + // must never touch the column — this is the don't-touch path. + let mut local = local_record(); + apply_record_field_updates(&mut local, None, false, None) + .expect("absent effort must pass for local"); + assert_eq!( + local.effort_level, None, + "absent effort must not touch local column" + ); + + let mut provider = provider_record(true); + apply_record_field_updates(&mut provider, None, false, None) + .expect("absent effort must pass for provider"); + assert_eq!( + provider.effort_level, None, + "absent effort must not touch provider column" + ); +} + +#[test] +fn env_vars_applied_before_effort_ordering_invariant() { + // Order is load-bearing: env_vars BEFORE effort column write. A same-request + // env_vars map containing a stale alias (GOOSE_THINKING_EFFORT=low) alongside + // an explicit effort set (high) must end with the alias swept — not re-pinned. + // If env_vars were applied AFTER effort, the alias would survive. + let mut record = local_record(); + let mut env_vars = std::collections::BTreeMap::new(); + env_vars.insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + apply_record_field_updates( + &mut record, + Some(&env_vars), + false, + Some(Some("high".to_string())), + ) + .expect("ordering test must succeed for local record"); + + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "effort column must be set to the explicit value" + ); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "alias in the same-request env_vars must be swept before the column is read at launch" + ); +} + +// ── Defensive direct-IPC contract ───────────────────────────────────────────── +// +// Non-blocking defensive coverage (Wes/Carl review): a contradictory request +// combining the ACP inherit sentinel in `env_vars` and a non-null effort_level +// must be deterministic — the effort write wins over the sentinel, and the +// sentinel is swept by the alias-removal step so it cannot shadow the column +// at launch time. The shipped renderer suppresses this combination, but the +// backend must not leave an ambiguous state. + +#[test] +fn effort_write_sweeps_acp_sentinel_in_env_vars() { + // A local record whose env_vars contain BUZZ_ACP_EFFORT_LEVEL (e.g. manually + // set by a user) plus a concurrent explicit effort_level write. The column + // must be set to the explicit value AND the sentinel must be removed. + let mut record = local_record(); + record.env_vars.insert( + "BUZZ_ACP_EFFORT_LEVEL".to_string(), + "old-sentinel".to_string(), + ); + apply_record_field_updates(&mut record, None, false, Some(Some("high".to_string()))) + .expect("local record must accept effort set"); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "effort write must set the column" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "ACP sentinel in env_vars must be swept by the alias-removal step" + ); +} + +#[test] +fn effort_clear_sweeps_acp_sentinel_in_env_vars() { + // A concurrent clear (None inner value) plus a pre-existing ACP sentinel. + // After the clear the column is None and the sentinel is gone — no ambiguity. + let mut record = local_record(); + record.effort_level = Some("high".to_string()); + record.env_vars.insert( + "BUZZ_ACP_EFFORT_LEVEL".to_string(), + "old-sentinel".to_string(), + ); + apply_record_field_updates(&mut record, None, false, Some(None)) + .expect("local record must accept effort clear"); + assert_eq!( + record.effort_level, None, + "effort clear must zero the column" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "ACP sentinel in env_vars must be swept on clear" + ); +} + +// ── Helper disk-persistence contract ───────────────────────────────────────── +// +// This test drives the production helper sequence directly in its own body: +// load_managed_agents → apply_record_field_updates → stamp_record_updated_at +// → save_managed_agents → load-from-disk. +// +// Mutation proofs (scoped to this test body): +// - Removing `apply_record_field_updates` from this test body leaves +// `applied` undefined at `stamp_record_updated_at` — a compile error. +// - Removing the function call and stubbing the token manually leaves +// `effort_level` unchanged on disk — assertion fails (expected +// Some("high"), got None). +// +// Outer-seam gate: the compile error that prevents skipping +// `apply_record_field_updates` inside `update_managed_agent` is described in +// the outer-seam comment above (undefined `applied` token at the +// `stamp_record_updated_at` site). This test proves only the helper's own +// disk-roundtrip contract; it does not independently gate the production +// invocation. + +#[cfg(not(target_os = "windows"))] +#[test] +fn record_field_updates_persist_effort_to_disk() { + use crate::app_state::build_app_state; + use crate::managed_agents::{load_managed_agents, save_managed_agents}; + + // A single crate-wide process-env lock covers PATH, HOME, XDG_DATA_HOME, + // and all effort env keys — `lock_path_mutex` and `lock_env_mutex` both + // delegate to the same `PROCESS_ENV_MUTEX` static. + let _env_guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + + // RAII guards restore HOME and XDG_DATA_HOME on Drop (even on panic). + // Uses OsString so a pre-existing non-Unicode value is restored exactly. + struct EnvVarGuard { + key: String, + prior: Option, + } + impl EnvVarGuard { + fn set(key: &str, value: &std::path::Path) -> Self { + let prior = std::env::var_os(key); + #[allow(deprecated)] + // SAFETY: caller holds the crate-wide process-env lock. + unsafe { + std::env::set_var(key, value) + }; + Self { + key: key.to_string(), + prior, + } + } + } + impl Drop for EnvVarGuard { + fn drop(&mut self) { + #[allow(deprecated)] + // SAFETY: caller holds the crate-wide process-env lock. + unsafe { + match &self.prior { + Some(v) => std::env::set_var(&self.key, v), + None => std::env::remove_var(&self.key), + } + } + } + } + + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_DATA_HOME", &home); + + let app = tauri::test::mock_builder() + .manage(build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app builds headless"); + + // Seed a local record with no effort set. + let seed: crate::managed_agents::ManagedAgentRecord = + serde_json::from_value(serde_json::json!({ + "pubkey": "test-effort-agent", + "name": "Effort Test Agent", + "relay_url": "", "acp_command": "", "agent_command": "", + "agent_args": [], "mcp_command": "", "turn_timeout_seconds": 0, + "system_prompt": null, "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", "last_started_at": null, + "last_stopped_at": null, "last_exit_code": null, "last_error": null + })) + .unwrap(); + save_managed_agents(app.handle(), &[seed]).unwrap(); + + // Drive the production seam: load → apply_record_field_updates → + // stamp_record_updated_at → save. This is the exact sequence that + // `update_managed_agent` executes inside its locked transaction. + let mut records = load_managed_agents(app.handle()).unwrap(); + let record = records + .iter_mut() + .find(|r| r.pubkey == "test-effort-agent") + .expect("seeded record must load"); + let applied = apply_record_field_updates(record, None, false, Some(Some("high".to_string()))) + .expect("local record must accept effort set"); + stamp_record_updated_at(record, applied); + save_managed_agents(app.handle(), &records).unwrap(); + + // Verify effort landed on disk. + let saved = load_managed_agents(app.handle()).unwrap(); + let saved_record = saved + .iter() + .find(|r| r.pubkey == "test-effort-agent") + .expect("agent must persist after update"); + assert_eq!( + saved_record.effort_level.as_deref(), + Some("high"), + "apply_record_field_updates + stamp_record_updated_at must write effort_level to disk" + ); + // _home_guard and _xdg_guard restore HOME and XDG_DATA_HOME via Drop. +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index eacef621fb..296123f470 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,14 +6,16 @@ use super::managed_agent_definition::validate_create_definition; use crate::{ app_state::AppState, managed_agents::{ + bestie_assignment::{recover_pending_assignment_cleanup, with_agent_assignments_cleared}, build_managed_agent_summary, current_instance_id, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, - managed_agent_avatar_url, normalize_agent_args, resolve_provider_binary, - save_managed_agents, start_managed_agent_process, stop_managed_agent_process, - stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, - validate_provider_config, BackendKind, CreateManagedAgentRequest, - CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, + resolve_provider_binary, save_managed_agents, start_managed_agent_process, + stop_managed_agent_process, stop_managed_agent_workspace_pair, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, + CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, + ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::relay_ws_url_with_override, util::now_iso, @@ -1087,6 +1089,20 @@ pub async fn stop_managed_agent( // Async so the blocking body (disk reads/writes, process termination, keyring // delete, nest regeneration) runs off the main UI thread via spawn_blocking. +fn run_managed_agent_deletion( + base_dir: &std::path::Path, + pubkey: &str, + records: &mut Vec, + delete: impl FnOnce(&mut Vec) -> Result, +) -> Result { + recover_pending_assignment_cleanup(base_dir, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + })?; + with_agent_assignments_cleared(base_dir, pubkey, || delete(records)) +} + #[tauri::command] pub async fn delete_managed_agent( pubkey: String, @@ -1102,6 +1118,12 @@ pub async fn delete_managed_agent( .lock() .map_err(|error| error.to_string())?; let mut records = load_managed_agents(&app)?; + let base_dir = managed_agents_base_dir(&app)?; + recover_pending_assignment_cleanup(&base_dir, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + })?; let mut runtimes = state .managed_agent_processes .lock() @@ -1135,16 +1157,17 @@ pub async fn delete_managed_agent( } } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - stop_managed_agent_process(&app, record, &mut runtimes)?; - } - state.clear_agent_session_caches(&pubkey); - let initial_len = records.len(); - records.retain(|record| record.pubkey != pubkey); - if records.len() == initial_len { + if !records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} not found")); } - save_managed_agents(&app, &records)?; + run_managed_agent_deletion(&base_dir, &pubkey, &mut records, |records| { + if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { + stop_managed_agent_process(&app, record, &mut runtimes)?; + } + state.clear_agent_session_caches(&pubkey); + records.retain(|record| record.pubkey != pubkey); + save_managed_agents(&app, records) + })?; crate::managed_agents::delete_agent_key(&pubkey); // Tombstone after confirmed removal (inside lock; every published // agent tombstones). The NIP-IA kind:9035 archive request — which diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 359a35abcf..de8ca8cc78 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -98,13 +98,13 @@ fn build_launch_block_for_policy( }; policy_env.insert(model_key.into(), value.to_string()); } - // I-4: remote parity for persisted startup effort. Mirrors the local spawn - // path in runtime.rs. The harness reads BUZZ_ACP_EFFORT_LEVEL into - // PoolStartup.startup_effort and applies it at first session creation via - // resolve_startup_effort(). - if let Some(ref value) = record.effort_level { - policy_env.insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.clone()); - } + // Startup effort needs no remote-specific handling: the harness-agnostic + // effort projection already ran inside `resolve_effective_harness_descriptor`, + // so `descriptor.env` (→ `launch.env`, tier 2) carries exactly one effort key + // holding the effective value, with every foreign/legacy/transport effort key + // stripped. Tier 2 later-wins over `policy_env` (tier 1) and no authoritative + // tier-3 key collides with an effort key, so the projected value reaches the + // remote pod verbatim — identical authority to the local spawn. if let Some(value) = record.idle_timeout_seconds { policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); } @@ -121,14 +121,6 @@ fn build_launch_block_for_policy( policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); } - // B5 remote parity: when a canonical effort_level is persisted, strip - // BUZZ_ACP_EFFORT_LEVEL from launch.env so it cannot shadow the canonical - // value in policy_env (tier 1). In the k8s three-tier model tier 2 - // (launch.env) overwrites tier 1 (policy_env) — later-wins — so the key - // must be absent from tier 2 whenever a canonical value is present. - // When effort_level is None there is no canonical to protect, so user - // env passthrough stands (env may legitimately seed startup effort). - // // B2 remote parity: mirror the local A1 model authority. For a Claude // launch, ALWAYS strip BOTH BUZZ_ACP_MODEL and ANTHROPIC_MODEL from // launch.env — the resolved canonical model rides policy_env.ANTHROPIC_MODEL @@ -138,10 +130,13 @@ fn build_launch_block_for_policy( // canonical model. When no canonical model is present, neither key is in // policy_env, so stripping them keeps the remote process free of both — // matching local, where `apply_claude_model_env(None)` removes both. + // + // Effort keys need no stripping here: the projection already reduced + // `descriptor.env` to exactly one effort key holding the effective value, + // so launch.env carries the authority directly (see the effort note above). let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); let strip_key = |k: &str| { k.eq_ignore_ascii_case(crate::managed_agents::ACP_SESSION_POLICY_ENV_VAR) - || (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL")) || (is_claude && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL") || k.eq_ignore_ascii_case("ANTHROPIC_MODEL"))) @@ -522,19 +517,27 @@ mod tests { } #[test] - fn launch_block_claude_runtime_injects_effort_level_when_set() { - // I-4: remote parity — record.effort_level → BUZZ_ACP_EFFORT_LEVEL in policy_env. - let mut record = record(); - record.effort_level = Some("high".to_string()); + fn launch_block_claude_runtime_carries_projected_effort_in_launch_env() { + // Under the harness-agnostic projection, effort no longer rides + // policy_env: `resolve_effective_harness_descriptor` reduces + // `descriptor.env` to exactly one effort key (for a keyless claude + // runtime, the ACP sentinel) holding the effective value, and + // build_launch_block passes that env through to launch.env verbatim. + let record = record(); let descriptor = EffectiveHarnessDescriptor { command: "claude".into(), args: vec![], - env: BTreeMap::new(), + // The single projected effort key the descriptor resolver emits. + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "high".to_string())]), }; let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); assert_eq!( - launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", - "claude remote must receive BUZZ_ACP_EFFORT_LEVEL when effort_level is set" + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "the projected effort key must survive into launch.env" + ); + assert!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "effort is not a policy_env value under the projection design" ); } @@ -561,26 +564,35 @@ mod tests { /// authoritative. #[test] fn launch_block_canonical_effort_strips_user_env_collision() { + // Remote parity for the authority collision: the canonical column and a + // conflicting user `BUZZ_ACP_EFFORT_LEVEL` both present. The projection + // (run inside `resolve_effective_harness_descriptor`) resolves it — + // canonical `high` wins over the user `low` transport sentinel — and + // build_launch_block carries exactly that one value into launch.env, + // identical to the local spawn path. let mut record = record(); + record.runtime = Some("claude".into()); record.effort_level = Some("high".to_string()); - let descriptor = EffectiveHarnessDescriptor { - command: "claude".into(), - args: vec![], - // User-supplied conflicting value in descriptor.env. - env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), - }; + record + .env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "low".into()); + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + &record, + &[], + &Default::default(), + ) + .expect("claude descriptor resolves"); let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); - // Canonical must be in policy_env (tier 1). + // The projected canonical authority is the single effort value carried. assert_eq!( - launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", - "canonical effort must be in policy_env when record.effort_level is Some" + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "canonical effort must win the collision and reach launch.env" ); - // Conflicting user value must be absent from launch.env (tier 2) so it - // cannot shadow the canonical tier-1 value in build_env. + // Effort is not a policy_env value under the projection design. assert!( - launch["env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), - "user BUZZ_ACP_EFFORT_LEVEL must be stripped from launch.env when canonical is present" + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "effort is carried in launch.env, never policy_env" ); } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index ef71321bed..59e04b09ff 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -188,6 +188,45 @@ fn deploy_resolver_inherits_global_when_definition_blank() { ); } +#[test] +fn production_delete_orchestration_restores_bestie_when_agent_save_fails() { + use crate::managed_agents::{ + bestie_assignment::{assignment_matches, replace_assignment}, + retention::open_retention_db, + }; + + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + std::fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let db_path = retention_dir.join("owner.db"); + let pubkey = "a".repeat(64); + replace_assignment( + &mut open_retention_db(&db_path) + .unwrap_or_else(|error| panic!("open assignment DB: {error}")), + &pubkey, + ) + .unwrap_or_else(|error| panic!("seed assignment: {error}")); + let mut record = bare_agent_record(None, None, None); + record.pubkey.clone_from(&pubkey); + let mut records = vec![record]; + + let result = run_managed_agent_deletion(dir.path(), &pubkey, &mut records, |_records| { + Err::<(), _>("injected managed-agent save failure".to_string()) + }); + + assert_eq!( + result, + Err("injected managed-agent save failure".to_string()) + ); + assert!(assignment_matches( + &open_retention_db(&db_path) + .unwrap_or_else(|error| panic!("reopen assignment DB: {error}")), + &pubkey, + ) + .unwrap_or_else(|error| panic!("read restored assignment: {error}"))); +} + /// Deploy resolver falls back to global when both definition and record have none. #[test] fn deploy_resolver_falls_back_to_global_when_definition_and_record_have_none() { diff --git a/desktop/src-tauri/src/commands/bestie.rs b/desktop/src-tauri/src/commands/bestie.rs new file mode 100644 index 0000000000..19f2118625 --- /dev/null +++ b/desktop/src-tauri/src/commands/bestie.rs @@ -0,0 +1,218 @@ +use std::sync::atomic::Ordering; + +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + bestie_assignment::{ + assignment_matches, clear_assignment, get_assignment, + recover_pending_assignment_cleanup, replace_assignment, BestieAssignment, + }, + load_managed_agents, managed_agents_base_dir, + retention::{active_retention_scope, open_retention_db, RetentionScope}, + BackendKind, ManagedAgentRecord, + }, + models::ChannelInfo, +}; + +fn canonical_relay(relay_url: &str) -> Result { + buzz_core_pkg::relay::normalize_relay_url(relay_url).map_err(|error| error.to_string()) +} + +fn assert_expected_scope( + scope: &RetentionScope, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, +) -> Result<(), String> { + if let Some(expected) = expected_relay_url { + if canonical_relay(expected)? != canonical_relay(&scope.relay_url)? { + return Err("active community changed while resolving Bestie".to_string()); + } + } + if let Some(expected) = expected_signer_pubkey { + if expected.trim().to_ascii_lowercase() != scope.owner_keys.public_key().to_hex() { + return Err("active identity changed while resolving Bestie".to_string()); + } + } + Ok(()) +} + +fn validate_agent_pubkey(pubkey: &str) -> Result { + let normalized = pubkey.trim().to_ascii_lowercase(); + if normalized.len() != 64 + || !normalized + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("Bestie agent pubkey must be 64 hexadecimal characters".to_string()); + } + Ok(normalized) +} + +fn require_eligible_local_agent( + records: &[ManagedAgentRecord], + pubkey: &str, +) -> Result<(), String> { + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(pubkey)) + .ok_or_else(|| "assigned Bestie agent no longer exists on this device".to_string())?; + if record.backend != BackendKind::Local { + return Err("only a local managed agent can be your Bestie".to_string()); + } + Ok(()) +} + +fn recover_pending_cleanup(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { + recover_pending_assignment_cleanup(&managed_agents_base_dir(app)?, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + }) +} + +#[tauri::command] +pub fn get_bestie_assignment( + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + let conn = open_retention_db(&scope.db_path)?; + get_assignment(&conn) +} + +#[tauri::command] +pub fn assign_bestie( + agent_pubkey: String, + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let pubkey = validate_agent_pubkey(&agent_pubkey)?; + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + require_eligible_local_agent(&records, &pubkey)?; + let mut conn = open_retention_db(&scope.db_path)?; + replace_assignment(&mut conn, &pubkey) +} + +#[tauri::command] +pub fn clear_bestie_assignment( + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + let mut conn = open_retention_db(&scope.db_path)?; + clear_assignment(&mut conn) +} + +#[tauri::command] +pub async fn resolve_bestie_conversation( + expected_relay_url: Option, + expected_signer_pubkey: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let generation = state.workspace_apply_generation.load(Ordering::Acquire); + let scope = active_retention_scope(&app, &state)?; + assert_expected_scope( + &scope, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + )?; + let assignment = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + recover_pending_cleanup(&app, &records)?; + let conn = open_retention_db(&scope.db_path)?; + let assignment = get_assignment(&conn)? + .ok_or_else(|| "choose an agent before opening Bestie".to_string())?; + require_eligible_local_agent(&records, &assignment.agent_pubkey)?; + assignment + }; + + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let channel = super::dms::open_dm_with_scope( + vec![assignment.agent_pubkey.clone()], + Some(&scope.relay_url), + Some(&owner_pubkey), + &state, + ) + .await?; + + if state.workspace_apply_generation.load(Ordering::Acquire) != generation { + return Err("active workspace changed while resolving Bestie".to_string()); + } + let current_scope = active_retention_scope(&app, &state)?; + assert_expected_scope(¤t_scope, Some(&scope.relay_url), Some(&owner_pubkey))?; + let conn = open_retention_db(&scope.db_path)?; + if !assignment_matches(&conn, &assignment.agent_pubkey)? { + return Err("Bestie assignment changed while opening the conversation".to_string()); + } + Ok(channel) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relay_scope_accepts_runtime_equivalences() { + assert_eq!( + canonical_relay(" WSS://LOCALHOST:443/ ") + .unwrap_or_else(|error| panic!("canonical relay: {error}")), + "wss://127.0.0.1" + ); + } + + #[test] + fn pubkeys_are_normalized_and_validated() { + assert_eq!( + validate_agent_pubkey(&"A".repeat(64)) + .unwrap_or_else(|error| panic!("valid pubkey: {error}")), + "a".repeat(64) + ); + assert!(validate_agent_pubkey("short").is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/dms.rs b/desktop/src-tauri/src/commands/dms.rs index 5f6ca27980..252068c94d 100644 --- a/desktop/src-tauri/src/commands/dms.rs +++ b/desktop/src-tauri/src/commands/dms.rs @@ -23,6 +23,21 @@ pub async fn open_dm( expected_relay_url: Option, expected_signer_pubkey: Option, state: State<'_, AppState>, +) -> Result { + open_dm_with_scope( + pubkeys, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + &state, + ) + .await +} + +pub(crate) async fn open_dm_with_scope( + pubkeys: Vec, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, + state: &AppState, ) -> Result { // Resolve the relay AND the signing identity once for the open + metadata // read pair. Callers with a captured tenant scope (Projects agent sends) @@ -34,25 +49,22 @@ pub async fn open_dm( // tenant-A DM signed as tenant B's identity — fail closed instead, and // use this exact key snapshot for both the event signature and the // NIP-98 auth of every request in this command. - let api_base_url = crate::relay::relay_api_base_url_with_override(&state); - assert_expected_relay_scope(expected_relay_url.as_deref(), &api_base_url)?; + let api_base_url = crate::relay::relay_api_base_url_with_override(state); + assert_expected_relay_scope(expected_relay_url, &api_base_url)?; let keys = state.signing_keys()?; - assert_expected_signer( - expected_signer_pubkey.as_deref(), - &keys.public_key().to_hex(), - )?; + assert_expected_signer(expected_signer_pubkey, &keys.public_key().to_hex())?; // Submit a kind:41010 dm-open event; the relay replies with the channel id // in its OK message payload. let builder = events::build_dm_open(&pubkeys)?; - let result = submit_event_at_with_keys(builder, &state, &api_base_url, &keys).await?; + let result = submit_event_at_with_keys(builder, state, &api_base_url, &keys).await?; let ack: OpenDmAck = parse_command_response(&result.message)?; // Re-fetch the channel metadata so the frontend gets the same `ChannelInfo` // shape as `get_channel_details` — through the same scope-checked base and // the same pinned identity. let metadata = query_relay_at_with_keys( - &state, + state, &api_base_url, &[serde_json::json!({ "kinds": [39000], diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 324e13e68a..c8184a0103 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ mod agent_providers; mod agent_settings; mod agent_update_rollback; mod agents; +mod bestie; mod canvas; mod channel_reconnect_repair; mod channel_templates; @@ -84,6 +85,7 @@ pub use agent_models::*; pub use agent_providers::*; pub use agent_settings::*; pub use agents::*; +pub use bestie::*; pub use canvas::*; pub use channel_reconnect_repair::*; pub use channel_templates::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fe2bba5024..12082a2a82 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -621,6 +621,10 @@ pub fn run() { create_channel, ensure_starter_channels, open_dm, + get_bestie_assignment, + assign_bestie, + clear_bestie_assignment, + resolve_bestie_conversation, hide_dm, get_channel_details, get_channel_members, @@ -715,7 +719,6 @@ pub fn run() { get_baked_build_env_keys, get_baked_build_env, put_agent_session_config, - persist_agent_effort_level, get_global_agent_config, set_global_agent_config, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/bestie_assignment.rs b/desktop/src-tauri/src/managed_agents/bestie_assignment.rs new file mode 100644 index 0000000000..e823db124b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/bestie_assignment.rs @@ -0,0 +1,595 @@ +//! Durable, owner-and-relay-scoped Bestie designation storage. + +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +use super::{retention::open_retention_db, storage::atomic_write_json_restricted}; + +const RECOVERY_JOURNAL_FILE: &str = "bestie-assignment-recovery.json"; + +/// The one durable Bestie designation in a retention scope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BestieAssignment { + pub agent_pubkey: String, +} + +fn ensure_table(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS bestie_assignments ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + agent_pubkey TEXT NOT NULL + );", + ) + .map_err(|error| format!("failed to create bestie assignment table: {error}")) +} + +/// Read the designation for the already-scoped retention database. +pub fn get_assignment(conn: &Connection) -> Result, String> { + ensure_table(conn)?; + conn.query_row( + "SELECT agent_pubkey FROM bestie_assignments WHERE singleton = 1", + [], + |row| { + Ok(BestieAssignment { + agent_pubkey: row.get(0)?, + }) + }, + ) + .optional() + .map_err(|error| format!("failed to read bestie assignment: {error}")) +} + +/// Atomically create or replace the one designation in this scope. +pub fn replace_assignment( + conn: &mut Connection, + agent_pubkey: &str, +) -> Result { + ensure_table(conn)?; + let normalized = agent_pubkey.trim().to_ascii_lowercase(); + let transaction = conn + .transaction() + .map_err(|error| format!("failed to begin bestie assignment transaction: {error}"))?; + transaction + .execute( + "INSERT INTO bestie_assignments (singleton, agent_pubkey) + VALUES (1, ?1) + ON CONFLICT(singleton) DO UPDATE SET agent_pubkey = excluded.agent_pubkey", + params![normalized], + ) + .map_err(|error| format!("failed to replace bestie assignment: {error}"))?; + transaction + .commit() + .map_err(|error| format!("failed to commit bestie assignment: {error}"))?; + get_assignment(conn)?.ok_or_else(|| "bestie assignment was not persisted".to_string()) +} + +/// Clear the designation without changing or stopping the agent. +pub fn clear_assignment(conn: &mut Connection) -> Result<(), String> { + ensure_table(conn)?; + let transaction = conn + .transaction() + .map_err(|error| format!("failed to begin bestie clear transaction: {error}"))?; + transaction + .execute("DELETE FROM bestie_assignments WHERE singleton = 1", []) + .map_err(|error| format!("failed to clear bestie assignment: {error}"))?; + transaction + .commit() + .map_err(|error| format!("failed to commit bestie clear: {error}")) +} + +/// Whether the same agent is still designated after an asynchronous operation. +pub fn assignment_matches(conn: &Connection, agent_pubkey: &str) -> Result { + ensure_table(conn)?; + let normalized = agent_pubkey.trim().to_ascii_lowercase(); + Ok(get_assignment(conn)?.is_some_and(|assignment| assignment.agent_pubkey == normalized)) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ScopedAssignment { + agent_pubkey: String, + path: PathBuf, +} + +#[derive(Debug, Deserialize, Serialize)] +struct AssignmentRecoveryJournal { + assignments: Vec, + version: u8, +} + +fn recovery_journal_path(base_dir: &Path) -> PathBuf { + base_dir.join(RECOVERY_JOURNAL_FILE) +} + +fn persist_recovery_journal( + base_dir: &Path, + assignments: &[ScopedAssignment], +) -> Result<(), String> { + fs::create_dir_all(base_dir) + .map_err(|error| format!("failed to create agents directory: {error}"))?; + let payload = serde_json::to_vec_pretty(&AssignmentRecoveryJournal { + assignments: assignments.to_vec(), + version: 1, + }) + .map_err(|error| format!("failed to serialize Bestie recovery journal: {error}"))?; + atomic_write_json_restricted(&recovery_journal_path(base_dir), &payload) + .map_err(|error| format!("failed to persist Bestie recovery journal: {error}")) +} + +fn load_recovery_journal(base_dir: &Path) -> Result, String> { + let path = recovery_journal_path(base_dir); + let payload = match fs::read(&path) { + Ok(payload) => payload, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read Bestie recovery journal {}: {error}", + path.display() + )) + } + }; + let journal: AssignmentRecoveryJournal = serde_json::from_slice(&payload) + .map_err(|error| format!("failed to parse Bestie recovery journal: {error}"))?; + if journal.version != 1 { + return Err(format!( + "unsupported Bestie recovery journal version {}", + journal.version + )); + } + let retention_dir = base_dir.join("retention"); + for assignment in &journal.assignments { + if assignment.path.parent() != Some(retention_dir.as_path()) + || assignment.path.extension().and_then(|value| value.to_str()) != Some("db") + { + return Err(format!( + "Bestie recovery journal contains an invalid retention path: {}", + assignment.path.display() + )); + } + } + Ok(Some(journal)) +} + +fn remove_recovery_journal(base_dir: &Path) -> Result<(), String> { + let path = recovery_journal_path(base_dir); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove Bestie recovery journal {}: {error}", + path.display() + )), + } +} + +fn retention_db_paths(base_dir: &Path) -> Result, String> { + let retention_dir = base_dir.join("retention"); + let entries = match fs::read_dir(&retention_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "failed to read retention directory {}: {error}", + retention_dir.display() + )) + } + }; + + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "failed to inspect retention directory {}: {error}", + retention_dir.display() + ) + })?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("db") { + continue; + } + paths.push(path); + } + paths.sort(); + Ok(paths) +} + +fn matching_assignments( + base_dir: &Path, + agent_pubkey: &str, +) -> Result, String> { + let normalized = agent_pubkey.trim().to_ascii_lowercase(); + let mut assignments = Vec::new(); + // Read and validate every scope before mutating any of them. A broken later + // database therefore cannot leave an already-cleared prefix behind. + for path in retention_db_paths(base_dir)? { + let conn = open_retention_db(&path)?; + ensure_table(&conn)?; + if assignment_matches(&conn, &normalized)? { + assignments.push(ScopedAssignment { + agent_pubkey: normalized.clone(), + path, + }); + } + } + Ok(assignments) +} + +fn clear_scope(assignment: &ScopedAssignment) -> Result<(), String> { + let conn = open_retention_db(&assignment.path)?; + conn.execute( + "DELETE FROM bestie_assignments WHERE singleton = 1 AND agent_pubkey = ?1", + params![assignment.agent_pubkey], + ) + .map_err(|error| { + format!( + "failed to clear bestie assignment in {}: {error}", + assignment.path.display() + ) + })?; + Ok(()) +} + +fn apply_to_assignments( + assignments: &[ScopedAssignment], + mut apply: impl FnMut(&ScopedAssignment) -> Result<(), String>, + action: &str, +) -> Result<(), String> { + let mut failures = Vec::new(); + for assignment in assignments { + if let Err(error) = apply(assignment) { + failures.push(format!("{}: {error}", assignment.path.display())); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(format!( + "failed to {action} Bestie assignments: {}", + failures.join("; ") + )) + } +} + +fn restore_scope(assignment: &ScopedAssignment) -> Result<(), String> { + let mut conn = open_retention_db(&assignment.path)?; + replace_assignment(&mut conn, &assignment.agent_pubkey).map(|_| ()) +} + +fn restore_assignments(assignments: &[ScopedAssignment]) -> Result<(), String> { + apply_to_assignments(assignments, restore_scope, "restore") +} + +/// Replay a durable interrupted-deletion journal. +/// +/// The managed-agent store is authoritative for which side of the operation +/// committed: a retained agent gets its exact pre-delete assignments restored; +/// an absent agent gets those exact assignments cleared. The journal is only +/// removed after every scope reaches that deterministic state. +pub fn recover_pending_assignment_cleanup( + base_dir: &Path, + agent_exists: impl FnOnce(&str) -> bool, +) -> Result<(), String> { + let Some(journal) = load_recovery_journal(base_dir)? else { + return Ok(()); + }; + let agent_pubkey = journal + .assignments + .first() + .map(|assignment| assignment.agent_pubkey.as_str()) + .ok_or_else(|| "Bestie recovery journal contains no assignments".to_string())?; + if journal + .assignments + .iter() + .any(|assignment| assignment.agent_pubkey != agent_pubkey) + { + return Err("Bestie recovery journal contains multiple agents".to_string()); + } + if agent_exists(agent_pubkey) { + restore_assignments(&journal.assignments)?; + } else { + apply_to_assignments(&journal.assignments, clear_scope, "clear")?; + } + remove_recovery_journal(base_dir) +} + +fn clear_scoped_assignments( + assignments: &[ScopedAssignment], + mut clear: impl FnMut(&ScopedAssignment) -> Result<(), String>, +) -> Result<(), String> { + for assignment in assignments { + clear(assignment)?; + } + Ok(()) +} + +fn rollback_with_journal( + base_dir: &Path, + assignments: &[ScopedAssignment], + error: String, + restore: impl FnMut(&ScopedAssignment) -> Result<(), String>, +) -> Result { + match apply_to_assignments(assignments, restore, "restore") { + Ok(()) => match remove_recovery_journal(base_dir) { + Ok(()) => Err(error), + Err(journal_error) => Err(format!("{error}; {journal_error}")), + }, + Err(restore_error) => Err(format!("{error}; {restore_error}")), + } +} + +fn with_agent_assignments_cleared_using( + base_dir: &Path, + agent_pubkey: &str, + delete: impl FnOnce() -> Result, + clear: impl FnMut(&ScopedAssignment) -> Result<(), String>, + mut restore: impl FnMut(&ScopedAssignment) -> Result<(), String>, +) -> Result { + if load_recovery_journal(base_dir)?.is_some() { + return Err("pending Bestie assignment recovery must complete before deletion".to_string()); + } + let assignments = matching_assignments(base_dir, agent_pubkey)?; + if assignments.is_empty() { + return delete(); + } + persist_recovery_journal(base_dir, &assignments)?; + if let Err(error) = clear_scoped_assignments(&assignments, clear) { + return rollback_with_journal(base_dir, &assignments, error, &mut restore); + } + match delete() { + Ok(value) => { + if let Err(error) = remove_recovery_journal(base_dir) { + // The authoritative managed-agent write already committed. + // Keep the journal as a durable cleanup record; launch/command + // recovery will observe the absent agent, re-clear these exact + // scopes idempotently, and retry journal removal. + eprintln!("buzz-desktop: {error}; cleanup will retry"); + } + Ok(value) + } + Err(error) => rollback_with_journal(base_dir, &assignments, error, &mut restore), + } +} + +/// Run agent deletion work with this agent's community-scoped Bestie +/// assignments temporarily cleared. +/// +/// Call this while holding `managed_agents_store_lock`. Every matching scope is +/// snapshotted before the first write. A partial clear, or any later stop/save +/// failure returned by `delete`, restores the snapshot before the error is +/// propagated. Assignments remain cleared only when `delete` succeeds. +pub fn with_agent_assignments_cleared( + base_dir: &Path, + agent_pubkey: &str, + delete: impl FnOnce() -> Result, +) -> Result { + with_agent_assignments_cleared_using(base_dir, agent_pubkey, delete, clear_scope, restore_scope) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connection() -> Connection { + Connection::open_in_memory().unwrap_or_else(|error| panic!("open test db: {error}")) + } + + #[test] + fn assignment_is_singleton_and_idempotent() { + let mut conn = connection(); + let first = replace_assignment(&mut conn, &"A".repeat(64)) + .unwrap_or_else(|error| panic!("assign first: {error}")); + assert_eq!(first.agent_pubkey, "a".repeat(64)); + + let same = replace_assignment(&mut conn, &"a".repeat(64)) + .unwrap_or_else(|error| panic!("reassign same: {error}")); + assert_eq!(same.agent_pubkey, "a".repeat(64)); + + let replaced = replace_assignment(&mut conn, &"b".repeat(64)) + .unwrap_or_else(|error| panic!("replace: {error}")); + assert_eq!(replaced.agent_pubkey, "b".repeat(64)); + } + + #[test] + fn stale_resolver_is_fenced_after_replace_and_clear_is_idempotent() { + let mut conn = connection(); + replace_assignment(&mut conn, &"a".repeat(64)) + .unwrap_or_else(|error| panic!("assign: {error}")); + replace_assignment(&mut conn, &"b".repeat(64)) + .unwrap_or_else(|error| panic!("replace: {error}")); + assert!(!assignment_matches(&conn, &"a".repeat(64)) + .unwrap_or_else(|error| panic!("check stale assignment: {error}"))); + clear_assignment(&mut conn).unwrap_or_else(|error| panic!("clear: {error}")); + clear_assignment(&mut conn).unwrap_or_else(|error| panic!("clear again: {error}")); + assert_eq!( + get_assignment(&conn).unwrap_or_else(|error| panic!("read: {error}")), + None + ); + } + + #[test] + fn deleting_agent_clears_every_matching_scope_and_preserves_other_assignments() { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let agent = "a".repeat(64); + let other = "b".repeat(64); + let first_path = retention_dir.join("first.db"); + let second_path = retention_dir.join("second.db"); + let third_path = retention_dir.join("third.db"); + replace_assignment( + &mut open_retention_db(&first_path) + .unwrap_or_else(|error| panic!("open first db: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign first scope: {error}")); + replace_assignment( + &mut open_retention_db(&second_path) + .unwrap_or_else(|error| panic!("open second db: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign second scope: {error}")); + replace_assignment( + &mut open_retention_db(&third_path) + .unwrap_or_else(|error| panic!("open third db: {error}")), + &other, + ) + .unwrap_or_else(|error| panic!("assign third scope: {error}")); + + with_agent_assignments_cleared(dir.path(), &agent, || Ok(())) + .unwrap_or_else(|error| panic!("clear agent assignments: {error}")); + assert_eq!( + get_assignment( + &open_retention_db(&first_path) + .unwrap_or_else(|error| panic!("reopen first db: {error}")) + ) + .unwrap_or_else(|error| panic!("read first scope: {error}")), + None + ); + assert_eq!( + get_assignment( + &open_retention_db(&third_path) + .unwrap_or_else(|error| panic!("reopen third db: {error}")) + ) + .unwrap_or_else(|error| panic!("read third scope: {error}")) + .map(|assignment| assignment.agent_pubkey), + Some(other) + ); + } + + #[test] + fn later_scope_clear_failure_restores_the_already_cleared_prefix() { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let agent = "a".repeat(64); + for name in ["first.db", "second.db"] { + replace_assignment( + &mut open_retention_db(&retention_dir.join(name)) + .unwrap_or_else(|error| panic!("open {name}: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign {name}: {error}")); + } + + let result = with_agent_assignments_cleared_using( + dir.path(), + &agent, + || Ok(()), + |assignment| { + if assignment.path.ends_with("second.db") { + Err("injected later retention DB failure".to_string()) + } else { + clear_scope(assignment) + } + }, + restore_scope, + ); + + assert!(result.is_err()); + for name in ["first.db", "second.db"] { + let conn = open_retention_db(&retention_dir.join(name)) + .unwrap_or_else(|error| panic!("reopen {name}: {error}")); + assert!(assignment_matches(&conn, &agent) + .unwrap_or_else(|error| panic!("read {name}: {error}"))); + } + } + + fn assert_later_deletion_failure_restores_assignment(failure: &str) { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let path = retention_dir.join("owner.db"); + let agent = "a".repeat(64); + replace_assignment( + &mut open_retention_db(&path) + .unwrap_or_else(|error| panic!("open assignment db: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("assign agent: {error}")); + + let result = with_agent_assignments_cleared(dir.path(), &agent, || { + Err::<(), _>(failure.to_string()) + }); + + assert_eq!(result, Err(failure.to_string())); + let conn = open_retention_db(&path) + .unwrap_or_else(|error| panic!("reopen assignment db: {error}")); + assert!(assignment_matches(&conn, &agent) + .unwrap_or_else(|error| panic!("read restored assignment: {error}"))); + } + + #[test] + fn stop_failure_after_cleanup_restores_assignment() { + assert_later_deletion_failure_restores_assignment("injected stop failure"); + } + + #[test] + fn save_failure_after_cleanup_restores_assignment() { + assert_later_deletion_failure_restores_assignment("injected save failure"); + } + + #[test] + fn failed_rollback_leaves_a_durable_journal_that_repairs_on_restart() { + let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}")); + let retention_dir = dir.path().join("retention"); + fs::create_dir_all(&retention_dir) + .unwrap_or_else(|error| panic!("create retention dir: {error}")); + let agent = "a".repeat(64); + let first_path = retention_dir.join("first.db"); + let second_path = retention_dir.join("second.db"); + for path in [&first_path, &second_path] { + replace_assignment( + &mut open_retention_db(path) + .unwrap_or_else(|error| panic!("open {}: {error}", path.display())), + &agent, + ) + .unwrap_or_else(|error| panic!("assign {}: {error}", path.display())); + } + + let result = with_agent_assignments_cleared_using( + dir.path(), + &agent, + || Err::<(), _>("injected managed-agent save failure".to_string()), + clear_scope, + |assignment| { + if assignment.path == second_path { + Err("injected restore failure".to_string()) + } else { + restore_scope(assignment) + } + }, + ); + + assert!(result + .as_ref() + .is_err_and(|error| error.contains("injected restore failure"))); + assert!(recovery_journal_path(dir.path()).exists()); + assert!(!assignment_matches( + &open_retention_db(&second_path) + .unwrap_or_else(|error| panic!("reopen second scope: {error}")), + &agent, + ) + .unwrap_or_else(|error| panic!("read torn scope: {error}"))); + + recover_pending_assignment_cleanup(dir.path(), |pubkey| pubkey == agent) + .unwrap_or_else(|error| panic!("replay durable recovery: {error}")); + + for path in [&first_path, &second_path] { + assert!(assignment_matches( + &open_retention_db(path) + .unwrap_or_else(|error| panic!("reopen {}: {error}", path.display())), + &agent, + ) + .unwrap_or_else(|error| panic!("read repaired {}: {error}", path.display()))); + } + assert!(!recovery_journal_path(dir.path()).exists()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs index 647ea56209..0871544dbc 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs @@ -4,15 +4,10 @@ //! local Claude Code agents. `BUZZ_ACP_MODEL` is removed from the spawned //! env so the harness never sees two model authorities simultaneously. //! -//! B5 contract: `BUZZ_ACP_EFFORT_LEVEL` is the canonical persisted startup -//! effort authority for all local agents. Written after `descriptor.env` so -//! user-supplied entries cannot shadow a persisted canonical value. - -/// The spawn-time env var carrying startup effort. Shared by the spawn -/// application ([`apply_effort_env`]) and the snapshot projection -/// (`spawn_snapshot::effective_effort`) so the value the harness receives and -/// the value the restart badge compares are named from one place. -pub const EFFORT_LEVEL_ENV_VAR: &str = "BUZZ_ACP_EFFORT_LEVEL"; +//! Startup effort is no longer applied here: the harness-agnostic effort +//! projection (`config_bridge::effort`) runs inside the descriptor resolver, so +//! `descriptor.env` already carries exactly one effort key. See that module for +//! the single-authority contract, including the ACP-startup key constant. /// Apply the A1 model authority: inject `ANTHROPIC_MODEL` from `effective_model` /// (or remove it if `None`) and strip `BUZZ_ACP_MODEL` from the spawned env. @@ -33,21 +28,6 @@ pub fn apply_claude_model_env(command: &mut std::process::Command, effective_mod } } -/// Apply the B5 effort authority: inject `BUZZ_ACP_EFFORT_LEVEL` from -/// `effort_level` (or leave it untouched if `None`). -/// -/// Must be called after `descriptor.env` is written so the canonical persisted -/// value wins over any user-supplied `BUZZ_ACP_EFFORT_LEVEL` entry. When -/// `effort_level` is `None` there is no canonical value to assert; the command -/// env is left untouched so a user-supplied value from `descriptor.env` -/// legitimately seeds startup effort. -pub fn apply_effort_env(command: &mut std::process::Command, effort_level: Option<&str>) { - if let Some(e) = effort_level { - command.env(EFFORT_LEVEL_ENV_VAR, e); - } - // None: no canonical value — leave whatever descriptor.env wrote intact. -} - #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs index f6f0f90cb2..0e596bc72b 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs @@ -1,4 +1,4 @@ -use super::{apply_claude_model_env, apply_effort_env}; +use super::apply_claude_model_env; /// A1: BUZZ_ACP_MODEL must NOT be present in the spawned-child env after /// `apply_claude_model_env`, even if it was set before (dual-authority defect). @@ -54,74 +54,10 @@ fn a1_anthropic_model_removed_when_no_effective_model() { ); } -// ── B5 effort-authority contract tests ────────────────────────────────────── +// ── B5 effort-authority contract ───────────────────────────────────────────── // -// These tests verify that `apply_effort_env`, called after `descriptor.env`, -// makes the canonical persisted effort win over any user-supplied value. - -/// B5 (local): canonical effort wins when user env supplies a conflicting value. -/// Simulates the defect scenario: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low, -/// then apply_effort_env is called with the canonical "high". The canonical value -/// must be what survives in the spawned-child env. -#[test] -fn b5_canonical_effort_wins_over_user_env_collision() { - let mut cmd = std::process::Command::new("true"); - // Simulate descriptor.env writing a user-supplied value (the pre-fix - // ordering: effort written before the loop, then loop overwrote it, or - // equivalently: effort written post-loop but with user value also post-loop). - cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); - - // Post-loop canonical application — the fix. - apply_effort_env(&mut cmd, Some("high")); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "high", - "canonical effort must win over the user-supplied 'low' — B5 authority ordering" - ); -} - -/// B5 (local): when no canonical effort is persisted (effort_level is None), -/// user env passthrough is preserved — the descriptor.env entry seeds startup effort. -/// Simulates: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low (already in command), -/// then apply_effort_env(None) is called — user value must survive. -#[test] -fn b5_user_effort_env_survives_when_no_canonical_value() { - let mut cmd = std::process::Command::new("true"); - // Simulate descriptor.env loop having written a user-supplied value first. - cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); - - // No canonical value — apply_effort_env(None) is a no-op so the user - // value already written by the descriptor.env loop survives intact. - apply_effort_env(&mut cmd, None); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "low", - "user-supplied effort must survive when no canonical value is persisted" - ); -} - -/// B5 (local): canonical effort is present in the spawned env even when user -/// env did NOT supply a conflicting value (basic injection contract). -#[test] -fn b5_canonical_effort_injected_when_no_user_collision() { - let mut cmd = std::process::Command::new("true"); - // No user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env. - apply_effort_env(&mut cmd, Some("medium")); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "medium", - "canonical effort must be injected when no collision" - ); -} +// Startup-effort application moved out of this module into the single +// harness-agnostic projection (`config_bridge::effort`). Its authority, +// collision, and single-key contract is exercised by +// `config_bridge::effort::tests`; there is no longer a Claude-local effort +// helper to test here. diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs new file mode 100644 index 0000000000..e06fe06216 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -0,0 +1,506 @@ +//! The single harness-agnostic effort authority (plan-of-record, PR #4625). +//! +//! ## One projection, one destination key, one snapshot leaf +//! +//! [`effort_launch_projection`] resolves the effective startup effort a spawn +//! would apply, over the canonical persisted column (`record.effort_level`) AND +//! the sanitized per-tier env inputs, in the CLEAR authority order: +//! +//! ```text +//! record native(valid) > canonical column(valid) > record legacy(valid) +//! > persona(native, then legacy) > global(native) > definition(native) +//! > baked(native) +//! ``` +//! +//! (The reader adds the live-ACP tier between column and persona and the config +//! file tier at the bottom; the launch projection has neither — a spawn reads +//! neither a running session nor the on-disk harness file.) +//! +//! The **tier-reading** native key is the runtime's real `thinking_env_var` +//! (`None` for Claude/Codex — those have no native key, so the column is the +//! sole authority and a user-supplied `BUZZ_ACP_EFFORT_LEVEL` is transport, not +//! a tier). The **emission** key ([`EffortLaunch::key`]) is +//! `thinking_env_var.unwrap_or(BUZZ_ACP_EFFORT_LEVEL)`: Goose emits +//! `GOOSE_THINKING_EFFORT`, buzz-agent emits `BUZZ_AGENT_THINKING_EFFORT`, +//! Claude/Codex/keyless-ACP and any unknown/custom runtime emit the retained +//! ACP-startup sentinel `BUZZ_ACP_EFFORT_LEVEL`. +//! +//! [`EffortLaunch::suppress`] lists every known native/legacy effort key plus +//! the sentinel; every consumer strips them all first, then emits at most the +//! one `key`. This is what guarantees a launched process, a remote payload, and +//! a restart snapshot can never carry two effort authorities. + +use std::collections::BTreeMap; + +use super::LEGACY_THINKING_EFFORT_KEY; +use crate::managed_agents::custom_harnesses::HarnessDefinition; +use crate::managed_agents::discovery::{EffortNormalization, KnownAcpRuntime}; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + +/// The retained ACP-startup transport key. Claude, Codex, keyless ACP adapters, +/// and any unknown/custom runtime route the effective effort through this key +/// (the harness reads it into `PoolStartup.startup_effort`). It is *transport*, +/// never a value-authority tier: a user-supplied entry is suppressed and +/// overwritten by the projected effective value. +pub(crate) const ACP_STARTUP_EFFORT_KEY: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +/// The resolved launch effort for one runtime: the single fact every spawn +/// path (local, remote, snapshot) consumes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EffortLaunch { + /// The final effective effort value, normalized for contract runtimes and + /// raw for contract-less ones, resolved over ALL tiers (column + env). + /// `None` when no tier supplies a value the destination can express. + pub value: Option, + /// The destination env key the value is emitted under. + pub key: &'static str, + /// Every effort key to strip from the launch env before emitting `key`. + /// Always includes the sentinel and all known native/legacy effort keys, so + /// no foreign or transport effort key can shadow the projected authority. + pub suppress: Vec<&'static str>, + /// When no tier resolved a `value`, preserve a value the launch env already + /// carries under `key` (collapsing every case variant to the canonical + /// spelling). Set only for unknown/custom runtimes, where the ACP sentinel + /// is user pass-through transport that must survive a spawn — not a foreign + /// key to drop. Known runtimes leave it `false`: a bare destination-key + /// value with no resolved authority is invalid/foreign and is dropped. + pub preserve_passthrough: bool, +} + +impl EffortLaunch { + /// Apply the projection to a launch env map: strip every `suppress` key, + /// then emit `key = value` when a value is present. After this call the map + /// holds at most one effort key (`key`), carrying the effective value. + /// + /// Suppression is ASCII-case-insensitive: Windows `Command` case-folds env + /// names, so a hand-set `goose_thinking_effort` would otherwise evade an + /// exact-case strip and shadow the projected authority. + /// + /// When `preserve_passthrough` is set and no tier resolved a value, a value + /// already present under `key` (in any case) is carried forward and + /// re-emitted canonically. Multiple case spellings can survive the + /// case-sensitive layer merge (e.g. a lower-tier `BUZZ_ACP_EFFORT_LEVEL` + /// plus a higher-tier `buzz_acp_effort_level`); the carry selects the LAST + /// case-insensitive match in `BTreeMap` iteration order, which is exactly + /// the value Rust's Windows `Command` writer produces — it sets each spelling + /// in iteration order into a case-folded env map, so the last set wins. This + /// keeps an unknown/custom runtime's hand-set sentinel alive, preserves the + /// value the child would actually receive, and guarantees one canonical + /// spelling downstream. + pub(crate) fn apply(&self, env: &mut BTreeMap) { + let carried = (self.value.is_none() && self.preserve_passthrough) + .then(|| { + env.iter() + .rev() + .find(|(k, _)| k.eq_ignore_ascii_case(self.key)) + .map(|(_, v)| v.clone()) + }) + .flatten(); + env.retain(|k, _| { + !self + .suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); + if let Some(v) = self.value.as_ref().or(carried.as_ref()) { + env.insert(self.key.to_string(), v.clone()); + } + } +} + +/// Look up `key` in `map` case-insensitively (ASCII), selecting the LAST +/// case-insensitive match in `BTreeMap` iteration order. Effort key resolution +/// must match Windows `Command` env semantics: `Command` writes each spelling +/// in iteration order into a case-folded env map, so the last-set spelling wins +/// and is the value the child actually receives. Preferring an exact match +/// instead would pick a different case variant than the child gets — e.g. +/// `GOOSE_THINKING_EFFORT=low` plus `goose_thinking_effort=high` would resolve +/// to `low` while the child runs `high`. This mirrors `EffortLaunch::apply`'s +/// `.rev().find` carry so the tier reader, the passthrough carry, and the child +/// all agree on one value. +pub(crate) fn get_ci<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a String> { + map.iter() + .rev() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v) +} + +/// Resolve the single harness-agnostic effort authority and apply it to a fully +/// layered launch `env`: strip every known/legacy/transport effort key, then +/// emit exactly the one destination key holding the effective value. Called by +/// the descriptor resolver AFTER the full layer stack, so the launch env, the +/// remote deploy payload, and the restart snapshot all carry one effort key and +/// one value — no double authority, no foreign key, no launch/badge disagreement. +#[allow(clippy::too_many_arguments)] +pub(crate) fn apply_launch_effort( + env: &mut BTreeMap, + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) { + effort_launch_projection( + record, + runtime, + personas, + record.persona_id.as_deref(), + global_env, + harness_def, + baked_env, + ) + .apply(env); +} + +/// Resolve one effort tier's value, applying within-tier legacy aliasing and +/// normalization. Returns the canonical (or raw, contract-less) value, or +/// `None` when no usable candidate exists. +/// +/// Lookup (per tier, independent of other tiers): +/// 1. Native key — normalized; invalid → skip as absent. +/// 2. Legacy key (`BUZZ_AGENT_THINKING_EFFORT`) — only when the native key +/// differs from it AND `allow_legacy_alias` is set AND the value +/// normalizes. Invalid legacy is skipped so the next tier can supply one. +pub(crate) fn effort_tier_alias( + map: &BTreeMap, + native_key: &str, + norm: impl Fn(&str) -> Option, + allow_legacy_alias: bool, +) -> Option { + if let Some(raw) = get_ci(map, native_key) { + if let Some(canonical) = norm(raw) { + return Some(canonical); + } + } + if allow_legacy_alias && native_key != LEGACY_THINKING_EFFORT_KEY { + if let Some(raw) = get_ci(map, LEGACY_THINKING_EFFORT_KEY) { + if let Some(canonical) = norm(raw) { + return Some(canonical); + } + } + } + None +} + +/// Normalize/validate an effort candidate for a runtime's destination +/// vocabulary. The single value gate shared by the launch projection and the +/// reader, so the panel and the next spawn never disagree on a value's validity. +/// +/// - `contract` present (Goose): canonicalize through the alias table; invalid +/// → `None` (skip as absent). +/// - `contract` absent but `accepted` present (buzz-agent): validation-only — +/// accept a value case-insensitively iff the destination parser would +/// (`parse_thinking_effort`), emit it lowercased; a foreign canonical (e.g. +/// Goose `off`) is rejected so it is never emitted as +/// `BUZZ_AGENT_THINKING_EFFORT=off`, which crashes the child at config init. +/// - both absent (Claude/Codex, unknown/custom): raw passthrough — the value +/// rides `BUZZ_ACP_EFFORT_LEVEL` to an adapter that accepts any string. +pub(crate) fn normalize_effort( + contract: Option<&EffortNormalization>, + accepted: Option<&[&str]>, + raw: &str, +) -> Option { + match contract { + Some(c) => c.normalize_str(raw), + None => match accepted { + Some(values) => { + let lower = raw.trim().to_ascii_lowercase(); + values.iter().any(|v| *v == lower).then_some(lower) + } + None => Some(raw.to_string()), + }, + } +} + +/// The destination env key the effective effort is emitted under for `runtime`: +/// the runtime's native `thinking_env_var`, else the ACP-startup sentinel +/// (Claude, Codex, keyless ACP adapters, and unknown/custom runtimes). +pub(crate) fn effort_dest_key(runtime: Option<&KnownAcpRuntime>) -> &'static str { + runtime + .and_then(|r| r.thinking_env_var) + .unwrap_or(ACP_STARTUP_EFFORT_KEY) +} + +/// Every effort key to strip before emitting the single destination key: all +/// known native effort keys, the legacy alias, and the ACP-startup sentinel. +/// Stripping the full set guarantees no foreign or transport effort key can +/// shadow the projected authority. +pub(crate) fn effort_suppress_keys() -> Vec<&'static str> { + let mut keys: Vec<&'static str> = super::all_known_effort_keys().collect(); + if !keys.contains(&ACP_STARTUP_EFFORT_KEY) { + keys.push(ACP_STARTUP_EFFORT_KEY); + } + if !keys.contains(&LEGACY_THINKING_EFFORT_KEY) { + keys.push(LEGACY_THINKING_EFFORT_KEY); + } + keys +} + +/// Strip every known effort key from a [`std::process::Command`] before the +/// descriptor overlay is written. +/// +/// Only used in tests to verify tombstone assertions on individual keys. +/// Production stripping runs inside `apply_effort_launch_to_command` +/// (the loop over `launch.suppress`) which is exercised by the +/// production-sequence tests. +#[cfg(test)] +pub(crate) fn strip_effort_keys_from_command(cmd: &mut std::process::Command) { + for key in effort_suppress_keys() { + cmd.env_remove(key); + // Belt-and-suspenders for Unix inherited env with non-canonical casing + // (e.g. a shell export of `goose_thinking_effort`). Our own cmd.env() + // calls always use UPPER_SNAKE_CASE; only ambient inherited keys can + // arrive in non-standard case on Unix. + let lower = key.to_ascii_lowercase(); + if lower != key { + cmd.env_remove(&lower); + } + } +} + +/// Strip effort keys and emit the projected effort value to a +/// [`std::process::Command`]. +/// +/// This is the production command-boundary seam: call after +/// `build_buzz_agent_provider_defaults` (which writes raw baked env) and +/// before the `descriptor.env` loop (which overlays the projected key). +/// Extracting both steps into one call lets tests exercise the full +/// baked-write → strip → emit sequence and inspect the child's effective +/// environment, making the test fail if either step is removed or misordered +/// in production. +/// +/// Strip policy follows `launch.suppress`: for known runtimes that is the full +/// effort vocabulary; for unknown/custom runtimes it is only the ACP sentinel, +/// leaving foreign effort keys (e.g. a wrapper's own `GOOSE_THINKING_EFFORT`) +/// untouched. Each key is stripped in canonical and lowercase form so ambient +/// inherited env with non-canonical casing is swept on Unix. +/// +/// When `launch.preserve_passthrough` is set and `launch.value` is `None` +/// (unknown runtime, no authoritative column), the suppress set is skipped +/// entirely: the inherited process env carries the user's hand-set sentinel, +/// and stripping it here without a re-emit would silently drop it. Known +/// runtimes always have a resolved `value` or do not set `preserve_passthrough`. +pub(crate) fn apply_effort_launch_to_command( + cmd: &mut std::process::Command, + launch: &EffortLaunch, +) { + // For unknown/custom runtimes with no resolved value the suppress set is + // only the ACP sentinel, and stripping it without re-emitting would destroy + // the user's ambient pass-through config. Skip the strip entirely and let + // the inherited env carry it through unchanged. + // MUTATION: removing this guard strips the sentinel and breaks + // `production_sequence_custom_inherited_acp_sentinel_survives`. + if launch.preserve_passthrough && launch.value.is_none() { + return; + } + for key in &launch.suppress { + cmd.env_remove(key); + let lower = key.to_ascii_lowercase(); + if lower.as_str() != *key { + cmd.env_remove(&lower); + } + } + if let Some(ref value) = launch.value { + cmd.env(launch.key, value); + } +} + +/// The effort keys the restart snapshot must strip from its captured launch env +/// so effort keeps exactly ONE representation (`effort_level`), mirroring what +/// [`effort_launch_projection`] actually suppressed for `runtime`: +/// +/// - **known runtime** — the full suppress set. The projection already swept +/// every effort key to the single destination key, so this removes only that +/// destination key (a no-op on the already-swept siblings). +/// - **unknown/custom runtime** — only the ACP-startup sentinel. The projection +/// suppresses just the sentinel here (reconciling every case variant to the +/// canonical spelling — external review, Carl P2), leaving every other +/// effort-looking key (e.g. a hand-rolled `GOOSE_THINKING_EFFORT`) untouched +/// as ordinary env. Those must remain in `env` so an edit to them diffs the +/// snapshot normally; only the sentinel — the key the projection emits and +/// `effective_effort` reads into `effort_level` — is removed. +pub(crate) fn snapshot_suppress_keys(runtime: Option<&KnownAcpRuntime>) -> Vec<&'static str> { + if runtime.is_some() { + effort_suppress_keys() + } else { + vec![effort_dest_key(runtime)] + } +} + +/// Build the single effective-effort projection for a launch. +/// +/// `global_env`, `persona_id`+`personas`, `harness_def`, and `baked_env` supply +/// the same per-tier inputs the layered spawn env is built from; the projection +/// re-reads them so an invalid high-tier value skips as absent and a lower tier +/// can win (which a merged last-wins env map cannot express). +pub(crate) fn effort_launch_projection( + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) -> EffortLaunch { + let key = effort_dest_key(runtime); + + // Suppress the full effort vocabulary for KNOWN runtimes. For an + // unknown/custom runtime (external review #2) we keep every foreign + // effort-looking key as pass-through — a hand-rolled `GOOSE_THINKING_EFFORT` + // on a custom Goose wrapper must reach the child untouched — EXCEPT our own + // ACP-startup sentinel, which we always reconcile to a single canonical + // spelling (external review, Carl P2): the projection emits the sentinel, so + // a user-set case variant (e.g. `buzz_acp_effort_level`) is never intentional + // config, and leaving one to shadow the emitted `BUZZ_ACP_EFFORT_LEVEL` on + // Windows (where `Command` case-folds env names) would hand the child a + // different value than the snapshot reads. Stripping the sentinel here and + // re-emitting canonically guarantees at most ONE sentinel spelling downstream, + // so the child, the restart snapshot, and the badge cannot disagree on case. + let suppress = if runtime.is_some() { + effort_suppress_keys() + } else { + vec![ACP_STARTUP_EFFORT_KEY] + }; + // When no tier resolves a value, an unknown runtime still preserves a + // hand-set sentinel the user routed to the child (the retained pass-through + // from external review #2) — carried forward and re-emitted canonically by + // `apply`. Known runtimes never preserve a bare dest-key value: it is either + // the projection's own emission or a foreign key, both handled by `value`. + let preserve_passthrough = runtime.is_none(); + + // Value gate: Goose canonicalizes through its alias contract; buzz-agent + // validates against its accepted set (invalid → skip, so a foreign + // canonical like Goose `off` is never emitted where the destination parser + // rejects it); Claude/Codex and unknown/custom pass raw over the sentinel. + let contract = runtime.and_then(|r| r.effort_normalization); + let accepted = runtime.and_then(|r| r.effort_accepted_values); + let norm = |raw: &str| -> Option { normalize_effort(contract, accepted, raw) }; + + // Tier-reading native key: the runtime's REAL native key. `None` (Claude, + // Codex, unknown/custom) means there are no env-tier authorities — the + // sentinel in user env is transport only — so the column is the sole source. + let native_key = runtime.and_then(|r| r.thinking_env_var); + + let value = resolve_effective_effort( + record, + native_key, + &norm, + personas, + persona_id, + global_env, + harness_def, + baked_env, + ); + + EffortLaunch { + value, + key, + suppress, + preserve_passthrough, + } +} + +/// Resolve the effective effort value in CLEAR authority order (launch tiers). +#[allow(clippy::too_many_arguments)] +fn resolve_effective_effort( + record: &ManagedAgentRecord, + native_key: Option<&str>, + norm: &impl Fn(&str) -> Option, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) -> Option { + use crate::managed_agents::env_vars::{is_reserved_env_key, live_persona_env, merged_user_env}; + + // Sanitize env tiers exactly as the layered spawn env does (reserved/ + // malformed/NUL filtering), so the resolved authority matches what launches. + let record_env = merged_user_env(&BTreeMap::new(), &record.env_vars); + + // 1. record native — only for runtimes with a real native key. + if let Some(nk) = native_key { + if let Some(raw) = get_ci(&record_env, nk) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + } + // 2. canonical column — normalized (raw passthrough for contract-less). + if let Some(raw) = record.effort_level.as_deref() { + if let Some(v) = norm(raw) { + return Some(v); + } + } + // 3. record legacy alias — only when the native key differs from it. + if let Some(nk) = native_key { + if nk != LEGACY_THINKING_EFFORT_KEY { + if let Some(raw) = get_ci(&record_env, LEGACY_THINKING_EFFORT_KEY) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + } + } + // Env tiers below require a native key to read. + let nk = native_key?; + + // 4. persona (native, then legacy) — sanitized like the layered spawn env. + let persona_env = merged_user_env(&BTreeMap::new(), &live_persona_env(personas, persona_id)); + if let Some(v) = effort_tier_alias(&persona_env, nk, norm, true) { + return Some(v); + } + // 5. global (native only). + let global = merged_user_env(&BTreeMap::new(), global_env); + if let Some(v) = effort_tier_alias(&global, nk, norm, false) { + return Some(v); + } + // 6. definition (native only) — author-controlled; reserved keys stripped. + if let Some(def) = harness_def { + let def_env: BTreeMap = def + .env + .iter() + .filter(|(k, _)| !is_reserved_env_key(k)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if let Some(v) = effort_tier_alias(&def_env, nk, norm, false) { + return Some(v); + } + } + // 7. baked build floor (native only). + if let Some(raw) = get_ci(baked_env, nk) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + None +} + +/// Combined spawn seam: baked-env write + effort strip + emit. +/// +/// Called by `apply_effort_to_spawn_command` in `runtime.rs` (production path) +/// and by `effort_cmd_tests` (test seam). Deleting `build_buzz_agent_provider_defaults` +/// or `apply_effort_launch_to_command` inside turns the production-sequence tests RED. +/// Deleting the outer `apply_effort_to_spawn_command` call from `spawn_agent_child` +/// is a compile error — `spawn_with_effort_proof` consumes the returned `EffortApplied` +/// token, so removing the binding leaves `effort` undefined at the spawn site. +pub(crate) fn apply_spawn_effort_env( + cmd: &mut std::process::Command, + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + baked_env: &BTreeMap, +) { + crate::managed_agents::agent_env::build_buzz_agent_provider_defaults(cmd); + let launch = effort_launch_projection( + record, runtime, personas, persona_id, global_env, None, baked_env, + ); + apply_effort_launch_to_command(cmd, &launch); +} + +#[cfg(test)] +#[path = "effort_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs new file mode 100644 index 0000000000..172f373d79 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_cmd_tests.rs @@ -0,0 +1,356 @@ +//! Command-boundary strip and production-sequence seam tests for effort. +//! +//! Split from `effort_tests.rs` to stay within the file-size ratchet. +//! Covers `strip_effort_keys_from_command` tombstone assertions and the +//! child-process spawn sequence via `apply_effort_to_spawn_command`. +//! +//! The production-sequence tests call `apply_effort_to_spawn_command` +//! (`runtime.rs`), the same function `spawn_agent_child` calls. Deleting +//! `apply_spawn_effort_env` from that wrapper turns these tests RED. +//! Deleting the `apply_effort_to_spawn_command` call from `spawn_agent_child` +//! is a compile error: `spawn_with_effort_proof` consumes the returned +//! `EffortApplied` by value, so removing the binding leaves `effort` undefined +//! at the spawn site. + +use std::collections::BTreeMap; + +use super::super::strip_effort_keys_from_command; +use super::*; +use crate::managed_agents::runtime::apply_effort_to_spawn_command; + +// -------------------------------------------------------------------------- +// Command-boundary strip (P1: inherited + baked collision) +// -------------------------------------------------------------------------- + +/// ACP sentinel baked/inherited collision: registered for removal after strip. +#[test] +fn strip_removes_baked_acp_sentinel_collision() { + let mut cmd = std::process::Command::new("echo"); + cmd.env(ACP_KEY, "high"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == ACP_KEY && value.is_none()); + assert!( + removed, + "ACP sentinel must be registered for removal after strip" + ); +} + +/// Baked `GOOSE_THINKING_EFFORT` collision: stripped before descriptor overlay. +#[test] +fn strip_removes_baked_goose_native_key_collision() { + let mut cmd = std::process::Command::new("echo"); + cmd.env(GOOSE_KEY, "high"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == GOOSE_KEY && value.is_none()); + assert!( + removed, + "GOOSE_THINKING_EFFORT must be registered for removal after strip" + ); +} + +/// Baked `BUZZ_AGENT_THINKING_EFFORT` collision: legacy alias stripped. +#[test] +fn strip_removes_baked_buzz_agent_native_key_collision() { + let mut cmd = std::process::Command::new("echo"); + cmd.env(BUZZ_AGENT_KEY, "medium"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == BUZZ_AGENT_KEY && value.is_none()); + assert!( + removed, + "BUZZ_AGENT_THINKING_EFFORT must be registered for removal after strip" + ); +} + +/// Lowercase inherited key: both canonical and lowercase variants are stripped. +#[test] +fn strip_removes_lowercase_goose_key_inherited_from_shell() { + let lower = GOOSE_KEY.to_ascii_lowercase(); + let mut cmd = std::process::Command::new("echo"); + cmd.env(&lower, "stale"); + strip_effort_keys_from_command(&mut cmd); + let removed = cmd + .get_envs() + .any(|(key, value)| key == lower.as_str() && value.is_none()); + assert!( + removed, + "lowercase GOOSE key must be registered for removal" + ); +} + +/// Custom passthrough: non-suppress-set keys are not removed. +#[test] +fn strip_does_not_remove_unrelated_env_key() { + let mut cmd = std::process::Command::new("echo"); + cmd.env("MY_CUSTOM_EFFORT", "high"); + strip_effort_keys_from_command(&mut cmd); + let value_present = cmd + .get_envs() + .any(|(key, value)| key == "MY_CUSTOM_EFFORT" && value.is_some()); + assert!( + value_present, + "strip must not touch env keys outside the suppress set" + ); +} + +// -------------------------------------------------------------------------- +// Production-sequence seam tests +// -------------------------------------------------------------------------- +// Spawn the child directly so its actual env is the ground truth. +// These call `apply_effort_to_spawn_command` (in `runtime.rs`), the same function +// `spawn_agent_child` calls. Deleting `apply_spawn_effort_env` from that wrapper +// turns these tests RED. The `EffortApplied` sentinel makes the call site in +// `spawn_agent_child` a compile-time requirement. +// Deletion proofs: +// - remove `build_buzz_agent_provider_defaults` inside → baked keys leak; +// - remove `effort_launch_projection` → suppress list is empty, keys leak; +// - remove `apply_effort_launch_to_command` → stale keys remain, assertion fails. +// +// Inherited-state tests seed the parent env via `std::env::set_var` under the +// crate-wide env lock (`crate::managed_agents::lock_env_mutex`). `EnvVarGuard` +// restores the exact prior value (including non-Unicode) in `Drop`, so panics +// do not leak the seeded value into unrelated child-spawn tests. + +/// RAII guard: snapshots a process-env variable and restores the exact prior +/// value (or removes it if it was absent) on `Drop`, even on panic. +/// Uses `OsString` so a pre-existing non-Unicode value is restored exactly +/// rather than being silently lost. +struct EnvVarGuard { + key: String, + prior: Option, +} +impl EnvVarGuard { + fn set(key: &str, value: &str) -> Self { + let prior = std::env::var_os(key); + #[allow(deprecated)] + unsafe { + std::env::set_var(key, value); + } + Self { + key: key.to_string(), + prior, + } + } +} +impl Drop for EnvVarGuard { + fn drop(&mut self) { + #[allow(deprecated)] + unsafe { + match &self.prior { + Some(v) => std::env::set_var(&self.key, v), + None => std::env::remove_var(&self.key), + } + } + } +} + +fn run_env_cmd(cmd: &mut std::process::Command) -> String { + let output = cmd + .output() + .expect("env-dump command must be executable on this host"); + assert!( + output.status.success(), + "env command failed: {:?}", + output.status + ); + String::from_utf8_lossy(&output.stdout).to_string() +} + +/// After projection + strip + emit, the child sees exactly the projected Goose +/// key with no collision. Inherited lowercase key is seeded via EnvVarGuard. +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_goose_inherited_collision_resolved_in_child() { + let lower = GOOSE_KEY.to_ascii_lowercase(); + let _lock = crate::managed_agents::lock_env_mutex(); + let _guard = EnvVarGuard::set(&lower, "inherited-low"); + + let mut cmd = std::process::Command::new("/usr/bin/env"); + cmd.env(GOOSE_KEY, "baked-high"); + cmd.env(BUZZ_AGENT_KEY, "legacy-medium"); + cmd.env("MY_AGENT_CONFIG", "keep-me"); + + let mut r = record(); + r.effort_level = Some("high".into()); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &r, + Some(goose()), + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + + assert!( + child_env.contains(&format!("{GOOSE_KEY}=high")), + "child must receive the projected Goose key; env:\n{child_env}" + ); + assert!( + !child_env.contains(BUZZ_AGENT_KEY), + "legacy buzz-agent key must not reach child; env:\n{child_env}" + ); + assert!( + !child_env.contains(ACP_KEY), + "ACP sentinel must not reach child for Goose; env:\n{child_env}" + ); + assert!( + !child_env.contains(&format!("{lower}=inherited-low")), + "inherited lowercase key must be stripped; env:\n{child_env}" + ); + assert!( + child_env.contains("MY_AGENT_CONFIG=keep-me"), + "unrelated key must survive; env:\n{child_env}" + ); + let effort_key_count = [GOOSE_KEY, BUZZ_AGENT_KEY, ACP_KEY] + .iter() + .filter(|k| child_env.contains(&format!("{k}="))) + .count(); + assert_eq!( + effort_key_count, 1, + "exactly one effort key must reach child; env:\n{child_env}" + ); +} + +/// Windows: OS case-folds env keys, so stripping canonical removes ALL case variants. +#[test] +#[cfg(target_os = "windows")] +fn production_sequence_arbitrary_mixedcase_collision_absent_from_child_windows() { + let mixed = "GoOsE_ThInKiNg_EfFoRt"; + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/c", "set"]); + cmd.env_clear(); + cmd.env(mixed, "stale-mixed"); + let mut r = record(); + r.effort_level = Some("high".into()); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &r, + Some(goose()), + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env + .to_ascii_uppercase() + .contains(&format!("{}=HIGH", GOOSE_KEY.to_ascii_uppercase())), + "canonical effort key must reach the child; env:\n{child_env}" + ); + assert!( + !child_env + .to_ascii_uppercase() + .contains(&format!("{}=STALE-MIXED", mixed.to_ascii_uppercase())), + "mixed-case effort key must not reach the child; env:\n{child_env}" + ); +} + +/// Custom passthrough: non-suppress-set effort keys survive the production sequence. +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_custom_passthrough_survives() { + let mut cmd = std::process::Command::new("/usr/bin/env"); + cmd.env_clear(); + cmd.env("MY_HARNESS_EFFORT", "high"); + cmd.env("MY_UNRELATED_CONFIG", "keep"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env.contains("MY_HARNESS_EFFORT=high"), + "custom key must survive; env:\n{child_env}" + ); + assert!( + child_env.contains("MY_UNRELATED_CONFIG=keep"), + "unrelated key must survive; env:\n{child_env}" + ); +} + +/// Custom-runtime: inherited `GOOSE_THINKING_EFFORT` survives (unknown-runtime +/// suppress set excludes foreign effort keys). +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_custom_inherited_goose_key_survives() { + let _lock = crate::managed_agents::lock_env_mutex(); + let _guard = EnvVarGuard::set(GOOSE_KEY, "inherited-high"); + let mut cmd = std::process::Command::new("/usr/bin/env"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env.contains(&format!("{GOOSE_KEY}=inherited-high")), + "GOOSE key must survive for unknown runtime; env:\n{child_env}" + ); +} + +/// Custom-runtime: inherited ACP sentinel survives as pass-through (no column). +#[test] +#[cfg(not(target_os = "windows"))] +fn production_sequence_custom_inherited_acp_sentinel_survives() { + let _lock = crate::managed_agents::lock_env_mutex(); + let _guard = EnvVarGuard::set(ACP_KEY, "inherited-val"); + let mut cmd = std::process::Command::new("/usr/bin/env"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!( + child_env.contains(&format!("{ACP_KEY}=inherited-val")), + "ACP sentinel must survive for unknown runtime with no column; env:\n{child_env}" + ); +} + +/// Windows: custom-wrapper effort keys survive the production sequence. +#[test] +#[cfg(target_os = "windows")] +fn production_sequence_custom_passthrough_survives() { + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/c", "set"]); + cmd.env_clear(); + cmd.env("MY_HARNESS_EFFORT", "high"); + cmd.env("MY_UNRELATED_CONFIG", "keep"); + let _effort = apply_effort_to_spawn_command( + &mut cmd, + &record(), + None, + &[], + None, + &BTreeMap::new(), + &BTreeMap::new(), + ); + let child_env = run_env_cmd(&mut cmd); + assert!(child_env + .to_ascii_uppercase() + .contains("MY_HARNESS_EFFORT=HIGH")); + assert!(child_env + .to_ascii_uppercase() + .contains("MY_UNRELATED_CONFIG=KEEP")); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs new file mode 100644 index 0000000000..9c4568fceb --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -0,0 +1,701 @@ +//! Parity matrix for the single harness-agnostic effort projection +//! (`effort_launch_projection`, PR #4625). +//! +//! Covers, per runtime: CLEAR authority order; decisive mixed-authority; +//! `value == None` when no tier resolves; single-key emission + suppress; +//! unknown/custom-runtime ACP-sentinel fallback. + +use std::collections::BTreeMap; + +use super::{effort_launch_projection, effort_suppress_keys, EffortLaunch}; +use crate::managed_agents::custom_harnesses::HarnessDefinition; +use crate::managed_agents::discovery::{known_acp_runtime_exact, KnownAcpRuntime}; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + +pub(super) const GOOSE_KEY: &str = "GOOSE_THINKING_EFFORT"; +pub(super) const BUZZ_AGENT_KEY: &str = "BUZZ_AGENT_THINKING_EFFORT"; +pub(super) const ACP_KEY: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +pub(super) fn goose() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("goose").expect("goose runtime in catalog") +} +fn claude() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("claude").expect("claude runtime in catalog") +} +fn buzz_agent() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("buzz-agent").expect("buzz-agent runtime in catalog") +} + +pub(super) fn record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "test".to_string(), + name: "Test Agent".to_string(), + persona_id: None, + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + description: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::types::BackendKind::Local, + backend_agent_id: None, + provider_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + team_catalog_source: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: crate::managed_agents::types::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + effort_level: None, + agent_command_override: None, + persona_source_version: None, + provider: None, + } +} + +fn env(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn persona(id: &str, env_vars: BTreeMap) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "P".to_string(), + avatar_url: None, + description: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + env_vars, + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +fn harness_def(env: BTreeMap) -> HarnessDefinition { + HarnessDefinition { + id: "custom".to_string(), + label: "Custom".to_string(), + command: "custom".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + } +} + +/// Convenience: project with no persona/global/definition/baked tiers. +fn project_record_only( + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, +) -> EffortLaunch { + effort_launch_projection( + record, + runtime, + &[], + None, + &BTreeMap::new(), + None, + &BTreeMap::new(), + ) +} + +// -------------------------------------------------------------------------- +// Destination key + emission strategy per runtime +// -------------------------------------------------------------------------- + +#[test] +fn goose_emits_only_goose_key() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, GOOSE_KEY); +} + +#[test] +fn claude_routes_canonical_through_acp_sentinel() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(claude())); + // Claude has no native key: the column is the sole authority and it emits + // under the retained ACP-startup sentinel. + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +#[test] +fn buzz_agent_passes_raw_contract_less_value_under_native_key() { + let mut r = record(); + // buzz-agent has no static normalization contract: a per-model value that + // Goose would reject (e.g. "minimal") passes through raw. + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!(launch.value.as_deref(), Some("minimal")); + assert_eq!(launch.key, BUZZ_AGENT_KEY); +} + +#[test] +fn unknown_runtime_falls_back_to_acp_sentinel() { + let mut r = record(); + r.effort_level = Some("high".into()); + // No runtime metadata (custom/unknown adapter): preserve main's behavior — + // canonical routes through the raw ACP sentinel path. + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +// -------------------------------------------------------------------------- +// CLEAR authority order + the decisive mixed-authority case +// -------------------------------------------------------------------------- + +#[test] +fn decisive_record_native_outranks_a_different_valid_column() { + // The mixed-authority pin Thufir/Will require: a valid record-native env + // key and a DIFFERENT valid canonical column must resolve to the + // record-native value — reader, local, remote, and snapshot all agree. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "low")]); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "record-native env outranks the canonical column" + ); +} + +#[test] +fn canonical_column_wins_when_no_record_native() { + // No record-native key present: the column is the next tier and wins over + // lower tiers (here, persona). + let mut r = record(); + r.persona_id = Some("p".into()); + r.effort_level = Some("high".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "low")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + +#[test] +fn record_legacy_alias_wins_over_persona_for_goose() { + // Record legacy `BUZZ_AGENT_THINKING_EFFORT` outranks persona for a runtime + // whose native key differs from the legacy key. + let mut r = record(); + r.persona_id = Some("p".into()); + r.env_vars = env(&[(BUZZ_AGENT_KEY, "max")]); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "low")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("max")); +} + +#[test] +fn persona_then_global_then_definition_then_baked_fall_through() { + // With no record tier set, each lower tier wins in order once the ones + // above it are absent. Verify persona > global by presence. + let mut r = record(); + r.persona_id = Some("p".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "high")]))]; + let global = env(&[(GOOSE_KEY, "low")]); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &global, + None, + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("high"), + "persona outranks global" + ); + + // Drop the persona value: global wins. + let personas = vec![persona("p", BTreeMap::new())]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &global, + None, + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "global outranks definition" + ); + + // Drop global too: definition wins. + let def = harness_def(env(&[(GOOSE_KEY, "medium")])); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + Some(&def), + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("medium"), + "definition outranks baked" + ); + + // Drop definition: baked build floor wins. + let baked = env(&[(GOOSE_KEY, "off")]); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &baked, + ); + assert_eq!(launch.value.as_deref(), Some("off")); +} + +// -------------------------------------------------------------------------- +// Normalization + skip-as-absent fall-through +// -------------------------------------------------------------------------- + +#[test] +fn goose_alias_column_xhigh_normalizes_to_max() { + let mut r = record(); + r.effort_level = Some("xhigh".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value.as_deref(), Some("max")); +} + +#[test] +fn invalid_goose_column_skips_and_falls_through_to_persona() { + // "minimal" is invalid for Goose: it skips as absent so the persona tier + // supplies the effective value (nondestructive switch policy relies on this). + let mut r = record(); + r.persona_id = Some("p".into()); + r.effort_level = Some("minimal".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "high")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + +#[test] +fn invalid_goose_value_with_no_lower_tier_is_none() { + let mut r = record(); + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value, None, + "invalid canonical with no fallback → None" + ); +} + +#[test] +fn no_tier_set_is_none() { + let launch = project_record_only(&record(), Some(goose())); + assert_eq!(launch.value, None); +} + +// -------------------------------------------------------------------------- +// Suppression + single-key emission (the double-authority guard) +// -------------------------------------------------------------------------- + +#[test] +fn suppress_covers_all_native_legacy_and_sentinel_keys() { + let keys = effort_suppress_keys(); + assert!(keys.contains(&GOOSE_KEY), "goose native key suppressed"); + assert!( + keys.contains(&BUZZ_AGENT_KEY), + "buzz-agent native + legacy key suppressed" + ); + assert!(keys.contains(&ACP_KEY), "ACP transport sentinel suppressed"); +} + +#[test] +fn apply_strips_every_foreign_effort_key_then_emits_one() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + let mut launch_env = env(&[ + (ACP_KEY, "stale"), + (BUZZ_AGENT_KEY, "stale"), + (GOOSE_KEY, "stale"), + ("UNRELATED", "keep"), + ]); + launch.apply(&mut launch_env); + assert_eq!(launch_env.get(GOOSE_KEY).map(String::as_str), Some("high")); + assert_eq!(launch_env.get(ACP_KEY), None); + assert_eq!(launch_env.get(BUZZ_AGENT_KEY), None); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); + let effort_keys = launch_env + .keys() + .filter(|k| effort_suppress_keys().contains(&k.as_str())) + .count(); + assert_eq!(effort_keys, 1, "exactly one effort key survives"); +} + +#[test] +fn apply_with_no_value_strips_all_effort_keys() { + let launch = project_record_only(&record(), Some(goose())); + assert_eq!(launch.value, None); + let mut launch_env = env(&[(ACP_KEY, "x"), (GOOSE_KEY, "y")]); + launch.apply(&mut launch_env); + assert!( + launch_env + .keys() + .all(|k| !effort_suppress_keys().contains(&k.as_str())), + "no effort key remains when the projection has no value" + ); +} + +#[test] +fn buzz_agent_generic_column_does_not_leak_acp_sentinel() { + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "high")]); + r.effort_level = Some("medium".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!(launch.value.as_deref(), Some("medium")); + assert_eq!(launch.key, BUZZ_AGENT_KEY); + + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY), + None, + "ACP sentinel stripped for buzz-agent" + ); + assert_eq!( + launch_env.get(BUZZ_AGENT_KEY).map(String::as_str), + Some("medium") + ); +} + +// -------------------------------------------------------------------------- +// External review fix #2 — unknown/custom runtimes restore main's pass-through +// -------------------------------------------------------------------------- + +#[test] +fn unknown_runtime_does_not_suppress_user_effort_env() { + // Regression: a custom wrapper with GOOSE_THINKING_EFFORT=high in record env + // must reach the child unchanged. For unknown runtimes `suppress` is exactly + // `[BUZZ_ACP_EFFORT_LEVEL]` — no foreign effort key is stripped. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "high"), ("UNRELATED", "keep")]); + let launch = project_record_only(&r, None); + assert_eq!( + launch.suppress, + vec![ACP_KEY], + "unknown runtime suppresses only its own sentinel, never a foreign key" + ); + + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(GOOSE_KEY).map(String::as_str), + Some("high"), + "custom-wrapper effort key survives an unknown-runtime launch" + ); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); +} + +#[test] +fn unknown_runtime_keeps_user_acp_sentinel_when_no_column() { + // Custom adapter with hand-set sentinel and no column: sentinel carries through. + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + assert!(launch.preserve_passthrough); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "hand-set sentinel survives on an unknown runtime with no column" + ); +} + +#[test] +fn unknown_runtime_collapses_mixed_case_sentinel_to_canonical_when_no_column() { + // Carl P2 (no-column): a hand-set mixed-case sentinel on a custom runtime + // must survive AND be re-emitted under the canonical spelling. Leaving the + // lowercase variant would hand the child a value the snapshot read misses. + let mut r = record(); + r.env_vars = env(&[("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "mixed-case pass-through sentinel re-emitted under canonical key" + ); + assert_eq!( + launch_env.get("buzz_acp_effort_level"), + None, + "mixed-case spelling is collapsed away" + ); +} + +#[test] +fn unknown_runtime_no_column_multi_variant_preserves_windows_effective_value() { + // Pass-3 IMPORTANT (Thufir): both case spellings of the sentinel survive the + // case-sensitive layer merge. Rust `Command` writes in `BTreeMap` iteration + // order into a case-folded env map (last set wins); canonical `B` sorts before + // lowercase `b`, so the lowercase `low` is written last and wins. The carry + // selects the LAST case-insensitive match, matching that. + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "high"), ("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + assert!(launch.preserve_passthrough); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "carry preserves the last-in-iteration-order value the Windows child receives" + ); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + // Mutation: reverting the carry to exact-first `get_ci` selects `high`. +} + +#[test] +fn unknown_runtime_column_wins_over_mixed_case_sentinel() { + // Carl P2 (with-column): canonical column plus mixed-case sentinel. Column + // wins; the projection strips ALL case variants of the sentinel before emit. + let mut r = record(); + r.effort_level = Some("high".into()); + r.env_vars = env(&[("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("high"), + "column wins, emitted under canonical sentinel key" + ); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + // Mutation: empty suppress set leaves `buzz_acp_effort_level=low` in child. +} + +#[test] +fn unknown_runtime_column_still_emits_under_acp_sentinel() { + // The retained compatibility emission: an unknown runtime with a canonical + // column emits it raw under the ACP sentinel (matches the PR-body decision). + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +// -------------------------------------------------------------------------- +// External review fix #3 — destination-vocabulary validation at projection +// -------------------------------------------------------------------------- + +#[test] +fn goose_off_column_skips_for_buzz_agent_destination() { + // Regression: canonical column `off` is valid Goose but NOT a buzz-agent + // effort. Switching a record with effort_level=off to buzz-agent must NOT + // emit BUZZ_AGENT_THINKING_EFFORT=off — parse_thinking_effort rejects it and + // the child exits 2. Invalid → skip as absent → no key emitted. + let mut r = record(); + r.effort_level = Some("off".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!( + launch.value, None, + "foreign canonical `off` skipped for buzz-agent's vocabulary" + ); + + let mut launch_env = BTreeMap::new(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(BUZZ_AGENT_KEY), + None, + "no effort key emitted when the value is outside the destination vocabulary" + ); +} + +#[test] +fn buzz_agent_minimal_column_skips_for_goose_destination() { + // The reverse: `minimal` is a valid buzz-agent effort but invalid Goose, so + // switching to Goose skips it as absent (already covered by normalization, + // pinned here as the symmetric vocabulary case). + let mut r = record(); + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value, None); +} + +#[test] +fn buzz_agent_accepts_its_own_distinct_efforts() { + // buzz-agent keeps xhigh and max distinct (no Goose-style xhigh→max + // collapse): both are valid and pass through unchanged. + for v in ["xhigh", "max", "none", "minimal"] { + let mut r = record(); + r.effort_level = Some(v.into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!( + launch.value.as_deref(), + Some(v), + "buzz-agent accepts `{v}` verbatim (no alias collapse)" + ); + } +} + +// -------------------------------------------------------------------------- +// External review fix #4 — case-insensitive suppression / lookup +// -------------------------------------------------------------------------- + +#[test] +fn mixed_case_native_key_is_read_and_wins() { + // Windows Command case-folds env names, so `goose_thinking_effort` is the + // same variable as the canonical form. The tier reader must find it. + let mut r = record(); + r.env_vars = env(&[("goose_thinking_effort", "low")]); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "mixed-case record-native key is read and outranks the column" + ); +} + +#[test] +fn duplicate_case_native_variants_resolve_to_windows_effective_value() { + // Carl P2 (r8): both case spellings of a known native key in the record env. + // Rust `Command` writes in `BTreeMap` order into a case-folded map; canonical + // `GOOSE_THINKING_EFFORT` sorts before lowercase, so the lowercase `high` is + // written last and wins. `get_ci` must select the LAST match, not exact-case. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "low"), ("goose_thinking_effort", "high")]); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("high"), + "known-runtime native lookup selects the last case variant Windows Command sets" + ); + // Mutation: reverting `get_ci` to exact-first selects `low`. +} + +#[test] +fn apply_strips_mixed_case_effort_keys() { + // A hand-set mixed-case foreign effort key must be swept, not left to + // shadow the projected value once Windows case-folds it at spawn. + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + + let mut launch_env = env(&[ + ("Goose_Thinking_Effort", "stale"), + ("buzz_acp_effort_level", "stale"), + ("UNRELATED", "keep"), + ]); + launch.apply(&mut launch_env); + + // Only the canonical projected key remains; both mixed-case foreign keys + // are gone. + assert_eq!(launch_env.get(GOOSE_KEY).map(String::as_str), Some("high")); + assert_eq!(launch_env.get("Goose_Thinking_Effort"), None); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); +} + +// Command-boundary strip and production-sequence tests are in the sibling module. +#[cfg(test)] +#[path = "effort_cmd_tests.rs"] +mod cmd_tests; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72..9ac2e5bc10 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -1,6 +1,7 @@ mod buzz_agent; mod claude; mod codex; +pub(crate) mod effort; mod goose; pub(crate) mod reader; mod schema_walker; @@ -8,6 +9,25 @@ pub(crate) mod types; pub(crate) use types::*; +/// The legacy effort env key written by pre-migration saves. +/// +/// Harnesses whose native `thinking_env_var` differs from this constant +/// (currently: Goose uses `GOOSE_THINKING_EFFORT`) need the alias resolver in +/// [`effort`] to translate old saves. buzz-agent's native key equals this +/// constant, so no aliasing applies there. +pub(crate) const LEGACY_THINKING_EFFORT_KEY: &str = "BUZZ_AGENT_THINKING_EFFORT"; + +/// Return all known native thinking-effort env keys across all runtimes. +/// +/// Derived from `KNOWN_ACP_RUNTIMES::thinking_env_var` so that adding a new +/// runtime automatically participates in foreign-key suppression without a +/// separate constant to update. +pub(crate) fn all_known_effort_keys() -> impl Iterator { + crate::managed_agents::discovery::KNOWN_ACP_RUNTIMES + .iter() + .filter_map(|rt| rt.thinking_env_var) +} + /// Read the goose harness config file (`~/.config/goose/config.yaml`). /// /// Used by readiness evaluation to silence requirements that are already diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 93827635e9..84eec8db33 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -1,7 +1,10 @@ +use crate::managed_agents::discovery::EffortNormalization; use crate::managed_agents::discovery::KnownAcpRuntime; use crate::managed_agents::types::ManagedAgentRecord; +use super::effort::effort_tier_alias; use super::types::*; +use super::LEGACY_THINKING_EFFORT_KEY; /// Build the full config surface for an agent, merging all tiers. /// @@ -40,6 +43,8 @@ pub(crate) fn read_config_surface( let provider_env_var = runtime_meta.and_then(|m| m.provider_env_var); let provider_locked = runtime_meta.is_some_and(|m| m.provider_locked); let thinking_env_var = runtime_meta.and_then(|m| m.thinking_env_var); + let effort_norm = runtime_meta.and_then(|m| m.effort_normalization); + let effort_accepted = runtime_meta.and_then(|m| m.effort_accepted_values); let supports_acp_native = runtime_meta.is_some_and(|m| m.supports_acp_native_config); let required_fields: &[&str] = runtime_meta .map(|m| m.required_normalized_fields) @@ -93,6 +98,8 @@ pub(crate) fn read_config_surface( &acp_effort, effort_option.map(|o| o.config_id.as_str()), thinking_env_var, + effort_norm, + effort_accepted, is_pre_spawn, tiers, ), @@ -126,7 +133,7 @@ pub(crate) fn read_config_surface( .collect(); // Collect the env var keys already covered by normalized fields. - let normalized_env_keys: Vec<&str> = [ + let mut normalized_env_keys: Vec<&str> = [ model_env_var, provider_env_var, thinking_env_var, @@ -138,10 +145,40 @@ pub(crate) fn read_config_surface( .flatten() .collect(); - // Tier 2a: remaining env vars not covered by normalized fields. + // Hide the legacy effort key from advanced only when it actually wins the + // record tier: native and canonical column are absent/invalid, then legacy + // normalizes. Otherwise `build_thinking_field` represents another winner + // and the legacy key stays editable in Advanced. + let record_legacy_consumed = thinking_env_var + .zip(effort_norm) + .is_some_and(|(native, norm)| { + native != LEGACY_THINKING_EFFORT_KEY + && super::effort::get_ci(&record.env_vars, native) + .and_then(|v| norm.normalize_str(v)) + .is_none() + && record + .effort_level + .as_deref() + .and_then(|v| norm.normalize_str(v)) + .is_none() + && super::effort::get_ci(&record.env_vars, LEGACY_THINKING_EFFORT_KEY) + .and_then(|v| norm.normalize_str(v)) + .is_some() + }); + if record_legacy_consumed { + normalized_env_keys.push(LEGACY_THINKING_EFFORT_KEY); + } + + // Tier 2a: remaining env vars not covered by normalized fields. Matching is + // ASCII-case-insensitive so a mixed-case managed key (e.g. Windows + // `goose_thinking_effort`) the launch projection already consumed is hidden + // from Advanced rather than shown as a spurious editable extra. let mut advanced = advanced; for (k, v) in &record.env_vars { - if normalized_env_keys.contains(&k.as_str()) { + if normalized_env_keys + .iter() + .any(|nk| nk.eq_ignore_ascii_case(k)) + { continue; } if file_config.extra.contains_key(k) { @@ -542,40 +579,92 @@ fn build_thinking_field( acp_effort: &Option, effort_config_id: Option<&str>, thinking_env_var: Option<&str>, + effort_norm: Option<&'static EffortNormalization>, + effort_accepted: Option<&'static [&'static str]>, is_pre_spawn: bool, tiers: &InheritedConfigTiers, ) -> Option { - // Tier ordering: - // record env > record.effort_level (canonical Buzz-persisted) > ACP > - // persona env > global env > definition env > config file. + // Tier ordering (mirrors the launch projection in `config_bridge::effort`, + // plus the two reader-only tiers the projection has no input for — live ACP + // and the on-disk config file): + // record native > canonical column > record legacy > ACP > + // persona > global > definition > config file. // - // `record.effort_level` is the B5 canonical value: the effort a spawn will - // actually apply at next session start (via `apply_effort_env`). Sitting it - // above ACP means the panel shows the *configured* value the agent will - // launch with rather than a stale live-session reading — the record can't - // be masked by, nor mask, the running value silently. - let [rec_env, pers_env, glob_env, def_env] = thinking_env_var - .map(|k| { - env_candidates( - k, - &record.env_vars, - &tiers.persona_env, - &tiers.global_env, - &tiers.definition_env, - ) - }) - .unwrap_or([None, None, None, None]); + // Every candidate is normalized through the runtime's declared contract + // (`effort_norm`) before validity, precedence, override tracking, and the B + // same-value collapse — the SAME normalizer the launch projection applies — + // so the panel and the next spawn resolve one effective value AND authority. + // For contract runtimes an invalid value (e.g. Goose `minimal`) normalizes + // to `None` and is skipped as absent so a lower tier can win; aliases + // (`none`→`off`, `xhigh`→`max`, case-fold) canonicalize. Contract-less + // runtimes (buzz-agent, Claude/Codex column) pass raw. + let norm = |raw: &str| -> Option { + super::effort::normalize_effort(effort_norm, effort_accepted, raw) + }; - let canonical_effort = record.effort_level.as_deref(); + // Record tiers, split exactly as the projection resolves them: native env + // strictly above the canonical column, legacy env strictly below it. + let rec_native = thinking_env_var + .and_then(|k| super::effort::get_ci(&record.env_vars, k)) + .and_then(|v| norm(v)); + let column = record.effort_level.as_deref().and_then(&norm); + let rec_legacy = thinking_env_var + .filter(|k| *k != LEGACY_THINKING_EFFORT_KEY) + .and_then(|_| super::effort::get_ci(&record.env_vars, LEGACY_THINKING_EFFORT_KEY)) + .and_then(|v| norm(v)); + + // Inherited env tiers: persona resolves native-then-legacy; global and + // definition are native-only (legacy alias excluded), matching the launch + // projection's per-tier alias policy. + let pers = thinking_env_var.and_then(|k| effort_tier_alias(&tiers.persona_env, k, norm, true)); + let glob = thinking_env_var.and_then(|k| effort_tier_alias(&tiers.global_env, k, norm, false)); + let def = + thinking_env_var.and_then(|k| effort_tier_alias(&tiers.definition_env, k, norm, false)); + let file = file_effort.as_deref().and_then(&norm); + + // Live ACP value: normalized through the runtime CONTRACT only, never the + // persisted `effort_accepted` vocabulary. The ACP running value comes from + // the session's own config-option namespace (e.g. buzz-agent reports + // `default` for its live thinking-level option) — it is a descriptive + // "currently running" fact, never emitted to a spawn, so the + // destination-vocabulary gate that guards the writable tiers must not skip + // it. Goose still canonicalizes (its ACP option values ARE effort values); + // contract-less runtimes pass raw. The matched `config_id` is preserved for + // `write_via` regardless of value validity. + let acp_norm = acp_effort + .as_deref() + .and_then(|v| super::effort::normalize_effort(effort_norm, None, v)); + + // B same-value collapse: when NO record-level authority exists and the live + // ACP value exactly equals what inheritance would already resolve to, drop + // ACP so the panel shows the true baseline origin ("Global default") rather + // than a spurious "Runtime override (this session only)" — the session is + // almost certainly echoing what spawn injected. When a record tier is + // present it wins over ACP anyway, so ACP stays only for override tracking. + let record_present = rec_native.is_some() || column.is_some() || rec_legacy.is_some(); + let baseline_first = [ + pers.as_deref(), + glob.as_deref(), + def.as_deref(), + file.as_deref(), + ] + .into_iter() + .flatten() + .next(); + let acp_for_list = match (record_present, acp_norm.as_deref(), baseline_first) { + (false, Some(a), Some(b)) if a == b => None, + _ => acp_norm.as_deref(), + }; let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ - (rec_env, ConfigOrigin::BuzzExplicit), - (canonical_effort, ConfigOrigin::BuzzExplicit), - (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), - (pers_env, ConfigOrigin::PersonaDefault), - (glob_env, ConfigOrigin::GlobalDefault), - (def_env, ConfigOrigin::HarnessDefault), - (file_effort.as_deref(), ConfigOrigin::ConfigFile), + (rec_native.as_deref(), ConfigOrigin::BuzzExplicit), + (column.as_deref(), ConfigOrigin::BuzzExplicit), + (rec_legacy.as_deref(), ConfigOrigin::BuzzExplicit), + (acp_for_list, ConfigOrigin::AcpConfigOption), + (pers.as_deref(), ConfigOrigin::PersonaDefault), + (glob.as_deref(), ConfigOrigin::GlobalDefault), + (def.as_deref(), ConfigOrigin::HarnessDefault), + (file.as_deref(), ConfigOrigin::ConfigFile), ]; let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; @@ -746,11 +835,20 @@ fn find_config_option_value(cache: &SessionConfigCache, category: &str) -> Optio /// config id (Claude Code uses `id="effort"`). Selecting by category — not by /// a hardcoded id — is what lets the running value, the write config id, and /// the picker options all derive from one entry. +/// +/// `thought_level` is preferred; the legacy invented category `effort` is a +/// fallback for old test fixtures and pre-canonical adapters. The fallback +/// fires only when `thought_level` is entirely absent — an advertised-but-unset +/// `thought_level` entry is still returned (its `current_value` is `None`), so +/// the reader never flips write-routing to the legacy `effort` config id. fn find_effort_option(cache: &SessionConfigCache) -> Option<&AcpConfigOptionEntry> { - cache - .config_options - .iter() - .find(|o| o.category.as_deref() == Some("thought_level")) + let by_category = |category: &str| { + cache + .config_options + .iter() + .find(|o| o.category.as_deref() == Some(category)) + }; + by_category("thought_level").or_else(|| by_category("effort")) } fn has_config_option(cache: Option<&SessionConfigCache>, category: &str) -> bool { diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 64834d1017..34b4f1496f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -28,7 +28,7 @@ fn with_goose_path_root(value: Option<&str>, body: impl FnOnce() -> T) -> T { } fn test_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { + static RUNTIME: KnownAcpRuntime = KnownAcpRuntime { id: "goose", label: "Goose", commands: &["goose"], @@ -54,13 +54,16 @@ fn test_runtime() -> &'static KnownAcpRuntime { config_file_format: Some("yaml"), supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&crate::managed_agents::discovery::GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, - } + }; + &RUNTIME } fn test_record() -> ManagedAgentRecord { @@ -647,6 +650,8 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { config_file_format: None, supports_acp_native_config: false, thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), + effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), @@ -958,3 +963,6 @@ fn numeric_max_tokens_inherits_from_global_env() { // ── Extended tests (split file to respect line-count ratchet) ──────────────── #[path = "reader_tests_ext.rs"] mod ext; + +#[path = "reader_tests_ext2.rs"] +mod ext2; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index 0974bf9c58..fc18a51c62 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -518,3 +518,460 @@ fn claude_default_config_dir_reports_static_settings_path() { .as_deref() .is_some_and(|p| !p.starts_with('~'))); } + +// ── Goose-contract reader normalization + reader/projection parity ──────────── +// +// The reader (`build_thinking_field`) and the launch projection +// (`effort_launch_projection`) must resolve one effective value AND one +// authority for every record/inherited input, or the config panel displays a +// different effort than the next spawn launches. `test_runtime()` is Goose with +// `effort_normalization = GOOSE_EFFORT_NORMALIZATION`, so these exercise the +// normalization gate, alias canonicalization, invalid-value skip/fallthrough, +// and the decisive mixed-authority case — the phase-1 behavior block, not just +// fixture metadata. + +use crate::managed_agents::config_bridge::effort::effort_launch_projection; + +/// Drive the projection from the SAME record + global env the reader sees, so +/// the two resolvers are compared on identical inputs. Persona/definition tiers +/// use distinct input shapes across the two layers and are covered separately; +/// record-native/column/legacy and global are expressible identically here, +/// which is exactly where the authority-order contract is decisive. +fn projection_value( + record: &ManagedAgentRecord, + global_env: &BTreeMap, +) -> Option { + effort_launch_projection( + record, + Some(test_runtime()), + &[], + None, + global_env, + None, + &BTreeMap::new(), + ) + .value +} + +/// Goose invalid record-native value (`minimal` — not in the Goose contract) +/// skips as absent so a valid lower tier wins, IDENTICALLY in reader and +/// projection. This is Thufir's named regression: a raw winner in the panel +/// while the launch skips it. +#[test] +fn goose_invalid_record_native_skips_to_column_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "minimal".to_string()); + record.effort_level = Some("high".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("valid column must win when native is invalid"); + // Reader: invalid native skipped, column wins. + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // Projection agrees on value. + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +/// Goose alias canonicalization: `xhigh` → `max` in BOTH resolvers (record +/// native), `none` → `off` (column). +#[test] +fn goose_aliases_canonicalize_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "xhigh".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("max")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("max") + ); + + let mut record2 = test_record(); + record2.effort_level = Some("none".to_string()); + let surface2 = read_config_surface(&record2, Some(runtime), None, &no_tiers(), None); + assert_eq!( + surface2 + .normalized + .thinking_effort + .unwrap() + .value + .as_deref(), + Some("off") + ); + assert_eq!( + projection_value(&record2, &BTreeMap::new()).as_deref(), + Some("off") + ); +} + +/// The decisive mixed-authority case (Thufir/Paul acceptance pin): a valid +/// record-native value and a DIFFERENT valid column → the native value wins in +/// reader and projection alike. The column is the surfaced override baseline. +#[test] +fn goose_record_native_outranks_column_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); + record.effort_level = Some("low".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // Column is the overridden baseline (next distinct tier below native). + assert_eq!(effort.overridden_value.as_deref(), Some("low")); + // Projection resolves the same authority. + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +/// Invalid column AND invalid native → both skip; a valid global tier wins in +/// the reader, and the projection (driven from the same global env) agrees. +#[test] +fn goose_invalid_record_tiers_fall_through_to_global_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "bogus".to_string()); + record.effort_level = Some("alsobad".to_string()); + let runtime = test_runtime(); + let mut global = BTreeMap::new(); + global.insert("GOOSE_THINKING_EFFORT".to_string(), "medium".to_string()); + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); + let effort = surface + .normalized + .thinking_effort + .expect("global tier must win when both record tiers are invalid"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::GlobalDefault); + assert_eq!( + projection_value(&record, &global).as_deref(), + Some("medium") + ); +} + +/// Goose legacy alias (`BUZZ_AGENT_THINKING_EFFORT`) is accepted for the record +/// tier below the column, canonicalized, in reader and projection alike. +#[test] +fn goose_record_legacy_alias_below_column_in_reader_and_projection() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "xhigh".to_string(), + ); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("record legacy alias must surface when native and column are absent"); + assert_eq!(effort.value.as_deref(), Some("max")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("max") + ); +} + +/// B same-value collapse: no record authority, live ACP echoes the inherited +/// global value → the panel shows the inherited origin (GlobalDefault), not a +/// spurious per-session AcpConfigOption override. +#[test] +fn goose_acp_equal_to_global_collapses_to_global_origin() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("medium".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!( + effort.origin, + ConfigOrigin::GlobalDefault, + "ACP echoing the inherited value must not masquerade as a session override" + ); +} + +/// B same-value collapse does NOT fire on genuine divergence: live ACP differs +/// from the inherited baseline → ACP wins as the per-session override, global +/// is the surfaced baseline. +#[test] +fn goose_acp_diverging_from_global_wins_as_override() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("low".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "high"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("low")); + assert_eq!(effort.origin, ConfigOrigin::AcpConfigOption); + assert_eq!(effort.overridden_value.as_deref(), Some("high")); + assert_eq!(effort.overridden_origin, Some(ConfigOrigin::GlobalDefault)); +} + +/// Invalid live ACP value is skipped as absent; a valid record tier wins and +/// no phantom ACP override is surfaced. +#[test] +fn goose_invalid_acp_skips_and_record_wins() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("garbage".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +// ── Consumed-legacy Advanced suppression (F2) ──────────────────────────────── +// +// When the record's native effort key is absent/invalid and the legacy key +// (`BUZZ_AGENT_THINKING_EFFORT`) supplies the normalized record effort, the +// legacy key must NOT also re-appear as a generic Advanced field — one +// persisted fact must not surface through two controls. Invalid/unconsumed +// legacy values stay visible in Advanced. + +/// Record has valid legacy `BUZZ_AGENT_THINKING_EFFORT=high` and no native +/// `GOOSE_THINKING_EFFORT` → effort surfaces from the legacy alias AND the +/// legacy key must NOT re-appear in Advanced. +#[test] +fn record_consumed_legacy_effort_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose (native GOOSE_THINKING_EFFORT) + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("valid legacy value must surface as effort via record-tier alias"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "consumed legacy effort key must not double-emit in advanced; got {advanced_keys:?}" + ); +} + +/// A valid legacy value shadowed by the canonical column is not consumed, so +/// it remains editable in Advanced rather than silently resurfacing later if +/// the column is cleared. +#[test] +fn record_legacy_effort_shadowed_by_column_stays_visible_in_advanced_reader() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "low".to_string()); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("canonical column must win over legacy record effort"); + assert_eq!(effort.value.as_deref(), Some("high")); + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "valid but unconsumed record legacy must remain visible in Advanced; got {advanced_keys:?}" + ); +} + +/// An invalid legacy `BUZZ_AGENT_THINKING_EFFORT` value is unconsumed, so it +/// stays visible in Advanced. +#[test] +fn record_invalid_legacy_effort_stays_visible_in_advanced_reader() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "bogus".to_string(), + ); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + assert!( + surface.normalized.thinking_effort.is_none(), + "invalid legacy value must not be consumed as effort" + ); + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "unconsumed legacy key must stay visible in advanced; got {advanced_keys:?}" + ); +} + +// ── F4: legacy `effort` category fallback in find_effort_option ────────────── +// +// `thought_level` is preferred; the legacy invented category `effort` is a +// fallback for pre-canonical adapters. An advertised-but-unset `thought_level` +// must NOT fall through to a set `effort` (that would route the write to the +// wrong config_id), but a cache that advertises only `effort` must still +// surface a thinking field and write route. + +/// `thought_level` present but unset, `effort` present and set → effort must +/// NOT surface from the live cache (no fallthrough); write routing never picks +/// up the legacy `effort` config id. +#[test] +fn unset_thought_level_does_not_fall_through_to_effort_category() { + let record = test_record(); + let runtime = test_runtime(); // Goose + let cache = SessionConfigCache { + config_options: vec![ + AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Thinking Effort".to_string()), + current_value: None, // advertised but unset + options: vec![], + }, + AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort (legacy)".to_string()), + current_value: Some("low".to_string()), + options: vec![], + }, + ], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None) + }); + + assert!( + surface.normalized.thinking_effort.is_none(), + "unset thought_level must not fall through to the legacy effort category" + ); +} + +/// `effort` category present and set, no `thought_level` at all → legacy +/// fallback still surfaces the field and routes the write to the matched +/// `effort` config id. +#[test] +fn effort_category_fallback_used_when_thought_level_absent() { + let record = test_record(); + let runtime = test_runtime(); // Goose + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort (legacy)".to_string()), + current_value: Some("high".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("legacy effort category must surface when thought_level is absent"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert!( + matches!( + &effort.write_via, + ConfigWriteMechanism::AcpSetConfigOption { config_id } + if config_id == "effort" + ), + "write route must use the legacy effort config_id when it is the only category; got {:?}", + effort.write_via + ); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs new file mode 100644 index 0000000000..0c5aa69c40 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs @@ -0,0 +1,69 @@ +//! Additional tests for `config_bridge/reader.rs` — split out to keep +//! `reader_tests_ext.rs` under the 1000-line file-size ratchet. +//! +//! Included as `mod ext2` inside `reader_tests.rs`, so `use super::*` gives +//! access to all helpers and types from that module. + +use super::*; + +// ── Fix (external review #4): reader resolves record effort keys ───────────── +// case-insensitively, matching the launch projection. +// +// Windows `Command` case-folds env names, so a hand-set `goose_thinking_effort` +// is the same variable as its canonical form. The reader must resolve it as the +// record-native effort winner AND hide it from Advanced, or the panel disagrees +// with the child the launch projection already consumed the key for. + +/// Mixed-case native record key `goose_thinking_effort=high` wins the record +/// tier and is hidden from Advanced (not shown as a spurious editable extra). +#[test] +fn record_mixed_case_native_effort_wins_and_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("goose_thinking_effort".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose (native GOOSE_THINKING_EFFORT) + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("mixed-case native key must surface as the record effort winner"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"goose_thinking_effort"), + "consumed mixed-case native effort key must not appear in advanced; got {advanced_keys:?}" + ); +} + +/// Mixed-case legacy record key `buzz_agent_thinking_effort=high` (no native, +/// no column) supplies the record effort AND is hidden from Advanced. +#[test] +fn record_mixed_case_legacy_effort_consumed_and_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("buzz_agent_thinking_effort".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("mixed-case legacy key must surface as effort via record-tier alias"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"buzz_agent_thinking_effort"), + "consumed mixed-case legacy effort key must not appear in advanced; got {advanced_keys:?}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 1ee7e6e556..84f88e406b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -15,6 +15,8 @@ mod presets; mod runtime_metadata; #[macro_use] mod windows_install; +mod catalog; +pub(crate) use catalog::KNOWN_ACP_RUNTIMES; pub use login_shell::{find_nvm_default_bin, login_shell_path}; pub(crate) use login_shell::{find_via_login_shell, refresh_login_shell_path}; #[cfg(test)] @@ -26,7 +28,10 @@ pub(crate) use presets::{ preset_harness_ids, }; use presets::{preset_catalog_entry, PRESET_HARNESSES}; +pub(crate) use runtime_metadata::EffortNormalization; pub(crate) use runtime_metadata::KnownAcpRuntime; +#[cfg(test)] +pub(crate) use runtime_metadata::GOOSE_EFFORT_NORMALIZATION; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; @@ -83,144 +88,6 @@ fn common_binary_paths() -> &'static [PathBuf] { }) } -const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ - KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: GOOSE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("goose"), - cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], - // Goose's stable release currently publishes only the Unix installer; - // its official Windows instructions intentionally point at this main-branch script. - cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], - adapter_install_commands: &[], - cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", - adapter_install_instructions_url: "", - cli_install_hint: "Buzz talks to Goose through the Goose CLI.", - adapter_install_hint: "", - skill_dir: Some(".goose/skills"), - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[("GOOSE_MODE", "auto")], - config_file_path: Some("~/.config/goose/config.yaml"), - config_file_format: Some("yaml"), - supports_acp_native_config: true, - thinking_env_var: Some("GOOSE_THINKING_EFFORT"), - max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), - context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), - max_rounds_env_var: None, - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - }, - KnownAcpRuntime { - id: "claude", - label: "Claude Code", - commands: &["claude-agent-acp", "claude-code-acp"], - aliases: &["claude-code", "claudecode"], - avatar_url: CLAUDE_CODE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("claude"), - cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], - cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], - cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", - adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", - skill_dir: Some(".claude/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: true, - default_env: &[], - config_file_path: Some("~/.claude/settings.json"), - config_file_format: Some("json"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - max_rounds_env_var: None, - required_normalized_fields: &[], - login_hint: Some("Run the Claude CLI to complete authentication."), - auth_probe_args: Some(&["claude", "auth", "status"]), - }, - KnownAcpRuntime { - id: "codex", - label: "Codex", - commands: &["codex-acp"], - aliases: &[], - avatar_url: CODEX_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: false, - underlying_cli: Some("codex"), - cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], - cli_install_instructions_url: "https://developers.openai.com/codex/cli/", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Buzz talks to Codex through the Codex CLI.", - adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", - skill_dir: Some(".codex/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.codex/config.toml"), - config_file_format: Some("toml"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - max_rounds_env_var: None, - required_normalized_fields: &[], - login_hint: Some("Run `codex login` to authenticate."), - // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. - auth_probe_args: Some(&["codex", "login", "status"]), - }, - KnownAcpRuntime { - id: "buzz-agent", - label: "Buzz Agent", - commands: &["buzz-agent"], - aliases: &[], - avatar_url: BUZZ_AGENT_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: true, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "https://github.com/block/buzz", - adapter_install_instructions_url: "https://github.com/block/buzz", - cli_install_hint: "Ships with the Buzz desktop app.", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: true, - model_env_var: Some("BUZZ_AGENT_MODEL"), - provider_env_var: Some("BUZZ_AGENT_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: None, - config_file_format: None, - supports_acp_native_config: false, - thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), - max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), - context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), - max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - }, -]; - /// Skill discovery directories declared by known runtimes. pub(crate) fn known_skill_dirs() -> impl Iterator { KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir) @@ -375,7 +242,11 @@ pub fn effective_agent_command( } mod overrides; -pub use overrides::{apply_agent_command_update, create_time_agent_command_override}; +pub use overrides::remove_record_effort_aliases; +pub use overrides::{ + apply_agent_command_update, apply_env_vars_then_effort_transition, + create_time_agent_command_override, +}; /// Prefix of the typed dangling-harness error produced by /// `try_record_agent_command` / `resolve_effective_harness_descriptor`. @@ -1168,6 +1039,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) - model_env_var: runtime.model_env_var.map(str::to_string), provider_env_var: runtime.provider_env_var.map(str::to_string), thinking_env_var: runtime.thinking_env_var.map(str::to_string), + effort_canonical_values: runtime + .effort_normalization + .map(|norm| norm.canonical.iter().map(|s| s.to_string()).collect()), max_tokens_env_var: runtime.max_tokens_env_var.map(str::to_string), context_limit_env_var: runtime.context_limit_env_var.map(str::to_string), max_rounds_env_var: runtime.max_rounds_env_var.map(str::to_string), @@ -1308,6 +1182,7 @@ pub fn discover_acp_runtimes_from( model_env_var: None, provider_env_var: None, thinking_env_var: None, + effort_canonical_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs new file mode 100644 index 0000000000..fecf792f21 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs @@ -0,0 +1,156 @@ +//! The known-ACP-runtime catalog. Extracted from `discovery.rs` as pure data +//! (mirroring `presets::PRESET_HARNESSES`) so the module stays under the +//! file-size ratchet. The `windows_install_command!` macro is in textual scope +//! here because this module is declared after `#[macro_use] mod windows_install` +//! in the parent. + +use super::runtime_metadata::{ + KnownAcpRuntime, BUZZ_AGENT_EFFORT_VALUES, GOOSE_EFFORT_NORMALIZATION, +}; +use super::{BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL}; + +pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ + KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: GOOSE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("goose"), + cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], + // Goose's stable release currently publishes only the Unix installer; + // its official Windows instructions intentionally point at this main-branch script. + cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], + adapter_install_commands: &[], + cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz talks to Goose through the Goose CLI.", + adapter_install_hint: "", + skill_dir: Some(".goose/skills"), + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[("GOOSE_MODE", "auto")], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, // goose: validated via effort_normalization + max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), + context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + }, + KnownAcpRuntime { + id: "claude", + label: "Claude Code", + commands: &["claude-agent-acp", "claude-code-acp"], + aliases: &["claude-code", "claudecode"], + avatar_url: CLAUDE_CODE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("claude"), + cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], + cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], + adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", + cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", + adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", + skill_dir: Some(".claude/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.claude/settings.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + effort_normalization: None, // claude: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + effort_accepted_values: None, // claude: adapter accepts any value over BUZZ_ACP_EFFORT_LEVEL + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run the Claude CLI to complete authentication."), + auth_probe_args: Some(&["claude", "auth", "status"]), + }, + KnownAcpRuntime { + id: "codex", + label: "Codex", + commands: &["codex-acp"], + aliases: &[], + avatar_url: CODEX_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: false, + underlying_cli: Some("codex"), + cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], + adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], + cli_install_instructions_url: "https://developers.openai.com/codex/cli/", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", + cli_install_hint: "Buzz talks to Codex through the Codex CLI.", + adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", + skill_dir: Some(".codex/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.codex/config.toml"), + config_file_format: Some("toml"), + supports_acp_native_config: false, + thinking_env_var: None, + effort_normalization: None, // codex: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + effort_accepted_values: None, // codex: adapter accepts any value over BUZZ_ACP_EFFORT_LEVEL + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `codex login` to authenticate."), + // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. + auth_probe_args: Some(&["codex", "login", "status"]), + }, + KnownAcpRuntime { + id: "buzz-agent", + label: "Buzz Agent", + commands: &["buzz-agent"], + aliases: &[], + avatar_url: BUZZ_AGENT_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: true, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://github.com/block/buzz", + adapter_install_instructions_url: "https://github.com/block/buzz", + cli_install_hint: "Ships with the Buzz desktop app.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + model_env_var: Some("BUZZ_AGENT_MODEL"), + provider_env_var: Some("BUZZ_AGENT_PROVIDER"), + provider_locked: false, + default_env: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), + effort_normalization: None, // buzz-agent: per-model catalog; see getProviderEffortConfig() in TS + effort_accepted_values: Some(BUZZ_AGENT_EFFORT_VALUES), // buzz-agent: parse_thinking_effort's accepted set + max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + }, +]; diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 5140bb2cdd..fa339a03b7 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -83,27 +83,85 @@ pub fn update_time_agent_command_override( /// Apply an explicit `agent_command` edit to `record`: persist the override /// pin decided by [`update_time_agent_command_override`], and on the inherit /// sentinel (empty/whitespace command) also clear the materialized -/// `record.runtime` so the resolution ladder falls through to the live -/// definition immediately instead of silently keeping the stale instance copy. +/// `record.runtime` AND the persisted per-instance effort column so the +/// resolution ladder falls through to the live definition immediately instead +/// of silently keeping the stale instance copy. /// -/// The runtime clear is guarded on a live persona link: for a definition-less -/// record the materialized runtime is the only harness source left after the -/// override clear, so a stray empty `agent_command` from a non-dialog caller -/// must not change what the agent runs. +/// The clears are guarded on a live persona link: for a definition-less record +/// the materialized runtime is the only harness source left after the override +/// clear, so a stray empty `agent_command` from a non-dialog caller must not +/// change what the agent runs. +/// +/// Returns `true` when the pin→inherit transition fired. The caller MUST then, +/// AFTER applying any caller-supplied `env_vars`, strip the record effort env +/// aliases via [`remove_record_effort_aliases`] — clearing them here would be +/// undone by a same-request `env_vars` replacement (see the update boundary in +/// `agent_models_update.rs`), so the alias strip is an update-boundary +/// invariant, not a helper-local one. +#[must_use] pub fn apply_agent_command_update( record: &mut crate::managed_agents::types::ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], agent_command: &str, harness_override: bool, -) { +) -> bool { record.agent_command_override = update_time_agent_command_override( record.persona_id.as_deref(), personas, Some(agent_command), harness_override, ); - if agent_command.trim().is_empty() && record.persona_id.is_some() { + let inherit_transition = agent_command.trim().is_empty() && record.persona_id.is_some(); + if inherit_transition { record.runtime = None; + // The generic canonical effort column is a per-instance pin; on the + // pin→inherit transition it is dropped so the agent inherits the + // persona/global effort. The record effort ENV aliases are stripped by + // the caller after `env_vars` is applied (see the doc above). + record.effort_level = None; + } + inherit_transition +} + +/// Strip every record-level thinking-effort env alias — all known native keys +/// plus the legacy `BUZZ_AGENT_THINKING_EFFORT` alias — from `env_vars`. +/// +/// Called at the `update_managed_agent` boundary on the pin→inherit transition, +/// AFTER caller-supplied `env_vars` have been applied, so the cleared aliases +/// cannot be reintroduced by the same request. Together with the column clear +/// in [`apply_agent_command_update`], this makes the instance drop its entire +/// per-instance effort override atomically at Save. +pub fn remove_record_effort_aliases(env_vars: &mut std::collections::BTreeMap) { + let suppress = crate::managed_agents::config_bridge::effort::effort_suppress_keys(); + env_vars.retain(|k, _| { + !suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); +} + +/// Apply a same-request `env_vars` replacement and then enforce the pin→inherit +/// effort-alias strip, in that exact order. +/// +/// This is the ordering invariant Thufir's plan-of-record pins: the effort +/// column is cleared eagerly inside [`apply_agent_command_update`], but a stale +/// effort env alias in a caller-supplied `env_vars` map submitted in the SAME +/// request would otherwise survive the transition. Applying `env_vars` first, +/// then stripping the aliases only on the transition, guarantees the instance +/// cannot re-pin effort through the generic env channel while inheriting its +/// harness. `env_vars = None` leaves the record's existing env untouched; +/// validation of the supplied map is the caller's responsibility (it runs +/// before this seam at the update boundary). +pub fn apply_env_vars_then_effort_transition( + record: &mut crate::managed_agents::types::ManagedAgentRecord, + env_vars: Option>, + inherit_transition: bool, +) { + if let Some(env_vars) = env_vars { + record.env_vars = env_vars; + } + if inherit_transition { + remove_record_effort_aliases(&mut record.env_vars); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 61c589770c..438cb5eb86 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -67,6 +67,7 @@ pub(super) fn preset_catalog_entry( model_env_var: None, provider_env_var: None, thinking_env_var: None, + effort_canonical_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 34edecdcd9..b68bc84c23 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -1,3 +1,69 @@ +/// Canonicalization contract for a harness's thinking-effort env var. +/// +/// The single value authority shared by UI choices, the spawn/deploy launch +/// projection, and the reader. All effort candidates (native env, legacy env, +/// ACP tier, file tier) are normalized through `normalize_str` before any +/// validity, precedence, override, or B-equality check. +/// +/// Source for Goose: `crates/goose-provider-types/src/thinking.rs` +/// • `FromStr` (aliases, case-insensitive): `off|disabled|none`, `low`, +/// `medium|med`, `high`, `max|xhigh` +/// • `Display` (canonical): `off`, `low`, `medium`, `high`, `max` +/// • Live ACP emits Display values via `response_builder.rs:326-337`. +pub(crate) struct EffortNormalization { + /// Canonical values in UI display order (drive choices, persistence, ACP comparison). + pub canonical: &'static [&'static str], + /// `(alias, canonical)` pairs, case-insensitive. Only aliases that differ + /// from their canonical form are listed. + pub aliases: &'static [(&'static str, &'static str)], +} + +/// Goose thinking-effort canonicalization contract. +/// +/// Source: `crates/goose-provider-types/src/thinking.rs` at Goose `2db0e31fe`. +/// Canonical Display values: `off`, `low`, `medium`, `high`, `max`. +/// Aliases (case-insensitive): `none|disabled→off`, `med→medium`, `xhigh→max`. +/// `minimal` (Buzz-only) is invalid — skipped as absent at every tier. +pub(crate) static GOOSE_EFFORT_NORMALIZATION: EffortNormalization = EffortNormalization { + canonical: &["off", "low", "medium", "high", "max"], + aliases: &[ + ("none", "off"), + ("disabled", "off"), + ("med", "medium"), + ("xhigh", "max"), + ], +}; + +/// buzz-agent's accepted persisted thinking-effort values — a validation-only +/// contract, NOT a canonicalization one. Unlike Goose, buzz-agent keeps `xhigh` +/// and `max` as *distinct* efforts, so these values are validated (invalid → +/// skip as absent) but never aliased or collapsed. +/// +/// Source of truth: `parse_thinking_effort`, `crates/buzz-agent/src/config.rs` +/// (`none|minimal|low|medium|high|xhigh|max`). A destination-vocabulary check +/// at projection time keeps a foreign canonical (e.g. Goose `off`) from being +/// emitted as `BUZZ_AGENT_THINKING_EFFORT=off`, which the parser rejects at +/// config init (child exits 2). +pub(crate) static BUZZ_AGENT_EFFORT_VALUES: &[&str] = + &["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + +impl EffortNormalization { + /// Normalize `raw` to canonical form. `None` → invalid for this harness; + /// the caller must treat it as absent (skip-as-absent policy). + pub fn normalize_str(&self, raw: &str) -> Option { + let lower = raw.to_lowercase(); + if self.canonical.contains(&lower.as_str()) { + return Some(lower); + } + for &(alias, canon) in self.aliases { + if lower == alias { + return Some(canon.to_string()); + } + } + None + } +} + /// Static capabilities and installation metadata for a known ACP runtime. pub(crate) struct KnownAcpRuntime { pub id: &'static str, @@ -47,6 +113,35 @@ pub(crate) struct KnownAcpRuntime { pub config_file_format: Option<&'static str>, pub supports_acp_native_config: bool, // tier 1a: config/read+write pub thinking_env_var: Option<&'static str>, + /// Canonicalization contract for `thinking_env_var` on this harness. + /// + /// `Some(contract)` — harness uses a finite, static effort vocabulary. + /// All candidates (native env, legacy env, ACP tier, file tier) are + /// normalized through this contract before validity checks, precedence + /// resolution, override tracking, and B-equality comparison. + /// + /// `None` — harness accepts any provider/model-specific value via its own + /// catalog (buzz-agent); see `getProviderEffortConfig()` in TS for that + /// path. Contract-less does NOT mean keyless: buzz-agent still has a native + /// `thinking_env_var`, and Claude/Codex route the canonical through + /// `BUZZ_ACP_EFFORT_LEVEL` for ACP startup even with `thinking_env_var: None`. + /// + /// The single canonical authority shared by UI choices, the launch + /// projection, and the reader. No value-authority logic may live outside + /// this struct for harnesses that declare one. + pub effort_normalization: Option<&'static EffortNormalization>, + /// Accepted persisted effort values for a runtime that has NO + /// canonicalization contract but still constrains its vocabulary + /// (buzz-agent: `parse_thinking_effort`'s accepted set). Used only for + /// destination-vocabulary validation at projection/read time — a candidate + /// outside this set is skipped as absent, so a foreign canonical (e.g. + /// Goose `off`) is never emitted under `thinking_env_var` where the + /// destination parser would reject it and crash the child. + /// + /// `None` means "no validation": Goose validates through + /// `effort_normalization`; Claude/Codex and unknown/custom runtimes accept + /// any string over the `BUZZ_ACP_EFFORT_LEVEL` transport. + pub effort_accepted_values: Option<&'static [&'static str]>, /// Env var for normalizing `max_output_tokens`. `None` when the harness /// does not have a first-class env var for this field (config-file only). pub max_tokens_env_var: Option<&'static str>, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 577a780d6c..dc155d82f5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -2,12 +2,13 @@ use std::path::PathBuf; use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ - apply_agent_command_update, classify_runtime, codex_adapter_availability, - codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, - effective_agent_command, find_nvm_default_bin, is_login_shell_path_uninit, is_safe_nvm_tag, - managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, probe_codex_acp_version, - record_agent_command, refresh_login_shell_path, try_record_agent_command, - BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + apply_agent_command_update, apply_env_vars_then_effort_transition, classify_runtime, + codex_adapter_availability, codex_adapter_is_outdated, create_time_agent_command_override, + default_agent_command, effective_agent_command, find_nvm_default_bin, + is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, + parse_semver_tag, probe_codex_acp_version, record_agent_command, refresh_login_shell_path, + remove_record_effort_aliases, try_record_agent_command, BUZZ_AGENT_AVATAR_URL, + CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -606,51 +607,9 @@ fn update_time_override_preserves_pin_for_persona_less_agent() { ); } -#[test] -fn apply_agent_command_update_inherit_sentinel_clears_pin_and_runtime() { - // Choosing Inherit on a persona-linked record clears BOTH the explicit - // pin and the materialized runtime, so resolution falls through to the - // live definition immediately — not on the next spawn. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); - - apply_agent_command_update(&mut record, &personas, "", false); - - assert_eq!(record.agent_command_override, None); - assert_eq!(record.runtime, None); - assert_eq!(record_agent_command(&record, &personas), "goose"); -} - -#[test] -fn apply_agent_command_update_sentinel_keeps_runtime_for_definition_less_record() { - // For a record with no persona link the materialized runtime is the only - // harness source left once the pin is cleared — a stray empty - // agent_command must not change what the agent runs. - let mut record = record_with(Some("claude"), None, Some("codex-acp")); - - apply_agent_command_update(&mut record, &[], "", false); - - assert_eq!(record.agent_command_override, None); - assert_eq!(record.runtime.as_deref(), Some("claude")); - assert_eq!(record_agent_command(&record, &[]), "claude-agent-acp"); -} - -#[test] -fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { - // A concrete pick only sets the pin; the materialized runtime is left for - // the next snapshot apply. The pin shadows it in resolution either way. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - let mut record = record_with(Some("claude"), Some("p1"), None); - - apply_agent_command_update(&mut record, &personas, "codex-acp", true); - - assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); - assert_eq!(record.runtime.as_deref(), Some("claude")); - assert_eq!(record_agent_command(&record, &personas), "codex-acp"); -} - // ── probe_codex_acp_version ─────────────────────────────────────────────────── +mod effort_clear; mod forced_discovery; mod managed_path_resolution; #[cfg(unix)] diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs new file mode 100644 index 0000000000..bdeb1a1080 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs @@ -0,0 +1,188 @@ +//! Backend tests for the pin→inherit effort clear (PR #4625, plan-of-record +//! item 1): the sentinel transition clears the canonical column eagerly and the +//! update boundary strips the record effort env aliases AFTER caller `env_vars` +//! is applied. Split out of `discovery/tests.rs` to hold that file under the +//! desktop file-size ratchet. +//! +//! `use super::*` pulls the parent test module's helpers (`record_with`, +//! `persona_with_runtime`, `record_agent_command`) and its imported command +//! surface (`apply_agent_command_update`, `apply_env_vars_then_effort_transition`, +//! `remove_record_effort_aliases`). + +use super::*; + +#[test] +fn apply_agent_command_update_inherit_sentinel_clears_pin_runtime_and_column() { + // Choosing Inherit on a persona-linked record clears the explicit pin, the + // materialized runtime, AND the per-instance effort column, so resolution + // falls through to the live definition immediately — not on the next spawn. + // The transition flag fires so the caller strips the record effort env + // aliases after `env_vars` is applied. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "", false); + + assert!(transition, "the pin→inherit transition must be signalled"); + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime, None); + assert_eq!( + record.effort_level, None, + "the effort column must be cleared" + ); + assert_eq!(record_agent_command(&record, &personas), "goose"); +} + +#[test] +fn apply_agent_command_update_sentinel_keeps_runtime_for_definition_less_record() { + // For a record with no persona link the materialized runtime is the only + // harness source left once the pin is cleared — a stray empty + // agent_command must not change what the agent runs, nor clear its effort. + let mut record = record_with(Some("claude"), None, Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &[], "", false); + + assert!( + !transition, + "a definition-less stray sentinel is not a pin→inherit transition" + ); + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "a definition-less record must preserve its effort column" + ); + assert_eq!(record_agent_command(&record, &[]), "claude-agent-acp"); +} + +#[test] +fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime_and_column() { + // A concrete pick only sets the pin; the materialized runtime and the + // effort column are left intact (no ownership transition). The pin shadows + // the runtime in resolution either way. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), None); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "codex-acp", true); + + assert!( + !transition, + "a concrete pin is not a pin→inherit transition" + ); + assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "a concrete pin must preserve the effort column" + ); + assert_eq!(record_agent_command(&record, &personas), "codex-acp"); +} + +#[test] +fn remove_record_effort_aliases_strips_all_known_and_legacy_keys() { + // The update-boundary alias strip: after `env_vars` is applied on the + // pin→inherit transition, every known native effort key and the legacy + // alias must be removed, while unrelated env survives. This proves the + // second half of the atomic clear that a helper-only column clear cannot. + let mut env: std::collections::BTreeMap = [ + ("GOOSE_THINKING_EFFORT", "high"), + ("BUZZ_AGENT_THINKING_EFFORT", "high"), + ("BUZZ_ACP_EFFORT_LEVEL", "high"), + ("UNRELATED_KEY", "keep"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + remove_record_effort_aliases(&mut env); + + assert!(!env.contains_key("GOOSE_THINKING_EFFORT")); + assert!(!env.contains_key("BUZZ_AGENT_THINKING_EFFORT")); + assert!(!env.contains_key("BUZZ_ACP_EFFORT_LEVEL")); + assert_eq!( + env.get("UNRELATED_KEY").map(String::as_str), + Some("keep"), + "unrelated env must survive the effort-alias strip" + ); +} + +#[test] +fn update_boundary_inherit_sentinel_with_alias_bearing_env_vars_strips_after_apply() { + // The update-boundary ORDERING invariant (Thufir pass-3): on the pin→inherit + // transition, a SAME-REQUEST `env_vars` map carrying a stale effort alias + // must NOT survive. `apply_agent_command_update` clears the column eagerly; + // then `apply_env_vars_then_effort_transition` applies the caller env FIRST + // and strips the aliases AFTER — so the alias the request tried to + // reintroduce is gone. A helper-only test cannot prove this order. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "", false); + assert!( + transition, + "empty command on a persona-linked record is inherit" + ); + + // The request replaces env_vars with a map that re-pins effort via an alias + // plus an unrelated key. + let request_env: std::collections::BTreeMap = [ + ("GOOSE_THINKING_EFFORT", "max"), + ("BUZZ_ACP_EFFORT_LEVEL", "max"), + ("UNRELATED_KEY", "keep"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + apply_env_vars_then_effort_transition(&mut record, Some(request_env), transition); + + assert_eq!(record.effort_level, None, "column stays cleared"); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "same-request native alias must not survive the transition" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "same-request ACP sentinel must not survive the transition" + ); + assert_eq!( + record.env_vars.get("UNRELATED_KEY").map(String::as_str), + Some("keep"), + "unrelated env from the same request is preserved" + ); +} + +#[test] +fn update_boundary_concrete_pin_preserves_alias_bearing_env_vars() { + // No transition (concrete pin): the caller `env_vars` — including any effort + // alias — is applied verbatim and NOT stripped. Effort env is only cleared + // on the ownership transition, never on an ordinary env edit. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), None); + + let transition = apply_agent_command_update(&mut record, &personas, "codex-acp", true); + assert!(!transition, "a concrete pin is not a transition"); + + let request_env: std::collections::BTreeMap = + [("GOOSE_THINKING_EFFORT", "max")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + apply_env_vars_then_effort_transition(&mut record, Some(request_env), transition); + + assert_eq!( + record + .env_vars + .get("GOOSE_THINKING_EFFORT") + .map(String::as_str), + Some("max"), + "without a transition the caller effort env is preserved" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index ad112f9ff9..a66f9c75ba 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -11,6 +11,7 @@ pub(crate) use agent_env::{ mod agent_description; pub(crate) use agent_description::{effective_agent_description, record_effective_description}; mod backend; +pub(crate) mod bestie_assignment; pub(crate) mod claude_config; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; @@ -48,13 +49,27 @@ pub(crate) use team_repair::team_persona_key; mod teams; mod types; -// Shared guard for tests that mutate or read process-global PATH. +// Shared lock for tests that call `lock_path_mutex` or `lock_env_mutex`. +// Both helpers delegate here so any two tests using either helper are mutually +// exclusive with each other. Tests in other modules that maintain their own +// independent locks (app_state_tests, agent_config_tests, reader_tests) are +// NOT in this domain and are not covered by this mutex. #[cfg(test)] -static PATH_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); +static PROCESS_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); +// Acquires the shared process-env lock. Call from any test in this module that +// reads, writes, or removes a process-global environment variable (including PATH). #[cfg(test)] pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { - PATH_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) + PROCESS_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) +} + +// Delegates to the same lock as `lock_path_mutex`. Tests using either helper +// are mutually exclusive with each other; PATH and env-key mutations that go +// through these helpers cannot race. +#[cfg(test)] +pub(crate) fn lock_env_mutex() -> std::sync::MutexGuard<'static, ()> { + PROCESS_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) } pub use backend::*; diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 9af3c989f4..88cc7884c4 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -269,6 +269,20 @@ fn resolve_effective_agent_env_with_def( ); env.extend(user_env); + // Single harness-agnostic effort authority (PR #4625): resolve effective + // effort over the canonical column AND all env tiers, emit one destination + // key. Runs AFTER the layer stack so launch, remote deploy, and the restart + // snapshot agree — no double authority, no foreign key, no badge disagreement. + super::config_bridge::effort::apply_launch_effort( + &mut env, + record, + runtime, + personas, + &global.env_vars, + harness_def.as_deref(), + &baked_build_env(), + ); + // Buzz shared compute is a native Buzz provider. Translate it to buzz-agent's // OpenAI-compatible transport only in the effective runtime environment. #[cfg(feature = "mesh-llm")] @@ -1049,6 +1063,8 @@ mod tests { default_env: &[], supports_acp_native_config: false, thinking_env_var: None, + effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -1241,6 +1257,8 @@ mod tests { default_env: &[], supports_acp_native_config: false, thinking_env_var: None, + effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -1681,56 +1699,10 @@ mod tests { })); } - // ── OpenRouter readiness ───────────────────────────────────────────── - - #[test] - fn buzz_agent_openrouter_with_all_fields_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "openrouter with all fields should be ready" - ); - } - - #[test] - fn buzz_agent_openrouter_missing_key_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string() - })); - } - #[test] - fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "OPENROUTER_MODEL fallback should satisfy model requirement" - ); - } + // buzz-agent OpenRouter readiness tests live in a sibling file so this + // module stays under the desktop file-size ratchet. + #[path = "openrouter_tests.rs"] + mod openrouter_tests; } // Goose file-config-aware requirement tests live in a sibling file so this diff --git a/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs b/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs new file mode 100644 index 0000000000..73b3fcda4b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs @@ -0,0 +1,57 @@ +//! buzz-agent OpenRouter readiness tests, split from `readiness.rs`'s `tests` +//! module so that file stays under the desktop file-size ratchet. +//! +//! Declared as a child of `mod tests` via `#[path]`, so `use super::*` resolves +//! against that module and reaches its `make_env`/`env_with` helpers. + +use super::*; + +#[test] +fn buzz_agent_openrouter_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "openrouter with all fields should be ready" + ); +} + +#[test] +fn buzz_agent_openrouter_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "OPENROUTER_MODEL fallback should satisfy model requirement" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index bbda1e7c86..1d7fb34120 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,5 +1,6 @@ use super::{ - find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, + bestie_assignment::recover_pending_assignment_cleanup, find_managed_agent_mut, + kill_stale_tracked_processes, load_managed_agents, load_personas, managed_agents_base_dir, save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; @@ -114,6 +115,11 @@ pub async fn restore_managed_agents_on_launch( } let mut records = load_managed_agents(app)?; + recover_pending_assignment_cleanup(&managed_agents_base_dir(app)?, |pending_pubkey| { + records + .iter() + .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) + })?; let mut runtimes = state .managed_agent_processes .lock() diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b17f361584..b120336361 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -2,9 +2,7 @@ use std::collections::HashMap; use tauri::{AppHandle, Manager}; -use super::agent_env::{ - build_buzz_agent_provider_defaults, clear_inherited_agent_config, idle_pool_sleep_env, -}; +use super::agent_env::{clear_inherited_agent_config, idle_pool_sleep_env}; use crate::{ managed_agents::{ @@ -16,7 +14,7 @@ use crate::{ util::now_iso, }; -use super::claude_config::{apply_claude_model_env, apply_effort_env}; +use super::claude_config::apply_claude_model_env; mod path; pub(in crate::managed_agents) use path::build_augmented_path; pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; @@ -227,23 +225,14 @@ pub fn build_managed_agent_summary( } }; - // Restart badge: the running process stamped the effective spawn config - // it was launched with; recompute a prospective one from current disk - // state and report every differing field. Only the tracked live pair for - // THIS workspace can drift — stopped agents spawn fresh, adopted - // (runtime_pid-only) processes have no stamp to compare, and pairs running - // for other communities are judged in their own community (comparing them - // against this workspace's relay would flag a spurious restart on every - // community switch). - // - // Adapter-availability drift (codex only) contributes its own synthetic - // entry, so an out-of-band adapter change (manual npm install/downgrade) - // that Phase-1 auto-restart doesn't cover still shows the user what moved. - // The cache is read-only here — no subprocess is spawned. - // - // Global config drives both the prospective snapshot and the descriptor - // env layering below — the caller loads it once and passes it in, so - // list-style callers pay one disk read per call rather than one per record. + // Restart badge: the running process stamped its effective spawn config; + // recompute a prospective one from current disk state and report every + // differing field. Only the tracked live pair for THIS workspace can drift + // (stopped agents spawn fresh; adopted processes have no stamp; other- + // community pairs are judged in their own community). Adapter drift + // (codex only) contributes a synthetic entry for out-of-band npm changes. + // Global config drives both snapshot and descriptor env layering; the + // caller loads it once so list callers pay one disk read per call. // The prospective side is computed only for a tracked pair: an unstamped // agent has nothing to compare against. @@ -399,6 +388,47 @@ pub(crate) fn configure_runtime_cli( } } +/// Proof token for the effort-application outer binding. `#[must_use]`; +/// makes `let effort = apply_effort_to_spawn_command(…)` a compile-time +/// requirement — deleting the binding is a compile error because +/// `spawn_with_effort_proof` consumes it by value. +/// +/// The private field prevents any crate-local code from constructing +/// `EffortApplied` directly (same shape as `RecordFieldsApplied(())`), so +/// the only way to obtain a token is to call `apply_effort_to_spawn_command`. +#[must_use] +pub(crate) struct EffortApplied(()); + +/// Apply effort env to an agent spawn command. Called by `spawn_agent_child` +/// (production) and `effort_cmd_tests` (test seam). Inner-seam: removing +/// `apply_spawn_effort_env` below turns the production-sequence tests RED. +/// Outer-seam: the returned token is consumed by `spawn_with_effort_proof`; +/// deleting this call leaves `effort` undefined at the spawn site. +pub(crate) fn apply_effort_to_spawn_command( + cmd: &mut std::process::Command, + record: &crate::managed_agents::types::ManagedAgentRecord, + runtime: Option<&crate::managed_agents::discovery::KnownAcpRuntime>, + personas: &[crate::managed_agents::types::AgentDefinition], + persona_id: Option<&str>, + global_env: &std::collections::BTreeMap, + baked_env: &std::collections::BTreeMap, +) -> EffortApplied { + super::config_bridge::effort::apply_spawn_effort_env( + cmd, record, runtime, personas, persona_id, global_env, baked_env, + ); + EffortApplied(()) +} + +/// Spawn the agent command, consuming the `EffortApplied` proof token. +/// Deleting `apply_effort_to_spawn_command` from `spawn_agent_child` leaves +/// `effort` undefined here — a compile error CI catches before any test runs. +pub(crate) fn spawn_with_effort_proof( + cmd: &mut std::process::Command, + _effort: EffortApplied, +) -> std::io::Result { + cmd.spawn() +} + /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. @@ -554,23 +584,16 @@ pub fn spawn_agent_child( // ── Readiness check: set setup-payload if agent is not ready ───────────── // - // Build the effective env the agent would have at start-time, run the - // readiness predicate, and if anything is missing, serialize the payload - // into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp detects this env var on startup - // and enters the minimal setup-listener mode instead of the agent pool. + // Build the effective env, run the readiness predicate, and serialize any + // missing requirements into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp enters + // setup-listener mode when this env var is present. // - // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS so user env - // cannot set it, but we also explicitly remove it after writing user env - // to guard against the parent-process environment. We then set it only - // when desktop has computed NotReady — the desktop is the sole readiness - // source and buzz-acp only transports the payload. + // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS (user env cannot + // set it). We also remove it after writing user env as a parent-process guard, + // then set it only when desktop computes NotReady — desktop is the sole source. // - // The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: - // { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] } - // - // `spawned_setup_mode` is captured outside the block so it can be stamped - // on `ManagedAgentProcess` — used by `install_acp_runtime` to target only - // stuck agents for auto-restart. + // `spawned_setup_mode` is captured outside the block to stamp + // `ManagedAgentProcess` (used by `install_acp_runtime` for auto-restart). let spawned_setup_mode; { use crate::managed_agents::readiness::EffectiveAgentEnv; @@ -743,8 +766,21 @@ pub fn spawn_agent_child( &mut command, resolve_session_title(record.display_name.as_deref(), &record.name), ); - build_buzz_agent_provider_defaults(&mut command); + // Remove ambient provider configuration before applying Buzz's baked and + // record-derived launch configuration. clear_inherited_agent_config(&mut command); + // Strip all known effort keys and emit exactly one projected key. Command + // inherits the parent env — the returned EffortApplied token is consumed + // by spawn_with_effort_proof below; deleting this call is a compile error. + let effort = apply_effort_to_spawn_command( + &mut command, + record, + runtime_meta, + &personas, + record.persona_id.as_deref(), + &global.env_vars, + &super::agent_env::baked_build_env(), + ); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( meta.model_env_var, @@ -815,14 +851,6 @@ pub fn spawn_agent_child( let acp_session_policy = super::apply_app_acp_session_policy_env(app, &mut command); crate::build_identity::apply_demo_config_home(&mut command)?; - // B5: carry persisted effort; harness resolves thought_level configId at first session. - // Written AFTER descriptor.env so the canonical persisted value wins over any - // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern - // (ANTHROPIC_MODEL is applied post-loop for the same reason). When effort_level is - // None there is no canonical value to assert, so env passthrough stands — user env - // legitimately seeds startup effort in that case. - apply_effort_env(&mut command, record.effort_level.as_deref()); - // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority. // BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env // would be ambiguous). @@ -852,10 +880,8 @@ pub fn spawn_agent_child( .env("BUZZ_MANAGED_AGENT", current_instance_id(app)) .env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce); - // Stamp the effective spawn config from the values that populated the - // `Command` above, BEFORE spawning. Re-resolving after `spawn()` would let - // a persona/harness/global edit landing in between stamp the NEW config - // onto a child running the OLD one, silently suppressing the badge. + // Stamp spawn config from values above, BEFORE spawning — a post-spawn + // re-resolve races config edits and would stamp the wrong values. let spawn_config = super::spawn_snapshot::SpawnConfigSnapshot::from_inputs( super::spawn_snapshot::SpawnConfigInputs { record, @@ -870,8 +896,7 @@ pub fn spawn_agent_child( }, ); - // Spawn the harness in its own process group so we can kill the entire - // tree (harness + MCP servers + agent subprocesses) on shutdown. + // Spawn in its own process group (Unix) or with CREATE_NO_WINDOW (Windows). #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -887,7 +912,7 @@ pub fn spawn_agent_child( command.creation_flags(CREATE_NO_WINDOW); } - let child = command.spawn().map_err(|error| { + let child = spawn_with_effort_proof(&mut command, effort).map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", resolved_acp_command.display(), @@ -895,14 +920,8 @@ pub fn spawn_agent_child( ) })?; - // Stamp the adapter availability for runtimes with a version gate (codex - // only). The summary builder compares this against the current cached value - // to detect out-of-band adapter changes after spawn (Phase-2 badge fallback). - // Non-codex runtimes get `None` — nothing changes for them. - // When the cache is cold (e.g. Doctor just installed and cleared the cache), - // `adapter_availability_cached()` returns `None`, so the stamp is `None` and - // the drift check is skipped until discovery warms the cache — preventing a - // false restart badge immediately after auto-restart. + // Codex: stamp adapter availability for the Phase-2 badge drift check. + // Cold cache returns `None` → drift check skipped until discovery warms it. let spawned_adapter_availability = if runtime_meta.is_some_and(|r| r.id == "codex") { super::adapter_availability_cached() } else { diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index c201c0a8a5..810ad439f2 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -31,7 +31,6 @@ use std::collections::BTreeMap; use serde::Serialize; use super::{ - claude_config::EFFORT_LEVEL_ENV_VAR, effective_config::{resolve_effective_config, EffectiveConfigResult}, known_acp_runtime, normalize_agent_args, persona_events::preview_prospective_persona_snapshot, @@ -134,13 +133,16 @@ pub(crate) struct SpawnConfigSnapshot { pub max_turn_duration_seconds: Option, pub parallelism: u32, /// The startup effort the harness will actually apply, resolved by - /// [`effective_effort`]: the persisted canonical `record.effort_level` when - /// present, else the user-seeded `BUZZ_ACP_EFFORT_LEVEL` from the layered - /// env. This is the *sole* representation of effort in the snapshot — the - /// key is stripped from `env` (see `from_inputs`) so an authority handoff - /// that leaves the effective value unchanged (canonical `low` replacing a - /// user env `low`, or the reverse) produces no spurious drift entry, and an - /// env-only edit still surfaces as exactly one `effort_level` entry. + /// [`effective_effort`]: the single effort key the harness-agnostic + /// projection left in `descriptor.env` under the runtime's destination key. + /// This is the *sole* representation of the effective effort in the + /// snapshot: the projection's destination key is stripped from `env` (see + /// `from_inputs`) so an authority handoff that leaves the effective value + /// unchanged produces no spurious drift entry, and an effort edit the + /// projection consumed surfaces as exactly one `effort_level` entry. For an + /// unknown/custom runtime the projection consumes nothing beyond the + /// sentinel, so any other effort-looking key the child receives stays in + /// `env` as ordinary state and diffs normally. pub effort_level: Option, /// The effective ACP session policy this launch applies (`channel` or /// `thread`). The harness reads `BUZZ_ACP_SESSION_POLICY` only at launch, so @@ -152,20 +154,28 @@ pub(crate) struct SpawnConfigSnapshot { pub session_policy: String, } -/// The startup effort a spawn would actually apply, mirroring `apply_effort_env` -/// exactly: the persisted canonical `record.effort_level` wins, and only when it -/// is absent does a user-supplied `BUZZ_ACP_EFFORT_LEVEL` from the layered env -/// seed startup effort. This is the resolver input for the snapshot's single -/// `effort_level` representation; the same precedence runs at spawn time in -/// `runtime.rs`, so badge and process can never disagree. -pub(crate) fn effective_effort( - record: &ManagedAgentRecord, - descriptor_env: &BTreeMap, -) -> Option { - record - .effort_level - .clone() - .or_else(|| descriptor_env.get(EFFORT_LEVEL_ENV_VAR).cloned()) +/// The startup effort a spawn actually applied, read from the single effort key +/// the harness-agnostic projection left in `descriptor.env`. +/// +/// The projection (`config_bridge::effort`) ran inside the descriptor resolver, +/// resolving the effective value over the canonical column and every env tier, +/// then reducing the env to exactly one effort key under the runtime's +/// destination key (`effort_dest_key`). Reading that key here means the badge +/// compares precisely what launched — no separate precedence to drift from the +/// spawn path, and an invalid canonical that fell through to an inherited tier +/// is reflected as the inherited value, not the raw column. +pub(crate) fn effective_effort(descriptor: &EffectiveHarnessDescriptor) -> Option { + let runtime = known_acp_runtime(&descriptor.command); + let dest_key = super::config_bridge::effort::effort_dest_key(runtime); + // Read case-insensitively (exact-first) so a mixed-case sentinel a custom + // runtime passed through (the projection uses an EMPTY suppress set, so a + // user-set `buzz_acp_effort_level` survives into `descriptor.env` and the + // child reads it as `BUZZ_ACP_EFFORT_LEVEL` on Windows) is captured here. + // The read must match the snapshot strip, which is also case-insensitive: + // if the read were exact-case it would miss the mixed-case sentinel, the + // strip would still remove it, and the value would land in neither + // `snapshot.env` nor `effort_level` — producing no restart diff on an edit. + super::config_bridge::effort::get_ci(&descriptor.env, dest_key).cloned() } impl SpawnConfigSnapshot { @@ -193,14 +203,27 @@ impl SpawnConfigSnapshot { .unwrap_or("") .to_string(), // Effort has ONE representation in the snapshot: `effort_level` - // below, always holding `effective_effort`. Stripping the env key - // here means a canonical/user-env authority handoff at the same - // value is a no-op (no phantom `env.BUZZ_ACP_EFFORT_LEVEL` add or - // remove) and an env-only effort edit surfaces as exactly one - // `effort_level` entry rather than a duplicate under `env.`. + // below, always holding the projected effective value. The keys + // stripped here mirror EXACTLY what the launch projection suppressed + // for this runtime (`snapshot_suppress_keys`): a known runtime swept + // every effort key to its single destination key, so the full set is + // stripped (a no-op beyond that dest key); an unknown/custom runtime + // used an empty suppress set (external-review-#2 pass-through), so + // only the ACP-startup sentinel is stripped and every other + // effort-looking key the child actually receives (e.g. a hand-rolled + // `GOOSE_THINKING_EFFORT`) stays as ordinary env — an edit to it must + // diff the snapshot and fire the restart badge. Stripping is + // ASCII-case-insensitive to match the projection's `apply`. env: { let mut env = descriptor.env.clone(); - env.remove(EFFORT_LEVEL_ENV_VAR); + let suppress = super::config_bridge::effort::snapshot_suppress_keys( + known_acp_runtime(&descriptor.command), + ); + env.retain(|k, _| { + !suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); env }, relay_url: relay_url.to_string(), @@ -230,10 +253,10 @@ impl SpawnConfigSnapshot { // effective value — that is correct, it is what actually runs. parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), // Sole effort representation — see the field doc and the `env` - // strip above. Resolver reads the record's canonical value and the - // raw descriptor env (before the strip), so a user-seeded env value - // is preserved as the effective effort when no canonical is set. - effort_level: effective_effort(record, &descriptor.env), + // strip above. Reads the single projected effort key the descriptor + // resolver left in `descriptor.env`, so the badge compares exactly + // what launched regardless of which tier supplied the value. + effort_level: effective_effort(descriptor), session_policy: session_policy.as_str().to_string(), } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index 03bba90cff..b5ee8d4522 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -27,86 +27,112 @@ fn effort_set_then_cleared_round_trips_to_no_effort_projection() { } #[test] -fn shadowed_user_env_effort_edit_under_canonical_is_empty_diff() { - // Canonical `high` shadows the user env seed. Editing that seed low→medium - // changes nothing effective (canonical wins and the env key is stripped), - // so the projections are identical and no badge lights. - let mut low_env = record_with_env_effort("low"); - low_env.effort_level = Some("high".into()); - let mut medium_env = record_with_env_effort("medium"); - medium_env.effort_level = Some("high".into()); +fn canonical_edit_under_record_native_env_is_empty_diff() { + // For Goose, the record-native env key `GOOSE_THINKING_EFFORT` outranks the + // canonical column (CLEAR authority order). With a record-native `low` + // present, editing the shadowed canonical high→medium changes nothing + // effective, so the projections are identical and no badge lights. + let mut high_col = record_with_env_effort("low"); + high_col.effort_level = Some("high".into()); + let mut medium_col = record_with_env_effort("low"); + medium_col.effort_level = Some("medium".into()); assert_eq!( - snap(&low_env), - snap(&medium_env), - "editing a canonical-shadowed user env must not badge" + snap(&high_col), + snap(&medium_col), + "editing a record-native-env-shadowed canonical must not badge" ); } #[test] -fn clearing_canonical_reveals_env_fallback_and_creates_a_diff() { - // Canonical `high` over a user env seed `low`: clearing the canonical drops - // the effective effort to the env fallback `low`, a real change that badges. - let mut canonical = record_with_env_effort("low"); - canonical.effort_level = Some("high".into()); - let env_only = record_with_env_effort("low"); +fn clearing_record_native_env_reveals_canonical_and_creates_a_diff() { + // Record-native env `low` shadows canonical `high`: removing the record env + // key drops resolution to the canonical `high`, a real change that badges. + let mut env_over_canonical = record_with_env_effort("low"); + env_over_canonical.effort_level = Some("high".into()); + let mut canonical_only = goose_record(); + canonical_only.effort_level = Some("high".into()); assert_ne!( - snap(&canonical), - snap(&env_only), - "clearing canonical must reveal the env fallback and badge" + snap(&env_over_canonical), + snap(&canonical_only), + "removing the record-native env must reveal the canonical and badge" ); } -// ── B5 effort: single canonical representation ─────────────────────────── +// ── Effort: single canonical representation ────────────────────────────── // // `effective_effort` and the snapshot's `effort_level` field are the sole -// carrier of startup effort. `BUZZ_ACP_EFFORT_LEVEL` is stripped from the -// snapshot `env` so an authority handoff at an unchanged effective value -// (canonical replacing a user-env seed, or the reverse) raises no spurious -// restart badge, while a genuine effort change surfaces exactly once. +// carrier of startup effort. Every effort key is stripped from the snapshot +// `env` so an authority handoff at an unchanged effective value raises no +// spurious restart badge, while a genuine effort change surfaces exactly once. -/// Look up the `env.BUZZ_ACP_EFFORT_LEVEL` leaf of a canonical snapshot, if any. +/// Look up the `env.GOOSE_THINKING_EFFORT` leaf of a canonical snapshot, if any +/// (the record()'s runtime is Goose, so this is its destination key). fn effort_env_leaf(canonical: &serde_json::Value) -> Option<&serde_json::Value> { canonical .get("env") - .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")) + .and_then(|env| env.get("GOOSE_THINKING_EFFORT")) } -/// A record whose user env seeds `BUZZ_ACP_EFFORT_LEVEL` (the pre-canonical -/// authority: no persisted `effort_level`, effort comes from user env_vars). +/// A Goose record whose record-native env seeds `GOOSE_THINKING_EFFORT` (the +/// top authority tier for Goose: effort comes from user env_vars, no column). +/// Pins `runtime = "goose"` so the effective command resolves to Goose and +/// `GOOSE_THINKING_EFFORT` is the record-*native* key — without it the record +/// falls back to the default `buzz-agent` runtime, for which that key is a +/// foreign env alias the projection suppresses rather than an authority tier. fn record_with_env_effort(value: &str) -> ManagedAgentRecord { let mut rec = record(); + rec.runtime = Some("goose".into()); rec.env_vars - .insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.into()); + .insert("GOOSE_THINKING_EFFORT".into(), value.into()); + rec +} + +/// A Goose record with no effort env: the canonical column is the authority. +fn goose_record() -> ManagedAgentRecord { + let mut rec = record(); + rec.runtime = Some("goose".into()); rec } #[test] -fn effective_effort_prefers_persisted_canonical_over_user_env() { - // Canonical wins, mirroring spawn's `apply_effort_env` (written after the - // user env layer). The env value is ignored when a canonical is present. - let mut rec = record(); - rec.effort_level = Some("high".into()); - let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); - assert_eq!(effective_effort(&rec, &env).as_deref(), Some("high")); +fn effective_effort_reads_the_projected_key_for_the_runtime() { + // The projection reduced the descriptor env to one effort key under the + // runtime's destination key. `effective_effort` reads exactly that key. + // A Goose descriptor carries `GOOSE_THINKING_EFFORT`. + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::from([("GOOSE_THINKING_EFFORT".to_string(), "high".to_string())]), + }; + assert_eq!(effective_effort(&descriptor).as_deref(), Some("high")); } #[test] -fn effective_effort_falls_back_to_user_env_when_no_canonical() { - // No persisted canonical → the user-seeded env value is the effective - // startup effort, exactly what a spawn would leave in place. - let rec = record(); - let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); - assert_eq!(effective_effort(&rec, &env).as_deref(), Some("low")); +fn effective_effort_reads_acp_sentinel_for_keyless_runtime() { + // Claude/Codex/keyless-ACP descriptors carry the effective value under the + // ACP-startup sentinel, which is the destination key for a runtime with no + // native thinking-effort env var (here: the claude adapter command). + let descriptor = EffectiveHarnessDescriptor { + command: "claude-code-acp".into(), + args: vec![], + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), + }; + assert_eq!(effective_effort(&descriptor).as_deref(), Some("low")); } #[test] -fn effective_effort_is_none_without_canonical_or_env() { - assert_eq!(effective_effort(&record(), &BTreeMap::new()), None); +fn effective_effort_is_none_without_a_projected_key() { + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + assert_eq!(effective_effort(&descriptor), None); } #[test] fn snapshot_carries_effort_in_field_not_env() { - // Always-canonicalize: a user-seeded effort reaches the snapshot ONLY as + // Always-canonicalize: a record-native effort reaches the snapshot ONLY as // the `effort_level` field; the raw env key is stripped so effort has one // representation, never two. let canonical = snap(&record_with_env_effort("low")); @@ -118,16 +144,42 @@ fn snapshot_carries_effort_in_field_not_env() { assert_eq!( effort_env_leaf(&canonical), None, - "BUZZ_ACP_EFFORT_LEVEL must be stripped from the snapshot env" + "GOOSE_THINKING_EFFORT must be stripped from the snapshot env" + ); +} + +#[test] +fn foreign_transport_sentinel_is_suppressed_for_goose() { + // A user-seeded `BUZZ_ACP_EFFORT_LEVEL` is a foreign transport key for a + // Goose descriptor: never an authority tier, and stripped from the snapshot + // env by the suppress set. Editing it low→medium changes nothing. + let mut low = record(); + low.env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "low".into()); + let mut medium = record(); + medium + .env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "medium".into()); + assert_eq!( + snap(&low), + snap(&medium), + "a foreign transport effort key must be suppressed for Goose and never badge" + ); + let canonical = snap(&low); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")), + None, + "the foreign sentinel must be stripped from the snapshot env" ); } #[test] fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { - // User env `low` (no canonical) → persisted canonical `low` while the env - // seed remains: the effective effort is `low` either way, so a restart - // would change nothing. Old raw-env snapshots would have shown drift; the - // single canonical representation makes the projections identical. + // Record-native env `low` (no column) → canonical column `low` while the + // record env remains: the effective effort is `low` either way (env wins, + // but the value is identical), so a restart would change nothing. let env_authority = record_with_env_effort("low"); let mut canonical_authority = record_with_env_effort("low"); canonical_authority.effort_level = Some("low".into()); @@ -138,30 +190,17 @@ fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { ); } -#[test] -fn equal_value_effort_authority_handoff_canonical_to_env_is_no_op() { - // The reverse direction: canonical `low` (env seed present) → env `low` - // only (canonical cleared). Effective effort stays `low`; no badge. - let mut canonical_authority = record_with_env_effort("low"); - canonical_authority.effort_level = Some("low".into()); - let env_authority = record_with_env_effort("low"); - assert_eq!( - snap(&canonical_authority), - snap(&env_authority), - "clearing the canonical while the env seed holds the same value must not badge" - ); -} - #[test] fn env_only_effort_edit_changes_effort_level_not_env() { - // An env-only effort edit (no canonical) moves the single `effort_level` - // representation and never reintroduces an `env.BUZZ_ACP_EFFORT_LEVEL` - // leaf, so the diff names `effort_level` once rather than duplicating it. + // A record-native env effort edit (no column) moves the single + // `effort_level` representation and never reintroduces a + // `env.GOOSE_THINKING_EFFORT` leaf, so the diff names `effort_level` once + // rather than duplicating it. let low = snap(&record_with_env_effort("low")); let high = snap(&record_with_env_effort("high")); assert_ne!( low, high, - "an env-only effort edit must change the snapshot" + "a record-native effort edit must change the snapshot" ); assert_eq!( low.get("effort_level").and_then(|v| v.as_str()), @@ -188,6 +227,163 @@ fn canonical_effort_edit_changes_snapshot() { ); } +/// A custom-command record whose runtime matches no known ACP runtime, so the +/// launch projection suppresses ONLY its own ACP sentinel (external-review-#2 +/// pass-through, r5): every foreign effort key survives untouched and the child +/// receives its raw effort env. +fn custom_command_record() -> ManagedAgentRecord { + let mut rec = record(); + rec.agent_command_override = Some("/opt/custom/my-agent".into()); + rec +} + +#[test] +fn custom_runtime_effort_env_stays_in_snapshot_and_diffs() { + // Regression (external review, Carl): for an unknown/custom runtime the + // launch projection strips only its own ACP sentinel, so the child receives + // the raw `GOOSE_THINKING_EFFORT` from the wrapper's env. The snapshot must + // retain that key as ordinary env — the projection consumed nothing into + // `effort_level` (its dest key, the ACP sentinel, is absent) — so an edit to + // it diffs the snapshot and fires the restart badge. The prior full strip + // erased the key from both places, producing NO restart diff on an effort + // edit and leaving the running agent on stale effort. + let mut high = custom_command_record(); + high.env_vars + .insert("GOOSE_THINKING_EFFORT".into(), "high".into()); + let canonical = snap(&high); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("GOOSE_THINKING_EFFORT")) + .and_then(|v| v.as_str()), + Some("high"), + "a custom runtime's effort env must remain in the snapshot as ordinary env" + ); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + None, + "the custom sentinel dest key is absent, so effort_level captures nothing" + ); + + let mut low = custom_command_record(); + low.env_vars + .insert("GOOSE_THINKING_EFFORT".into(), "low".into()); + assert_ne!( + snap(&low), + canonical, + "editing a custom runtime's effort env must trip the restart badge" + ); +} + +#[test] +fn known_runtime_still_strips_native_effort_env_from_snapshot() { + // The counter-case pinning the scoping: for a KNOWN runtime the full sweep + // still applies, so `GOOSE_THINKING_EFFORT` reaches the snapshot only as the + // single `effort_level` field — never as a phantom `env` entry alongside it. + let canonical = snap(&record_with_env_effort("high")); + assert_eq!( + effort_env_leaf(&canonical), + None, + "a known runtime must still strip its native effort key from the snapshot env" + ); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "the known runtime's effort must land solely in the effort_level field" + ); +} + +#[test] +fn custom_runtime_mixed_case_sentinel_is_captured_not_lost() { + // Regression (external review, Carl, P2): for an unknown/custom runtime a + // user-set mixed-case `buzz_acp_effort_level` (no column) must not vanish. + // The launch projection now reconciles it — stripping the mixed-case + // spelling and re-emitting the pass-through value under the canonical + // `BUZZ_ACP_EFFORT_LEVEL` (see `effort_tests:: + // unknown_runtime_collapses_mixed_case_sentinel_to_canonical_when_no_column`) + // — so `descriptor.env` carries exactly one canonical sentinel. The snapshot + // captures it into `effort_level` and strips it from `env`. Before the r4/r5 + // fixes the mixed-case key survived while the exact-case read missed it, so + // the value vanished from BOTH fields and an edit produced no restart diff. + let mut high = custom_command_record(); + high.env_vars + .insert("buzz_acp_effort_level".into(), "high".into()); + let canonical = snap(&high); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "a mixed-case pass-through sentinel must be captured into effort_level" + ); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("buzz_acp_effort_level")), + None, + "the sentinel is the projection's dest key and is stripped from env once represented" + ); + + // The mutation pin: editing the mixed-case sentinel must trip the badge. + // Reverting the fix (exact-case read + case-insensitive strip, or an empty + // unknown-runtime suppress set) makes both snapshots carry + // `effort_level = null` with the key stripped, so they compare equal and + // this assertion fails. + let mut low = custom_command_record(); + low.env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + assert_ne!( + snap(&low), + canonical, + "editing a mixed-case custom-runtime sentinel must trip the restart badge" + ); +} + +#[test] +fn custom_runtime_canonical_column_wins_over_mixed_case_sentinel() { + // The with-canonical-column collision case Carl asked for, verified at the + // SNAPSHOT here and — decisively — at the projection/descriptor seam in + // `effort_tests::unknown_runtime_column_wins_over_mixed_case_sentinel`. The + // projection strips every case variant of the sentinel before emitting the + // column value, so `descriptor.env` carries exactly `BUZZ_ACP_EFFORT_LEVEL= + // ` and the child receives the column value on every platform (no + // lowercase variant survives for Windows to case-fold over the canonical + // key). This snapshot therefore reads the same truth the child gets: the + // column wins `effort_level` and both case variants are absent from `env`. + let mut high_col = custom_command_record(); + high_col.effort_level = Some("high".into()); + high_col + .env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + let canonical = snap(&high_col); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "the canonical column wins effort_level over the pass-through sentinel" + ); + let env = canonical.get("env").expect("snapshot has an env object"); + assert_eq!( + env.get("BUZZ_ACP_EFFORT_LEVEL"), + None, + "the projection-emitted canonical sentinel is stripped from env" + ); + assert_eq!( + env.get("buzz_acp_effort_level"), + None, + "the user's mixed-case sentinel duplicate is stripped case-insensitively" + ); + + // Editing the authority (the column) still trips the badge. + let mut low_col = custom_command_record(); + low_col.effort_level = Some("low".into()); + low_col + .env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + assert_ne!( + snap(&low_col), + canonical, + "editing the canonical column must trip the restart badge" + ); +} + use crate::managed_agents::spawn_snapshot::{ eligible_restart_diff, prospective_spawn_config_snapshot, RestartDiffEntry, SpawnConfigSnapshot, TrackedSpawnState, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index b3c9d4b53c..2620f0337f 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -466,8 +466,14 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, - /// Canonical Claude Code effort level. Injected as `BUZZ_ACP_EFFORT_LEVEL` at spawn - /// so the harness applies it via `session/set_config_option` at session creation. + /// Canonical, harness-agnostic startup effort level. This is the single + /// persisted effort authority: at spawn the launch projection + /// (`config_bridge::effort`) resolves the effective value over this column + /// and all env tiers, then emits it under the destination runtime's native + /// key — `GOOSE_THINKING_EFFORT` for Goose, `BUZZ_AGENT_THINKING_EFFORT` for + /// buzz-agent, or the `BUZZ_ACP_EFFORT_LEVEL` startup sentinel for + /// Claude/Codex and keyless/unknown adapters. Preserved across runtime + /// switches (invalid values skip-as-absent at projection time). #[serde(default, skip_serializing_if = "Option::is_none")] pub effort_level: Option, } @@ -656,6 +662,18 @@ pub struct AcpRuntimeCatalogEntry { pub provider_env_var: Option, /// Environment variable used to apply thinking effort, when supported. pub thinking_env_var: Option, + /// Canonical accepted effort values for this runtime, in display order. + /// Serialized from `KnownAcpRuntime::effort_normalization.canonical` for + /// runtimes with a static finite vocabulary (e.g. Goose). `None` for + /// runtimes with no canonicalization contract (buzz-agent uses a + /// provider/model catalog; Claude/Codex/unknown runtimes accept any string). + /// + /// The renderer uses this to drive choices and validation, replacing the + /// TS-side `GOOSE_EFFORT_CANONICAL_VALUES` duplicate. When non-null, the + /// `harnessNative` effort field uses this list exclusively — `off` and all + /// other valid Goose values are always present when this is Goose, so + /// `useEffortAutoClear` never incorrectly deletes a valid saved value. + pub effort_canonical_values: Option>, pub max_tokens_env_var: Option, pub context_limit_env_var: Option, pub max_rounds_env_var: Option, diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index a7b379ac83..824ca4ccf3 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -260,6 +260,16 @@ pub struct UpdateManagedAgentRequest { /// normalized server-side). #[serde(default)] pub respond_to_allowlist: Option>, + /// Absent = don't touch. `null` = clear the canonical effort column + /// (revert to inherited default). `"value"` = set the column. + /// + /// When present, persisted inside the locked update/restart transaction + /// so that an access-policy-change restart snapshots and launches the new + /// effort value rather than the old one. Uses the same + /// `apply_picker_effort_level` logic (via `apply_effort_update`) so + /// the record-scope alias sweep runs atomically with the column write. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub effort_level: Option>, } #[cfg(test)] diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 64c8df05a7..51f648769d 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -57,30 +57,47 @@ fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator Option { - let target_hex = event.pubkey.to_hex(); - let Ok(target_pubkey) = nostr::PublicKey::from_hex(&target_hex) else { + if event.kind != nostr::Kind::Metadata { return None; - }; - - for tag in event.tags.iter() { - let slice = tag.as_slice(); - if slice.first().map(String::as_str) != Some("auth") || slice.len() != 4 { - continue; - } - let Ok(json) = serde_json::to_string(slice) else { - continue; - }; - if let Ok(owner_pubkey) = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey) { - return Some(owner_pubkey.to_hex()); - } } - None + let mut auth_tags = tags_named(event, "auth"); + let auth_tag = auth_tags.next()?; + // Count malformed auth tags too: no first-valid-tag fallback is permitted. + if auth_tags.next().is_some() { + return None; + } + + let json = serde_json::to_string(auth_tag).ok()?; + // The structural parser also enforces canonical lowercase key/signature hex. + buzz_sdk_pkg::nip_oa::parse_auth_tag(&json).ok()?; + event.verify().ok()?; + let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &event.pubkey).ok()?; + let conditions = auth_tag.get(2)?; + // Syntax/ranges were checked by the SDK; evaluate every signed clause as-is. + let applies = conditions.is_empty() + || conditions.split('&').all(|clause| { + if let Some(value) = clause.strip_prefix("kind=") { + value.parse::() == Ok(event.kind.as_u16()) + } else if let Some(value) = clause.strip_prefix("created_at<") { + value + .parse::() + .is_ok_and(|bound| event.created_at.as_secs() < bound) + } else if let Some(value) = clause.strip_prefix("created_at>") { + value + .parse::() + .is_ok_and(|bound| event.created_at.as_secs() > bound) + } else { + false + } + }); + + applies.then(|| owner.to_hex()) } pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool { @@ -588,3 +605,6 @@ fn days_to_ymd(days: i64) -> (i64, u32, u32) { #[cfg(test)] mod tests; + +#[cfg(test)] +mod oa_profile_tests; diff --git a/desktop/src-tauri/src/nostr_convert/agent_directory.rs b/desktop/src-tauri/src/nostr_convert/agent_directory.rs index 28604de5e5..1429efa4fa 100644 --- a/desktop/src-tauri/src/nostr_convert/agent_directory.rs +++ b/desktop/src-tauri/src/nostr_convert/agent_directory.rs @@ -14,6 +14,7 @@ use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey, pub fn managed_agent_pubkeys_from_events(events: &[Event]) -> std::collections::HashSet { events .iter() + .filter(|event| event.kind == nostr::Kind::Custom(30177) && event.verify().is_ok()) .filter_map(|event| first_tag_value(event, "d")) .filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok()) .map(|pubkey| pubkey.to_hex()) @@ -40,9 +41,25 @@ fn relay_agents_from_legacy_events(events: &[Event]) -> Vec { latest .into_values() .filter_map(|event| { + if event.kind != nostr::Kind::Custom(10100) || event.verify().is_err() { + return None; + } let value = agents_from_events(std::slice::from_ref(event)); let mut agent: RelayAgentInfo = serde_json::from_value(value.get("agents")?.as_array()?.first()?.clone()).ok()?; + // The generic converter defaults missing status to offline for + // compatibility. Discovery must retain only explicit, known runtime + // evidence from this verified latest event, never that fallback. + agent.status = serde_json::from_str::(&event.content) + .ok() + .and_then(|content| { + content + .get("status")? + .as_str() + .filter(|status| matches!(*status, "online" | "away" | "offline")) + .map(str::to_owned) + }) + .unwrap_or_else(|| "unknown".to_string()); // Legacy directory entries are not authenticated managed-policy // coordinates, so they must not drive the live 30177 watcher. agent.owner_pubkey = None; @@ -67,11 +84,15 @@ pub fn relay_agents_from_directory_events( .into_iter() .map(|agent| (agent.pubkey.clone(), agent)) .collect(); - for agent_pubkey in verified_policies.keys() { - agents.remove(agent_pubkey); - } for (agent_pubkey, event) in verified_policies { - if let Some(agent) = relay_agent_from_managed_policy(&agent_pubkey, event) { + // Remove even when policy parsing fails: invalid latest policy must not + // revive runtime permissions. Only verified runtime liveness survives + // a valid policy overlay; ownership, permissions and membership do not. + let runtime = agents.remove(&agent_pubkey); + if let Some(mut agent) = relay_agent_from_managed_policy(&agent_pubkey, event) { + if let Some(runtime) = runtime { + agent.status = runtime.status; + } agents.insert(agent_pubkey, agent); } } @@ -96,6 +117,9 @@ pub fn verified_agent_owners_from_profiles(events: &[Event]) -> HashMap( } fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option { + // Check the envelope as well as the declared author. Keep invalid latest + // coordinates reserved above so they cannot revive older legacy permissions. + if event.kind != nostr::Kind::Custom(30177) || event.verify().is_err() { + return None; + } let content = managed_agent_content_from_event(event).ok()?; Some(RelayAgentInfo { pubkey: agent_pubkey.to_string(), @@ -135,7 +164,8 @@ fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option< channels: Vec::new(), channel_ids: Vec::new(), capabilities: Vec::new(), - status: "offline".to_string(), + // Ownership/policy proves discovery, not conversational liveness. + status: "unknown".to_string(), respond_to: Some(content.respond_to), respond_to_allowlist: content.respond_to_allowlist, }) @@ -157,28 +187,48 @@ pub fn relay_agents_from_managed_agent_events( } /// Build a pubkey-to-channel-id candidate map from relay-signed membership -/// events. Only p-tags explicitly marked with the `bot` role are agents. +/// events. Known agent identities need not have the cosmetic `bot` role; +/// otherwise only explicit bot tags seed discovery. pub fn member_agent_channel_ids_from_events( events: &[Event], relay_pubkey: &str, + known_agent_pubkeys: &std::collections::HashSet, ) -> HashMap> { - let mut channel_ids: HashMap> = HashMap::new(); + let mut latest: HashMap = HashMap::new(); for event in events { - if !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) { + if event.kind != nostr::Kind::Custom(39002) + || !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) + || event.verify().is_err() + { continue; } let Some(channel_id) = first_tag_value(event, "d") else { continue; }; + if latest + .get(channel_id) + .is_none_or(|previous| event_is_newer(event, previous)) + { + latest.insert(channel_id.to_string(), event); + } + } + let mut channel_ids: HashMap> = HashMap::new(); + for (channel_id, event) in latest { for tag in tags_named(event, "p") { - let (Some(pubkey), Some(role)) = (tag.get(1), tag.get(3)) else { + let Some(pubkey) = tag + .get(1) + .and_then(|key| nostr::PublicKey::from_hex(key).ok()) + else { continue; }; - if role != "bot" || nostr::PublicKey::from_hex(pubkey).is_err() { + let pubkey = pubkey.to_hex(); + if tag.get(3).map(String::as_str) != Some("bot") + && !known_agent_pubkeys.contains(&pubkey) + { continue; } channel_ids - .entry(pubkey.clone()) + .entry(pubkey) .or_default() .insert(channel_id.to_string()); } diff --git a/desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs b/desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs new file mode 100644 index 0000000000..0e031b70b5 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs @@ -0,0 +1,216 @@ +//! NIP-OA profile regressions. All keys, timestamps and Schnorr nonces are +//! synthetic and fixed; these fixtures require no clock, RNG, relay or config. + +use nostr::hashes::{sha256, Hash}; +use nostr::secp256k1::{schnorr::Signature, Keypair, Message}; +use nostr::{Event, EventBuilder, Keys, Kind, SecretKey, Tag, Timestamp, SECP256K1}; + +use super::{ + profile_has_valid_oa_owner, profile_info_from_event, profile_valid_oa_owner_pubkey, tags_named, + user_search_result_from_event, users_batch_from_events, verified_agent_owners_from_profiles, +}; + +const CREATED_AT: u64 = 1_700_000_000; + +// Public test scalars, matching the owner/agent identities in NIP-OA's vectors. +fn keys(scalar: u8) -> Keys { + let mut bytes = [0; 32]; + bytes[31] = scalar; + Keys::new(SecretKey::from_slice(&bytes).unwrap()) +} + +fn sign(keys: &Keys, message: Message) -> Signature { + let keypair = Keypair::from_secret_key(SECP256K1, keys.secret_key()); + SECP256K1.sign_schnorr_no_aux_rand(&message, &keypair) +} + +// Intentionally bypass the SDK's *creation* validation so malformed conditions +// and self-attestation can have genuine signatures and exercise verification. +fn auth_tag_for(owner: &Keys, agent: &Keys, conditions: &str) -> Tag { + let preimage = format!( + "nostr:agent-auth:{}:{conditions}", + agent.public_key().to_hex() + ); + let digest = sha256::Hash::hash(preimage.as_bytes()).to_byte_array(); + let signature = sign(owner, Message::from_digest(digest)); + Tag::parse(vec![ + "auth".to_string(), + owner.public_key().to_hex(), + conditions.to_string(), + signature.to_string(), + ]) + .unwrap() +} + +fn auth_tag(conditions: &str) -> Tag { + auth_tag_for(&keys(1), &keys(2), conditions) +} + +fn event(kind: Kind, created_at: u64, tags: Vec) -> Event { + let agent = keys(2); + let mut unsigned = EventBuilder::new(kind, r#"{"display_name":"Synthetic agent"}"#) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .build(agent.public_key()); + let signature = sign(&agent, Message::from_digest(unsigned.id().to_bytes())); + unsigned.add_signature(signature).unwrap() +} + +fn profile(tags: Vec) -> Event { + event(Kind::Metadata, CREATED_AT, tags) +} + +fn assert_ownership(event: &Event, expected: Option) { + assert_eq!(profile_valid_oa_owner_pubkey(event), expected); + assert_eq!(profile_has_valid_oa_owner(event), expected.is_some()); + + let info = profile_info_from_event(event).unwrap(); + assert_eq!(info.owner_pubkey, expected); + assert_eq!(info.pubkey, event.pubkey.to_hex()); + let search = user_search_result_from_event(event); + assert_eq!(search.owner_pubkey, expected); + assert_eq!(search.is_agent, expected.is_some()); + assert_eq!(search.pubkey, event.pubkey.to_hex()); + let pubkey = event.pubkey.to_hex(); + let batch = users_batch_from_events(std::slice::from_ref(event), std::slice::from_ref(&pubkey)); + assert_eq!(batch.profiles[&pubkey].owner_pubkey, expected); + assert_eq!(batch.profiles[&pubkey].is_agent, expected.is_some()); + let owners = verified_agent_owners_from_profiles(std::slice::from_ref(event)); + assert_eq!(owners.get(&pubkey), expected.as_ref()); +} + +#[test] +fn accepts_unconditional_and_applicable_conditional_ownership() { + for conditions in [ + "", + "kind=0", + "created_at>1699999999&kind=0&created_at<1700000001", + "created_at<1700000001&created_at>1699999999&kind=0&kind=0", + ] { + let tag = auth_tag(conditions); + // Check that deterministic fixture signing agrees with the SDK verifier. + let json = serde_json::to_string(tag.as_slice()).unwrap(); + assert_eq!( + buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &keys(2).public_key()).unwrap(), + keys(1).public_key() + ); + assert_ownership(&profile(vec![tag]), Some(keys(1).public_key().to_hex())); + } +} + +#[test] +fn rejects_duplicate_auth_tags_including_malformed_tags_in_either_order() { + let valid = auth_tag(""); + let malformed = Tag::parse(["auth"]).unwrap(); + for tags in [ + vec![valid.clone(), valid.clone()], + vec![valid.clone(), auth_tag("kind=0")], + vec![valid.clone(), malformed.clone()], + vec![malformed, valid], + ] { + let event = profile(tags); + assert_eq!(tags_named(&event, "auth").count(), 2); + assert_ownership(&event, None); + } +} + +#[test] +fn rejects_wrong_kind_condition_and_conflicting_clauses() { + for conditions in ["kind=1", "kind=0&kind=1", "kind=1&kind=0"] { + assert_ownership(&profile(vec![auth_tag(conditions)]), None); + } +} + +#[test] +fn time_bounds_are_strict_and_use_event_time_not_wall_clock() { + let tag = auth_tag("created_at>1699999999&created_at<1700000001"); + for (timestamp, accepted) in [ + (1_699_999_998, false), + (1_699_999_999, false), + (CREATED_AT, true), + (1_700_000_001, false), + (1_700_000_002, false), + (u64::from(u32::MAX) + 1, false), + ] { + let event = event(Kind::Metadata, timestamp, vec![tag.clone()]); + let expected = accepted.then(|| keys(1).public_key().to_hex()); + assert_ownership(&event, expected); + } +} + +#[test] +fn rejects_malformed_tag_shapes_and_hex() { + let valid = auth_tag("").as_slice().to_vec(); + let mut extra = valid.clone(); + extra.push("extra".to_string()); + let mut bad_owner = valid.clone(); + bad_owner[1] = "not-a-pubkey".to_string(); + let mut uppercase_owner = valid.clone(); + uppercase_owner[1] = uppercase_owner[1].to_uppercase(); + let mut uppercase_signature = valid.clone(); + uppercase_signature[3] = uppercase_signature[3].to_uppercase(); + let mut bad_signature = valid.clone(); + bad_signature[3] = "00".repeat(64); + for values in [ + vec!["auth".to_string()], + valid[..3].to_vec(), + extra, + bad_owner, + uppercase_owner, + uppercase_signature, + bad_signature, + ] { + assert_ownership(&profile(vec![Tag::parse(values).unwrap()]), None); + } +} + +#[test] +fn rejects_signed_but_malformed_conditions() { + for conditions in [ + "kind=0&", + "&kind=0", + "kind=0&&kind=0", + "kind=00", + "kind=65536", + "kind=0 ", + "kind=٠", + "Kind=0", + "created_at=1700000000", + "created_at<4294967296", + "created_at>-1", + "unsupported=0", + ] { + assert_ownership(&profile(vec![auth_tag(conditions)]), None); + } +} + +#[test] +fn rejects_absent_authority_self_attestation_and_wrong_agent_binding() { + assert_ownership(&profile(vec![]), None); + assert_ownership( + &profile(vec![ + Tag::parse(["owner", &keys(1).public_key().to_hex()]).unwrap() + ]), + None, + ); + assert_ownership(&profile(vec![auth_tag_for(&keys(2), &keys(2), "")]), None); + assert_ownership(&profile(vec![auth_tag_for(&keys(1), &keys(3), "")]), None); +} + +#[test] +fn rejects_non_profile_and_invalid_event_even_with_valid_auth_tag() { + assert_ownership(&event(Kind::TextNote, CREATED_AT, vec![auth_tag("")]), None); + + let mut wrong_id = profile(vec![auth_tag("")]); + wrong_id.content = r#"{"display_name":"Tampered"}"#.to_string(); + assert!(wrong_id.verify().is_err()); + assert_ownership(&wrong_id, None); + + let mut wrong_signature = profile(vec![auth_tag("")]); + wrong_signature.sig = sign( + &keys(3), + Message::from_digest(wrong_signature.id.to_bytes()), + ); + assert!(wrong_signature.verify().is_err()); + assert_ownership(&wrong_signature, None); +} diff --git a/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs b/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs new file mode 100644 index 0000000000..dd0dda7f11 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs @@ -0,0 +1,143 @@ +//! Bind availability provenance to the production runtime/policy merge. + +use super::*; + +fn fixture() -> (Keys, Event, Event) { + let owner = Keys::generate(); + let agent = Keys::generate(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("compute ownership"); + let values: Vec = serde_json::from_str(&auth).expect("parse ownership"); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags([Tag::parse(values).expect("ownership tag")]) + .sign_with_keys(&agent) + .expect("sign identity"); + let policy = managed_agent_event( + &owner, + &agent.public_key().to_hex(), + "Policy name", + "allowlist", + &["a".repeat(64)], + ); + (agent, profile, policy) +} + +fn runtime(keys: &Keys, status: Option, timestamp: u64) -> Event { + let mut content = serde_json::json!({ + "name": "Runtime name", + "owner_pubkey": "b".repeat(64), + "respond_to": "anyone", + "respond_to_allowlist": ["b".repeat(64)], + "channels": ["Untrusted"], + "channel_ids": ["untrusted-channel"], + "capabilities": ["untrusted-capability"] + }); + if let Some(status) = status { + content["status"] = status; + } + EventBuilder::new(Kind::Custom(10100), content.to_string()) + .custom_created_at(nostr::Timestamp::from(timestamp)) + .sign_with_keys(keys) + .expect("sign runtime") +} + +fn assert_merge(directory: &[Event], profile: &Event, policy: &Event, status: &str) { + let merged = relay_agents_from_directory_events( + directory, + std::slice::from_ref(policy), + std::slice::from_ref(profile), + ); + assert_eq!(merged.len(), 1); + let agent = &merged[0]; + assert_eq!(agent.status, status); + assert_eq!(serde_json::to_value(agent).unwrap()["status"], status); + assert_eq!(agent.pubkey, profile.pubkey.to_hex()); + assert_eq!(agent.owner_pubkey, Some(policy.pubkey.to_hex())); + assert_eq!(agent.name, "Policy name"); + assert_eq!( + agent.respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(agent.respond_to_allowlist, vec!["a".repeat(64)]); + assert!( + agent.channel_ids.is_empty(), + "runtime cannot grant membership" + ); + assert!(agent.channels.is_empty()); + assert!(agent.capabilities.is_empty()); +} + +fn assert_known_status(status: &str) { + let (keys, profile, policy) = fixture(); + assert_merge( + &[runtime(&keys, Some(json!(status)), 10)], + &profile, + &policy, + status, + ); +} + +#[test] +fn policy_preserves_signed_online_runtime() { + assert_known_status("online"); +} + +#[test] +fn policy_preserves_signed_away_runtime() { + assert_known_status("away"); +} + +#[test] +fn policy_preserves_signed_offline_runtime() { + assert_known_status("offline"); +} + +#[test] +fn missing_or_unrecognized_runtime_status_is_unknown() { + let (keys, profile, policy) = fixture(); + for status in [ + None, + Some(Value::Null), + Some(json!(42)), + Some(json!("busy")), + Some(json!("unknown")), + ] { + let directory = runtime(&keys, status, 10); + assert_merge( + std::slice::from_ref(&directory), + &profile, + &policy, + "unknown", + ); + let legacy = relay_agents_from_directory_events(&[directory], &[], &[]); + assert_eq!(legacy[0].status, "unknown", "no default offline evidence"); + } +} + +#[test] +fn policy_only_has_unknown_availability() { + let (_, profile, policy) = fixture(); + assert_merge(&[], &profile, &policy, "unknown"); +} + +#[test] +fn latest_runtime_without_status_does_not_revive_older_online_status() { + let (keys, profile, policy) = fixture(); + let online = runtime(&keys, Some(json!("online")), 10); + let missing = runtime(&keys, None, 20); + for directory in [[online.clone(), missing.clone()], [missing, online]] { + assert_merge(&directory, &profile, &policy, "unknown"); + } +} + +#[test] +fn forged_latest_runtime_cannot_supply_or_revive_availability() { + let (keys, profile, policy) = fixture(); + let old = runtime(&keys, Some(json!("online")), 10); + let new = runtime(&keys, Some(json!("away")), 20); + let mut value = serde_json::to_value(new).unwrap(); + value["content"] = json!(r#"{"status":"online"}"#); + let forged: Event = serde_json::from_value(value).unwrap(); + assert!(forged.verify().is_err()); + assert_merge(&[old, forged], &profile, &policy, "unknown"); +} diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs index 9401d19add..68d8cb7dcb 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -3,6 +3,9 @@ use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; +#[path = "runtime_policy_tests.rs"] +mod runtime_policy_tests; + /// Build a signed event for testing with the given kind, content, and tags. fn ev(kind: u16, content: &str, tags: Vec>) -> Event { let keys = Keys::generate(); @@ -437,6 +440,11 @@ fn managed_agent_directory_accepts_only_the_verified_owner_policy() { assert_eq!(agents.len(), 1); assert_eq!(agents[0].pubkey, agent_pubkey); assert_eq!(agents[0].name, "Codex"); + assert_eq!(agents[0].status, "unknown"); + assert_eq!( + serde_json::to_value(&agents[0]).unwrap()["status"], + "unknown" + ); assert_eq!( agents[0].respond_to, Some(crate::managed_agents::RespondTo::Allowlist) @@ -510,8 +518,11 @@ fn managed_agent_candidates_use_only_relay_signed_bot_membership() { vec![vec!["d", "forged"], vec!["p", &agent_pubkey, "", "bot"]], ); - let channel_ids = - member_agent_channel_ids_from_events(&[forged, general], &relay_keys.public_key().to_hex()); + let channel_ids = member_agent_channel_ids_from_events( + &[forged, general], + &relay_keys.public_key().to_hex(), + &Default::default(), + ); assert_eq!( channel_ids.get(&agent_pubkey), @@ -760,3 +771,62 @@ fn timestamp_to_iso_known_value() { // Epoch assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z"); } + +#[test] +fn known_owned_agents_have_membership_independent_of_role() { + let relay = Keys::generate(); + let agent = Keys::generate().public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "general"]).unwrap(), + Tag::parse(["p", &agent, "", "member"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + let memberships = member_agent_channel_ids_from_events( + &[event], + &relay.public_key().to_hex(), + &std::collections::HashSet::from([agent.clone()]), + ); + assert_eq!(memberships.get(&agent), Some(&vec!["general".to_string()])); +} + +#[test] +fn managed_directory_rejects_tampered_event_envelopes() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let auth: Vec = serde_json::from_str(&auth).unwrap(); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags([Tag::parse(auth).unwrap()]) + .sign_with_keys(&agent) + .unwrap(); + let policy = managed_agent_event( + &owner, + &agent.public_key().to_hex(), + "Scout", + "owner-only", + &[], + ); + let tamper = |event: &Event, content: &str| -> Event { + let mut value = serde_json::to_value(event).unwrap(); + value["content"] = serde_json::json!(content); + serde_json::from_value(value).unwrap() + }; + let forged_policy = tamper( + &policy, + r#"{"name":"Scout","parallelism":1,"respond_to":"anyone"}"#, + ); + assert!(forged_policy.verify().is_err()); + assert!( + relay_agents_from_managed_agent_events(&[forged_policy], std::slice::from_ref(&profile),) + .is_empty(), + "an owner pubkey string is not an owner signature" + ); + let forged_profile = tamper(&profile, r#"{"name":"forged"}"#); + assert!(forged_profile.verify().is_err()); + assert!( + relay_agents_from_managed_agent_events(&[policy], &[forged_profile],).is_empty(), + "a valid OA tag does not authenticate the profile envelope" + ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index eb5ab5a95d..57cd1554b4 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { ProtectedGlobalOverlay } from "@protected-feature-components"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; @@ -986,6 +987,7 @@ export function AppShell() { onOpenChange={setIsSendFeedbackOpen} open={isSendFeedbackOpen} /> + {!isHuddleRoom ? : null} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 64509387fe..a4dd4b346d 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -200,25 +200,30 @@ with a TypeScript lookup table or an id comparison in a component. agent from Agents, a DM, or a channel must expose the same actions, tabs, fields, and profile-wide activity selection. Caller context may control the panel shell or return navigation, but must not filter or replace profile - content. + content. Explicit public-key targets are always exact, including stopped, + archived, and relay-only identities. Only explicit persona navigation may + select a representative or offer persona Start; a relay persona link cannot + borrow a local sibling's management controls. See + [the identity contract](../../../../docs/agent-profile-identity.md). 14. **Thinking effort has two surfaces: a local-only WRITE control and a read-only two-facts DISPLAY.** The write control is `EffortPickerField` (`ui/EffortPickerField.tsx`), a self-contained section component mounted in - `AgentInstanceEditDialog` beside the Model block. It is direct-write, not - part of the frozen `UpdateManagedAgentInput` shape: each selection calls - `persistAgentEffortLevel` and invalidates the config-surface query, mirroring - the `setManagedAgentAutoRestart` standalone-setter precedent. Its gating and - option compute live in the pure helper `ui/effortPicker.ts` - (`effortPickerState`): the picker renders only when - `agent.backend.type === "local"` **AND** a `thought_level` `effortConfigId` - has been discovered from the running session (absent pre-first-session and - for runtimes/models without effort support). Local-only is load-bearing, not - cosmetic — the Rust command rejects non-local backends because remote effort - is set at deploy time via `policy_env`. Because it reads its inputs from the - config surface the dialog already fetches (`useAgentConfigSurface`) and owns - its own mutation, it does **not** thread new props through the dialog (see - rule 11): keep effort state inside the section component, never as - dialog-level props. The read-only display is the `thinkingEffort` + `AgentInstanceEditDialog` beside the Model block. It is **Save-gated, not + direct-write**: the control is fully controlled by the parent dialog + (`value`/`onChange`) and owns no mutation. The dialog persists the selection + by embedding `effortLevel` in the locked `update_managed_agent` IPC call, so + the effort write is atomic with any access-policy change and can never race + or survive a Cancel or failed Save. There is no standalone + `persistAgentEffortLevel` setter. Its gating and option compute live in the + pure helper `ui/effortPicker.ts` (`effortPickerState`): the picker renders + only when `agent.backend.type === "local"` **AND** a `thought_level` + `effortConfigId` has been discovered from the running session (absent + pre-first-session and for runtimes/models without effort support). Local-only + is load-bearing, not cosmetic — the Rust command rejects non-local backends + because remote effort is set at deploy time via `policy_env`. Because the + control reads its inputs from the config surface the dialog already fetches + (`useAgentConfigSurface`), it integrates into the dialog's existing field + group without additional IPC. The read-only display is the `thinkingEffort` normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which already shows both facts — `field.value` (canonical, the effort the next spawn will launch with) and, when a running ACP session differs, diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index 92159ff275..520b51b99e 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -79,20 +79,93 @@ test("Goose exposes provider, model, and its real effort application key", () => scope: "global", }); - assert.equal( - field(model, "effort").optionSource, - "legacyProviderModelCatalog", - ); + assert.equal(field(model, "effort").optionSource, "harnessNative"); assert.deepEqual(field(model, "effort").currentPersistence, { kind: "envVar", - key: "BUZZ_AGENT_THINKING_EFFORT", + key: "GOOSE_THINKING_EFFORT", }); assert.deepEqual(field(model, "effort").targetApplication, { kind: "envVar", key: "GOOSE_THINKING_EFFORT", }); + // Goose reads/writes its native key at global scope — the launch projection's + // global tier is native-only, so the legacy BUZZ_AGENT_THINKING_EFFORT in the + // config is not surfaced as the effort value (it would be silently ignored). + assert.equal(field(model, "effort").value, null); }); +// Carl (review 5036131024): global/onboarding effort persistence must use the +// runtime's native key so a selection reaches the spawn. The launch projection's +// global tier reads native-only (legacy alias is record/persona-scope), so +// persisting the legacy key for Goose round-trips in the UI but is ignored at +// spawn. Both scopes derive the same persistence/application key. +for (const scope of ["global", "onboarding"]) { + test(`effort persists to the runtime native key at ${scope} scope`, () => { + const goose = deriveAgentConfigFieldModel({ + config: { ...config, env_vars: { GOOSE_THINKING_EFFORT: "high" } }, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope, + }); + const gooseEffort = field(goose, "effort"); + assert.deepEqual(gooseEffort.currentPersistence, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.deepEqual(gooseEffort.targetApplication, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.equal(gooseEffort.value, "high"); + assert.deepEqual(structuredEnvKeys([gooseEffort]), [ + "GOOSE_THINKING_EFFORT", + ]); + + const buzz = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + }), + scope, + }); + const buzzEffort = field(buzz, "effort"); + assert.deepEqual(buzzEffort.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); + assert.equal(buzzEffort.value, "high"); + }); +} + +// Per-agent scopes (definition/instance) intentionally keep effort on the +// generic legacy BUZZ_AGENT_THINKING_EFFORT row until PR 2.7 migrates Goose — +// currentPersistence/value stay legacy while targetApplication is native +// (agents/AGENTS.md rule 2). The scope gate must not broaden to these scopes. +for (const scope of ["definition", "instance"]) { + test(`Goose effort stays on the legacy persistence key at ${scope} scope`, () => { + const model = deriveAgentConfigFieldModel({ + config: { + ...config, + env_vars: { + BUZZ_AGENT_THINKING_EFFORT: "high", + GOOSE_THINKING_EFFORT: "low", + }, + }, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope, + }); + const effort = field(model, "effort"); + assert.deepEqual(effort.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); + assert.deepEqual(effort.targetApplication, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.equal(effort.value, "high"); + }); +} + test("Claude models effort as a deferred native ACP option", () => { const model = deriveAgentConfigFieldModel({ config, @@ -561,3 +634,90 @@ test("NUMERIC_KIND_MIN_contextLimit_is_1", () => { test("NUMERIC_KIND_MIN_maxRounds_is_0", () => { assert.equal(NUMERIC_KIND_MIN.maxRounds, 0); }); + +// ── P2 regression: Goose optionSource + isHarnessNativeEffort guard ─────────── +// +// Source-level reproduction of the P2 blocker: save global Goose defaults with +// GOOSE_THINKING_EFFORT=off, then open AI defaults. Previously, optionSource +// was "legacyProviderModelCatalog" → AgentConfigFields passed the persisted key +// to useEffortAutoClear with buzz-agent provider/model vocab → "off" not in +// that list → hook deleted the valid native value on mount. Fix: emit +// "harnessNative" so AgentConfigFields can detect isHarnessNativeEffort and +// make the hook a no-op. + +test("Goose_optionSource_is_harnessNative_not_legacyProviderModelCatalog", () => { + // The sole optionSource change (P2 fix): Goose must NOT be + // "legacyProviderModelCatalog" because that routes effort into the + // buzz-agent provider/model catalog, deleting valid Goose values on mount. + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope: "global", + }); + const effortField = field(model, "effort"); + assert.equal( + effortField.optionSource, + "harnessNative", + 'Goose global optionSource must be "harnessNative" — "legacyProviderModelCatalog" routes to buzz-agent vocab and deletes valid `off` on mount', + ); +}); + +test("Goose_global_off_value_is_preserved_by_harnessNative_optionSource", () => { + // A saved GOOSE_THINKING_EFFORT=off must round-trip through the field model + // without deletion. The field value reflects the config value, and + // optionSource="harnessNative" signals to AgentConfigFields that the + // auto-clear hook should be a no-op (no buzz-agent vocab gate). + const savedConfig = { + ...config, + env_vars: { GOOSE_THINKING_EFFORT: "off" }, + }; + const model = deriveAgentConfigFieldModel({ + config: savedConfig, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope: "global", + }); + const effortField = field(model, "effort"); + assert.equal( + effortField.optionSource, + "harnessNative", + "Goose effort field must use harnessNative optionSource", + ); + assert.equal( + effortField.value, + "off", + "saved GOOSE_THINKING_EFFORT=off must survive round-trip through field model (not deleted by buzz-agent vocab check)", + ); +}); + +test("Goose_onboarding_optionSource_is_harnessNative", () => { + // Same contract at onboarding scope — the persistence key is the native key + // at both global and onboarding, so both must guard against buzz-agent vocab. + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope: "onboarding", + }); + assert.equal( + field(model, "effort").optionSource, + "harnessNative", + "Goose onboarding optionSource must also be harnessNative", + ); +}); + +test("buzz_agent_optionSource_unchanged_still_buzzAgentCatalog", () => { + // Ensure the fix did not accidentally change buzz-agent's optionSource. + // buzz-agent's effort MUST go through the provider/model catalog for the + // per-provider effort validation to work (e.g. "none" vs "off"). + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + }), + scope: "global", + }); + assert.equal( + field(model, "effort").optionSource, + "buzzAgentCatalog", + "buzz-agent optionSource must remain buzzAgentCatalog", + ); +}); diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 5a8b8cb1c3..de31e724cc 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -204,19 +204,31 @@ export function deriveAgentConfigFieldModel({ }); if (runtime?.thinkingEnvVar) { + // targetApplication is always the runtime's native key — how the harness + // should receive effort. currentPersistence (where the value lives today) + // is scope-split until PR 2.7 migrates per-agent Goose/Claude: + // - global/onboarding: native key, matching the launch projection's global + // tier (native-only; the legacy alias is record/persona scope), so a + // selection actually reaches the spawn rather than persisting a key the + // projection ignores. For buzz-agent this IS BUZZ_AGENT_THINKING_EFFORT. + // - definition/instance: still the generic legacy BUZZ_AGENT_THINKING_EFFORT + // row, unchanged pending the per-agent migration. + const nativeKey = runtime.thinkingEnvVar; + const persistenceKey = + scope === "global" || scope === "onboarding" + ? nativeKey + : BUZZ_AGENT_THINKING_EFFORT; fields.push({ kind: "effort", optionSource: - runtime.id === "buzz-agent" - ? "buzzAgentCatalog" - : "legacyProviderModelCatalog", + runtime.id === "buzz-agent" ? "buzzAgentCatalog" : "harnessNative", currentPersistence: { kind: "envVar", - key: BUZZ_AGENT_THINKING_EFFORT, + key: persistenceKey, }, - targetApplication: { kind: "envVar", key: runtime.thinkingEnvVar }, + targetApplication: { kind: "envVar", key: nativeKey }, render: "control", - value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT), + value: valueFromEnv(config, persistenceKey), }); } else if (runtime?.id === "claude") { fields.push({ diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index 710b5fc4be..9e542be5a9 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - pickDirectProfileAgent, - pickProfileAgent, -} from "./pickProfileAgent.ts"; +import { pickProfileAgent } from "./pickProfileAgent.ts"; const NONE_ARCHIVED = () => false; @@ -68,60 +65,3 @@ test("a fail-open predicate keeps every instance eligible while loading", () => // Fail-open (all false) during the archive-snapshot window: normal ranking. assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running); }); - -test("a direct-opened active instance is never redirected to a sibling", () => { - // "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an - // access edit on Tyler would target the sibling. - const sibling = { - name: "Alpha Sibling", - pubkey: "a".repeat(64), - status: "running", - }; - const clicked = { - name: "Tyler Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED), - clicked, - ); -}); - -test("a direct-opened inactive instance redirects to the active sibling", () => { - const historical = { - name: "Earlier Parity Agent", - pubkey: "a".repeat(64), - status: "stopped", - }; - const current = { - name: "Current Parity Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED), - current, - ); -}); - -test("a direct-opened inactive instance with no active sibling stays put", () => { - const clicked = { - name: "Only Instance", - pubkey: "a".repeat(64), - status: "stopped", - }; - const otherStopped = { - name: "Another Stopped", - pubkey: "b".repeat(64), - status: "stopped", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED), - clicked, - ); - assert.equal(pickDirectProfileAgent(clicked, [], NONE_ARCHIVED), clicked); -}); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts index dc2437c86e..19de21f790 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -5,8 +5,8 @@ import type { ManagedAgent } from "@/shared/api/types"; * Pick the instance that represents a persona throughout the UI. * * A persona can have several historical agent instances. Keeping this rule in - * one place prevents an avatar click on an older message from opening a - * different detail surface than the card in the Agents library. + * one place keeps persona navigation consistent. Explicit pubkey navigation + * never uses this selector: older messages still name their exact author. * * Relay-archived instances are never eligible, so an archived record early in * file order can't hijack the persona target. Returns `undefined` when every @@ -28,25 +28,3 @@ export function pickProfileAgent( return left.name.localeCompare(right.name); })[0]; } - -/** - * Resolve which instance a profile panel opened for `directAgent` should - * show, given every instance of the same persona. - * - * Access edits must target the exact instance the user clicked — resolving a - * running sidebar member to an alphabetically-earlier sibling would let a - * "tighten access" save widen the wrong agent. But when the clicked instance - * is inactive and the persona has an active instance elsewhere (an avatar on - * an old message from a retired instance), redirect to the active one so the - * panel matches the Agents library. The `isArchived` predicate keeps that - * redirect from ever landing on an archived sibling. - */ -export function pickDirectProfileAgent( - directAgent: ManagedAgent, - personaInstances: readonly ManagedAgent[], - isArchived: (pubkey: string) => boolean, -) { - if (isManagedAgentActive(directAgent)) return directAgent; - const canonical = pickProfileAgent(personaInstances, isArchived); - return canonical && isManagedAgentActive(canonical) ? canonical : directAgent; -} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 52a58c9134..5e0a4ab961 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -86,14 +86,10 @@ type AgentConfigDisclosure = | "onboarding-essential" | "progressive-defaults"; -// Canonical behaviors (PR 2 flag cleanup). These were per-surface props; -// onboarding's values won every call and are now the only behavior: -// - auto-select a valid model when the provider changes -// - keep the model select usable during discovery -// - preserve credential env vars across provider switches (the abandoned -// provider's key stays in env_vars — visible/deletable under Advanced) -// - require a provider before model/effort are editable (no saveable -// invalid state — design principle #4) +// Canonical behaviors (formerly per-surface props; onboarding's values won +// every call and are now the only behavior). Design principle #4: require a +// provider before model/effort are editable; preserve credential env vars +// across provider switches; auto-select model on provider change. const autoSelectModelOnProviderChange = true; const disableModelSelectDuringDiscovery = false; const preserveCredentialEnvVarsOnProviderChange = true; @@ -255,6 +251,11 @@ export function AgentConfigFields({ effortField?.currentPersistence.kind === "envVar" ? effortField.currentPersistence.key : null; + // True when the runtime owns its own effort vocabulary (e.g. Goose) and + // should bypass the buzz-agent provider/model catalog: harnessNative + envVar. + const isHarnessNativeEffort = + effortField?.optionSource === "harnessNative" && + effortField?.currentPersistence.kind === "envVar"; const numericDescriptors = fieldModel.fields.filter( (d): d is NumericDescriptor => @@ -287,8 +288,7 @@ export function AgentConfigFields({ const modelField = fieldModel.fields.find( (field) => field.kind === "model" && field.render === "control", ); - // CLI-login harnesses apply this setting through ACP rather than an env var - // and provide their own default when no model override is persisted. + // CLI-login harnesses use ACP for this setting; they provide their own default. const modelIsOptional = modelField?.targetApplication.kind === "acpNative"; const modelIsValid = modelIsOptional || @@ -384,20 +384,15 @@ export function AgentConfigFields({ showCustomModelOption, }); - // Mount-time healing policy: onboarding page 4 edits the root config during - // first-run (no higher layers to inherit from), so acting on open is safe - // and intentional there — it heals stale state and picks a valid model. - // Evergreen surfaces (Settings, dialogs) edit saved data that may pair with - // higher layers (see PR #2148 review thread), so they only act after the - // user explicitly edits the provider in this session. + // Mount-time healing policy: onboarding (first-run, no higher layers) heals + // on open. Evergreen surfaces (Settings, dialogs) only heal after an explicit + // provider edit — acting on open would break multi-layer configs (PR #2148). const healOnMount = fieldModel.dependentValuePolicy.onCatalogMismatch === "onboardingCleanup"; const userEditedProviderRef = React.useRef(false); - // Advanced visibility is user-controlled. Provider changes can add required - // rows, but must not open this section without an explicit toggle click. + // Advanced visibility is user-controlled; must not auto-open on provider change. const [advancedOpen, setAdvancedOpen] = React.useState(false); - // Read inside effects via ref so biome's exhaustive-deps stays honest: - // refs are stable, and healOnMount is captured at declaration. + // Stable ref for effects; healOnMount is captured at declaration time. const mayMutateDependentFieldsRef = React.useRef(false); mayMutateDependentFieldsRef.current = healOnMount || userEditedProviderRef.current; @@ -439,14 +434,10 @@ export function AgentConfigFields({ ? (config.env_vars[effortPersistenceKey] ?? "") : ""; - // When the selected harness changes outside this component (Back → setup - // page → choose a different harness → Next), the saved model can belong to - // the old harness. In onboarding, heal that stale value as soon as the new - // harness catalog proves it is unsupported; otherwise a Codex id like - // `gpt-5.5[low]` appears as a Claude Code custom model. - // Also clear when the Model control is omitted after a confirmed successful - // empty catalog — never while discovery failed/unavailable (transient - // failures must not erase saved model/effort). + // Heal a stale model when the harness changes (e.g. Back → pick different + // harness → Next in onboarding). Clear once the new catalog proves the saved + // model unsupported; also clear when Model is omitted after a confirmed empty + // catalog. Never clear on failure/unavailable — transient errors must not erase. React.useEffect(() => { if (!healOnMount) return; const currentModel = (config.model ?? "").trim(); @@ -463,7 +454,9 @@ export function AgentConfigFields({ if (!catalogMiss && !omittedAfterSuccessfulEmpty) return; const nextEnvVars = { ...config.env_vars }; - if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; + // Harness-native effort is model-independent; never clear it on a catalog miss. + if (effortPersistenceKey && !isHarnessNativeEffort) + delete nextEnvVars[effortPersistenceKey]; onCustomModelEditingChange(false); onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); }, [ @@ -477,28 +470,29 @@ export function AgentConfigFields({ onCustomModelEditingChange, healOnMount, effortPersistenceKey, + isHarnessNativeEffort, ]); // Orphan-model clearing follows the mount-time healing policy above: the - // backend resolves provider and model independently across layers - // (agent → definition → global), so a saved global model WITHOUT a global - // provider can be a deliberate, working pattern (provider supplied by a - // higher layer). Clearing it on page-open in evergreen surfaces silently - // breaks that agent on its next restart — see PR #2148 review thread. - // Onboarding heals on open by design (discriminating spec: "gates stale - // saved model and effort until provider selection"). + // backend resolves provider+model independently across layers, so a global + // model without a global provider can be deliberate (PR #2148). Evergreen + // surfaces only clear on explicit edit; onboarding heals on open. React.useEffect(() => { if (!mayMutateDependentFieldsRef.current) return; if (!dependentFieldsDisabled) return; + // When model is absent, harness-native effort is model-independent so no + // orphan to fix; non-native effort with no value is already clean. if ( (config.model ?? "").trim().length === 0 && - currentEffortForAutoClear.length === 0 - ) { + (isHarnessNativeEffort || currentEffortForAutoClear.length === 0) + ) return; - } const nextEnvVars = { ...config.env_vars }; - if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; + // Preserve harness-native effort — it is model-independent and must survive + // the provider→Custom transition. + if (effortPersistenceKey && !isHarnessNativeEffort) + delete nextEnvVars[effortPersistenceKey]; onCustomModelEditingChange(false); onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); }, [ @@ -508,13 +502,16 @@ export function AgentConfigFields({ onConfigChange, onCustomModelEditingChange, effortPersistenceKey, + isHarnessNativeEffort, ]); + // `useEffortAutoClear` must not delete a valid harness-native value (e.g. + // "off" for Goose). Suppress it by passing "" as the current effort. const { validValues: effortValidForAutoClear } = getProviderEffortConfig( config.provider ?? "", config.model ?? "", ); useEffortAutoClear({ - currentEffort: currentEffortForAutoClear, + currentEffort: isHarnessNativeEffort ? "" : currentEffortForAutoClear, effortValid: effortValidForAutoClear, onClear: () => { const nextEnvVars = { ...config.env_vars }; @@ -635,6 +632,11 @@ export function AgentConfigFields({ : implicitEffortProvider; const { validValues: effortValid, defaultValue: effortDefault } = getProviderEffortConfig(effortProvider, config.model ?? ""); + // Harness-native runtimes own their effort vocabulary via the catalog entry. + const effortValidForRenderer = isHarnessNativeEffort + ? (selectedRuntime?.effortCanonicalValues ?? []) + : effortValid; + const effortDefaultForRenderer = isHarnessNativeEffort ? null : effortDefault; const currentEffort = effortPersistenceKey ? (config.env_vars[effortPersistenceKey] ?? "") : ""; @@ -853,21 +855,21 @@ export function AgentConfigFields({ currentEffort={dependentFieldsDisabled ? "" : currentEffort} disabled={dependentFieldsDisabled} emptyOptionLabel={ - // Semantic, not copy: onboarding-essential hides inheritance - // concepts (first-run users pick, they don't inherit), so the - // zero option is a plain placeholder. Full disclosure leaves - // this unset so EffortSelectField computes the inherit/default - // label ("Default (medium)", "Inherit (high)", …). + // Onboarding-essential hides inherit/default labels; show a plain + // placeholder. Full disclosure lets EffortSelectField compute + // its own label ("Default (medium)", "Inherit (high)", …). disclosure === "onboarding-essential" ? "Select effort level" : undefined } - effortDefault={effortDefault} - effortValid={effortValid} + effortDefault={effortDefaultForRenderer} + effortValid={effortValidForRenderer} fieldClassName={unstyled ? fieldClassName : undefined} htmlFor="global-agent-thinking-effort" inheritFallbackLabel={ - effortDefault !== null ? `Default (${effortDefault})` : undefined + effortDefaultForRenderer !== null + ? `Default (${effortDefaultForRenderer})` + : undefined } inheritedEffort={bakedEffort ?? undefined} label="Effort" diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx index bd67def785..60d16c833e 100644 --- a/desktop/src/features/agents/ui/AgentIdentityCard.tsx +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -9,6 +9,7 @@ type AgentIdentityCardProps = { ariaLabel: string; avatar?: ReactNode; avatarUrl?: string | null; + footerAccessory?: ReactNode; dataTestId: string; label: string; /** @@ -28,6 +29,7 @@ export function AgentIdentityCard({ avatar, avatarUrl, dataTestId, + footerAccessory, label, subtitle, onClick, @@ -74,20 +76,25 @@ export function AgentIdentityCard({
{actions}
) : null} -
- - {label} - - {subtitle ? ( - - {subtitle} +
+
+ + {label} - ) : null} - {/* pointer-events-auto: the overlay button above has pointer-events-none - on this container, but the status badge itself (a sibling of the button - in z-order) needs hover so the restart diff tooltip can fire. */} - {statusBadge ? ( -
{statusBadge}
+ {subtitle ? ( + + {subtitle} + + ) : null} + {/* pointer-events-auto: the overlay button above has pointer-events-none + on this container, but the status badge itself (a sibling of the button + in z-order) needs hover so the restart diff tooltip can fire. */} + {statusBadge ? ( +
{statusBadge}
+ ) : null} +
+ {footerAccessory ? ( +
{footerAccessory}
) : null}
diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 62d85d385d..205cf13a44 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -1,9 +1,11 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { + agentConfigSurfaceQueryKey, useAcpRuntimesQuery, useAgentConfigSurface, useBakedBuildEnvKeysQuery, @@ -40,7 +42,6 @@ import { NO_RUNTIME_DROPDOWN_VALUE, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, - PERSONA_LABEL_OPTIONAL_CLASS, runtimeSupportsLlmProviderSelection, shouldClearKnownModelForSelectionScope, sortPersonaRuntimes, @@ -55,6 +56,7 @@ import { envVarsEqual, isEditAgentProviderSaveValid, resolveAgentCommandUpdate, + resolveEffortSubmission, resolveInheritedRuntimeSubmission, resolveRuntimeProviderCapability, } from "./personaRuntimeModel"; @@ -74,15 +76,12 @@ import { MODEL_DISCOVERY_LOADING_VALUE, usePersonaModelDiscovery, } from "./usePersonaModelDiscovery"; -import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField"; +import { EditAgentProviderModelFields } from "./EditAgentProviderModelFields"; import { getBakedModelInheritLabel, getBakedProviderInheritLabel, } from "./bakedEnvHelpers"; -import { - getProviderApiKeyEnvVar, - getProviderApiKeyLabel, -} from "./agentConfigOptions"; +import { getProviderApiKeyEnvVar } from "./agentConfigOptions"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -116,6 +115,13 @@ export function AgentInstanceEditDialog({ }) { const updateMutation = useUpdateManagedAgentMutation(); const startMutation = useStartManagedAgentMutation(); + const queryClient = useQueryClient(); + // Spans the COMPLETE Save sequence (locked update + standalone setters). + // Every gate must key off this, not updateMutation.isPending alone. + const [isSaving, setIsSaving] = React.useState(false); + // Surfaces a standalone-setter failure (auto-restart or effort) that React + // Query does not track — keeps the dialog open so the user can retry Save. + const [setterError, setSetterError] = React.useState(null); const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; @@ -146,6 +152,13 @@ export function AgentInstanceEditDialog({ const [envVars, setEnvVars] = React.useState(agent.envVars); const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] = React.useState(agent.autoRestartOnConfigChange); + // Effort picker is Save-gated: hold the pending selection in dialog state and + // embed it in the locked update payload on Save alone (see + // resolveEffortSubmission / handleSubmit — PR #4625), never on selection. + // `effortTouched` distinguishes "user picked a value" from "showing the + // config-surface effective value", so an untouched Save writes nothing. + const [effortLevel, setEffortLevel] = React.useState(null); + const effortTouched = React.useRef(false); const personasQuery = usePersonasQuery(); const linkedPersona = React.useMemo( () => @@ -195,6 +208,9 @@ export function AgentInstanceEditDialog({ setIsCustomProviderEditing(false); setEnvVars(agent.envVars); setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange); + setEffortLevel(null); + effortTouched.current = false; + setSetterError(null); setRespondTo(agent.respondTo); setRespondToAllowlist(agent.respondToAllowlist); setAvatarUrl(agent.avatarUrl ?? ""); @@ -507,10 +523,10 @@ export function AgentInstanceEditDialog({ // Mark that the user has made an explicit runtime choice. The catalog-arrival // effect will no longer overwrite selectedRuntimeId after this point. runtimeTouched.current = true; - const resolvedRuntimeId = nextRuntimeId || "custom"; setSelectedRuntimeId(resolvedRuntimeId); - + effortTouched.current = false; + setEffortLevel(null); const isCustomCommand = resolvedRuntimeId === "custom"; // Only pin the harness when the selection can actually supply a command: @@ -588,6 +604,10 @@ export function AgentInstanceEditDialog({ } function handleOpenChange(next: boolean) { + // Reject user-originated dismissals (Escape, overlay, close-X, Cancel) while + // a Save is in flight — the in-flight setters must not commit to a closed dialog. + // The success path calls onOpenChange(false) directly, bypassing this guard. + if (!next && isSaving) return; onOpenChange(next); } @@ -613,10 +633,12 @@ export function AgentInstanceEditDialog({ requiredEnvKeyMissing, }) && providerValid && - !updateMutation.isPending && + !isSaving && !isAvatarUploadPending; async function handleSubmit() { + setIsSaving(true); + setSetterError(null); try { const parsedParallelism = Number.parseInt(parallelism, 10); const parsedArgs = agentArgs @@ -725,17 +747,53 @@ export function AgentInstanceEditDialog({ : undefined, }; - const result = await updateMutation.mutateAsync(input); - if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) { - // Standalone setter (mirrors start-on-app-launch) — not part of - // UpdateManagedAgentInput, so the frozen update shape stays frozen. - await setManagedAgentAutoRestart( - agent.pubkey, - autoRestartOnConfigChange, - ); + // Resolve effort before the update so access-change restarts can + // snapshot and launch the NEW effort value atomically. + const effortSubmission = resolveEffortSubmission({ + effortLevel, + originalEffortLevel: + configSurfaceQuery.data?.normalized.thinkingEffort?.value ?? null, + inheritTransition: agentCommandUpdate === "", + }); + // Include effort in the locked update when touched (tri-state: absent = + // don't touch; null = clear; string = set). Only when effortSubmission.persist. + if (effortTouched.current && effortSubmission.persist) { + input.effortLevel = effortSubmission.level; } + + const result = await updateMutation.mutateAsync(input); + + // Standalone setters — sequenced after the locked update resolves so the + // dialog remains fully gated (isSaving) for the COMPLETE Save transaction. + // A failure here surfaces as setterError (retryable) and aborts before + // close, keeping the dialog open so the user can retry Save. + try { + if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) { + // Mirrors start-on-app-launch; not part of UpdateManagedAgentInput so + // the frozen update shape stays frozen. + await setManagedAgentAutoRestart( + agent.pubkey, + autoRestartOnConfigChange, + ); + } + // Effort disk write happened inside the locked update. Only need to + // invalidate the cache here (when effortTouched && effortSubmission.persist). + // If effort was not included (!effortSubmission.persist), nothing to do. + if (effortTouched.current && effortSubmission.persist) { + // Disk write already done; invalidate so the panel tier reflects it. + await queryClient.invalidateQueries({ + queryKey: agentConfigSurfaceQueryKey(agent.pubkey), + }); + } + } catch (e) { + setSetterError(e instanceof Error ? e : new Error("Failed to save")); + return; + } + showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); - handleOpenChange(false); + // Close via onOpenChange directly — handleOpenChange guards against + // mid-save dismissal and must not block the intentional post-success close. + onOpenChange(false); onUpdated?.(result.agent); // The auto-restart policy deliberately never fires for a stopped or // failing agent (a broken agent must not auto-loop), so an edit meant @@ -761,7 +819,9 @@ export function AgentInstanceEditDialog({ }); } } catch { - // React Query stores the error; keep dialog open and render it inline. + // React Query stores the update error; keep dialog open and render it inline. + } finally { + setIsSaving(false); } } @@ -844,6 +904,11 @@ export function AgentInstanceEditDialog({ const advancedFieldsTransition = shouldReduceMotion ? { duration: 0 } : ADVANCED_FIELDS_MOTION_TRANSITION; + // Displayed inline when either the locked update or a standalone setter fails. + // setterError takes precedence — the update already committed when it fires. + const displayError = + setterError ?? + (updateMutation.error instanceof Error ? updateMutation.error : null); return ( @@ -857,7 +922,7 @@ export function AgentInstanceEditDialog({ footer={
} @@ -890,6 +955,7 @@ export function AgentInstanceEditDialog({ {onEditLinkedPersona ? (