diff --git a/.env.example b/.env.example index 67a3374222..6ed7cdcaed 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +51,8 @@ TYPESENSE_URL=http://localhost:8108 BUZZ_BIND_ADDR=0.0.0.0:3000 # Public WebSocket URL — used in NIP-42 auth challenges RELAY_URL=ws://localhost:3000 -# Stable relay signing key. Set this in dev if you want REST-created forum posts -# to keep resolving to the original author across relay restarts. +# Stable relay signing key (required). `just bootstrap` generates a random key in +# the gitignored .env file. Preserve that value across restarts and backups. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> # Optional: path to the web UI dist directory. When set, the relay serves # the web frontend at / for browser requests. Leave unset for local dev @@ -65,9 +65,15 @@ RELAY_URL=ws://localhost:3000 BUZZ_PUSH_ENABLED=false # BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns +# Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and +# authenticated desktop clients use this relay as the metadata/search proxy. +# Keep the real value in your deployment's secret manager; never commit it. +# BUZZ_KLIPY_API_KEY= + # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. # BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60 +# BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN=30 # BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300 # BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10 # BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af8e2abb08..5f86e7d33a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Relay image eligibility contract + run: scripts/test-relay-image-eligibility-workflow.sh - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh - name: OSS desktop promotion contract @@ -527,6 +529,7 @@ jobs: 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 \ @@ -692,6 +695,18 @@ jobs: env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Database pressure observability PostgreSQL tests + # Explicit pool acquisition and advisory-lock metrics require real + # Postgres and are ignored by the infrastructure-free unit-test job. + run: | + filter='package(buzz-db) and test(/observability::tests::(pool_acquire_records_success_timeout_and_error_with_wait_time|advisory_lock_records_success_contention_timeout_and_error)/)' + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E "${filter}" \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Start relay run: | chmod +x ./target/ci/buzz-relay @@ -700,6 +715,7 @@ jobs: 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 \ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 564cd74e9d..f48f1bc92f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -49,6 +49,10 @@ on: - "Dockerfile.push-gateway" - ".dockerignore" - ".github/workflows/docker.yml" + - "deploy/charts/buzz/Chart.yaml" + - "scripts/create-deployment-eligibility-predicate.jq" + - "scripts/select-qualified-ci-run.jq" + - "scripts/test-relay-image-eligibility-workflow.sh" - "Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -172,6 +176,10 @@ jobs: target: runtime platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUZZ_SOURCE_SHA=${{ github.sha }} + BUZZ_BUILD_ID=github-actions:${{ github.run_id }}:${{ github.run_attempt }} + BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} # Push by digest, not by tag — the merge job assembles the tags # into one multi-arch manifest. This is what makes the native-arm # matrix possible. @@ -190,6 +198,10 @@ jobs: target: runtime-debug platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUZZ_SOURCE_SHA=${{ github.sha }} + BUZZ_BUILD_ID=github-actions:${{ github.run_id }}:${{ github.run_attempt }} + BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} @@ -222,11 +234,86 @@ jobs: if-no-files-found: error retention-days: 1 + qualify: + name: Qualify relay image source + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 70 + permissions: + actions: read + contents: read + outputs: + source_sha: ${{ steps.qualify.outputs.source_sha }} + ci_run_id: ${{ steps.qualify.outputs.ci_run_id }} + ci_run_attempt: ${{ steps.qualify.outputs.ci_run_attempt }} + ci_run_url: ${{ steps.qualify.outputs.ci_run_url }} + chart_version: ${{ steps.qualify.outputs.chart_version }} + steps: + - name: Checkout source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Require successful same-SHA CI run + id: qualify + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Invalid source SHA: $SOURCE_SHA" + exit 1 + fi + + deadline=$((SECONDS + 3900)) + while (( SECONDS < deadline )); do + payload=$(gh api \ + "/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${SOURCE_SHA}&event=push&per_page=100") + successful_run=$(jq -c --arg source_sha "$SOURCE_SHA" \ + -f "$GITHUB_WORKSPACE/scripts/select-qualified-ci-run.jq" <<<"$payload") + + if [[ -n "$successful_run" ]]; then + ci_run_id=$(jq -r '.id' <<<"$successful_run") + ci_run_attempt=$(jq -r '.run_attempt' <<<"$successful_run") + ci_run_url=$(jq -r '.html_url' <<<"$successful_run") + chart_version=$(awk '/^version:/ { print $2; exit }' deploy/charts/buzz/Chart.yaml) + if [[ ! "$chart_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid Buzz chart version: $chart_version" + exit 1 + fi + { + echo "source_sha=$SOURCE_SHA" + echo "ci_run_id=$ci_run_id" + echo "ci_run_attempt=$ci_run_attempt" + echo "ci_run_url=$ci_run_url" + echo "chart_version=$chart_version" + } >>"$GITHUB_OUTPUT" + echo "Qualified source $SOURCE_SHA with CI run $ci_run_id (attempt $ci_run_attempt)." + exit 0 + fi + + matching=$(jq --arg source_sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $source_sha) | select(.event == "push") | select(.head_branch == "main" or .head_branch == "release")] | length' <<<"$payload") + pending=$(jq --arg source_sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $source_sha) | select(.event == "push") | select(.head_branch == "main" or .head_branch == "release") | select(.status != "completed")] | length' <<<"$payload") + if (( matching > 0 && pending == 0 )); then + echo "::error::No successful CI run exists for source $SOURCE_SHA" + jq -r --arg source_sha "$SOURCE_SHA" '.workflow_runs[] | select(.head_sha == $source_sha) | "run=\(.id) attempt=\(.run_attempt) status=\(.status) conclusion=\(.conclusion)"' <<<"$payload" + exit 1 + fi + + echo "Waiting for same-SHA CI qualification ($matching matching, $pending pending)..." + sleep 30 + done + + echo "::error::Timed out waiting for successful CI qualification of $SOURCE_SHA" + exit 1 + merge: name: Merge ${{ matrix.variant }} multi-arch manifest if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 - needs: build + needs: [build, qualify] timeout-minutes: 15 permissions: contents: read @@ -243,6 +330,11 @@ jobs: tag_prefix: debug- steps: + - name: Checkout source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Download all per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -286,6 +378,10 @@ jobs: env: IMAGE_NAME: ${{ env.IMAGE_NAME }} META_TAGS: ${{ steps.meta.outputs.tags }} + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_ID: ${{ needs.qualify.outputs.ci_run_id }} + CI_RUN_ATTEMPT: ${{ needs.qualify.outputs.ci_run_attempt }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} run: | set -euo pipefail # Build -t flags from the metadata-action output. @@ -300,7 +396,15 @@ jobs: digests+=("${IMAGE_NAME}@sha256:${digest}") done - docker buildx imagetools create "${tags[@]}" "${digests[@]}" + annotations=( + --annotation "index:org.opencontainers.image.revision=${SOURCE_SHA}" + --annotation "index:xyz.block.buzz.build.id=github-actions:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}" + --annotation "index:xyz.block.buzz.qualification.ci-run-id=${CI_RUN_ID}" + --annotation "index:xyz.block.buzz.qualification.ci-run-attempt=${CI_RUN_ATTEMPT}" + --annotation "index:xyz.block.buzz.qualification.ci-conclusion=success" + --annotation "index:xyz.block.buzz.helm-chart.version=${CHART_VERSION}" + ) + docker buildx imagetools create "${tags[@]}" "${annotations[@]}" "${digests[@]}" # Capture the merged manifest digest for the attestation step. first_tag=$(echo "$META_TAGS" | head -n1) @@ -317,17 +421,58 @@ jobs: subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true + - name: Create deployment eligibility predicate + if: matrix.variant == 'release' + env: + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_ID: ${{ needs.qualify.outputs.ci_run_id }} + CI_RUN_ATTEMPT: ${{ needs.qualify.outputs.ci_run_attempt }} + CI_RUN_URL: ${{ needs.qualify.outputs.ci_run_url }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} + run: | + jq -n \ + --arg source_repository "$GITHUB_REPOSITORY" \ + --arg source_ref "$GITHUB_REF" \ + --arg source_sha "$SOURCE_SHA" \ + --arg build_workflow ".github/workflows/docker.yml" \ + --argjson build_run_id "$GITHUB_RUN_ID" \ + --argjson build_run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg build_run_url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}" \ + --arg qualification_workflow ".github/workflows/ci.yml" \ + --argjson qualification_run_id "$CI_RUN_ID" \ + --argjson qualification_run_attempt "$CI_RUN_ATTEMPT" \ + --arg qualification_run_url "$CI_RUN_URL" \ + --arg chart_version "$CHART_VERSION" \ + -f "$GITHUB_WORKSPACE/scripts/create-deployment-eligibility-predicate.jq" \ + >/tmp/deployment-eligibility.json + + - name: Attest deployment eligibility + if: matrix.variant == 'release' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.manifest.outputs.digest }} + predicate-type: https://buzz.block.xyz/attestations/deployment-eligibility/v1 + predicate-path: /tmp/deployment-eligibility.json + push-to-registry: true + - name: Summary env: IMAGE_NAME: ${{ env.IMAGE_NAME }} VARIANT: ${{ matrix.variant }} MERGED_DIGEST: ${{ steps.manifest.outputs.digest }} META_TAGS: ${{ steps.meta.outputs.tags }} + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_URL: ${{ needs.qualify.outputs.ci_run_url }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} run: | { echo "### Published \`${IMAGE_NAME}\` (${VARIANT})" echo echo "**Digest:** \`${MERGED_DIGEST}\`" + echo "**Source:** \`${SOURCE_SHA}\`" + echo "**Compatible Buzz chart:** \`${CHART_VERSION}\`" + echo "**Qualifying CI:** ${CI_RUN_URL} (success)" echo echo "**Tags:**" echo '```' @@ -338,6 +483,13 @@ jobs: echo '```' echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block" echo '```' + if [[ "$VARIANT" == "release" ]]; then + echo + echo "Verify deployment eligibility:" + echo '```' + echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --repo block/buzz --signer-workflow block/buzz/.github/workflows/docker.yml --predicate-type https://buzz.block.xyz/attestations/deployment-eligibility/v1 --source-digest ${SOURCE_SHA}" + echo '```' + fi } >> "$GITHUB_STEP_SUMMARY" push-gateway-build: diff --git a/.github/workflows/staging-dev-relay-image.yml b/.github/workflows/staging-dev-relay-image.yml new file mode 100644 index 0000000000..c8a746a0b9 --- /dev/null +++ b/.github/workflows/staging-dev-relay-image.yml @@ -0,0 +1,276 @@ +name: Staging dev relay image + +# Publishes pre-merge relay runtime images for bb-block staging only. +# +# Operators run this workflow from the default branch and provide a target ref in +# this repository. GitHub's workflow_dispatch permission model limits triggering +# to collaborators who can run repository workflows; no actor allowlist is kept +# here. The target ref is resolved to an immutable commit SHA before checkout, +# and the published tag is derived from that full SHA. + +on: + workflow_dispatch: + inputs: + target_ref: + description: "Branch, tag, refs/heads/*, or refs/tags/* in block/buzz to publish for bb-block staging" + required: true + type: string + +permissions: {} + +env: + IMAGE_NAME: ghcr.io/block/buzz-staging-dev + ECR_REPOSITORY: 929862310821.dkr.ecr.us-west-2.amazonaws.com/ghcr.io/block/buzz-staging-dev + +jobs: + resolve: + name: Resolve target ref + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + target_sha: ${{ steps.resolve.outputs.target_sha }} + image_tag: ${{ steps.resolve.outputs.image_tag }} + steps: + - name: Require reviewed workflow from main in canonical repository + env: + DISPATCH_REF: ${{ github.ref }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + if [ "$REPOSITORY" != "block/buzz" ]; then + echo "::error::Staging dev relay image publication is restricted to block/buzz" + exit 1 + fi + + if [ "$DISPATCH_REF" != "refs/heads/main" ]; then + echo "::error::Dispatch this workflow from main, not $DISPATCH_REF" + exit 1 + fi + + - name: Resolve target ref in this repository + id: resolve + env: + TARGET_REF_INPUT: ${{ inputs.target_ref }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + target_ref=$(printf '%s' "$TARGET_REF_INPUT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + + if [ -z "$target_ref" ]; then + echo "target_ref must not be empty" >&2 + exit 1 + fi + + if [ ${#target_ref} -gt 255 ]; then + echo "target_ref is too long" >&2 + exit 1 + fi + + if [[ ! "$target_ref" =~ ^[A-Za-z0-9._/@-]+$ ]]; then + echo "target_ref contains unsupported characters; use a branch, tag, or full refs/heads/* or refs/tags/* name" >&2 + exit 1 + fi + + case "$target_ref" in + -*|*..*|*.lock|refs/pull/*|pull/*|*/pull/*) + echo "target_ref is not an allowed repository branch/tag/ref" >&2 + exit 1 + ;; + esac + + workdir=$(mktemp -d) + trap 'rm -rf "$workdir"' EXIT + git -C "$workdir" init --quiet + git -C "$workdir" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + + fetch_commit() { + local ref=$1 + git -C "$workdir" fetch --no-tags --depth=1 origin "$ref" >/dev/null 2>&1 + git -C "$workdir" rev-parse --verify "FETCH_HEAD^{commit}" + } + + target_sha="" + if [[ "$target_ref" == refs/heads/* || "$target_ref" == refs/tags/* ]]; then + target_sha=$(fetch_commit "$target_ref") || { + echo "target_ref did not resolve in ${GITHUB_REPOSITORY}: $target_ref" >&2 + exit 1 + } + else + if target_sha=$(fetch_commit "refs/heads/${target_ref}"); then + : + elif target_sha=$(fetch_commit "refs/tags/${target_ref}"); then + : + else + echo "target_ref did not resolve as a branch or tag in ${GITHUB_REPOSITORY}: $target_ref" >&2 + exit 1 + fi + fi + + target_sha=$(printf '%s' "$target_sha" | tr '[:upper:]' '[:lower:]') + if [[ ! "$target_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "resolved target SHA is invalid: $target_sha" >&2 + exit 1 + fi + + image_tag="dev-sha-${target_sha}-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + { + echo "target_sha=${target_sha}" + echo "image_tag=${image_tag}" + } >> "$GITHUB_OUTPUT" + + printf "Resolved \`%s\` to \`%s\`; image tag \`%s\`.\n" "$target_ref" "$target_sha" "$image_tag" >> "$GITHUB_STEP_SUMMARY" + + build: + name: Build staging relay runtime (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + needs: resolve + timeout-minutes: 60 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + steps: + - name: Checkout resolved target + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.resolve.outputs.target_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + with: + buildkitd-config-inline: | + [worker.oci] + max-parallelism = 2 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE_NAME }} + labels: | + org.opencontainers.image.title=Buzz staging dev relay + org.opencontainers.image.description=Pre-merge Buzz relay runtime image for bb-block staging only + org.opencontainers.image.licenses=Apache-2.0 + org.opencontainers.image.revision=${{ needs.resolve.outputs.target_sha }} + + - name: Build and push runtime image by digest + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile + target: runtime + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + cache-to: | + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }},mode=max,compression=zstd + + - name: Export digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + mkdir -p /tmp/digests-release + touch "/tmp/digests-release/${DIGEST#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: staging-dev-relay-digests-${{ matrix.arch }} + path: /tmp/digests-release/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Publish staging relay runtime manifest + runs-on: ubuntu-24.04 + needs: + - resolve + - build + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - name: Download per-arch digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: staging-dev-relay-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest list + id: manifest + working-directory: /tmp/digests + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + IMAGE_TAG: ${{ needs.resolve.outputs.image_tag }} + run: | + set -euo pipefail + digests=() + for digest in *; do + digests+=("${IMAGE_NAME}@sha256:${digest}") + done + + docker buildx imagetools create -t "${IMAGE_NAME}:${IMAGE_TAG}" "${digests[@]}" + merged_digest=$(docker buildx imagetools inspect "${IMAGE_NAME}:${IMAGE_TAG}" \ + --format '{{json .Manifest}}' | jq -r '.digest') + echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT" + + - name: Deployment summary + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }} + IMAGE_TAG: ${{ needs.resolve.outputs.image_tag }} + TARGET_SHA: ${{ needs.resolve.outputs.target_sha }} + MERGED_DIGEST: ${{ steps.manifest.outputs.digest }} + run: | + { + echo "### Published bb-block staging dev relay image" + echo + echo "**Source commit:** \`${TARGET_SHA}\`" + echo "**GHCR image:** \`${IMAGE_NAME}:${IMAGE_TAG}\`" + echo "**Manifest digest:** \`${MERGED_DIGEST}\`" + echo + echo "Set bb-block staging BPCI values to:" + echo '```yaml' + echo "buzz:" + echo " image:" + echo " repository: ${ECR_REPOSITORY}" + echo " tag: ${IMAGE_TAG}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 784725b2e6..4751a7d351 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.18", - "base_sha": "aea0ef8df9fc24d9aa8bf5c761ab2910026a601b", - "previous_tag": "desktop-v0.5.17", - "previous_base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c", - "previous_merge_sha": "8232299cbe6d90692fac3de46cde0ec123edd6c1", - "tag": "desktop-v0.5.18", - "commit_count": 66 + "version": "0.5.20", + "base_sha": "8471049c430073474939336dfc6aa98272bc8762", + "previous_tag": "desktop-v0.5.19", + "previous_base_sha": "ee6ca5fa28bce04dfecb6717de65b08a57f2ac47", + "previous_merge_sha": "7a1b7d8e09f96c10b6617a66bb8589e9984ffb3c", + "tag": "desktop-v0.5.20", + "commit_count": 4 } diff --git a/AGENTS.md b/AGENTS.md index b1f11bd3db..6d97f4dfd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,7 +226,9 @@ channel and derives its containing root. An optional `thread` parameter is accepted only when it matches that derived root. The explicit `--channel --event ` form remains available. -All reads return sig-stripped JSON arrays; all writes return +All event reads return normalized JSON arrays. Normal output preserves the seven +canonical signed Nostr event fields (`id`, `pubkey`, `kind`, `content`, +`created_at`, `tags`, `sig`); all writes return `{event_id, accepted, message}`; creates add the entity ID. Exit codes: 0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW). diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fc482515..eec43c3871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,98 @@ # Changelog +## v0.5.20 + +### Desktop and shared changes + +- feat(desktop): add KLIPY GIF search to composers ([#5554](https://github.com/block/buzz/pull/5554)) ([`8471049c430073474939336dfc6aa98272bc8762`](https://github.com/block/buzz/commit/8471049c430073474939336dfc6aa98272bc8762)) +- fix(desktop): respect automatic mention preference after send ([#6837](https://github.com/block/buzz/pull/6837)) ([`e8cd7516e6df62c2a9025d7a821bf98e0e8f83b4`](https://github.com/block/buzz/commit/e8cd7516e6df62c2a9025d7a821bf98e0e8f83b4)) + +### Other repository changes + +- fix(release): attribute desktop candidates to the operator ([#6831](https://github.com/block/buzz/pull/6831)) ([`820a8589971df49bb9285a236ce1e2955a301abd`](https://github.com/block/buzz/commit/820a8589971df49bb9285a236ce1e2955a301abd)) +- fix(ci): check out source in docker.yml merge job ([#6833](https://github.com/block/buzz/pull/6833)) ([`cae7f826b39b38e7d49a90d342499234dec007bf`](https://github.com/block/buzz/commit/cae7f826b39b38e7d49a90d342499234dec007bf)) + +[Compare desktop-v0.5.19...desktop-v0.5.20](https://github.com/block/buzz/compare/desktop-v0.5.19...desktop-v0.5.20) + +## v0.5.19 + +### Desktop and shared changes + +- docs(nest): make commit attribution policy-neutral ([#6707](https://github.com/block/buzz/pull/6707)) ([`22f32c99e2e983b7e015e8c058cac47438d05b2f`](https://github.com/block/buzz/commit/22f32c99e2e983b7e015e8c058cac47438d05b2f)) +- fix(desktop-messages): preserve inline agent mentions with persistent addressing ([#6793](https://github.com/block/buzz/pull/6793)) ([`b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0`](https://github.com/block/buzz/commit/b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0)) +- feat(desktop): persist agent addressing across composer messages ([#6714](https://github.com/block/buzz/pull/6714)) ([`7ba1197aa6b01616b5920487dd585a37d2e7f74d`](https://github.com/block/buzz/commit/7ba1197aa6b01616b5920487dd585a37d2e7f74d)) +- feat: navigate images across message threads ([#6705](https://github.com/block/buzz/pull/6705)) ([`a526dca9bcaa08dfb5db77999cc1f584a17a9d64`](https://github.com/block/buzz/commit/a526dca9bcaa08dfb5db77999cc1f584a17a9d64)) +- revert fixed mention highlight ([#6716](https://github.com/block/buzz/pull/6716)) ([`12f3fea26e4c638a5fae20dce1ec0876e3bbca41`](https://github.com/block/buzz/commit/12f3fea26e4c638a5fae20dce1ec0876e3bbca41)) +- highlight search terms in results and messages ([#6702](https://github.com/block/buzz/pull/6702)) ([`29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1`](https://github.com/block/buzz/commit/29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1)) +- fix(desktop): make lightbox zoom controls interactive ([#6710](https://github.com/block/buzz/pull/6710)) ([`9b6a637d014607760d116b90c57062f82ab27cf3`](https://github.com/block/buzz/commit/9b6a637d014607760d116b90c57062f82ab27cf3)) +- Support community deletion in versioned media buckets ([#6738](https://github.com/block/buzz/pull/6738)) ([`d12dea4e67c5224a626d9c00f45e68d1def72d4c`](https://github.com/block/buzz/commit/d12dea4e67c5224a626d9c00f45e68d1def72d4c)) +- Fix TipTap editor mount race ([#6779](https://github.com/block/buzz/pull/6779)) ([`bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9`](https://github.com/block/buzz/commit/bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9)) +- feat(buzz-agent): gate LLM tool calls on session/request_permission ([#5712](https://github.com/block/buzz/pull/5712)) ([`a1219070fa6c3263c8a29637c70b7a317d4ecd9d`](https://github.com/block/buzz/commit/a1219070fa6c3263c8a29637c70b7a317d4ecd9d)) +- Fix mobile Huddle agent voice turn states ([#6611](https://github.com/block/buzz/pull/6611)) ([`8b812017a79c9279594330fb2b04c1eaf0e6e6c1`](https://github.com/block/buzz/commit/8b812017a79c9279594330fb2b04c1eaf0e6e6c1)) +- fix(desktop): polish inline chip states ([#6718](https://github.com/block/buzz/pull/6718)) ([`a8e1c66c4a5017a32e41e04e2ba6059e2dfcae21`](https://github.com/block/buzz/commit/a8e1c66c4a5017a32e41e04e2ba6059e2dfcae21)) +- feat(workflows): discover trigger filter values ([#6712](https://github.com/block/buzz/pull/6712)) ([`e760c51820b2103d965c22b44254678e10fb689a`](https://github.com/block/buzz/commit/e760c51820b2103d965c22b44254678e10fb689a)) +- feat(desktop): simplify the message action rail ([#6529](https://github.com/block/buzz/pull/6529)) ([`c5166f2164035ca96787daee6528d5dc04c4a02e`](https://github.com/block/buzz/commit/c5166f2164035ca96787daee6528d5dc04c4a02e)) +- fix(desktop): restore icon-only remote marker ([#6491](https://github.com/block/buzz/pull/6491)) ([`30d2fc52f96138311f2006627ffc1a6d5ff1865b`](https://github.com/block/buzz/commit/30d2fc52f96138311f2006627ffc1a6d5ff1865b)) +- fix(composer): wrap Buzz chip labels without orphaning icons ([#6581](https://github.com/block/buzz/pull/6581)) ([`f79d346a178408661fcad85122364ac2ad7e9cb2`](https://github.com/block/buzz/commit/f79d346a178408661fcad85122364ac2ad7e9cb2)) +- fix(desktop): bound thread /query and surface load errors, not false-empty ([#6447](https://github.com/block/buzz/pull/6447)) ([`f6e6617a9dcc2308d5039f8afaab974b49fb9577`](https://github.com/block/buzz/commit/f6e6617a9dcc2308d5039f8afaab974b49fb9577)) +- fix(messages): route edits to the owning composer ([#6575](https://github.com/block/buzz/pull/6575)) ([`4bf80978f52981f0035e6c0b86bdf1108bbf64c8`](https://github.com/block/buzz/commit/4bf80978f52981f0035e6c0b86bdf1108bbf64c8)) +- fix(desktop): align jump-to-latest pill with composer height ([#6606](https://github.com/block/buzz/pull/6606)) ([`9f55bf67456be10ff7c8238bf0d9e12e582848f6`](https://github.com/block/buzz/commit/9f55bf67456be10ff7c8238bf0d9e12e582848f6)) +- fix(desktop): emit singular `mention` feed category so alerts route correctly ([#6665](https://github.com/block/buzz/pull/6665)) ([`db5617dd1541aeab7bacaf039b6ca98f856776d0`](https://github.com/block/buzz/commit/db5617dd1541aeab7bacaf039b6ca98f856776d0)) +- show mention counts in channel notifications ([#6696](https://github.com/block/buzz/pull/6696)) ([`0e69b3fd7c44c09da62e2c4e89fdb4a26e666869`](https://github.com/block/buzz/commit/0e69b3fd7c44c09da62e2c4e89fdb4a26e666869)) +- fix(desktop): hide selection formatting tray on composer right-click ([#6683](https://github.com/block/buzz/pull/6683)) ([`2f13e30e88e84851e7ad336364dd3cfd547b8c16`](https://github.com/block/buzz/commit/2f13e30e88e84851e7ad336364dd3cfd547b8c16)) +- fix(desktop): stabilize members dialog scrolling ([#6670](https://github.com/block/buzz/pull/6670)) ([`72ba987c365abb98939153c4d43dde73257c1264`](https://github.com/block/buzz/commit/72ba987c365abb98939153c4d43dde73257c1264)) +- fix(desktop): keep member runtime status off the UI thread ([#6445](https://github.com/block/buzz/pull/6445)) ([`17af15effac63e6bc5338448326ce52ba4426e5f`](https://github.com/block/buzz/commit/17af15effac63e6bc5338448326ce52ba4426e5f)) +- perf(desktop): persist channel heads, collapse thread reads and reply sends ([#6572](https://github.com/block/buzz/pull/6572)) ([`2d280376ad36134cec1f23bead6d866d30bed147`](https://github.com/block/buzz/commit/2d280376ad36134cec1f23bead6d866d30bed147)) +- Downgrade desktop Huddles to audio protocol v2 ([#6610](https://github.com/block/buzz/pull/6610)) ([`0720f5380ce8a6c050afac159f8462c06cd51ab5`](https://github.com/block/buzz/commit/0720f5380ce8a6c050afac159f8462c06cd51ab5)) +- perf(desktop): make the Projects surface render-cheap ([#6460](https://github.com/block/buzz/pull/6460)) ([`040b203f73576e15ef749b0ff0ee6243f06a5c48`](https://github.com/block/buzz/commit/040b203f73576e15ef749b0ff0ee6243f06a5c48)) +- refactor(acp): clarify agent prompt sections ([#6501](https://github.com/block/buzz/pull/6501)) ([`f99532585a0715bac73b4a6361a9b4966bdb5095`](https://github.com/block/buzz/commit/f99532585a0715bac73b4a6361a9b4966bdb5095)) +- Add mobile Huddles voice MVP ([#6056](https://github.com/block/buzz/pull/6056)) ([`8c0f42e187ca82d701251fc849217530178ebace`](https://github.com/block/buzz/commit/8c0f42e187ca82d701251fc849217530178ebace)) +- feat(desktop-messages): keep agents addressed across messages ([#6315](https://github.com/block/buzz/pull/6315)) ([`a2d8be5efa126221c7676f7797555dfb2bf5b0e0`](https://github.com/block/buzz/commit/a2d8be5efa126221c7676f7797555dfb2bf5b0e0)) +- fix(desktop): remove Buzz entity link previews ([#6512](https://github.com/block/buzz/pull/6512)) ([`723affe5d1340896209bf3aca94c7b791bdcf38b`](https://github.com/block/buzz/commit/723affe5d1340896209bf3aca94c7b791bdcf38b)) +- fix(composer): preserve caret when inserting mentions mid-message ([#6531](https://github.com/block/buzz/pull/6531)) ([`074561233eef71df9690ec22c2a9c5e798c297a7`](https://github.com/block/buzz/commit/074561233eef71df9690ec22c2a9c5e798c297a7)) +- chore(deps): update rust crate futures-util to v0.3.33 ([#5448](https://github.com/block/buzz/pull/5448)) ([`d874d67c117e4582cc9549ebe85c942e5e49faf7`](https://github.com/block/buzz/commit/d874d67c117e4582cc9549ebe85c942e5e49faf7)) +- fix(desktop): restore true zoom by scaling the root rem ([#6514](https://github.com/block/buzz/pull/6514)) ([`97b1fee5c3d9ad574196e974b520061fccc47d07`](https://github.com/block/buzz/commit/97b1fee5c3d9ad574196e974b520061fccc47d07)) +- chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec ([#6517](https://github.com/block/buzz/pull/6517)) ([`eb484387d5816b6f9155ad3a0be79ede3c3d7ad5`](https://github.com/block/buzz/commit/eb484387d5816b6f9155ad3a0be79ede3c3d7ad5)) +- feat(workflows): clarify workflow setup and activation ([#6470](https://github.com/block/buzz/pull/6470)) ([`0e48ff26915aa32d5f05208847b9aba75f4f19cd`](https://github.com/block/buzz/commit/0e48ff26915aa32d5f05208847b9aba75f4f19cd)) +- perf(desktop): stop the Projects fan refetching on re-entry and running after leave ([#6458](https://github.com/block/buzz/pull/6458)) ([`b85d680fb1e3cc7eef6d31d47598741b35836a2e`](https://github.com/block/buzz/commit/b85d680fb1e3cc7eef6d31d47598741b35836a2e)) +- perf(desktop): keep the member roster off the channel-switch path ([#6456](https://github.com/block/buzz/pull/6456)) ([`b0466ac465336cb773fbf7355ec05f7d61f4a3aa`](https://github.com/block/buzz/commit/b0466ac465336cb773fbf7355ec05f7d61f4a3aa)) +- Clarify huddle message destination ([#6496](https://github.com/block/buzz/pull/6496)) ([`7da8f9abf3245d7ab31ba6e4ad72598b03471f2b`](https://github.com/block/buzz/commit/7da8f9abf3245d7ab31ba6e4ad72598b03471f2b)) +- feat(archive): add observer-frame retention schema and gated DB adapter ([#5719](https://github.com/block/buzz/pull/5719)) ([`fc2ce6728b3b4805040c0a2f2cc5c15f1c1806ce`](https://github.com/block/buzz/commit/fc2ce6728b3b4805040c0a2f2cc5c15f1c1806ce)) +- fix(desktop): restore human barge-in over agent TTS in huddles ([#6431](https://github.com/block/buzz/pull/6431)) ([`6039fed565fd73a07cdddc3143c86733cad91709`](https://github.com/block/buzz/commit/6039fed565fd73a07cdddc3143c86733cad91709)) + +### Other repository changes + +- Remove public relay signing key fallback ([#6729](https://github.com/block/buzz/pull/6729)) ([`ee6ca5fa28bce04dfecb6717de65b08a57f2ac47`](https://github.com/block/buzz/commit/ee6ca5fa28bce04dfecb6717de65b08a57f2ac47)) +- Qualify canonical relay images for staged delivery ([#6781](https://github.com/block/buzz/pull/6781)) ([`f24971033178926153b49d320bd876d15d9cb2bf`](https://github.com/block/buzz/commit/f24971033178926153b49d320bd876d15d9cb2bf)) +- Add database pressure observability ([#6700](https://github.com/block/buzz/pull/6700)) ([`113a33b7e49b7173ee1767c49ef2f49c63803034`](https://github.com/block/buzz/commit/113a33b7e49b7173ee1767c49ef2f49c63803034)) +- Add staging dev relay image workflow ([#6709](https://github.com/block/buzz/pull/6709)) ([`931747c9c42df14d5c23c87fe57e30b995321ae3`](https://github.com/block/buzz/commit/931747c9c42df14d5c23c87fe57e30b995321ae3)) +- Extract community persistence ([#6668](https://github.com/block/buzz/pull/6668)) ([`9d1e4b257657f382d3111ce748f3da8d063b7671`](https://github.com/block/buzz/commit/9d1e4b257657f382d3111ce748f3da8d063b7671)) +- Add inline profile camera capture ([#6680](https://github.com/block/buzz/pull/6680)) ([`9aa332af03b4fb416dd8648b320447617ffd6fa5`](https://github.com/block/buzz/commit/9aa332af03b4fb416dd8648b320447617ffd6fa5)) +- Hide Huddles in mobile agent DMs ([#6676](https://github.com/block/buzz/pull/6676)) ([`822c5ab231bc253d809d2d13da4b381f723dcd25`](https://github.com/block/buzz/commit/822c5ab231bc253d809d2d13da4b381f723dcd25)) +- Centralize replaceable event persistence ([#6660](https://github.com/block/buzz/pull/6660)) ([`8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8`](https://github.com/block/buzz/commit/8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8)) +- fix(ci): prevent poisoned Rust caches ([#6618](https://github.com/block/buzz/pull/6618)) ([`69b1225923c9bd98784e86a6976bbc34a3dc8630`](https://github.com/block/buzz/commit/69b1225923c9bd98784e86a6976bbc34a3dc8630)) +- docs(security): route reports through private advisories ([#6728](https://github.com/block/buzz/pull/6728)) ([`02dc49f0e60f75027e926e33a2d0021f7b4e0cd3`](https://github.com/block/buzz/commit/02dc49f0e60f75027e926e33a2d0021f7b4e0cd3)) +- fix(mobile): join starter channels after accepting invite ([#5915](https://github.com/block/buzz/pull/5915)) ([`6eff84d1271eb1b90e07c5a0673343a76a0753fc`](https://github.com/block/buzz/commit/6eff84d1271eb1b90e07c5a0673343a76a0753fc)) +- Add mobile profile editing ([#6583](https://github.com/block/buzz/pull/6583)) ([`a0298539f7043cd0f2d961030e60cc0fd82970b1`](https://github.com/block/buzz/commit/a0298539f7043cd0f2d961030e60cc0fd82970b1)) +- fix(mobile): recover stale and shuffled messages ([#6691](https://github.com/block/buzz/pull/6691)) ([`01091c15a15d6057d80463dfd828e6e1e4b60743`](https://github.com/block/buzz/commit/01091c15a15d6057d80463dfd828e6e1e4b60743)) +- feat(mobile): browse and join open channels ([#6243](https://github.com/block/buzz/pull/6243)) ([`26f4c3ed304db2c273f0bd4d2746aa9598f38366`](https://github.com/block/buzz/commit/26f4c3ed304db2c273f0bd4d2746aa9598f38366)) +- Polish Huddle participant interactions ([#6312](https://github.com/block/buzz/pull/6312)) ([`e23632941331502c0330e51d407e667bea26ef57`](https://github.com/block/buzz/commit/e23632941331502c0330e51d407e667bea26ef57)) +- Downgrade mobile Huddles to audio protocol v2 ([#6558](https://github.com/block/buzz/pull/6558)) ([`4baccd5394d6166bb68ff03b24e376e322281a59`](https://github.com/block/buzz/commit/4baccd5394d6166bb68ff03b24e376e322281a59)) +- chore(deps): update rust crate async-trait to v0.1.92 ([#6094](https://github.com/block/buzz/pull/6094)) ([`f7942167372501576c9f0f589cf2c166882668bb`](https://github.com/block/buzz/commit/f7942167372501576c9f0f589cf2c166882668bb)) +- chore(deps): update dependency sonner to v2.0.8 ([#6093](https://github.com/block/buzz/pull/6093)) ([`2d93ea095535e42ee3a9933f00a4c0bd5e9e1c67`](https://github.com/block/buzz/commit/2d93ea095535e42ee3a9933f00a4c0bd5e9e1c67)) +- chore(deps): update rust crate http-body-util to v0.1.4 ([#5452](https://github.com/block/buzz/pull/5452)) ([`9390e11c9babeef221aeb0a22cc61a52700d168f`](https://github.com/block/buzz/commit/9390e11c9babeef221aeb0a22cc61a52700d168f)) +- chore(deps): update rust crate http to v1.4.2 ([#5451](https://github.com/block/buzz/pull/5451)) ([`1a0a27d3586b14fb79ec8e162441e610f8548188`](https://github.com/block/buzz/commit/1a0a27d3586b14fb79ec8e162441e610f8548188)) +- chore(deps): update rust crate futures to v0.3.33 ([#5445](https://github.com/block/buzz/pull/5445)) ([`2cef92df676490654f13ebe1de56923b2636123f`](https://github.com/block/buzz/commit/2cef92df676490654f13ebe1de56923b2636123f)) +- chore(deps): update dependency @tauri-apps/api to v2.11.1 ([#5444](https://github.com/block/buzz/pull/5444)) ([`f84511c169cf9a98ac03e9c1acb6bf4d68de5cb8`](https://github.com/block/buzz/commit/f84511c169cf9a98ac03e9c1acb6bf4d68de5cb8)) +- chore(deps): update ubuntu:24.04 docker digest to 561618e ([#5442](https://github.com/block/buzz/pull/5442)) ([`9008a4d1724afbd8dba0fd99e99cd8305b5d2ecb`](https://github.com/block/buzz/commit/9008a4d1724afbd8dba0fd99e99cd8305b5d2ecb)) +- chore(deps): update swatinem/rust-cache digest to 6323deb ([#5441](https://github.com/block/buzz/pull/5441)) ([`694d804b24cd0b97403b3caf159a55ffd1fc3b08`](https://github.com/block/buzz/commit/694d804b24cd0b97403b3caf159a55ffd1fc3b08)) +- fix(deletion): allow IRSA S3 credentials ([#6495](https://github.com/block/buzz/pull/6495)) ([`47526784d5c1967de6b2c5d1ee642bccfefbfab9`](https://github.com/block/buzz/commit/47526784d5c1967de6b2c5d1ee642bccfefbfab9)) +- docs(nips): comprehensive NIP-FI — core + claimable profiles (EDGE/LIFECYCLE/DELEG/CONF) ([#5946](https://github.com/block/buzz/pull/5946)) ([`d97780b4777f2fe3430b4e30a7d47fc6837ee059`](https://github.com/block/buzz/commit/d97780b4777f2fe3430b4e30a7d47fc6837ee059)) +- fix(benchmarks): wait for scripted event delivery ([#6487](https://github.com/block/buzz/pull/6487)) ([`025425591ed67518a63870316f1473ffd02dd520`](https://github.com/block/buzz/commit/025425591ed67518a63870316f1473ffd02dd520)) +- Polish mobile channel navigation and message sends ([#6488](https://github.com/block/buzz/pull/6488)) ([`aeb741fd31044ec560d953b0986dec2e7e93e2c6`](https://github.com/block/buzz/commit/aeb741fd31044ec560d953b0986dec2e7e93e2c6)) + +[Compare desktop-v0.5.18...desktop-v0.5.19](https://github.com/block/buzz/compare/desktop-v0.5.18...desktop-v0.5.19) + ## v0.5.18 ### Desktop and shared changes diff --git a/Cargo.lock b/Cargo.lock index fabea91844..50b7946e7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -929,6 +929,7 @@ dependencies = [ "chrono", "futures-util", "hex", + "metrics", "serde", "serde_json", "sha2 0.11.0", @@ -1044,6 +1045,8 @@ dependencies = [ name = "buzz-datastore-tracing" version = "0.1.0" dependencies = [ + "metrics", + "metrics-util", "opentelemetry 0.32.0", "opentelemetry_sdk 0.32.1", "proc-macro2", @@ -1145,6 +1148,7 @@ dependencies = [ "infer", "mp4", "nostr 0.44.7", + "quick-xml 0.38.4", "rust-s3", "serde", "serde_json", @@ -1368,6 +1372,7 @@ version = "0.1.0" dependencies = [ "buzz-core", "buzz-datastore-tracing", + "metrics", "sqlx", "thiserror 2.0.18", "tokio", diff --git a/Dockerfile b/Dockerfile index d883ac6b01..2c1e8adbb5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,6 +67,14 @@ COPY --from=planner /build/recipe.json recipe.json # scoping to -p buzz-relay misses transitive deps and re-builds them later. RUN cargo chef cook --release --recipe-path recipe.json COPY . . +# Compile immutable artifact identity into the relay. Defaults preserve local +# and third-party builds that do not run in provenance-aware CI. +ARG BUZZ_SOURCE_SHA=unknown +ARG BUZZ_BUILD_ID=local +ARG BUZZ_BUILD_URL=unknown +ENV BUZZ_SOURCE_SHA=${BUZZ_SOURCE_SHA} \ + BUZZ_BUILD_ID=${BUZZ_BUILD_ID} \ + BUZZ_BUILD_URL=${BUZZ_BUILD_URL} RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \ -p buzz-admin --bin buzz-admin \ -p buzz-pair-relay --bin buzz-pair-relay diff --git a/Justfile b/Justfile index 4f82a558c4..838f6e7d47 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,7 @@ bootstrap: cp .env.example .env echo "Created .env from .env.example — review it before running just dev." fi + ./scripts/ensure-local-relay-key.sh .env # Start Docker services, run migrations, install desktop deps setup: bootstrap @@ -307,6 +308,7 @@ test: test-unit: #!/usr/bin/env bash set -euo pipefail + ./scripts/test-ensure-local-relay-key.sh if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-voice --lib @@ -424,6 +426,9 @@ relay: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport cargo run -p buzz-relay # Start the relay with the built web UI served from it @@ -431,6 +436,9 @@ relay-web: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport [[ -d node_modules ]] || pnpm install pnpm -C web build BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay @@ -440,6 +448,9 @@ admin: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport [[ -d node_modules ]] || pnpm install pnpm -C admin-web build export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}" @@ -460,7 +471,12 @@ admin-check: fmt-check pnpm -C admin-web exec playwright test # Start the relay server in release mode -relay-release: _ensure-migrations +relay-release: bootstrap _ensure-migrations + #!/usr/bin/env bash + set -euo pipefail + set -o allexport + source .env + set +o allexport cargo run -p buzz-relay --release @@ -469,6 +485,9 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}" relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000 health_port="${BUZZ_HEALTH_PORT:-8080}" diff --git a/TESTING.md b/TESTING.md index 29d07a80de..0e64b74066 100644 --- a/TESTING.md +++ b/TESTING.md @@ -29,7 +29,7 @@ CLI signs every request with NIP-98, so you don't need `nak` or hand-rolled ```bash . ./bin/activate-hermit # activate pinned toolchain -cp .env.example .env # one-time +just bootstrap # create .env and its stable relay key once just setup # start Docker services, run migrations ``` @@ -70,6 +70,9 @@ Rebuild after any code change — the steps below use the release binaries. In a separate terminal (it runs in the foreground): ```bash +set -o allexport +source .env # includes the key generated by just bootstrap +set +o allexport buzz-relay # release binary from step 2, serves ws://localhost:3000 # alternatives: # cargo run --release -p buzz-relay # rebuild + run in release @@ -89,9 +92,9 @@ curl -s http://localhost:8080/_readiness # → {"status":"ready"} > `BUZZ_HEALTH_PORT`) so K8s probes bypass auth middleware. The main app > port also exposes `/health` for convenience. -The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`). The startup -log emits a WARN about this — that's expected for local testing. See the env -vars table at the bottom if you need to lock it down. +The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`) with the stable +relay identity generated in `.env`. See the env vars table at the bottom if +you need to lock it down. > **Already running Buzz Desktop (or another relay) on `:3000` / `:8080` / > `:9102`?** Buzz binds three ports — main, health, metrics — and any of diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index 93ebceeb14..8f0ba3d6c3 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -195,6 +195,7 @@ h1 { .feedback-filters label { display: grid; gap: 0.45rem; + min-width: 0; } .feedback-filters label > span { @@ -221,6 +222,8 @@ h1 { } .feedback-filters select { + width: 100%; + min-width: 0; padding: 0 2.25rem 0 1rem; } diff --git a/admin-web/tests/routes.spec.ts b/admin-web/tests/routes.spec.ts index 3c965dd2d8..ca6abe7143 100644 --- a/admin-web/tests/routes.spec.ts +++ b/admin-web/tests/routes.spec.ts @@ -224,6 +224,74 @@ test("feedback can be searched and filtered by community and time", async ({ await expect(page.getByText("Calls are much more reliable")).toHaveCount(0); }); +test("feedback filters keep long community names usable", async ({ page }) => { + await page.route("**/api/admin/v1/feedback", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify([ + { + id: "long-community", + communityId: "long-community", + communityHost: `${"long-community-name.".repeat(4)}buzz.example.com`, + submitterPubkey: "21".repeat(32), + category: "bug", + bodySummary: "The filter row stays within its container", + receivedAt: new Date().toISOString(), + }, + ]), + }), + ); + + for (const viewport of [ + { name: "desktop", width: 1200 }, + { name: "mobile", width: 720 }, + ]) { + await test.step(viewport.name, async () => { + await page.setViewportSize({ width: viewport.width, height: 720 }); + await page.goto("/feedback"); + + const filters = page.locator(".feedback-filters"); + const search = page.getByRole("searchbox", { name: "Search feedback" }); + const community = page.getByRole("combobox", { name: "Community" }); + const status = page.getByLabel("Status"); + await expect(filters).toBeVisible(); + await expect(community).toBeVisible(); + await expect(status).toBeVisible(); + + const [filtersBox, searchBox, communityBox, statusBox] = + await Promise.all([ + filters.boundingBox(), + search.boundingBox(), + community.boundingBox(), + status.boundingBox(), + ]); + if (!filtersBox || !searchBox || !communityBox || !statusBox) { + throw new Error("feedback filter bounds were unavailable"); + } + expect(statusBox.x + statusBox.width).toBeLessThanOrEqual( + filtersBox.x + filtersBox.width, + ); + + if (viewport.name === "desktop") { + const rootFontSize = await page.evaluate(() => + Number.parseFloat( + getComputedStyle(document.documentElement).fontSize, + ), + ); + expect(communityBox.width).toBeGreaterThanOrEqual(14 * rootFontSize); + } else { + expect(Math.abs(communityBox.width - searchBox.width)).toBeLessThan(1); + } + + const pageWidths = await page.evaluate(() => ({ + client: document.documentElement.clientWidth, + scroll: document.documentElement.scrollWidth, + })); + expect(pageWidths.scroll).toBe(pageWidths.client); + }); + } +}); + test("feedback status is stored locally by feedback id", async ({ page }) => { await page.route("**/api/admin/v1/feedback", (route) => route.fulfill({ diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 7a979b62e0..4c6cf5357a 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -29,7 +29,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ Run `buzz --help` or `buzz --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. -When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. +When opening a pull request in response to channel work, always pass `--channel ` using the UUID from ``. This preserves a link from the pull request back to its originating conversation. `buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. @@ -39,7 +39,7 @@ To assign an issue to someone, run `buzz issues assign --issue --repo When someone asks to create an agent, ask for at most two things: its name and what it should do day-to-day. Write the `--system-prompt` yourself. Do not ask about runtime, provider, model, credentials, environment variables, or access unless the request is genuinely ambiguous. -Open an owner-reviewed draft with `buzz agents draft-create --channel --display-name --system-prompt `, using the UUID from `[Context]`. Never claim the agent exists until the owner saves it. For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. +Open an owner-reviewed draft with `buzz agents draft-create --channel --display-name --system-prompt `, using the UUID from ``. Never claim the agent exists until the owner saves it. For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. ## Communication Patterns @@ -58,15 +58,15 @@ Open an owner-reviewed draft with `buzz agents draft-create --channel ` block for ordinary replies in this turn. Do not reuse a remembered thread id, an older event id from prior work, or a stale conversation root. For human-facing work, keep the conversation flat and easy to read. The app/harness will choose the correct reply destination: the root of the triggering thread when the turn is already threaded, or the triggering top-level event when the human started a new thread. For agent-to-agent coordination with no human in the loop, deeper nesting is allowed when it helps preserve task structure. Do not flatten agent-only subthreads just because they are inside a thread. -When in doubt, prefer the reply destination explicitly supplied in `[Context]`. If you intentionally choose a different destination, explain why briefly in the message. +When in doubt, prefer the reply destination explicitly supplied in ``. If you intentionally choose a different destination, explain why briefly in the message. -All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from `[Context]`). Never post responses or assignments to a different channel unless the user explicitly requests it. +All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from ``). Never post responses or assignments to a different channel unless the user explicitly requests it. ### General diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 4a82cf6306..2d7b212832 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -391,7 +391,7 @@ pub struct CliArgs { /// /// Memory injection is on by default. When enabled, the harness /// fetches the agent's per-session core engram and renders it as an - /// `[Agent Memory — core]` prompt section (or renders the onboarding nudge + /// `` prompt section (or renders the onboarding nudge /// when the relay confirms no core engram exists). The `buzz mem` CLI /// and the relay's acceptance of kind:30174 engrams are unaffected — this /// flag controls prompt-time injection in the ACP harness only. @@ -410,8 +410,8 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_NO_MEMORY", conflicts_with = "memory")] pub no_memory: bool, - /// Disable the [Base] platform-context section prepended to every prompt. - /// When set, agents receive only the persona `[Agent Instructions]` prompt with no Buzz orientation. + /// Disable the `` platform-context section prepended to every prompt. + /// When set, agents receive only the persona `` prompt with no Buzz orientation. #[arg(long, env = "BUZZ_ACP_NO_BASE_PROMPT")] pub no_base_prompt: bool, @@ -480,7 +480,7 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')] pub allowed_respond_to: Option>, - /// Team-owned instructions layered after `[Agent Instructions]` and before agent memory. + /// Team-owned instructions layered after `` and before agent memory. #[arg(long, env = "BUZZ_ACP_TEAM_INSTRUCTIONS")] pub team_instructions: Option, @@ -549,7 +549,7 @@ pub struct Config { pub typing_enabled: bool, /// Whether NIP-AE agent core memory injection is enabled. When false, /// the harness skips the per-session core engram fetch and renders no - /// `[Agent Memory — core]` section. On by default; disabled via the + /// `` section. On by default; disabled via the /// `--no-memory` / `BUZZ_ACP_NO_MEMORY` opt-out. pub memory_enabled: bool, /// Desired LLM model ID. Applied after every `session_new_full()`. @@ -593,7 +593,7 @@ pub struct Config { /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, - /// Disable the [Base] platform-context section prepended to every prompt. + /// Disable the `` platform-context section prepended to every prompt. pub no_base_prompt: bool, /// Resolved content from `--base-prompt-file`, read and validated in /// `from_cli()`. `None` when using the compiled-in default or when diff --git a/crates/buzz-acp/src/engram_fetch.rs b/crates/buzz-acp/src/engram_fetch.rs index 534d05837c..d5ae6df076 100644 --- a/crates/buzz-acp/src/engram_fetch.rs +++ b/crates/buzz-acp/src/engram_fetch.rs @@ -3,7 +3,7 @@ //! //! Scope per Tyler's spec: //! - Fire one synchronous query for the core head when a *new* session is born. -//! - If a body is found, emit `[Agent Memory — core]\n`. +//! - If a body is found, emit `…`. //! - If no body is found, emit an onboarding nudge so the agent learns how //! to set its own core. //! - On any *error* (transport, parse), log and emit nothing. We must not @@ -17,9 +17,6 @@ use nostr::{Event, Keys, PublicKey}; use crate::relay::RestClient; -/// Section header rendered into the prompt. -const SECTION_LABEL: &str = "Agent Memory — core"; - /// Onboarding nudge for new agents with no core yet. /// /// Wording is from Tyler's brief: "No core memory found. Use `buzz mem` @@ -42,8 +39,14 @@ pub async fn build_core_section( owner: &PublicKey, ) -> Option { match fetch_core_body(rest, agent_keys, owner).await { - Ok(Some(profile)) => Some(format!("[{SECTION_LABEL}]\n{profile}")), - Ok(None) => Some(format!("[{SECTION_LABEL}]\n{ONBOARDING_NUDGE}")), + Ok(Some(profile)) => Some(crate::prompt_framing::semantic_section( + "core-memory", + &profile, + )), + Ok(None) => Some(crate::prompt_framing::semantic_section( + "core-memory", + ONBOARDING_NUDGE, + )), Err(reason) => { tracing::warn!( target: "engram::core", diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 146214197a..a662fccc23 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -7,6 +7,7 @@ mod filter; mod observer; mod pool; mod pool_lifecycle; +mod prompt_framing; mod queue; mod relay; mod setup_mode; @@ -3653,7 +3654,7 @@ fn try_native_steer( // channel context and the actor's profile in the original prompt, // duplicating it here would defeat the point of non-cancelling // steering (which is to inject only what's new). - let (header, closing) = queue::native_steer_framing(); + let (tag, closing) = queue::native_steer_framing(); let event_id_hex = event.id.to_hex(); let be = queue::BatchEvent { event, @@ -3661,7 +3662,13 @@ fn try_native_steer( received_at: std::time::Instant::now(), }; let event_block = queue::format_event_block(channel_id, None, &be, None); - let body = format!("{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n\n{closing}"); + let new_message = prompt_framing::semantic_section(tag, ""); + let event_section = prompt_framing::semantic_section_with_attributes( + "buzz-event", + &[("type", prompt_tag.as_str())], + &event_block, + ); + let body = format!("{new_message}\n\n{event_section}\n\n{closing}"); let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); let request = pool::SteerRequest { @@ -4454,6 +4461,14 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("Do not ask about runtime, provider, model, credentials")); } + #[test] + fn shared_base_prompt_names_current_context_framing() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("UUID from ``")); + assert!(prompt.contains("reply destination supplied in the `` block")); + assert!(!prompt.contains("`[Context]`")); + } + #[test] fn shared_base_prompt_teaches_real_newlines_for_multiline_messages() { let prompt = include_str!("base_prompt.md"); @@ -5092,8 +5107,8 @@ mod heartbeat_base_prompt_tests { use super::*; // Pins the heartbeat dispatch path (dispatch_heartbeat, ~line 2359): a - // legacy agent WITH a base_prompt must get [Base] prepended to the - // heartbeat user message, composed as `[Base]\n{bp}\n\n{prompt}`. This is + // legacy agent WITH a base_prompt must get prepended to the + // heartbeat user message. This is // the second half of the round-2 regression (the first being initial_message). fn heartbeat_standing() -> queue::StandingContext<'static> { @@ -5106,12 +5121,12 @@ mod heartbeat_base_prompt_tests { #[test] fn test_heartbeat_legacy_agent_gets_base_prepended() { // protocol_version 1 + Some(base_prompt): heartbeat prompt is prefixed - // with the [Base] section exactly as the legacy session/new path would. + // with the section exactly as the legacy session/new path would. let prompt = "[System: Heartbeat]\nrun feed get"; let composed = pool::prepend_standing_for_legacy(1, &heartbeat_standing(), prompt); assert_eq!( composed, - "[Base]\nyou are a helpful agent\n\n[System: Heartbeat]\nrun feed get" + "\nyou are a helpful agent\n\n\n[System: Heartbeat]\nrun feed get" ); } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 3874957739..f5176d3f15 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -120,12 +120,12 @@ pub struct SessionState { pub turn_counts: HashMap, /// Turn counter for the heartbeat session. pub heartbeat_turn_count: u32, - /// Whether the live heartbeat session has successfully received `[Base]`. + /// Whether the live heartbeat session has successfully received ``. pub heartbeat_standing_context_sent: bool, /// channel_id → rendered NIP-AE core prompt section, populated once at /// session creation per Tyler's spec (no mid-session refresh). pub core_sections: HashMap, - /// channel_id → rendered `[Channel Canvas]` metadata section. + /// channel_id → rendered `` metadata section. /// /// Populated once before session creation (same lifecycle as `core_sections`). /// Absent when the channel has no canvas, the canvas content is blank, or the @@ -631,7 +631,7 @@ pub struct PromptContext { /// Whether NIP-AE agent core memory injection is enabled. When false, /// the per-session core engram fetch is skipped and `core_sections` /// remains empty for every channel, so `format_prompt` renders no - /// `[Agent Memory — core]` section. On by default; disabled via + /// `` section. On by default; disabled via /// `--no-memory` / `BUZZ_ACP_NO_MEMORY`. pub memory_enabled: bool, /// Harness identity string for NIP-AM `harness` field. Derived from the @@ -1015,8 +1015,8 @@ async fn create_session_and_apply_model( // single prompt. Standard protocol-v2 agents receive it in `session/new`; // Goose receives it through the custom request below. Legacy agents receive // the same content as user-message sections via `format_prompt`. Core carries - // its own `[Agent Memory — core]` header, and canvas carries its own - // `[Channel Canvas]` header; both are appended with a blank-line separator. + // its own `` boundary, and canvas carries its own + // `` boundary; both are appended with a blank-line separator. let is_goose = agent.agent_name == "goose"; let combined_system_prompt = with_canvas( with_huddle_instructions( @@ -1608,13 +1608,13 @@ pub(crate) fn prepend_standing_for_legacy( } /// Frame the `session/new` `systemPrompt` so each present prompt carries its own -/// header, keeping the base/workspace/persona boundaries recoverable downstream. +/// paired tag, keeping the base/workspace/persona boundaries recoverable downstream. /// /// The static base remains first for prompt-prefix caching. When a base is /// present, the dynamic workspace anchor follows it and precedes the user-owned /// agent instructions. A persona-only agent still yields -/// `[Agent Instructions]\n{persona}` rather than an unlabeled blob that would -/// be mislabeled as `[Base]`. +/// `…` rather than an unlabeled blob that would be mistaken +/// for ``. fn framed_system_prompt( cwd: &str, base_prompt: Option<&str>, @@ -1622,34 +1622,42 @@ fn framed_system_prompt( ) -> Option { match (base_prompt, system_prompt) { (Some(bp), Some(sp)) => Some(format!( - "{}\n\n{}\n\n[Agent Instructions]\n{sp}", + "{}\n\n{}\n\n{}", crate::queue::base_section(bp), - workspace_section(cwd) + workspace_section(cwd), + crate::prompt_framing::semantic_section("system", sp), )), (Some(bp), None) => Some(format!( "{}\n\n{}", crate::queue::base_section(bp), workspace_section(cwd) )), - (None, Some(sp)) => Some(format!("[Agent Instructions]\n{sp}")), + (None, Some(sp)) => Some(crate::prompt_framing::semantic_section("system", sp)), (None, None) => None, } } fn workspace_section(cwd: &str) -> String { - format!("[Workspace]\nCurrent working directory: {cwd}") + crate::prompt_framing::semantic_section( + "workspace", + &format!("Current working directory: {cwd}"), + ) } -/// Append the team-owned instruction section after `[Agent Instructions]` and before core memory. +/// Append the team-owned instruction section after `` and before core memory. fn with_team(prompt: Option, instructions: Option<&str>) -> Option { let instructions = instructions .map(str::trim) .filter(|value| !value.is_empty()); match (prompt, instructions) { - (Some(prompt), Some(instructions)) => { - Some(format!("{prompt}\n\n[Team Instructions]\n{instructions}")) - } - (None, Some(instructions)) => Some(format!("[Team Instructions]\n{instructions}")), + (Some(prompt), Some(instructions)) => Some(format!( + "{prompt}\n\n{}", + crate::prompt_framing::semantic_section("team-instructions", instructions) + )), + (None, Some(instructions)) => Some(crate::prompt_framing::semantic_section( + "team-instructions", + instructions, + )), (Some(prompt), None) => Some(prompt), (None, None) => None, } @@ -1657,14 +1665,21 @@ fn with_team(prompt: Option, instructions: Option<&str>) -> Option` boundary from /// `engram_fetch::build_core_section`, so it is joined with a blank-line /// separator and never re-labeled. Either side may be absent. fn with_core(framed: Option, core: Option<&str>) -> Option { + let core = core.map(|core| { + crate::prompt_framing::normalize_semantic_section( + "core-memory", + "Agent Memory — core", + core, + ) + }); match (framed, core) { (Some(framed), Some(core)) => Some(format!("{framed}\n\n{core}")), (Some(framed), None) => Some(framed), - (None, Some(core)) => Some(core.to_string()), + (None, Some(core)) => Some(core), (None, None) => None, } } @@ -1675,25 +1690,36 @@ fn with_huddle_instructions(prompt: Option, instructions: Option<&str>) .map(str::trim) .filter(|value| !value.is_empty()); match (prompt, instructions) { - (Some(prompt), Some(instructions)) => { - Some(format!("{prompt}\n\n[Huddle Instructions]\n{instructions}")) - } - (None, Some(instructions)) => Some(format!("[Huddle Instructions]\n{instructions}")), + (Some(prompt), Some(instructions)) => Some(format!( + "{prompt}\n\n{}", + crate::prompt_framing::semantic_section("huddle-instructions", instructions) + )), + (None, Some(instructions)) => Some(crate::prompt_framing::semantic_section( + "huddle-instructions", + instructions, + )), (Some(prompt), None) => Some(prompt), (None, None) => None, } } -/// Append the `[Channel Canvas]` metadata section onto the accumulated system prompt. +/// Append the `` metadata section onto the accumulated system prompt. /// -/// The canvas section already carries its `[Channel Canvas]` header (from +/// The canvas section already carries its `` boundary (from /// `render_canvas_section`), so it is joined with a blank-line separator. /// Either side may be absent. fn with_canvas(prompt: Option, canvas: Option<&str>) -> Option { + let canvas = canvas.map(|canvas| { + crate::prompt_framing::normalize_semantic_section( + "channel-canvas", + "Channel Canvas", + canvas, + ) + }); match (prompt, canvas) { (Some(prompt), Some(canvas)) => Some(format!("{prompt}\n\n{canvas}")), (Some(prompt), None) => Some(prompt), - (None, Some(canvas)) => Some(canvas.to_string()), + (None, Some(canvas)) => Some(canvas), (None, None) => None, } } @@ -1831,7 +1857,7 @@ pub async fn run_prompt_task( // // Core memory is delivered inside the system prompt the harness already - // builds (system role for protocol >= 2, the `[Agent Instructions]` user-message + // builds (system role for protocol >= 2, the `` user-message // section for legacy agents). To put it on the wire at `session/new` for // modern agents, the fetch must run *before* the session is created — so // we do it here and cache the rendered section in `state.core_sections`. @@ -2288,7 +2314,7 @@ pub async fn run_prompt_task( // Heartbeats create their session before this point, so a Goose method-not-found // probe has already selected the correct framing for this process. // - // Only the first heartbeat of a session carries `[Base]`; later ticks + // Only the first heartbeat of a session carries ``; later ticks // reuse the same session, so the agent already has it. let text = if standing_context_sent { text @@ -3020,7 +3046,7 @@ fn huddle_instructions_from_query_response( } /// Fetch the latest canvas event for `channel_id` and return a rendered -/// `[Channel Canvas]` metadata section, or `None` if absent/blank/error. +/// `` metadata section, or `None` if absent/blank/error. /// /// Failure modes (all fail open — no crash, no block): /// * relay returns no event → `None` @@ -3083,7 +3109,7 @@ async fn fetch_canvas_section(channel_id: Uuid, rest: &RestClient) -> Option` section. /// /// Extracted as a pure function so tests can exercise the parsing/validation /// logic without async machinery or relay connectivity. @@ -3200,16 +3226,18 @@ pub(crate) fn canvas_section_from_query_response( Some(render_canvas_section(&id, ×tamp, channel_uuid)) } -/// Render the `[Channel Canvas]` metadata section string. +/// Render the `` metadata section string. /// /// Pure function — kept separate so unit tests can exercise rendering /// without async machinery or relay connectivity. pub(crate) fn render_canvas_section(event_id: &str, timestamp: &str, channel_uuid: &str) -> String { - format!( - "[Channel Canvas]\n\ - Canvas revision (event ID): {event_id}\n\ - Last modified: {timestamp}\n\ - Fetch current content with: buzz canvas get --channel {channel_uuid}" + crate::prompt_framing::semantic_section( + "channel-canvas", + &format!( + "Canvas revision (event ID): {event_id}\n\ + Last modified: {timestamp}\n\ + Fetch current content with: buzz canvas get --channel {channel_uuid}" + ), ) } @@ -4795,7 +4823,7 @@ mod tests { } // These pin the initial_message dispatch path (run_prompt_task, ~line 855): - // a legacy agent WITH a base_prompt must get [Base] prepended to the user + // a legacy agent WITH a base_prompt must get prepended to the user // message. This is the exact regression that shipped in the round-2 bug. fn base_only(base_prompt: Option<&str>) -> crate::queue::StandingContext<'_> { @@ -4807,14 +4835,17 @@ mod tests { #[test] fn test_initial_message_legacy_agent_gets_base_prepended() { - // protocol_version 1 + Some(base_prompt): [Base] rides along in the - // user message, composed as `[Base]\n{bp}\n\n{initial_msg}`. + // protocol_version 1 + Some(base_prompt): rides along in the + // user message. let composed = prepend_standing_for_legacy( 1, &base_only(Some("you are a helpful agent")), "hello channel", ); - assert_eq!(composed, "[Base]\nyou are a helpful agent\n\nhello channel"); + assert_eq!( + composed, + "\nyou are a helpful agent\n\n\nhello channel" + ); } #[test] @@ -4835,7 +4866,7 @@ mod tests { // construction — and it has never carried the persona. Pin that the // shared helper does not start handing heartbeats [Agent Instructions]. let composed = prepend_standing_for_legacy(1, &base_only(Some("be helpful")), "tick"); - assert_eq!(composed, "[Base]\nbe helpful\n\ntick"); + assert_eq!(composed, "\nbe helpful\n\n\ntick"); } #[test] @@ -4912,16 +4943,16 @@ mod tests { #[test] fn test_initial_message_legacy_agent_gets_whole_standing_block() { // The initial message is the legacy agent's first contact, so it must - // carry every standing section — not just [Base] and the canvas, which + // carry every standing section — not just and the canvas, which // left the agent acting on its first turn with no persona and no memory. let composed = prepend_standing_for_legacy(1, &full_standing(), "do the thing"); let positions: Vec = [ - "[Base]", - "[Agent Instructions]", - "[Team Instructions]", - "[Agent Memory — core]", - "[Huddle Instructions]", - "[Channel Canvas]", + "", + "", + "", + "", + "", + "", "do the thing", ] .iter() @@ -4966,7 +4997,7 @@ mod tests { } // Pin the session/new systemPrompt framing: each present prompt carries its - // own header so the desktop observer can split into labeled sub-sections. + // own paired tag so the desktop observer can split labeled sub-sections. #[test] fn test_framed_system_prompt_both_present_carries_both_headers() { @@ -4977,7 +5008,7 @@ mod tests { .expect("both present yields Some"); assert_eq!( framed, - "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace\n\n[Agent Instructions]\npersona text" + "\nbase text\n\n\n\nCurrent working directory: /workspace\n\n\n\npersona text\n" ); } @@ -4987,17 +5018,25 @@ mod tests { framed_system_prompt("/workspace", Some("base text"), None).expect("base yields Some"); assert_eq!( framed, - "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace" + "\nbase text\n\n\n\nCurrent working directory: /workspace\n" ); } #[test] fn test_framed_system_prompt_persona_only_labels_agent_instructions() { // A bare persona would be mislabeled "Base" downstream — it must carry - // its own [Agent Instructions] header even when no base prompt exists. + // its own boundary even when no base prompt exists. let framed = framed_system_prompt("/workspace", None, Some("persona text")) .expect("persona yields Some"); - assert_eq!(framed, "[Agent Instructions]\npersona text"); + assert_eq!(framed, "\npersona text\n"); + } + + #[test] + fn test_framed_system_prompt_preserves_persona_bytes_verbatim() { + let persona = "literal , , ", & "; + let framed = + framed_system_prompt("/workspace", None, Some(persona)).expect("persona yields Some"); + assert_eq!(framed, format!("\n{persona}\n")); } #[test] @@ -5009,7 +5048,7 @@ mod tests { fn test_workspace_section_preserves_windows_cwd() { assert_eq!( workspace_section(r"C:\Users\me\buzz"), - "[Workspace]\nCurrent working directory: C:\\Users\\me\\buzz" + "\nCurrent working directory: C:\\Users\\me\\buzz\n" ); } @@ -5022,7 +5061,7 @@ mod tests { .expect("both present yields Some"); assert_eq!( framed, - "[Agent Instructions]\npersona\n\n[Agent Memory — core]\nbe helpful" + "[Agent Instructions]\npersona\n\n\nbe helpful\n" ); } @@ -5037,7 +5076,7 @@ mod tests { fn test_with_core_core_only_is_just_core() { let framed = with_core(None, Some("[Agent Memory — core]\nbe helpful")) .expect("core-only yields Some"); - assert_eq!(framed, "[Agent Memory — core]\nbe helpful"); + assert_eq!(framed, "\nbe helpful\n"); } #[test] @@ -6031,10 +6070,13 @@ done"# .as_str() .expect("text prompt") }; - assert_eq!(prompt_text(0), "[Base]\nstanding-once\n\nheartbeat-1"); + assert_eq!( + prompt_text(0), + "\nstanding-once\n\n\nheartbeat-1" + ); assert_eq!( prompt_text(1), - "[Base]\nstanding-once\n\nheartbeat-2", + "\nstanding-once\n\n\nheartbeat-2", "retry after ACP failure must resend standing context" ); assert_eq!( @@ -6154,13 +6196,13 @@ done"# .as_str() .expect("text prompt") }; - assert!(prompt_text(0).contains("[Base]\nstanding-once")); + assert!(prompt_text(0).contains("\nstanding-once\n")); assert!( - prompt_text(1).contains("[Base]\nstanding-once"), + prompt_text(1).contains("\nstanding-once\n"), "retry after channel ACP failure must resend standing context" ); assert!( - !prompt_text(2).contains("[Base]\nstanding-once"), + !prompt_text(2).contains("\nstanding-once\n"), "turn after channel ACP success must omit standing context" ); } @@ -7962,7 +8004,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn huddle_instructions_append_as_system_section() { assert_eq!( with_huddle_instructions(Some("base".into()), Some(" reply now ")).as_deref(), - Some("base\n\n[Huddle Instructions]\nreply now") + Some("base\n\n\nreply now\n") ); } @@ -8019,10 +8061,11 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let section = render_canvas_section(id, ts, uuid); assert_eq!( section, - "[Channel Canvas]\n\ + "\n\ Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n\ Last modified: 2024-01-15T10:30:00+00:00\n\ - Fetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae" + Fetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae\n\ + " ); } @@ -8031,13 +8074,19 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] fn test_with_canvas_appends_to_existing_prompt() { let result = with_canvas(Some("base content".into()), Some("[Channel Canvas]\nstuff")); - assert_eq!(result.unwrap(), "base content\n\n[Channel Canvas]\nstuff"); + assert_eq!( + result.unwrap(), + "base content\n\n\nstuff\n" + ); } #[test] fn test_with_canvas_returns_canvas_alone_when_no_prompt() { let result = with_canvas(None, Some("[Channel Canvas]\nstuff")); - assert_eq!(result.unwrap(), "[Channel Canvas]\nstuff"); + assert_eq!( + result.unwrap(), + "\nstuff\n" + ); } #[test] @@ -8134,7 +8183,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(section.contains(&id), "section must contain the event id"); assert!(section.contains("buzz canvas get --channel")); assert!(section.contains(CHANNEL_UUID)); - assert!(section.starts_with("[Channel Canvas]")); + assert!(section.starts_with("")); // Timestamp must use Z suffix, not +00:00 assert!(section.contains('Z'), "timestamp must use Z suffix"); } diff --git a/crates/buzz-acp/src/prompt_framing.rs b/crates/buzz-acp/src/prompt_framing.rs new file mode 100644 index 0000000000..860f364db8 --- /dev/null +++ b/crates/buzz-acp/src/prompt_framing.rs @@ -0,0 +1,92 @@ +//! Shared framing for standing prompt context. + +/// Wrap one standing-context body in an explicit paired boundary. +/// +/// The body is intentionally preserved verbatim: agent-definition review +/// surfaces must show the same instructions that the model executes. +pub(crate) fn semantic_section(tag: &str, content: &str) -> String { + format!("<{tag}>\n{content}\n") +} + +/// Wrap content in a paired semantic boundary carrying existing header metadata. +/// +/// Only attribute values are escaped; the section body remains byte-for-byte +/// model-visible, matching [`semantic_section`]. +pub(crate) fn semantic_section_with_attributes( + tag: &str, + attributes: &[(&str, &str)], + content: &str, +) -> String { + let attributes = attributes + .iter() + .map(|(name, value)| format!(" {name}=\"{}\"", escape_attribute(value))) + .collect::(); + format!("<{tag}{attributes}>\n{content}\n") +} + +fn escape_attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +/// Normalize an already-rendered or legacy bracket-framed standing section. +pub(crate) fn normalize_semantic_section(tag: &str, legacy_label: &str, content: &str) -> String { + if content.starts_with(&format!("<{tag}>")) && content.ends_with(&format!("")) { + return content.to_string(); + } + let legacy = format!("[{legacy_label}]\n"); + semantic_section(tag, content.strip_prefix(&legacy).unwrap_or(content)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semantic_section_preserves_model_visible_body_verbatim() { + assert_eq!( + semantic_section("system", "keep , , ", & "), + "\nkeep , , ", & \n" + ); + } + + #[test] + fn normalize_supports_legacy_and_already_semantic_sections() { + assert_eq!( + normalize_semantic_section( + "core-memory", + "Agent Memory — core", + "[Agent Memory — core]\nremember", + ), + "\nremember\n" + ); + let semantic = semantic_section("core-memory", "remember"); + assert_eq!( + normalize_semantic_section("core-memory", "Agent Memory — core", &semantic), + semantic + ); + } + + #[test] + fn semantic_section_preserves_body_whitespace() { + assert_eq!( + semantic_section("system", "\n keep this \n"), + "\n\n keep this \n\n" + ); + } + + #[test] + fn semantic_section_attributes_do_not_mutate_body() { + assert_eq!( + semantic_section_with_attributes( + "buzz-event", + &[("type", "say \"hi\" & ")], + "keep & ", + ), + "\nkeep & \n" + ); + } +} diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 60866518ba..8fd0a02fe7 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1249,17 +1249,17 @@ fn resolve_reply_anchor( ) } -/// Maximum length (in characters) of a channel description rendered into `[Context]`. +/// Maximum length (in characters) of a channel description rendered into ``. /// /// Limits prompt bloat from unusually long descriptions; a raw embedded newline -/// in a description must not be able to spoof another `[Context]` field, so +/// in a description must not be able to spoof another `` field, so /// multiline text is collapsed to single-space-joined lines before truncation. const MAX_DESCRIPTION_LEN: usize = 500; -/// Append a `Description: …` line to a `[Context]` block when non-empty. +/// Append a `Description: …` line to a `` body when non-empty. /// /// Collapses internal newlines (any `\r\n`, `\r`, or `\n`) to a single space -/// so a multi-line description cannot inject a fake `[Context]` field line. +/// so a multi-line description cannot inject a fake `` field line. /// Truncates at [`MAX_DESCRIPTION_LEN`] characters with a `…` marker. fn append_channel_description(s: &mut String, channel_info: Option<&PromptChannelInfo>) { let desc = match channel_info.and_then(|ci| ci.description.as_deref()) { @@ -1291,7 +1291,7 @@ fn append_channel_description(s: &mut String, channel_info: Option<&PromptChanne s.push_str(&format!("\nDescription: {truncated}")); } -/// Format a `[Context]` hints section based on event scope. +/// Format a `` hints section based on event scope. /// /// `reply_anchor` is the pre-resolved `--reply-to` target for this turn (see /// [`resolve_reply_anchor`]). In the thread/DM branches it threads ordinary @@ -1332,8 +1332,7 @@ fn format_context_hints( "Use `buzz messages get --channel ` for conversation context." }; let mut s = format!( - "[Context]\n\ - Scope: dm\n\ + "Scope: dm\n\ Channel: {channel_display}\n\ {ctx_hint}" ); @@ -1349,7 +1348,7 @@ fn format_context_hints( append_reply_instruction(&mut s, event_id); } } - s + crate::prompt_framing::semantic_section("context", &s) } else if let Some(ref root) = thread_tags.root_event_id { let ctx_hint = if has_conversation_context { "Thread context included below. Use `buzz messages thread --channel --event ` for full history if truncated." @@ -1359,8 +1358,7 @@ fn format_context_hints( "Use `buzz messages thread --channel --event ` to fetch thread context." }; let mut s = format!( - "[Context]\n\ - Scope: thread\n\ + "Scope: thread\n\ Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); @@ -1374,11 +1372,10 @@ fn format_context_hints( if let Some(event_id) = reply_anchor { append_reply_instruction(&mut s, event_id); } - s + crate::prompt_framing::semantic_section("context", &s) } else { let mut s = format!( - "[Context]\n\ - Scope: channel\n\ + "Scope: channel\n\ Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); @@ -1388,7 +1385,7 @@ fn format_context_hints( if let Some(event_id) = reply_anchor { append_new_thread_reply_instruction(&mut s, event_id); } - s + crate::prompt_framing::semantic_section("context", &s) } } @@ -1397,34 +1394,44 @@ fn format_conversation_context( ctx: &ConversationContext, profile_lookup: Option<&PromptProfileLookup>, ) -> String { - let (label, messages, total, truncated) = match ctx { + let (tag, messages, total, truncated) = match ctx { ConversationContext::Thread { messages, total, truncated, - } => ("Thread Context", messages, total, truncated), + } => ("thread-context", messages, total, truncated), ConversationContext::Dm { messages, total, truncated, - } => ("Conversation Context", messages, total, truncated), + } => ("conversation-context", messages, total, truncated), }; - let trunc_label = if *truncated { ", truncated" } else { "" }; - let mut s = format!( - "[{label} ({} of {total} messages{trunc_label})]", - messages.len() - ); + let mut body = String::new(); for (i, msg) in messages.iter().enumerate() { - s.push_str(&format!( - "\n[{}] {} ({}): {}", + if !body.is_empty() { + body.push('\n'); + } + body.push_str(&format!( + "[{}] {} ({}): {}", i + 1, format_prompt_actor(&msg.pubkey, profile_lookup), msg.timestamp, msg.content, )); } - s + let included = messages.len().to_string(); + let total = total.to_string(); + let truncated = truncated.to_string(); + crate::prompt_framing::semantic_section_with_attributes( + tag, + &[ + ("included", included.as_str()), + ("total", total.as_str()), + ("truncated", truncated.as_str()), + ], + &body, + ) } /// Arguments for [`format_prompt`] beyond the required [`FlushBatch`]. @@ -1441,15 +1448,15 @@ pub struct FormatPromptArgs<'a> { pub profile_lookup: Option<&'a PromptProfileLookup>, /// When true, base_prompt and system_prompt are delivered via the system /// role (session/new) and omitted from the user message. When false - /// (legacy agents), they are injected as `[Base]` and `[Agent Instructions]` sections. + /// (legacy agents), they are injected as `` and `` sections. pub has_system_prompt_support: bool, /// Base prompt content for legacy agents (protocol_version < 2). pub base_prompt: Option<&'a str>, /// System prompt content for legacy agents (protocol_version < 2). pub system_prompt: Option<&'a str>, - /// Team instructions for legacy agents, rendered after `[Agent Instructions]`. + /// Team instructions for legacy agents, rendered after ``. pub team_instructions: Option<&'a str>, - /// Rendered `[Channel Canvas]` metadata section for legacy agents. + /// Rendered `` metadata section for legacy agents. /// /// For modern agents (protocol_version >= 2) the section is delivered via /// the system role in session/new; omit here to avoid duplication. @@ -1493,50 +1500,64 @@ impl StandingContext<'_> { sections.push(base_section(bp)); } if let Some(sp) = self.system_prompt { - sections.push(format!("[Agent Instructions]\n{sp}")); + sections.push(crate::prompt_framing::semantic_section("system", sp)); } if let Some(team) = self .team_instructions .map(str::trim) .filter(|value| !value.is_empty()) { - sections.push(format!("[Team Instructions]\n{team}")); + sections.push(crate::prompt_framing::semantic_section( + "team-instructions", + team, + )); } if let Some(core) = self.agent_core { - sections.push(core.to_string()); + sections.push(crate::prompt_framing::normalize_semantic_section( + "core-memory", + "Agent Memory — core", + core, + )); } if let Some(instructions) = self .huddle_instructions .map(str::trim) .filter(|value| !value.is_empty()) { - sections.push(format!("[Huddle Instructions]\n{instructions}")); + sections.push(crate::prompt_framing::semantic_section( + "huddle-instructions", + instructions, + )); } if let Some(canvas) = self.agent_canvas { - sections.push(canvas.to_string()); + sections.push(crate::prompt_framing::normalize_semantic_section( + "channel-canvas", + "Channel Canvas", + canvas, + )); } sections } } -/// Format the `[Base]` section for the base prompt. +/// Format the `` section for the base prompt. /// -/// Single source of truth for the `[Base]` framing so the format is defined in +/// Single source of truth for the `` framing so the format is defined in /// exactly one place across all dispatch paths (batch flush, heartbeat, /// initial message). pub(crate) fn base_section(base_prompt: &str) -> String { - format!("[Base]\n{}", base_prompt.trim_end()) + crate::prompt_framing::semantic_section("base", base_prompt.trim_end()) } /// Format a [`FlushBatch`] into the per-section prompt blocks for the agent. /// /// Produces a stable prompt with these sections (in order): -/// 0. [`StandingContext`] — `[Base]`, `[Agent Instructions]`, `[Team Instructions]`, -/// `[Agent Memory — core]`, `[Channel Canvas]`. Legacy agents only, and only +/// 0. [`StandingContext`] — ``, ``, ``, +/// ``, ``, ``. Legacy agents only, and only /// on the session's first message (see `standing_context_sent`) -/// 1. `[Context]` — scope, channel name, and contextual hints for the agent -/// 2. `[Thread Context]` or `[Conversation Context]` — if fetched -/// 3. `[Event]` / `[Buzz events]` — the triggering event(s) +/// 1. `` — scope, channel name, and contextual hints for the agent +/// 2. `` or `` — if fetched +/// 3. `` / `` — the triggering event(s) /// /// Each section is returned as its own block rather than one joined string so /// the observer frame's size trimmer (`fit_observer_event_to_budget`) elides @@ -1634,55 +1655,71 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> Vec MergeFraming { - prior_header: "[Previous request — interrupted before completion]", - new_header_single: "[New request — supersedes previous]", - new_header_multi_prefix: "[New request — supersedes previous", + prior_tag: "previous-request-interrupted-before-completion", + new_tag: "new-request-supersedes-previous", closing_note: "Note: The previous request was interrupted. Please address the new \ request.\nIf the new request is unrelated to the previous one, you may \ briefly acknowledge the interruption.", @@ -1744,7 +1776,7 @@ impl MergeFraming { /// pulled from the same source-of-truth as the cancel+merge fallback /// (`MergeFraming::for_reason(Some(CancelReason::Steer))`). /// -/// Returns `(new_header_single, closing_note)`. Native-steer renders only +/// Returns `(new_tag, closing_note)`. Native-steer renders only /// the new-message header + the single event block + the closing note — /// no `prior_header`, no original-request section, because the in-flight /// goose turn already has all of that in context. The two paths share @@ -1753,7 +1785,7 @@ impl MergeFraming { /// requirement: native and fallback must not diverge in UX). pub(crate) fn native_steer_framing() -> (&'static str, &'static str) { let framing = MergeFraming::for_reason(Some(CancelReason::Steer)); - (framing.new_header_single, framing.closing_note) + (framing.new_tag, framing.closing_note) } #[cfg(test)] @@ -1821,12 +1853,14 @@ mod tests { #[test] fn test_base_section_prepends_header_and_trims_trailing_whitespace() { - // Trailing whitespace/newlines are stripped; the [Base] header is - // prepended exactly once with a single newline separator. - assert_eq!(base_section("hello \n\n"), "[Base]\nhello"); - assert_eq!(base_section("hello"), "[Base]\nhello"); + // Trailing whitespace/newlines are stripped and the boundary is paired. + assert_eq!(base_section("hello \n\n"), "\nhello\n"); + assert_eq!(base_section("hello"), "\nhello\n"); // Internal newlines and leading whitespace are preserved verbatim. - assert_eq!(base_section(" line1\nline2 "), "[Base]\n line1\nline2"); + assert_eq!( + base_section(" line1\nline2 "), + "\n line1\nline2\n" + ); } #[test] @@ -2010,10 +2044,10 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); - // Should contain [Context] section before the event. - assert!(prompt.contains("[Context]")); + // Should contain the context section before the event. + assert!(prompt.contains("")); assert!(prompt.contains("Scope: channel")); - assert!(prompt.contains("[Buzz event: @mention]\n")); + assert!(prompt.contains("\n")); assert!(prompt.contains(&format!("Channel: {}", ch))); assert!(prompt.contains(&format!("From: {}", npub))); assert!(prompt.contains("Content: Hello @agent")); @@ -2074,11 +2108,11 @@ mod tests { // Interrupt framing: the new request supersedes the previous one. assert!( - prompt.contains("supersedes previous"), + prompt.contains(""), "interrupt prompt should use supersede framing: {prompt}" ); assert!( - prompt.contains("interrupted before completion"), + prompt.contains(""), "interrupt prompt should label the prior work as interrupted: {prompt}" ); assert!( @@ -2146,7 +2180,7 @@ mod tests { ); // The honest prior header (no overclaimed partial-work capture). assert!( - prompt.contains("[What you were working on]"), + prompt.contains(""), "steer prior header must be the honest variant: {prompt}" ); // Both the original work and the steering message survive the merge. @@ -2180,7 +2214,7 @@ mod tests { cancel_reason: Some(CancelReason::Steer), }; let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); - assert!(prompt.contains("New messages — arrived while you were working — 2 events]")); + assert!(prompt.contains("")); assert!(!prompt.contains("supersedes")); } @@ -2245,7 +2279,7 @@ mod tests { "reply instruction must NOT target the original thread: {prompt}" ); // Steer framing still frames the original as in-progress work to continue. - assert!(prompt.contains("[What you were working on]")); + assert!(prompt.contains("")); assert!(prompt.contains("arrived while you were working")); assert!(!prompt.contains("supersedes")); } @@ -2411,8 +2445,8 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); - assert!(prompt.contains("[Context]")); - assert!(prompt.contains("[Buzz events — 3 events]")); + assert!(prompt.contains("")); + assert!(prompt.contains("")); assert!(prompt.contains("--- Event 1 (tag-a) ---")); assert!(prompt.contains("--- Event 2 (tag-b) ---")); assert!(prompt.contains("--- Event 3 (tag-c) ---")); @@ -2442,7 +2476,7 @@ mod tests { // so they must NOT appear in the user message. assert!(!prompt.contains("[Agent Instructions]")); assert!(!prompt.contains("[Base]")); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("")); } #[test] @@ -2469,8 +2503,8 @@ mod tests { ) .join("\n\n"); assert!( - prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]"), - "expected core block first, then [Context]; got: {prompt}" + prompt.starts_with("\nbe helpful\n\n\n"), + "expected core block first, then ; got: {prompt}" ); } @@ -2504,7 +2538,7 @@ mod tests { !prompt.contains("[Agent Memory — core]"), "modern agents must not get core in the user message; got: {prompt}" ); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("")); } #[test] @@ -2530,7 +2564,7 @@ mod tests { }, ) .join("\n\n"); - assert!(prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]")); + assert!(prompt.starts_with("\nbe helpful\n\n\n")); } #[test] @@ -2554,7 +2588,7 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!(!prompt.contains("[Base]")); assert!(!prompt.contains("[Agent Instructions]")); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("")); } #[test] @@ -2588,31 +2622,28 @@ mod tests { // Both sections must be present assert!( - prompt.contains("[Base]\ntest base prompt"), - "missing [Base] section" + prompt.contains("\ntest base prompt\n"), + "missing section" ); assert!( - prompt.contains("[Agent Instructions]\ntest system prompt"), - "missing [Agent Instructions] section" + prompt.contains("\ntest system prompt\n"), + "missing section" ); - // [Base] and [Agent Instructions] must appear BEFORE [Agent Memory] and [Context] - let base_pos = prompt.find("[Base]").unwrap(); - let system_pos = prompt.find("[Agent Instructions]").unwrap(); - let core_pos = prompt.find("[Agent Memory").unwrap(); - let context_pos = prompt.find("[Context]").unwrap(); + // and must appear before and . + let base_pos = prompt.find("").unwrap(); + let system_pos = prompt.find("").unwrap(); + let core_pos = prompt.find("").unwrap(); + let context_pos = prompt.find("").unwrap(); - assert!( - base_pos < system_pos, - "[Base] should come before [Agent Instructions]" - ); + assert!(base_pos < system_pos, " should come before "); assert!( system_pos < core_pos, - "[Agent Instructions] should come before [Agent Memory]" + " should come before " ); assert!( core_pos < context_pos, - "[Agent Memory] should come before [Context]" + " should come before " ); } @@ -2651,17 +2682,17 @@ mod tests { let later = format_prompt(&batch, &args(true)).join("\n\n"); for section in [ - "[Base]", - "[Agent Instructions]", - "[Team Instructions]", - "[Agent Memory — core]", - "[Channel Canvas]", + "", + "", + "", + "", + "", ] { assert!(first.contains(section), "first message missing {section}"); assert!(!later.contains(section), "turn 2 repeated {section}"); } // What the turn is actually about survives, and now leads. - assert!(later.starts_with("[Context]"), "got: {later}"); + assert!(later.starts_with(""), "got: {later}"); assert!(later.contains("hello")); assert!( later.len() < first.len(), @@ -2707,7 +2738,7 @@ mod tests { !prompt.contains("[Agent Instructions]"), "[Agent Instructions] should be suppressed for modern agents" ); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("")); } #[test] @@ -2747,22 +2778,20 @@ mod tests { ) .join("\n\n"); - // Verify section ordering: [Agent Memory] < [Context] < [Thread Context] - let core_pos = prompt - .find("[Agent Memory") - .expect("[Agent Memory] missing"); - let context_pos = prompt.find("[Context]").expect("[Context] missing"); + // Verify section ordering: core memory < context < thread context. + let core_pos = prompt.find("").expect(" missing"); + let context_pos = prompt.find("").expect(" missing"); let thread_pos = prompt - .find("[Thread Context") - .expect("[Thread Context] missing"); + .find(" missing"); assert!( core_pos < context_pos, - "[Agent Memory] must come before [Context]" + " must come before " ); assert!( context_pos < thread_pos, - "[Context] must come before [Thread Context]" + " must come before " ); // No [Base] or [Agent Instructions] in user message assert!(!prompt.contains("[Base]")); @@ -3394,7 +3423,7 @@ mod tests { }, ) .join("\n\n"); - assert!(prompt.contains("[Thread Context (2 of 5 messages, truncated)]")); + assert!(prompt.contains("")); assert!(prompt.contains("Let's refactor auth")); assert!(prompt.contains("Thread context included below")); } @@ -3439,7 +3468,8 @@ mod tests { ) .join("\n\n"); assert!(prompt.contains("Scope: dm")); - assert!(prompt.contains("[Conversation Context (1 of 1 messages)]")); + assert!(prompt + .contains("")); assert!(prompt.contains("Can you deploy?")); } @@ -3758,7 +3788,7 @@ mod tests { assert!(prompt.contains("Earlier thread context was already delivered in this session")); assert!(prompt.contains("buzz messages thread")); assert!(!prompt.contains("Thread context included below")); - assert!(!prompt.contains("[Thread Context")); + assert!(!prompt.contains(""), "legacy agent prompt must include canvas section; got: {prompt}" ); } @@ -5165,7 +5195,7 @@ mod tests { channel_type: "stream".into(), description: Some("Engineering discussions".into()), }; - let mut s = "[Context]\nScope: channel\nChannel: team (#abc)".to_string(); + let mut s = "Scope: channel\nChannel: team (#abc)".to_string(); append_channel_description(&mut s, Some(&ci)); assert!( s.contains("\nDescription: Engineering discussions"), @@ -5180,7 +5210,7 @@ mod tests { channel_type: "stream".into(), description: None, }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); assert!( !s.contains("Description:"), @@ -5190,7 +5220,7 @@ mod tests { #[test] fn test_append_channel_description_absent_when_channel_info_none() { - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, None); assert!( !s.contains("Description:"), @@ -5200,13 +5230,13 @@ mod tests { #[test] fn test_append_channel_description_collapses_newlines_spoof_prevention() { - // A multiline description must not be able to inject a fake [Context] field. + // A multiline description must not be able to inject a fake field. let ci = PromptChannelInfo { name: "team".into(), channel_type: "stream".into(), description: Some("Line one\nScope: injected\nLine two".into()), }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); // The whole description is on a single Description line — no injected field. let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); @@ -5229,7 +5259,7 @@ mod tests { channel_type: "stream".into(), description: Some(long_desc), }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); assert!( @@ -5254,7 +5284,7 @@ mod tests { channel_type: "stream".into(), description: Some(long_desc), }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); let value = desc_line.strip_prefix("Description: ").unwrap(); @@ -5268,7 +5298,7 @@ mod tests { channel_type: "stream".into(), description: Some("\n \r\n \n".into()), }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); assert!( !s.contains("Description:"), @@ -5313,7 +5343,7 @@ mod tests { ); assert!( prompt.contains("Description: Engineering discussions and planning."), - "description must appear in [Context] for channel turns; got: {prompt}" + "description must appear in for channel turns; got: {prompt}" ); } @@ -5350,7 +5380,7 @@ mod tests { ); assert!( prompt.contains("Description: Engineering discussions and planning."), - "description must appear in [Context] for thread turns; got: {prompt}" + "description must appear in for thread turns; got: {prompt}" ); } diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 9258ce449f..0743a75667 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -14,6 +14,7 @@ use crate::hints::SkillEntry; use crate::llm::Llm; use crate::mcp::McpRegistry; use crate::mcp::ResultBudget; +use crate::permission::PermissionDecision; use crate::types::{ AgentError, CacheTotalState, ContentBlock, HistoryItem, PricingIdentity, ProviderStop, @@ -142,6 +143,14 @@ pub struct RunCtx<'a> { pub system_prompt: &'a str, pub llm: &'a Llm, pub mcp: &'a Arc, + /// Process-wide permission broker (owned by `App`). Every LLM-issued MCP + /// tool call asks the client to authorize it through this broker before + /// executing. Shared across all sessions so the global admission cap bounds + /// simultaneously-outstanding asks process-wide. + pub permissions: &'a Arc, + /// ACP protocol version negotiated at `initialize`, fixed for the + /// connection. Selects the `session/request_permission` wire shape. + pub protocol_version: u32, /// Skills discovered at session creation; used by the built-in `load_skill` tool. pub skills: &'a [SkillEntry], pub wire: &'a WireSender, @@ -882,8 +891,10 @@ impl RunCtx<'_> { total: MAX_TOOL_RESULT_BYTES, text: self.cfg.max_tool_result_text_bytes, }; - let cancel = self.cancel.clone(); + let mut cancel = self.cancel.clone(); let sem = Arc::clone(&sem); + let permissions = Arc::clone(self.permissions); + let protocol_version = self.protocol_version; set.spawn(async move { // Acquire a permit; if the semaphore is closed (cancel), // emit a terminal wire update and skip the call. @@ -894,6 +905,37 @@ impl RunCtx<'_> { return (i, InvokeOutcome::Failed("cancelled".into())); } }; + // Argument-shape validation BEFORE the ask: a malformed + // non-object argument can never execute, so reject it locally + // without prompting the user to approve a doomed call. + if let Err(e) = crate::mcp::validate_arg_shape(&call.name, &call.arguments) { + let msg = e.to_string(); + emit_failed(&wire, &session_id, &call, &msg).await; + return (i, InvokeOutcome::Failed(msg)); + } + // Ask the client to authorize this call. The broker owns the + // full correlation lifecycle and races cancellation internally; + // every non-authorizing outcome fails closed. + match permissions + .request_permission(&wire, protocol_version, &session_id, &call, &mut cancel) + .await + { + PermissionDecision::Allowed => {} + PermissionDecision::Denied(msg) => { + emit_failed(&wire, &session_id, &call, msg).await; + return (i, InvokeOutcome::Failed(msg.into())); + } + PermissionDecision::Cancelled => { + emit_failed(&wire, &session_id, &call, "cancelled").await; + return (i, InvokeOutcome::Failed("cancelled".into())); + } + } + // Cancellation recheck: a cancel may have landed while we + // waited for approval. Do not start the call in that case. + if *cancel.borrow() { + emit_failed(&wire, &session_id, &call, "cancelled").await; + return (i, InvokeOutcome::Failed("cancelled".into())); + } emit_in_progress(&wire, &session_id, &call).await; let outcome = invoke_tool_inner(&mcp, &call, timeout, budget, cancel).await; match &outcome { diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 67d7c593b5..abf17004e8 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -479,6 +479,18 @@ pub struct Config { /// Set via `BUZZ_AGENT_MAX_HANDOFFS`. Default 10. pub max_handoffs: usize, pub max_parallel_tools: usize, + /// Process-wide cap on simultaneously-outstanding `session/request_permission` + /// asks. Bounds the [`PermissionBroker`](crate::permission::PermissionBroker) + /// correlation map independently of the per-turn tool semaphore (which is + /// fresh per turn) and of `max_sessions` (unbounded by default). Default 32. + /// Set via `BUZZ_AGENT_MAX_PENDING_PERMISSIONS`; validated `>= 1`. + pub max_pending_permissions: usize, + /// Single absolute deadline for a permission ask — shared by broker + /// admission and the response wait, so a saturated call cannot live for two + /// full timeout windows. Default 330s, chosen to outlast the client's 300s + /// auto-deny so the answer (or auto-deny) lands first. Set via + /// `BUZZ_AGENT_PERMISSION_TIMEOUT_SECS`; validated `>= 1`. + pub permission_timeout: Duration, pub hook_timeout: Duration, /// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to /// disable `_Stop` hooks entirely (agent always honors end_turn). @@ -622,6 +634,11 @@ impl Config { max_context_tokens: parse_env("BUZZ_AGENT_MAX_CONTEXT_TOKENS", 200_000u64)?, max_handoffs: parse_env("BUZZ_AGENT_MAX_HANDOFFS", 10)?, max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, + max_pending_permissions: parse_env("BUZZ_AGENT_MAX_PENDING_PERMISSIONS", 32usize)?, + permission_timeout: Duration::from_secs(parse_env( + "BUZZ_AGENT_PERMISSION_TIMEOUT_SECS", + 330u64, + )?), hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?), stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?, require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0, @@ -668,6 +685,8 @@ impl Config { max_context_tokens: 200_001, max_handoffs: 0, max_parallel_tools: 1, + max_pending_permissions: 32, + permission_timeout: Duration::from_secs(330), hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, require_reply: false, @@ -729,6 +748,12 @@ impl Config { if self.max_parallel_tools < 1 { return Err("config: BUZZ_AGENT_MAX_PARALLEL_TOOLS must be >= 1".into()); } + if self.max_pending_permissions < 1 { + return Err("config: BUZZ_AGENT_MAX_PENDING_PERMISSIONS must be >= 1".into()); + } + if self.permission_timeout < MIN_TIMEOUT { + return Err("config: BUZZ_AGENT_PERMISSION_TIMEOUT_SECS must be >= 1".into()); + } if self.mcp_max_restart_attempts < 1 { return Err("config: BUZZ_AGENT_MCP_RESTART_MAX_ATTEMPTS must be >= 1".into()); } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 98fa99ca5b..208acc3469 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -9,6 +9,7 @@ mod hints; mod llm; mod mcp; pub mod model_capabilities; +mod permission; pub mod types; mod wire; @@ -32,6 +33,7 @@ pub const WINDOWS_SHELL_RESOLUTION_ENV: &[&str] = &[ use std::collections::HashMap; use std::path::Path; +use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; use serde_json::{json, Value}; @@ -54,6 +56,17 @@ struct App { cfg: Config, llm: Arc, sessions: Mutex>, + /// ACP protocol version negotiated at `initialize`, stored for the whole + /// connection lifetime. The `session/request_permission` wire shape derives + /// from this value — never from a later mutable session field — so a strict + /// client always receives exactly the shape it negotiated. Defaults to + /// [`PROTOCOL_VERSION`] before `initialize`; no prompt (and thus no + /// permission ask) can run before then. + negotiated_version: AtomicU32, + /// Owns the entire `session/request_permission` correlation lifecycle: + /// process-wide admission, id allocation, response delivery, and abort-safe + /// cleanup. See [`permission::PermissionBroker`]. + permissions: Arc, /// Cached model catalog for Databricks providers. Populated lazily on the /// first successful `session/new` discovery call. Failed discovery is never /// cached: static-token authentication errors reject session creation, while @@ -181,28 +194,53 @@ async fn async_main() { let cfg = Config::from_env().unwrap_or_else(|e| die(e)); let llm = Arc::new(Llm::new(&cfg).unwrap_or_else(|e| die(e.to_string()))); let max_line = cfg.max_line_bytes; + let permissions = Arc::new(permission::PermissionBroker::new( + cfg.max_pending_permissions, + cfg.permission_timeout, + )); let app = Arc::new(App { cfg, llm, sessions: Mutex::new(HashMap::new()), + negotiated_version: AtomicU32::new(PROTOCOL_VERSION), + permissions, models_cache: tokio::sync::OnceCell::new(), }); let (wire_tx, wire_rx) = mpsc::channel::(64); - let writer = tokio::spawn(wire::writer_task(wire_rx)); - if let Err(e) = read_loop( - BufReader::new(tokio::io::stdin()), - app.clone(), - wire_tx, - max_line, - ) - .await - { - tracing::error!("io: reader: {e}"); + let mut writer = tokio::spawn(wire::writer_task(wire_rx)); + // Whichever ends first drives shutdown. The reader ending is the normal + // path (stdin EOF/error). The writer ending while the reader still runs + // means stdout is closed/broken: no reply can ever be written, so we must + // stop reading and cancel every session rather than leave the process + // reading input while outstanding permission asks wait out their full + // deadline for a response that can never arrive. + tokio::select! { + r = read_loop( + BufReader::new(tokio::io::stdin()), + app.clone(), + wire_tx, + max_line, + ) => { + if let Err(e) = r { + tracing::error!("io: reader: {e}"); + } + cancel_all_sessions(&app).await; + let _ = writer.await; + } + _ = &mut writer => { + tracing::error!("io: writer exited (stdout closed); shutting down connection"); + cancel_all_sessions(&app).await; + } } +} + +/// Signal every live session to cancel. Run on connection teardown so in-flight +/// prompts — including any waiting on a `session/request_permission` response — +/// resolve promptly instead of waiting out their deadline. +async fn cancel_all_sessions(app: &Arc) { for session in app.sessions.lock().await.values() { let _ = session.cancel_tx.send(true); } - let _ = writer.await; } async fn read_loop( @@ -235,7 +273,10 @@ async fn dispatch(app: &Arc, msg: Value, wire_tx: &WireSender) { handle_request(app, id, method, params, wire_tx).await } Inbound::Notification { method, params } => handle_notification(app, &method, params).await, - Inbound::Ignored => {} + // Client's answer to a `session/request_permission` we issued. The + // broker matches it to a live correlation id (waking that waiter) or + // ignores an unknown/late id. + Inbound::Response { id, result } => app.permissions.deliver(&id, result), Inbound::Invalid { id, code, message } => { wire::send(wire_tx, wire::err(id, code, &message)).await } @@ -250,7 +291,7 @@ async fn handle_request( wire_tx: &WireSender, ) { match method.as_str() { - "initialize" => initialize(id, params, wire_tx).await, + "initialize" => initialize(app, id, params, wire_tx).await, "session/new" => { let app = app.clone(); let wire_tx = wire_tx.clone(); @@ -291,7 +332,7 @@ async fn handle_notification(app: &Arc, method: &str, params: Value) { } } -async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { +async fn initialize(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { let p: InitializeParams = match decode(params, "initialize") { Ok(p) => p, Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, @@ -303,6 +344,12 @@ async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { // RFD. Revisit when that RFD merges; otherwise a genuine upstream-v2 agent // would silently lose `[Base]`. let negotiated_version = p.protocol_version.min(PROTOCOL_VERSION); + // Store the negotiated version for the connection lifetime: the + // `session/request_permission` wire shape derives from this value, never + // from a later mutable session field, so a strict client always receives + // exactly the shape it negotiated at `initialize`. + app.negotiated_version + .store(negotiated_version, Ordering::Relaxed); wire::send( wire_tx, wire::ok( @@ -734,6 +781,8 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender system_prompt: &effective_system_prompt, llm: &app.llm, mcp: &mcp, + permissions: &app.permissions, + protocol_version: app.negotiated_version.load(Ordering::Relaxed), skills: &skills, wire: &wire_tx, cancel: &mut cancel_rx, diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 83f642c123..599954292a 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2609,6 +2609,8 @@ mod tests { max_context_tokens: 200_000, max_handoffs: 1, max_parallel_tools: 1, + max_pending_permissions: 32, + permission_timeout: Duration::from_secs(330), hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, require_reply: false, diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 9ae125a0b7..42c9cc4878 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -594,15 +594,7 @@ impl McpRegistry { budget: ResultBudget, cancel: &mut watch::Receiver, ) -> Result { - let arg_obj = match arguments { - Value::Object(m) => Some(m.clone()), - Value::Null => None, - _ => { - return Err(AgentError::Mcp(format!( - "tool {qname} arguments must be a JSON object" - ))) - } - }; + let arg_obj = validate_arg_shape(qname, arguments)?; let mut params = CallToolRequestParams::default(); params.name = bare.to_owned().into(); params.arguments = arg_obj; @@ -812,6 +804,29 @@ async fn spawn_one( Ok((client, pgid, names, tools)) } +/// Validate that tool-call arguments are a shape the MCP transport can carry: +/// a JSON object (`Some(map)`) or absent (`None`). Any other JSON type is a +/// malformed call that the transport would reject. +/// +/// Hoisted out of `do_call` so the permission gate can run it *before* asking +/// the user: a malformed non-object argument is rejected locally without +/// prompting for approval of a call that could never execute. `do_call` runs +/// it again as the single authoritative shape check — the duplicate is a cheap +/// idempotent match, and keeping it here means no code path can reach the +/// transport with an unvalidated shape. +pub fn validate_arg_shape( + qname: &str, + arguments: &Value, +) -> Result>, AgentError> { + match arguments { + Value::Object(m) => Ok(Some(m.clone())), + Value::Null => Ok(None), + _ => Err(AgentError::Mcp(format!( + "tool {qname} arguments must be a JSON object" + ))), + } +} + /// Send `notifications/cancelled` to the MCP server, fire-and-forget. /// Per MCP spec, cancellation notifications are best-effort; we never /// block the agent on slow server stdio. diff --git a/crates/buzz-agent/src/permission.rs b/crates/buzz-agent/src/permission.rs new file mode 100644 index 0000000000..01dea078c4 --- /dev/null +++ b/crates/buzz-agent/src/permission.rs @@ -0,0 +1,1047 @@ +//! `session/request_permission` broker. +//! +//! buzz-agent asks the client to authorize every LLM-issued MCP tool call +//! *before* executing it; the client applies `BUZZ_ACP_PERMISSION_POLICY` and +//! answers. The agent never reads the policy — it always asks, matching the +//! layering of every other ACP harness. This module owns the whole request +//! correlation lifecycle so the rest of the agent only sees a single +//! `Allowed`/`Denied`/`Cancelled` decision. +//! +//! ## Invariants +//! +//! - **Process-wide admission.** The broker owns a global [`Semaphore`] +//! (`BUZZ_AGENT_MAX_PENDING_PERMISSIONS`) acquired *before* any correlation +//! entry is inserted. The per-turn `execute_parallel` semaphore is fresh per +//! turn and sessions are unbounded by default, so only this global cap bounds +//! simultaneously outstanding asks process-wide. +//! - **Abort-safe cleanup.** A successful admission returns a +//! [`PendingPermission`] lease that owns the admission permit and the +//! correlation id. Its `Drop` synchronously removes the still-pending entry +//! and releases the slot, covering task abort/panic that bypasses the normal +//! `run_prompt` tail. +//! - **Claim-before-wake / at-most-once.** [`PermissionBroker::deliver`] removes +//! the entry *before* waking the waiter, so each id resolves at most once and +//! a later lease `Drop` is a harmless no-op. +//! - **Unknown/late ids ignored.** A response whose id is not a live entry is +//! logged and dropped. +//! - **Undeliverable asks are terminal.** If the output wire is closed when the +//! request is enqueued, [`PermissionBroker::request_permission`] fails closed +//! immediately (dropping the lease removes the entry and releases the permit) +//! rather than leaving a resident waiter to time out — a closed wire can never +//! carry the reply. +//! - **Single absolute deadline.** Admission wait and response wait share one +//! absolute deadline computed at gate entry, so a saturated call cannot live +//! for two full timeout windows. +//! - **Cancellation races inside the wait.** The waiter selects on the turn's +//! cancel receiver directly; resolution never depends on the outer abort +//! drain. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::Value; +use tokio::sync::{oneshot, watch, OwnedSemaphorePermit, Semaphore}; +use tokio::time::Instant; + +use crate::types::ToolCall; +use crate::wire::{self, WireSender, ALLOW_OPTION_ID}; + +/// Model-visible tool error when a call is not authorized. Rides the normal +/// tool-failure path so the turn continues, matching every other tool error. +pub const PERMISSION_DENIED_MSG: &str = "permission denied: the tool call was not authorized"; + +/// Model-visible tool error when the client never answers within the deadline. +pub const PERMISSION_TIMEOUT_MSG: &str = + "permission request timed out: the tool call was not authorized"; + +/// Model-visible tool error when the permission request cannot be delivered +/// because the output wire is closed. Terminal and immediate — no waiter is +/// left resident, since a closed wire can never carry a reply. +pub const PERMISSION_WIRE_CLOSED_MSG: &str = + "permission request undeliverable: the tool call was not authorized"; + +/// Outcome of asking the client to authorize one tool call. +#[derive(Debug, PartialEq, Eq)] +pub enum PermissionDecision { + /// The client selected the offered allow option — execute the tool. + Allowed, + /// Every non-authorizing shape (reject, cancelled outcome, JSON-RPC error, + /// malformed response, unknown outcome, wrong/unknown optionId, timeout, + /// wire-channel closure). Fails closed with the given model-visible reason; + /// the turn continues. + Denied(&'static str), + /// The turn was cancelled while admitting or waiting. No tool runs and the + /// caller propagates cancellation exactly as the existing cancel path does. + Cancelled, +} + +/// Broker owned by `App` for the connection lifetime. +pub struct PermissionBroker { + /// Global admission cap. Acquired before any entry is inserted. + sem: Arc, + /// Live correlation entries: outbound request id -> response sender. + pending: Arc>>>, + /// Monotonic id allocator. Never reused within a process lifetime, so a + /// late response for a removed id can never collide with a fresh request. + next_id: AtomicU64, + /// Absolute deadline budget shared by admission + response wait. + timeout: Duration, + /// Test-only: invoked by the waiter the instant it observes a delivered + /// response, with `true` iff the entry was already claimed (removed) from + /// `pending` before the wake. Makes claim-before-wake ordering + /// mutation-sensitive — a wake-before-claim mutant reports `false`, which a + /// purely behavioral test cannot detect (the waiter reads the oneshot once + /// either way). + #[cfg(test)] + wake_observer: Mutex>, +} + +/// Test-only wake-boundary observer; see [`PermissionBroker::wake_observer`]. +#[cfg(test)] +type WakeObserver = Arc; + +impl PermissionBroker { + /// `max_pending` is validated `>= 1` by config; `timeout` is injectable so + /// broker unit tests exercise the timeout/abort paths without a 330s wait. + pub fn new(max_pending: usize, timeout: Duration) -> Self { + Self { + sem: Arc::new(Semaphore::new(max_pending.max(1))), + pending: Arc::new(Mutex::new(HashMap::new())), + next_id: AtomicU64::new(0), + timeout, + #[cfg(test)] + wake_observer: Mutex::new(None), + } + } + + /// Test-only: register a callback the waiter fires the instant it observes a + /// delivered response, with `true` iff the correlation entry was already + /// claimed (removed) before the wake. Used to prove claim-before-wake + /// ordering in a way a wake-before-claim mutant cannot satisfy. + #[cfg(test)] + pub fn set_wake_observer(&self, observer: WakeObserver) { + *self.wake_observer.lock().unwrap() = Some(observer); + } + + /// Test-only: fire the wake observer (if any) with the claimed-before-wake + /// status of `id`. Called synchronously by the waiter the moment it receives + /// its response, so the observed `pending` state is exactly the state at the + /// wake — deterministic in production (removal happens-before the send) and + /// violated by a wake-before-claim mutant. + #[cfg(test)] + fn observe_wake(&self, id: u64) { + let claimed = !self.pending.lock().unwrap().contains_key(&id); + let observer = self.wake_observer.lock().unwrap().clone(); + if let Some(observer) = observer { + observer(claimed); + } + } + + /// Number of live (unresolved, un-dropped) correlation entries. Test-only + /// observability for the drop-guard and delivery invariants. + #[cfg(test)] + pub fn pending_count(&self) -> usize { + self.pending.lock().unwrap().len() + } + + /// Free admission slots. Test-only, so a test can prove a terminal path + /// (delivery, timeout, cancel, drop) actually released the capacity it + /// held rather than leaking it. + #[cfg(test)] + pub fn available_permits(&self) -> usize { + self.sem.available_permits() + } + + /// Deliver a client response to its waiter. Claims (removes) the entry + /// before waking so the id resolves at most once; unknown/late ids are + /// logged and ignored. `result` is the JSON-RPC `result` field (or + /// `Value::Null` for an error/malformed response — every such shape fails + /// the authorization predicate and denies). + pub fn deliver(&self, id: &Value, result: Value) { + let Some(key) = parse_id(id) else { + tracing::debug!(target: "permission", "ignoring response with unrecognized id {id}"); + return; + }; + // Claim before wake: remove first, then send into the removed sender. + let sender = self.pending.lock().unwrap().remove(&key); + match sender { + Some(tx) => { + // The receiver may already be gone (waiter cancelled/timed out + // and dropped the lease); a failed send is a harmless no-op. + let _ = tx.send(result); + } + None => { + tracing::debug!(target: "permission", "ignoring unknown/late permission id {id}"); + } + } + } + + /// Ask the client to authorize `call`, returning the decision. + /// + /// Sequence: acquire global admission (racing cancel + deadline) → insert + /// correlation entry (held by an abort-safe lease) → send the version-aware + /// request → wait for the response (racing cancel + deadline). One absolute + /// deadline bounds both waits. + pub async fn request_permission( + &self, + wire: &WireSender, + version: u32, + session_id: &str, + call: &ToolCall, + cancel: &mut watch::Receiver, + ) -> PermissionDecision { + let deadline = Instant::now() + self.timeout; + + // ── Admission ────────────────────────────────────────────────────── + // Early cancel check: watch::changed() only fires on NEW writes. + if *cancel.borrow() { + return PermissionDecision::Cancelled; + } + let permit = tokio::select! { + biased; + _ = cancel.changed() => return PermissionDecision::Cancelled, + _ = tokio::time::sleep_until(deadline) => { + return PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG); + } + p = Arc::clone(&self.sem).acquire_owned() => match p { + Ok(p) => p, + // Semaphore is never closed in production; treat as fail-closed. + Err(_) => return PermissionDecision::Denied(PERMISSION_DENIED_MSG), + }, + }; + + // Insert the correlation entry under the owned permit. The lease's Drop + // removes the entry + releases the slot on every exit path below, + // including task abort. + let mut lease = self.register(permit); + + // ── Send the version-aware request ───────────────────────────────── + let params = wire::request_permission_params( + version, + session_id, + &call.provider_id, + &call.name, + &call.arguments, + ); + // ── Send (deadline- and cancel-governed) ─────────────────────────── + // Enqueue is the third phase under the single absolute deadline. A + // full-but-live channel makes `send_checked` wait for capacity; racing + // it against cancel + the deadline means a stalled writer cannot hold + // the ask (and its global permit) past the advertised deadline, and + // `session/cancel` resolves it promptly. On send error the wire is + // closed: fail closed at once — dropping `lease` removes the entry and + // releases the permit synchronously. + let request = wire::request_permission(lease.id_value.clone(), params); + tokio::select! { + biased; + _ = cancel.changed() => return PermissionDecision::Cancelled, + _ = tokio::time::sleep_until(deadline) => { + return PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG); + } + r = wire::send_checked(wire, request) => { + if r.is_err() { + return PermissionDecision::Denied(PERMISSION_WIRE_CLOSED_MSG); + } + } + } + + // ── Response wait ────────────────────────────────────────────────── + if *cancel.borrow() { + return PermissionDecision::Cancelled; + } + #[cfg(test)] + let id = lease.id; + tokio::select! { + biased; + _ = cancel.changed() => PermissionDecision::Cancelled, + r = &mut lease.rx => match r { + Ok(result) => { + // The waiter observes delivery here. At this instant the + // entry must already be claimed (removed) — delivery removes + // before it sends. The observer is test-only and a no-op in + // production. + #[cfg(test)] + self.observe_wake(id); + evaluate(&result) + } + // Sender dropped without sending — should not happen (delivery + // always sends before drop); fail closed. + Err(_) => PermissionDecision::Denied(PERMISSION_DENIED_MSG), + }, + _ = tokio::time::sleep_until(deadline) => { + PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG) + } + } + // `lease` drops here: entry removed (no-op if delivered) + slot released. + } + + /// Allocate an id, insert its response sender, and return the abort-safe + /// lease holding the receiver + owned permit. + fn register(&self, permit: OwnedSemaphorePermit) -> PendingPermission { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending.lock().unwrap().insert(id, tx); + PendingPermission { + id, + id_value: Value::String(format!("perm-{id}")), + rx, + pending: Arc::clone(&self.pending), + _permit: permit, + } + } +} + +/// Abort-safe correlation lease. Owns the admission permit and the correlation +/// id; its `Drop` synchronously removes the still-pending entry and releases +/// the slot. Delivery removes the entry first, so a later drop is a no-op. +struct PendingPermission { + id: u64, + id_value: Value, + rx: oneshot::Receiver, + pending: Arc>>>, + _permit: OwnedSemaphorePermit, +} + +impl Drop for PendingPermission { + fn drop(&mut self) { + // Synchronous, non-async removal — safe from a Drop and required for + // abort/panic paths. No-op if delivery already claimed the entry. + self.pending.lock().unwrap().remove(&self.id); + // `_permit` drops → global admission slot released. + } +} + +/// The authorization predicate, stated once: execute IFF the client selected an +/// option AND the selected `optionId` equals exactly this request's offered +/// allow-option id. Every other shape fails closed. +fn evaluate(result: &Value) -> PermissionDecision { + let outcome = &result["outcome"]; + if outcome["outcome"] == "selected" && outcome["optionId"].as_str() == Some(ALLOW_OPTION_ID) { + PermissionDecision::Allowed + } else { + PermissionDecision::Denied(PERMISSION_DENIED_MSG) + } +} + +/// Recover the correlation key from an outbound request id echoed by the +/// client. Only ids we minted (`perm-`, canonical decimal) are ours; a +/// noncanonical alias (`perm-01`, `perm-+0`, `perm-00`) or any other string is +/// a foreign/stale id and is ignored. Requiring an exact round-trip means only +/// the string the broker actually minted correlates — no alias is ever live. +fn parse_id(id: &Value) -> Option { + let s = id.as_str()?; + let n: u64 = s.strip_prefix("perm-")?.parse().ok()?; + (format!("perm-{n}") == s).then_some(n) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io; + use std::pin::Pin; + use std::task::{Context, Poll}; + use tokio::io::AsyncWrite; + use tokio::sync::mpsc; + + const LONG: Duration = Duration::from_secs(30); + const SHORT: Duration = Duration::from_millis(60); + + fn tool_call() -> ToolCall { + ToolCall { + provider_id: "fake".into(), + name: "fake__shell".into(), + arguments: json!({ "command": "ls" }), + provider_extra: serde_json::Map::new(), + } + } + + fn selected(option_id: &str) -> Value { + json!({ "outcome": { "outcome": "selected", "optionId": option_id } }) + } + + /// Pull the next outbound frame off the wire and return its JSON-RPC `id`. + /// Reading it also proves the request was registered and sent (delivery + /// only happens after `register`). + async fn next_request_id(rx: &mut mpsc::Receiver) -> Value { + let wire::WireMsg::Notify(v) = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("a request frame") + .expect("wire open"); + assert_eq!(v["method"], "session/request_permission"); + v["id"].clone() + } + + /// An `AsyncWrite` that accepts every write but fails on `flush`, modelling + /// Tokio's blocking stdout when the pipe has broken: `write_all` reports + /// `Ok` (the underlying blocking write is only scheduled) and the real + /// error surfaces at `flush`. Used to prove the writer treats flush failure + /// as connection-fatal. + struct FlushFailSink; + + impl AsyncWrite for FlushFailSink { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Poll::Ready(Ok(buf.len())) + } + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(io::Error::from(io::ErrorKind::BrokenPipe))) + } + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + // ── Authorization predicate (fail-closed) ──────────────────────────────── + + #[test] + fn test_selected_allow_option_authorizes() { + assert_eq!( + evaluate(&selected(ALLOW_OPTION_ID)), + PermissionDecision::Allowed + ); + } + + #[test] + fn test_every_non_allow_shape_denies() { + // reject, wrong/unknown option id, unknown outcome, missing fields, + // empty object — the full adversarial set the predicate must reject. + let denied = [ + selected("reject_once"), + selected("some_unknown_option"), + json!({ "outcome": { "outcome": "cancelled" } }), + json!({ "outcome": { "outcome": "selected" } }), // no optionId + json!({ "outcome": { "outcome": "banana", "optionId": ALLOW_OPTION_ID } }), + json!({ "outcome": {} }), + json!({}), + Value::Null, + ]; + for shape in denied { + assert_eq!( + evaluate(&shape), + PermissionDecision::Denied(PERMISSION_DENIED_MSG), + "shape must fail closed: {shape}" + ); + } + } + + // ── Id correlation ─────────────────────────────────────────────────────── + + #[test] + fn test_parse_id_accepts_only_minted_ids() { + assert_eq!(parse_id(&json!("perm-0")), Some(0)); + assert_eq!(parse_id(&json!("perm-42")), Some(42)); + assert_eq!(parse_id(&json!("perm-x")), None); + assert_eq!(parse_id(&json!("42")), None); // foreign numeric-string id + assert_eq!(parse_id(&json!(42)), None); // foreign numeric id + assert_eq!(parse_id(&Value::Null), None); + } + + /// Noncanonical strings that `u64::parse` would otherwise accept as aliases + /// of a minted id must NOT correlate. Only the exact string the broker + /// minted (`format!("perm-{n}")`) is live; leading zeros, a sign, or + /// whitespace make the id foreign and it is ignored. Without the exact + /// round-trip check these would resolve live asks under ids the broker + /// never issued. + #[test] + fn test_parse_id_rejects_noncanonical_aliases() { + for alias in [ + "perm-00", // extra leading zero + "perm-01", // leading zero + "perm-+0", // explicit sign + "perm-0x1", // hex + "perm- 1", // leading space + "perm-1 ", // trailing space + "perm-1_000", // digit separator + ] { + assert_eq!( + parse_id(&json!(alias)), + None, + "alias must be foreign: {alias}" + ); + } + } + + // ── Delivery: exact allow / deny ────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_deliver_allow_authorizes_and_frees_slot() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let id = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 1); + assert_eq!(broker.available_permits(), 3); + + broker.deliver(&id, selected(ALLOW_OPTION_ID)); + assert_eq!(task.await.unwrap(), PermissionDecision::Allowed); + assert_eq!(broker.pending_count(), 0, "entry claimed on delivery"); + assert_eq!( + broker.available_permits(), + 4, + "slot released after decision" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_deliver_reject_denies_and_frees_slot() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let id = next_request_id(&mut rx).await; + broker.deliver(&id, selected("reject_once")); + assert_eq!( + task.await.unwrap(), + PermissionDecision::Denied(PERMISSION_DENIED_MSG) + ); + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 4); + } + + // ── Malformed response frames deny (Carl's review) ──────────────────────── + + /// Route Carl's frame through the real `classify` → `deliver` path against a + /// live waiter and assert the tool is denied. Delivers on the exact id the + /// broker minted, so the only reason the waiter denies is that `classify` + /// refused to forward the ambiguous/malformed `result`. `provider_id` + /// carries which shape is under test so a failure names the mutant. + async fn assert_malformed_frame_denies(provider_id: &str, frame: Value) { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let id = next_request_id(&mut rx).await; + // Stamp the broker's minted id onto Carl's frame, then classify it + // exactly as the dispatch loop would before handing `result` to deliver. + let mut frame = frame; + frame["id"] = id.clone(); + match crate::wire::classify(&frame) { + crate::wire::Inbound::Response { id, result } => broker.deliver(&id, result), + other => panic!("[{provider_id}] expected Response, got {other:?}"), + } + + assert_eq!( + task.await.unwrap(), + PermissionDecision::Denied(PERMISSION_DENIED_MSG), + "[{provider_id}] malformed frame must deny, not authorize the tool", + ); + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 4); + } + + /// Carl frame #1: `result` (well-formed `selected`/`allow_once`) AND `error` + /// both present. The tool must not run — the ambiguous frame denies. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_frame_with_result_and_error_denies_tool() { + assert_malformed_frame_denies( + "result+error", + json!({ + "jsonrpc": "2.0", + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + "error": { "code": -32603, "message": "internal" }, + }), + ) + .await; + } + + /// Carl frame #2: present non-string `method: 7` alongside a well-formed + /// `selected` `result`. It is not a valid response — the tool must not run. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_frame_with_non_string_method_denies_tool() { + assert_malformed_frame_denies( + "non-string-method", + json!({ + "jsonrpc": "2.0", + "method": 7, + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + }), + ) + .await; + } + + // ── Stale / unknown id ignored ──────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_unknown_id_does_not_unblock_waiter() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let real_id = next_request_id(&mut rx).await; + // A stale/foreign id is dropped; the live entry survives. + broker.deliver(&json!("perm-999"), selected(ALLOW_OPTION_ID)); + broker.deliver(&json!(1), selected(ALLOW_OPTION_ID)); + broker.deliver(&Value::Null, selected(ALLOW_OPTION_ID)); + assert_eq!(broker.pending_count(), 1, "waiter still pending"); + + // The correct id resolves it exactly once. + broker.deliver(&real_id, selected(ALLOW_OPTION_ID)); + assert_eq!(task.await.unwrap(), PermissionDecision::Allowed); + assert_eq!(broker.pending_count(), 0); + } + + // ── Timeout ─────────────────────────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_timeout_denies_and_removes_state() { + let broker = Arc::new(PermissionBroker::new(4, SHORT)); + let (tx, _rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let call = tool_call(); + + // No delivery ever arrives: the shared deadline denies. + let decision = broker + .request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await; + assert_eq!(decision, PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG)); + assert_eq!( + broker.pending_count(), + 0, + "timeout removes correlation state" + ); + assert_eq!(broker.available_permits(), 4, "timeout releases the slot"); + } + + // ── Undeliverable ask (closed wire) is terminal ─────────────────────────── + + /// When the output wire is closed, the ask can never be written and no + /// reply can ever arrive. `request_permission` must fail closed + /// *immediately* — denying with the wire-closed reason and leaving zero + /// pending entries and zero held permits — rather than registering an entry + /// that waits out the full deadline. Uses a LONG timeout so a wrong + /// implementation that waits the deadline would visibly hang the test far + /// past its own assertions. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_closed_wire_denies_immediately_without_leaking_state() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + // Drop the receiver so every send fails: the writer is gone. + let (tx, rx) = mpsc::channel(8); + drop(rx); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let call = tool_call(); + + // Bound the whole call: correct behavior returns at once; a regression + // that waits the deadline blows this timeout instead of hanging LONG. + let decision = tokio::time::timeout( + Duration::from_secs(2), + broker.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx), + ) + .await + .expect("closed wire must deny immediately, not wait the deadline"); + + assert_eq!( + decision, + PermissionDecision::Denied(PERMISSION_WIRE_CLOSED_MSG) + ); + assert_eq!( + broker.pending_count(), + 0, + "undeliverable ask leaves no resident entry" + ); + assert_eq!( + broker.available_permits(), + 4, + "undeliverable ask releases its admission slot" + ); + } + + // ── Writer flush failure is connection-fatal ────────────────────────────── + + /// A blocking stdout can report `Ok` from `write_all` (the underlying + /// blocking write is only scheduled) and surface the real error at `flush`. + /// The writer must therefore treat flush failure exactly like write failure + /// — return, dropping its receiver — so the connection supervisor observes + /// writer death and cancels every session (which resolves any waiting ask). + /// Modelled here: `write_frames` fed one frame over a sink that accepts the + /// write but fails flush must terminate promptly; a cancellation wired to + /// that termination (as `async_main`'s writer arm does) then unblocks a + /// registered permission waiter, leaving zero pending entries and all + /// permits free — not after the injected deadline. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_flush_failure_kills_writer_and_resolves_waiter() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + // A live output channel; its frames are drained by `write_frames` into + // the flush-failing sink, modelling the real writer over a broken pipe. + let (wire_tx, wire_rx) = mpsc::channel(8); + let (cancel_tx, mut cancel_rx) = watch::channel(false); + + // Supervisor: run the writer over the failing sink; when it returns + // (flush error → connection-fatal), propagate cancellation exactly like + // `async_main`'s writer-death arm. + let writer = tokio::spawn(async move { + wire::write_frames(wire_rx, FlushFailSink).await; + let _ = cancel_tx.send(true); + }); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&wire_tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + // The ask registers and enqueues its frame; the writer accepts the + // write, fails the flush, returns, and the supervisor cancels. The + // waiter must resolve via that cancellation, not the LONG deadline. + let decision = tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("flush failure must cancel the waiter, not wait the deadline") + .unwrap(); + + writer.await.unwrap(); + assert_eq!(decision, PermissionDecision::Cancelled); + assert_eq!( + broker.pending_count(), + 0, + "writer death resolves the waiter and leaves no resident entry" + ); + assert_eq!( + broker.available_permits(), + 4, + "writer death releases the held admission slot" + ); + } + + // ── Enqueue backpressure is deadline- and cancel-governed ───────────────── + + /// A full-but-live output channel makes `send_checked` wait for capacity. + /// The send phase races the single absolute deadline, so a stalled writer + /// cannot hold the ask (and its global permit) past the advertised + /// deadline: `request_permission` must return the timeout deny within the + /// deadline and leave zero pending entries and zero held permits. Uses a + /// SHORT deadline bounded by a longer outer timeout, so a regression that + /// waits forever on capacity blows the outer bound. This is a distinct seam + /// from the dropped-receiver test: here the receiver is alive but never + /// drains. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_full_channel_send_is_bounded_by_the_deadline() { + let broker = Arc::new(PermissionBroker::new(4, SHORT)); + // Capacity-1 channel, prefilled and never drained: the next send blocks + // on capacity while the receiver stays alive (writer present, stalled). + let (tx, _rx) = mpsc::channel(1); + tx.send(wire::WireMsg::Notify(json!({ "fill": 1 }))) + .await + .unwrap(); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let call = tool_call(); + + let decision = tokio::time::timeout( + Duration::from_secs(2), + broker.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx), + ) + .await + .expect("a stalled writer must not hold the send past the deadline"); + + assert_eq!( + decision, + PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG), + "a full-but-live channel resolves via the deadline, not the wire-closed path" + ); + assert_eq!( + broker.pending_count(), + 0, + "a timed-out enqueue leaves no resident entry" + ); + assert_eq!( + broker.available_permits(), + 4, + "a timed-out enqueue releases its admission slot" + ); + } + + /// Cancellation must also resolve an ask stuck enqueueing on a stalled + /// writer: with a full-but-live channel and a LONG deadline, a + /// `session/cancel` returns `Cancelled` promptly rather than waiting the + /// deadline out. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_cancel_resolves_a_blocked_enqueue() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, _rx) = mpsc::channel(1); + tx.send(wire::WireMsg::Notify(json!({ "fill": 1 }))) + .await + .unwrap(); + let (cancel_tx, mut cancel_rx) = watch::channel(false); + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + // Give the task time to admit + block on the full channel, then cancel. + tokio::time::sleep(Duration::from_millis(50)).await; + cancel_tx.send(true).unwrap(); + let decision = tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("cancel must resolve a blocked enqueue promptly") + .unwrap(); + assert_eq!(decision, PermissionDecision::Cancelled); + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 4); + } + + // ── Claim-before-wake ordering (mutation-sensitive) ─────────────────────── + + /// The waiter must observe the correlation entry already *claimed* (removed + /// from `pending`) at the instant it wakes with the delivered response — + /// `deliver` removes before it sends. The wake observer fires synchronously + /// inside the waiter's response arm, so it captures the exact `pending` + /// state at the wake. A wake-before-claim mutant (send first, remove after) + /// makes the observed state `false` and fails this assertion; the behavioral + /// delivery tests cannot detect that mutant because the waiter reads the + /// oneshot exactly once either way. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_waiter_observes_entry_claimed_before_wake() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let claimed_at_wake = Arc::new(Mutex::new(None::)); + let sink = Arc::clone(&claimed_at_wake); + broker.set_wake_observer(Arc::new(move |claimed| { + *sink.lock().unwrap() = Some(claimed); + })); + + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let id = next_request_id(&mut rx).await; + broker.deliver(&id, selected(ALLOW_OPTION_ID)); + assert_eq!(task.await.unwrap(), PermissionDecision::Allowed); + assert_eq!( + *claimed_at_wake.lock().unwrap(), + Some(true), + "entry must be claimed (removed) before the waiter is woken" + ); + } + + // ── Cancellation while waiting ─────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_cancel_while_waiting_returns_cancelled_and_removes_state() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let _id = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 1); + cancel_tx.send(true).unwrap(); + assert_eq!(task.await.unwrap(), PermissionDecision::Cancelled); + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 4); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_precancelled_turn_never_sends_request() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(true); // already cancelled + let call = tool_call(); + + let decision = broker + .request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await; + assert_eq!(decision, PermissionDecision::Cancelled); + assert_eq!(broker.pending_count(), 0, "no entry inserted"); + assert_eq!(broker.available_permits(), 4); + assert!(rx.try_recv().is_err(), "no request frame emitted"); + } + + // ── Abort-safe drop guard ───────────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_abort_while_waiting_leaves_zero_pending_and_reusable_slot() { + let broker = Arc::new(PermissionBroker::new(1, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + // Registered + sent → then hard-abort the task (bypasses every normal + // exit path). The lease's Drop must still run. + let _id = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 1); + assert_eq!(broker.available_permits(), 0); + task.abort(); + let _ = task.await; + assert_eq!( + broker.pending_count(), + 0, + "drop guard removed the entry on abort" + ); + assert_eq!( + broker.available_permits(), + 1, + "drop guard released the slot on abort" + ); + } + + // ── Process-wide admission cap across multiple sessions ─────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_admission_cap_bounds_entries_across_sessions() { + // Capacity 2, shared by all sessions. Three distinct sessions ask at + // once; only two can register/send while the cap is saturated. The + // third is admitted only after a slot frees. Frame ids arrive in + // nondeterministic order across tasks, so the test never maps a task + // handle to a specific id — it proves the bound structurally (frame + // count + pending_count) and that every task ultimately resolves. + let broker = Arc::new(PermissionBroker::new(2, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_c, cancel_rx) = watch::channel(false); + + let spawn_req = |session: &'static str| { + let b = Arc::clone(&broker); + let tx = tx.clone(); + let mut cancel = cancel_rx.clone(); + let call = tool_call(); + tokio::spawn(async move { + b.request_permission(&tx, 2, session, &call, &mut cancel) + .await + }) + }; + + let tasks = [spawn_req("ses_a"), spawn_req("ses_b"), spawn_req("ses_c")]; + + // Only two frames appear while the cap is 2; the third is blocked in + // admission with no entry and no frame. + let id1 = next_request_id(&mut rx).await; + let id2 = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 2); + assert_eq!(broker.available_permits(), 0); + assert!( + tokio::time::timeout(SHORT, rx.recv()).await.is_err(), + "third session must not send a request while the cap is saturated" + ); + assert_eq!( + broker.pending_count(), + 2, + "cap holds: no third entry inserted" + ); + + // Free one slot → the third session is admitted and sends its frame. + broker.deliver(&id1, selected(ALLOW_OPTION_ID)); + let id3 = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 2, "still bounded after churn"); + + // Resolve the two remaining live entries. + broker.deliver(&id2, selected(ALLOW_OPTION_ID)); + broker.deliver(&id3, selected(ALLOW_OPTION_ID)); + + // Every session resolved to Allowed — none stranded or timed out. + for t in tasks { + assert_eq!(t.await.unwrap(), PermissionDecision::Allowed); + } + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 2); + } + + // ── Admission-phase cancel: fail-closed, zero entries ───────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_cancel_during_admission_inserts_no_entry() { + // Saturate the single slot directly (test-module access to `sem`) so the + // request under test blocks in the admission phase, before any insert. + let broker = Arc::new(PermissionBroker::new(1, LONG)); + let held = Arc::clone(&broker.sem).acquire_owned().await.unwrap(); + assert_eq!(broker.available_permits(), 0); + + let (tx, mut rx) = mpsc::channel(8); + let (cancel_tx, mut cancel_rx) = watch::channel(false); + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + // It cannot proceed past admission: no entry, no frame. + assert!(tokio::time::timeout(SHORT, rx.recv()).await.is_err()); + assert_eq!(broker.pending_count(), 0, "blocked before insert"); + + cancel_tx.send(true).unwrap(); + assert_eq!(task.await.unwrap(), PermissionDecision::Cancelled); + assert_eq!( + broker.pending_count(), + 0, + "cancel during admission inserts nothing" + ); + drop(held); + assert_eq!(broker.available_permits(), 1); + } + + // ── Admission-phase deadline: fail-closed deny, zero entries ────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_deadline_during_admission_denies_with_no_entry() { + let broker = Arc::new(PermissionBroker::new(1, SHORT)); + let held = Arc::clone(&broker.sem).acquire_owned().await.unwrap(); + + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let call = tool_call(); + + // Slot never frees within the deadline → admission times out. + let decision = broker + .request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await; + assert_eq!(decision, PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG)); + assert_eq!( + broker.pending_count(), + 0, + "no entry inserted on admission timeout" + ); + assert!(rx.try_recv().is_err(), "no request frame emitted"); + drop(held); + } +} diff --git a/crates/buzz-agent/src/wire.rs b/crates/buzz-agent/src/wire.rs index b4c876e0fe..e6fe89e07c 100644 --- a/crates/buzz-agent/src/wire.rs +++ b/crates/buzz-agent/src/wire.rs @@ -1,6 +1,6 @@ use serde::Deserialize; use serde_json::{json, Value}; -use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt}; use tokio::sync::mpsc; use crate::types::{ContentBlock, McpServerStdio}; @@ -27,7 +27,19 @@ pub enum Inbound { method: String, params: Value, }, - Ignored, + /// A bare JSON-RPC response (id present, no method) — the client's answer + /// to a request buzz-agent issued. Today the only such request is + /// `session/request_permission`. `result` carries the JSON-RPC `result` + /// field ONLY when the frame is a structurally valid response — no `method` + /// member and exactly one of `result`/`error`. Any malformed shape (present + /// non-string `method`, both `result` and `error`, or neither) is normalized + /// to `Null` so a possibly-`selected` payload is never laundered into an + /// approval; every non-`selected` shape fails the broker's authorization + /// predicate and denies. + Response { + id: Value, + result: Value, + }, Invalid { id: Value, code: i32, @@ -109,9 +121,27 @@ pub fn classify(msg: &Value) -> Inbound { params, }, (Some(m), None) => Inbound::Notification { method: m, params }, - // Bare responses (id present, no method) are unexpected — buzz-agent - // does not issue requests to the client. Ignore silently. - (None, Some(_)) => Inbound::Ignored, + // Bare responses (id present, no method) answer a request buzz-agent + // issued — today only `session/request_permission`. Route to the + // permission broker, which matches a live correlation id or ignores an + // unknown one. Forward the `result` ONLY when the frame is a + // structurally valid response — the exactly-one-of invariant: no + // `method` member at all, and `result` present with `error` absent. A + // present non-string `method` (which `as_str` above collapsed to + // `None`), both `result` and `error`, or neither is malformed; forward + // `Null` so the broker fails closed (deny) rather than laundering a + // possibly-`selected` payload into an approval. + (None, Some(id)) => { + let well_formed = msg.get("method").is_none() + && msg.get("result").is_some() + && msg.get("error").is_none(); + let result = if well_formed { + msg.get("result").cloned().unwrap_or(Value::Null) + } else { + Value::Null + }; + Inbound::Response { id, result } + } (None, None) => Inbound::Invalid { id: Value::Null, code: INVALID_REQUEST, @@ -120,6 +150,79 @@ pub fn classify(msg: &Value) -> Inbound { } } +/// `optionId`/`kind` of the single allow option offered on every +/// `session/request_permission`. buzz-acp's answering side selects the option +/// whose `kind == "allow_once"` (never by hardcoded `optionId`), and the +/// authorization predicate on this side requires the returned `optionId` to +/// equal exactly this value. Keeping option id and kind identical means both +/// sides agree without a separate lookup table. +pub const ALLOW_OPTION_ID: &str = "allow_once"; + +/// The two options offered on every permission request: allow-once and +/// reject-once. First cut ships only these (no session-scoped grant), so every +/// offered option is already in the desktop card's exact actionable allowlist. +fn permission_options() -> Value { + json!([ + { "optionId": ALLOW_OPTION_ID, "name": "Allow", "kind": ALLOW_OPTION_ID }, + { "optionId": "reject_once", "name": "Deny", "kind": "reject_once" }, + ]) +} + +/// Build `session/request_permission` params for the negotiated protocol +/// version. No hybrid shapes — the request must match exactly what the client +/// negotiated at `initialize`, or a strict client can reject it before policy +/// is applied. +/// +/// - **v2** (what buzz-agent negotiates with current buzz-acp): tool context +/// lives under `subject: {type: "tool_call", toolCall}` with top-level +/// `title` and `options`. +/// - **v1** (still negotiated when a client requests it): the legacy shape with +/// `toolCall` (carrying `kind`) directly at the params level. +pub fn request_permission_params( + version: u32, + session_id: &str, + tool_call_id: &str, + title: &str, + raw_input: &Value, +) -> Value { + if version >= 2 { + json!({ + "sessionId": session_id, + "title": title, + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": tool_call_id, + "title": title, + "rawInput": raw_input, + }, + }, + "options": permission_options(), + }) + } else { + json!({ + "sessionId": session_id, + "toolCall": { + "toolCallId": tool_call_id, + "title": title, + "kind": "other", + "rawInput": raw_input, + }, + "options": permission_options(), + }) + } +} + +/// Build an outbound JSON-RPC request `session/request_permission` frame. +pub fn request_permission(id: Value, params: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "session/request_permission", + "params": params, + }) +} + pub fn ok(id: Value, result: Value) -> Value { json!({ "jsonrpc": "2.0", "id": id, "result": result }) } @@ -267,7 +370,18 @@ pub fn session_update_with_goose_meta(sid: &str, update: Value, goose_meta: Valu } pub async fn send(wire: &WireSender, msg: Value) { - let _ = wire.send(WireMsg::Notify(msg)).await; + let _ = send_checked(wire, msg).await; +} + +/// Enqueue a frame, reporting whether the writer accepted it. Unlike mpsc's +/// non-blocking `try_send`, this awaits channel capacity; it fails only when +/// the writer task has dropped its receiver, which happens exactly when the +/// writer has exited because stdout is closed/broken. A frame that fails here +/// will never be written, so callers that correlate a response — the +/// permission broker — must fail closed immediately rather than wait out a +/// deadline for a reply that can never arrive. +pub async fn send_checked(wire: &WireSender, msg: Value) -> Result<(), ()> { + wire.send(WireMsg::Notify(msg)).await.map_err(|_| ()) } pub async fn read_bounded_line( @@ -316,8 +430,25 @@ pub async fn read_bounded_line( } } -pub async fn writer_task(mut rx: mpsc::Receiver) { - let mut stdout = tokio::io::stdout(); +pub async fn writer_task(rx: mpsc::Receiver) { + write_frames(rx, tokio::io::stdout()).await; +} + +/// Drain `rx`, writing each frame to `out` as a newline-terminated JSON line. +/// Generic over the sink so tests can inject an `AsyncWrite` that fails on +/// flush; production passes stdout. +/// +/// Both `write_all` and `flush` failure are connection-fatal: they return, +/// dropping `rx` so `async_main`'s writer-death arm cancels every session. +/// Flush must be fatal too — a blocking stdout can report `Ok` from +/// `write_all` when it only schedules the underlying write and surface the +/// real error at `flush`, so ignoring flush failure would leave a dead stdout +/// undetected and strand any correlated ask waiting for a reply that can never +/// be written. +pub(crate) async fn write_frames( + mut rx: mpsc::Receiver, + mut out: W, +) { while let Some(msg) = rx.recv().await { let WireMsg::Notify(v) = msg; let mut s = match serde_json::to_string(&v) { @@ -328,10 +459,9 @@ pub async fn writer_task(mut rx: mpsc::Receiver) { } }; s.push('\n'); - if stdout.write_all(s.as_bytes()).await.is_err() { + if out.write_all(s.as_bytes()).await.is_err() || out.flush().await.is_err() { return; } - let _ = stdout.flush().await; } } @@ -534,4 +664,181 @@ mod tests { assert_eq!(payload["accumulatedInputTokens"], serde_json::json!(1000)); assert_eq!(payload["accumulatedOutputTokens"], serde_json::json!(200)); } + + // ── request_permission_params: version-aware wire shape ────────────────── + + /// v2 (what buzz-agent negotiates with current buzz-acp): tool context is + /// nested under `subject: {type: "tool_call", toolCall}` with top-level + /// `title` and `options`, matching the ACP v2 `RequestPermissionRequest`. + #[test] + fn request_permission_params_v2_nests_tool_call_under_subject() { + let raw = json!({ "command": "ls" }); + let p = request_permission_params(2, "ses_1", "fake__shell", "fake__shell", &raw); + + assert_eq!(p["sessionId"], "ses_1"); + assert_eq!(p["title"], "fake__shell"); + assert_eq!(p["subject"]["type"], "tool_call"); + assert_eq!(p["subject"]["toolCall"]["toolCallId"], "fake__shell"); + assert_eq!(p["subject"]["toolCall"]["title"], "fake__shell"); + assert_eq!(p["subject"]["toolCall"]["rawInput"], raw); + // No hybrid: v2 must NOT carry a top-level `toolCall`. + assert!(p.get("toolCall").is_none(), "v2 must not use the v1 shape"); + assert_options(&p["options"]); + } + + /// v1 (still negotiated when a client requests it): the legacy shape with + /// `toolCall` (carrying `kind`) directly at the params level, no `subject`. + #[test] + fn request_permission_params_v1_uses_legacy_top_level_tool_call() { + let raw = json!({ "command": "ls" }); + let p = request_permission_params(1, "ses_1", "fake__shell", "fake__shell", &raw); + + assert_eq!(p["sessionId"], "ses_1"); + assert_eq!(p["toolCall"]["toolCallId"], "fake__shell"); + assert_eq!(p["toolCall"]["title"], "fake__shell"); + assert_eq!(p["toolCall"]["kind"], "other"); + assert_eq!(p["toolCall"]["rawInput"], raw); + // No hybrid: v1 must NOT carry the v2 `subject` or top-level `title`. + assert!(p.get("subject").is_none(), "v1 must not use the v2 shape"); + assert!(p.get("title").is_none(), "v1 has no top-level title"); + assert_options(&p["options"]); + } + + /// Both offered options are exactly allow-once and reject-once, with + /// `optionId == kind` so buzz-acp's `kind`-based selector and this side's + /// `optionId`-based predicate agree without a lookup table. + fn assert_options(options: &Value) { + let opts = options.as_array().expect("options is an array"); + assert_eq!(opts.len(), 2, "first cut offers exactly two options"); + assert_eq!(opts[0]["optionId"], ALLOW_OPTION_ID); + assert_eq!(opts[0]["kind"], ALLOW_OPTION_ID); + assert_eq!(opts[0]["name"], "Allow"); + assert_eq!(opts[1]["optionId"], "reject_once"); + assert_eq!(opts[1]["kind"], "reject_once"); + assert_eq!(opts[1]["name"], "Deny"); + } + + /// The outbound frame wraps params in a JSON-RPC request whose id echoes + /// back verbatim so the broker can correlate the response. + #[test] + fn request_permission_frame_is_a_correlatable_jsonrpc_request() { + let params = request_permission_params(2, "ses_1", "t", "t", &json!({})); + let frame = request_permission(json!("perm-7"), params); + assert_eq!(frame["jsonrpc"], "2.0"); + assert_eq!(frame["id"], "perm-7"); + assert_eq!(frame["method"], "session/request_permission"); + assert_eq!(frame["params"]["sessionId"], "ses_1"); + } + + // ── classify: bare responses route to the broker ───────────────────────── + + /// A bare JSON-RPC response (id, no method) is the client's answer to a + /// request buzz-agent issued; it routes to the broker with its `result`. + #[test] + fn classify_bare_response_routes_to_broker() { + let msg = json!({ + "jsonrpc": "2.0", + "id": "perm-3", + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + }); + match classify(&msg) { + Inbound::Response { id, result } => { + assert_eq!(id, json!("perm-3")); + assert_eq!(result["outcome"]["outcome"], "selected"); + } + other => panic!("expected Response, got {other:?}"), + } + } + + /// A JSON-RPC error response (id, `error`, no `result`) still routes to the + /// broker but with `result == Null`, which the authorization predicate + /// fails closed. buzz-agent never leaves the waiter hanging on an error. + #[test] + fn classify_error_response_routes_with_null_result() { + let msg = json!({ + "jsonrpc": "2.0", + "id": "perm-3", + "error": { "code": -32601, "message": "method not found" }, + }); + match classify(&msg) { + Inbound::Response { id, result } => { + assert_eq!(id, json!("perm-3")); + assert_eq!(result, Value::Null, "error/absent result → Null → deny"); + } + other => panic!("expected Response, got {other:?}"), + } + } + + /// Carl's frame #1: a response carrying BOTH `result` and `error` is + /// structurally ambiguous and must NOT deliver the `result`, even when that + /// `result` is a well-formed `selected`/`allow_once` payload. The wire layer + /// normalizes it to `Null` so the broker denies instead of the frame + /// laundering an approval upstream of every fail-closed check. + #[test] + fn classify_response_with_both_result_and_error_denies() { + let msg = json!({ + "jsonrpc": "2.0", + "id": "perm-3", + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + "error": { "code": -32603, "message": "internal" }, + }); + match classify(&msg) { + Inbound::Response { id, result } => { + assert_eq!(id, json!("perm-3")); + assert_eq!( + result, + Value::Null, + "result+error is malformed → Null → deny, never forward the allow payload" + ); + } + other => panic!("expected Response, got {other:?}"), + } + } + + /// Carl's frame #2: a present but non-string `method` is NOT "method + /// absent". `as_str` collapses `method: 7` to `None`, which lands the frame + /// in the response arm, but it is not a valid response and must not forward + /// its `result` (a well-formed `selected` payload here). The structural + /// check sees the present `method` member and normalizes to `Null` → deny. + #[test] + fn classify_response_with_non_string_method_denies() { + let msg = json!({ + "jsonrpc": "2.0", + "id": "perm-3", + "method": 7, + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + }); + match classify(&msg) { + Inbound::Response { id, result } => { + assert_eq!(id, json!("perm-3")); + assert_eq!( + result, + Value::Null, + "present non-string method → not a valid response → Null → deny" + ); + } + other => panic!("expected Response, got {other:?}"), + } + } + + // ── send_checked: observable wire closure ──────────────────────────────── + + /// `send_checked` reports `Ok` while the writer's receiver is alive and + /// `Err` once it is gone (writer task exited on closed/broken stdout). This + /// is the contract the permission broker relies on to fail an undeliverable + /// ask closed immediately instead of waiting out its deadline for a reply + /// that can never be written. + #[tokio::test] + async fn send_checked_reports_closure_when_writer_gone() { + let (tx, rx) = mpsc::channel::(4); + assert!( + send_checked(&tx, json!({ "ok": 1 })).await.is_ok(), + "send succeeds while the writer receiver is alive" + ); + drop(rx); // writer exited → receiver dropped + assert!( + send_checked(&tx, json!({ "ok": 2 })).await.is_err(), + "send reports failure once the writer is gone" + ); + } } diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 1b7f346162..8d96779bba 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -23,6 +23,12 @@ //! tree dies on timeout. //! FAKE_MCP_GRANDCHILD_PID_FILE=path //! — path to write the grandchild PID to. +//! FAKE_MCP_CANCEL_LOG=path — append each `notifications/cancelled` frame to +//! `path` (one JSON line per notification). +//! FAKE_MCP_CALL_LOG=path — append the tool name of each `tools/call` to +//! `path` (one name per line). Lets a test assert +//! a tool was invoked exactly once, or never — the +//! permission gate's core proof. //! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool //! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going") //! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying @@ -39,6 +45,11 @@ //! `command` string. Lets a test drive the //! reply guard's recognition of a real, //! registered shell tool. +//! FAKE_MCP_NAMED_TOOLS=a,b — expose one no-arg tool per comma-separated bare +//! name (each registered as `__`), in +//! addition to any `FAKE_MCP_TOOL_COUNT` tools. Lets +//! a test issue parallel calls to distinctly named +//! tools and tell them apart in `FAKE_MCP_CALL_LOG`. use std::io::{BufRead, Write}; @@ -83,6 +94,7 @@ fn make_tools( include_stop_hook: bool, include_post_compact_hook: bool, include_shell_tool: bool, + named_tools: &[String], ) -> Vec { let mut tools: Vec = (0..count) .map(|i| { @@ -93,6 +105,13 @@ fn make_tools( }) }) .collect(); + for name in named_tools { + tools.push(json!({ + "name": name, + "description": "named test tool", + "inputSchema": { "type": "object", "properties": {} }, + })); + } if include_stop_hook { tools.push(json!({ "name": "_Stop", @@ -156,6 +175,14 @@ fn main() { let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK"); let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL"); let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default(); + // One extra no-arg tool per comma-separated bare name. + let named_tools: Vec = std::env::var("FAKE_MCP_NAMED_TOOLS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); // Use a channel-based stdin reader so notifications (which carry no id) // are captured even while the main thread is sleeping during a tool call. @@ -231,6 +258,7 @@ fn main() { stop_hook, post_compact_hook, shell_tool, + &named_tools, ) }), ); @@ -248,6 +276,21 @@ fn main() { .and_then(|p| p.get("name")) .and_then(Value::as_str) .unwrap_or(""); + // Append every invoked tool name so a test can prove a call + // reached the server exactly once (or never). This fires for + // ALL tools/call, including `_Stop`/`_PostCompact` hooks, so a + // test can also prove hooks are NOT permission-gated by + // observing they still reach the server without an ask. + if let Ok(path) = std::env::var("FAKE_MCP_CALL_LOG") { + use std::io::Write as _; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = writeln!(f, "{called_name}"); + } + } // Optionally spawn a long-sleeping grandchild so the test // can verify process-group killing reaches the whole tree. if env_flag("FAKE_MCP_SPAWN_GRANDCHILD") { diff --git a/crates/buzz-agent/tests/common/mod.rs b/crates/buzz-agent/tests/common/mod.rs new file mode 100644 index 0000000000..02bdc3bc0e --- /dev/null +++ b/crates/buzz-agent/tests/common/mod.rs @@ -0,0 +1,279 @@ +//! Shared subprocess test harness for the buzz-agent ACP integration suites. +//! +//! Every integration test file is its own crate, so this module is included +//! with `mod common;` in each and compiles once per binary — each binary uses +//! only the subset it needs, hence the module-wide `dead_code` allow. +//! +//! It drives a real `buzz-agent` child over the ACP wire against a fake LLM +//! (`CapturingLlm`, which records each request body) and answers the +//! `session/request_permission` surface (`approve_permission`, selecting the +//! offered `allow_once` option by `kind`, never a hardcoded `optionId`). + +#![allow(dead_code)] + +use std::collections::VecDeque; +use std::process::Stdio; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +pub struct CapturingLlm { + pub url: String, + pub captured: Arc>>, +} + +pub async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { + spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await +} + +/// Like `spawn_capturing_llm` but each canned response carries its own HTTP +/// status, so a test can serve a real provider rejection (e.g. a context-window +/// 400) instead of only success bodies. +pub async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let queue = Arc::new(Mutex::new(VecDeque::from(responses))); + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap2 = captured.clone(); + tokio::spawn(async move { + loop { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let queue = queue.clone(); + let captured = cap2.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut tmp = [0u8; 8192]; + // Read until headers complete. + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + if buf.len() > 4_000_000 { + return; + } + } + // Parse Content-Length and read body. + let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; + let headers = &buf[..header_end]; + let mut body_len = 0usize; + for line in headers.split(|b| *b == b'\n') { + let line = std::str::from_utf8(line).unwrap_or(""); + if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") { + body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0); + } + } + while buf.len() < header_end + body_len { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + } + if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { + captured.lock().await.push(req); + } + let (status, body) = queue + .lock() + .await + .pop_front() + .unwrap_or_else(|| (200, json!({ "error": "no canned response" }))); + let body_s = serde_json::to_string(&body).unwrap(); + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + _ => "Error", + }; + let resp = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body_s.len(), body_s, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + } + }); + CapturingLlm { url, captured } +} + +pub struct Harness { + child: tokio::process::Child, + stdin: tokio::process::ChildStdin, + stdout: BufReader, + stderr: Arc>, + next_id: i64, +} + +impl Harness { + pub async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self { + let bin = env!("CARGO_BIN_EXE_buzz-agent"); + let mut cmd = tokio::process::Command::new(bin); + cmd.env("BUZZ_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") + .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") + .env("BUZZ_AGENT_MAX_ROUNDS", "8") + .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2"); + for (k, v) in extra { + cmd.env(k, v); + } + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd.spawn().expect("spawn buzz-agent"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let stderr = child.stderr.take().unwrap(); + let stderr_buf = Arc::new(StdMutex::new(String::new())); + let stderr_out = Arc::clone(&stderr_buf); + tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + let n = match reader.read_line(&mut line).await { + Ok(n) => n, + Err(_) => break, + }; + if n == 0 { + break; + } + if let Ok(mut out) = stderr_out.lock() { + out.push_str(&line); + } + } + }); + Self { + child, + stdin, + stdout, + stderr: stderr_buf, + next_id: 1, + } + } + + pub async fn spawn(base_url: &str) -> Self { + Self::spawn_with_env(base_url, &[]).await + } + + pub async fn send(&mut self, method: &str, params: Value) -> i64 { + let id = self.next_id; + self.next_id += 1; + self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await; + id + } + + pub async fn notify(&mut self, method: &str, params: Value) { + self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params })) + .await; + } + + pub async fn write(&mut self, msg: Value) { + let mut s = serde_json::to_string(&msg).unwrap(); + s.push('\n'); + self.stdin.write_all(s.as_bytes()).await.unwrap(); + self.stdin.flush().await.unwrap(); + } + + pub async fn recv(&mut self) -> Value { + let mut line = String::new(); + let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line)) + .await + .expect("recv timeout") + .expect("read line"); + assert!(n > 0, "agent EOF; stderr={}", self.stderr_text()); + serde_json::from_str(&line).expect("non-JSON line") + } + + pub async fn recv_until bool>(&mut self, mut pred: F) -> Value { + loop { + let v = self.recv().await; + if pred(&v) { + return v; + } + } + } + + /// Like `recv_until`, but auto-approves any `session/request_permission` + /// seen while waiting. Tests that exercise tool execution, not the + /// permission boundary (that lives in `permission_boundary.rs`), must + /// approve a model-issued tool call so it reaches the server. + pub async fn recv_until_approving bool>(&mut self, mut pred: F) -> Value { + loop { + let v = self.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + let resp = approve_permission(&v); + self.write(resp).await; + continue; + } + if pred(&v) { + return v; + } + } + } + + pub async fn shutdown(mut self) { + drop(self.stdin); + let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; + let _ = self.child.start_kill(); + } + + pub fn stderr_text(&self) -> String { + self.stderr.lock().map(|s| s.clone()).unwrap_or_default() + } +} + +pub fn openai_text(content: &str) -> Value { + json!({ + "id": "cc-1", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop", + }], + }) +} + +pub fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { + json!({ + "id": "cc-2", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", "content": null, + "tool_calls": [{ + "id": id, "type": "function", + "function": { "name": name, "arguments": args.to_string() }, + }], + }, + "finish_reason": "tool_calls", + }], + }) +} + +/// Select the offered option whose `kind == "allow_once"` and return the +/// `session/request_permission` response. Mirrors buzz-acp's answering side, +/// which selects by `kind`, never by a hardcoded `optionId`. Centralizing this +/// means a future option-id rename can't silently turn allow into a denial. +pub fn approve_permission(request: &Value) -> Value { + let option_id = request["params"]["options"] + .as_array() + .and_then(|opts| opts.iter().find(|o| o["kind"] == "allow_once")) + .and_then(|o| o["optionId"].as_str()) + .expect("request must offer an allow_once option"); + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { "outcome": { "outcome": "selected", "optionId": option_id } }, + }) +} diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index 4253ef329c..fefd5a24c5 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -16,6 +16,9 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio::sync::Mutex; +mod common; +use common::approve_permission; + async fn spawn_fake_llm(responses: Vec) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); @@ -396,12 +399,7 @@ async fn unsupported_image_response_recovers_without_replaying_image() { loop { let message = h.recv().await; if message.get("method") == Some(&json!("session/request_permission")) { - h.write(json!({ - "jsonrpc": "2.0", - "id": message["id"], - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&message)).await; } else if message["id"] == json!(prompt_id) { assert_eq!(message["result"]["stopReason"], "end_turn"); break; diff --git a/crates/buzz-agent/tests/permission_boundary.rs b/crates/buzz-agent/tests/permission_boundary.rs new file mode 100644 index 0000000000..2873ba14d6 --- /dev/null +++ b/crates/buzz-agent/tests/permission_boundary.rs @@ -0,0 +1,631 @@ +//! Production authorization-boundary tests for the `session/request_permission` +//! surface. +//! +//! These drive a real `buzz-agent` subprocess against a fake MCP server and a +//! capturing LLM, and prove the security invariant end to end: an LLM-issued +//! MCP tool call reaches the server IFF the client selected the offered +//! allow-once option, and every other outcome fails closed without invoking the +//! tool. `fake_mcp` appends each invoked *bare* tool name to `FAKE_MCP_CALL_LOG` +//! (fired for `_Stop`/`_PostCompact` too), so "reached the tool exactly once" +//! and "never reached the tool" are both directly observable from disk. +//! +//! Timeout/abort/multi-session-cap state invariants live in the broker-seam unit +//! tests (`src/permission.rs`), which use an injectable deadline and inspect the +//! private correlation map — neither of which a subprocess can do. +//! +//! The subprocess `Harness`, capturing LLM, and `approve_permission` helper are +//! shared with the other integration suites via `mod common`. + +use std::time::Duration; + +use serde_json::{json, Value}; + +mod common; +use common::{approve_permission, spawn_capturing_llm, Harness}; + +// ───────────────────────────────────────────────────────────────────────────── +// LLM response builders +// ───────────────────────────────────────────────────────────────────────────── + +fn openai_text(content: &str) -> Value { + json!({ + "id": "cc-1", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop", + }], + }) +} + +/// One assistant turn issuing `calls`, each `(id, qualified_name, arguments)`. +fn openai_tool_calls(calls: &[(&str, &str, Value)]) -> Value { + let tool_calls: Vec = calls + .iter() + .map(|(id, name, args)| { + json!({ + "id": id, "type": "function", + "function": { "name": name, "arguments": args.to_string() }, + }) + }) + .collect(); + json!({ + "id": "cc-tc", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": null, "tool_calls": tool_calls }, + "finish_reason": "tool_calls", + }], + }) +} + +fn shell_call(id: &str) -> Value { + openai_tool_calls(&[(id, "fake__shell", json!({ "command": "ls" }))]) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Permission-response builders + drivers +// ───────────────────────────────────────────────────────────────────────────── + +/// Bare JSON-RPC response selecting `option_id`. +fn resp_selected(id: &Value, option_id: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id.clone(), + "result": { "outcome": { "outcome": "selected", "optionId": option_id } }, + }) +} + +/// Bare JSON-RPC response carrying an arbitrary `result` shape. +fn resp_result(id: &Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id.clone(), "result": result }) +} + +/// Bare JSON-RPC *error* response (id present, `error`, no `result`). +fn resp_error(id: &Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id.clone(), + "error": { "code": -32601, "message": "method not found" }, + }) +} + +/// The tool title carried by a permission request, across both wire shapes. +fn perm_title(req: &Value) -> String { + let p = &req["params"]; + p["title"] + .as_str() + .or_else(|| p["toolCall"]["title"].as_str()) + .or_else(|| p["subject"]["toolCall"]["title"].as_str()) + .unwrap_or("") + .to_owned() +} + +/// Drive one prompt to completion. For each `session/request_permission`, +/// `decide(&req)` returns `Some(response)` to answer or `None` to leave it +/// unanswered. Returns the final prompt response and every request seen. +async fn drive( + h: &mut Harness, + sid: &str, + prompt: &str, + mut decide: impl FnMut(&Value) -> Option, +) -> (Value, Vec) { + let p = h + .send( + "session/prompt", + json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": prompt }] }), + ) + .await; + let mut requests: Vec = Vec::new(); + loop { + let v = h.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + requests.push(v.clone()); + if let Some(resp) = decide(&v) { + h.write(resp).await; + } + continue; + } + if v["id"] == json!(p) { + return (v, requests); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Session init + call-log helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn call_log_path(tag: &str) -> String { + let p = std::env::temp_dir().join(format!( + "buzz_perm_calllog_{tag}_{}_{:x}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let s = p.to_string_lossy().to_string(); + let _ = std::fs::remove_file(&s); + s +} + +/// Invoked tool names recorded by fake_mcp (bare names, one per `tools/call`). +/// A missing file means zero invocations. +fn call_log_lines(path: &str) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_owned) + .collect() +} + +/// Initialize + create a session with a single fake MCP server named `fake`, +/// negotiating `protocol_version` and passing `mcp_env` to the server. `cwd` +/// controls skill discovery (`.agents/skills`). +async fn init( + h: &mut Harness, + protocol_version: u32, + cwd: &str, + mcp_env: &[(&str, &str)], +) -> String { + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let env: Vec = mcp_env + .iter() + .map(|(k, v)| json!({ "name": k, "value": v })) + .collect(); + h.send( + "initialize", + json!({ "protocolVersion": protocol_version, "clientCapabilities": {} }), + ) + .await; + let _ = h.recv().await; + let servers = if mcp_env.is_empty() { + json!([]) + } else { + json!([{ "name": "fake", "command": fake_mcp, "args": [], "env": env }]) + }; + h.send("session/new", json!({ "cwd": cwd, "mcpServers": servers })) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + r["result"]["sessionId"] + .as_str() + .unwrap_or_else(|| panic!("session/new failed: {r}, stderr={}", h.stderr_text())) + .to_owned() +} + +fn stop_reason(resp: &Value) -> String { + resp["result"]["stopReason"] + .as_str() + .unwrap_or("") + .to_owned() +} + +// ═════════════════════════════════════════════════════════════════════════════ +// The authorization boundary +// ═════════════════════════════════════════════════════════════════════════════ + +/// Exact allow reaches the tool exactly once — and never *before* approval. +/// The pre-approval check proves the gate precedes the MCP call, not just that +/// the tally ends at one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_allow_reaches_tool_exactly_once_and_not_before_approval() { + let log = call_log_path("allow"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let log_for_check = log.clone(); + let (resp, requests) = drive(&mut h, &sid, "go", |req| { + // Before answering, the tool must not have run. + assert!( + call_log_lines(&log_for_check).is_empty(), + "tool invoked BEFORE approval" + ); + Some(approve_permission(req)) + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!(requests.len(), 1, "exactly one call → exactly one ask"); + assert_eq!( + call_log_lines(&log), + vec!["shell"], + "approved tool reached MCP exactly once" + ); + h.shutdown().await; +} + +/// Selecting the offered reject option never invokes the tool, and the model +/// sees a permission-denied tool error (the turn continues to `end_turn`). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_reject_option_never_invokes_tool() { + let log = call_log_path("reject"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("understood")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let (resp, requests) = drive(&mut h, &sid, "go", |req| { + Some(resp_selected(&req["id"], "reject_once")) + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!(requests.len(), 1); + assert!( + call_log_lines(&log).is_empty(), + "rejected tool must never reach MCP" + ); + h.shutdown().await; +} + +/// Every non-authorizing response shape fails closed: the tool never runs. +/// One subprocess per shape keeps the failure attributable. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_adversarial_outcomes_never_invoke_tool() { + // (tag, response-for-request builder) — the full fail-closed matrix. + type Shape = (&'static str, fn(&Value) -> Value); + let shapes: Vec = vec![ + ("cancelled", |req| { + resp_result(&req["id"], json!({ "outcome": { "outcome": "cancelled" } })) + }), + ("jsonrpc_error", |req| resp_error(&req["id"])), + ("missing_outcome", |req| resp_result(&req["id"], json!({}))), + ("empty_outcome", |req| { + resp_result(&req["id"], json!({ "outcome": {} })) + }), + ("selected_no_option", |req| { + resp_result(&req["id"], json!({ "outcome": { "outcome": "selected" } })) + }), + ("unknown_outcome", |req| { + resp_result( + &req["id"], + json!({ "outcome": { "outcome": "banana", "optionId": "allow_once" } }), + ) + }), + ("wrong_option_id", |req| { + resp_selected(&req["id"], "not_an_offered_option") + }), + // Structurally malformed *frames* that each still carry a well-formed + // `selected`/`allow_once` result — the payload would authorize, so only + // the frame-structure check (wire::classify) stands between them and the + // tool. These mirror the two broker-seam unit tests + // (`src/permission.rs::test_frame_with_{result_and_error,non_string_method}_denies_tool`) + // at the live MCP-log seam, proving the frame gate denies end to end. + ("result_and_error", |req| { + let mut frame = approve_permission(req); + frame["error"] = json!({ "code": -32603, "message": "internal" }); + frame + }), + ("non_string_method", |req| { + let mut frame = approve_permission(req); + frame["method"] = json!(7); + frame + }), + ]; + + for (tag, build) in shapes { + let log = call_log_path(tag); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("ok")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let (resp, requests) = drive(&mut h, &sid, "go", |req| Some(build(req))).await; + + assert_eq!( + stop_reason(&resp), + "end_turn", + "shape {tag}: turn should continue" + ); + assert_eq!(requests.len(), 1, "shape {tag}: exactly one ask"); + assert!( + call_log_lines(&log).is_empty(), + "shape {tag}: non-authorizing outcome must never reach MCP" + ); + h.shutdown().await; + } +} + +/// A stale/unknown/foreign response id is ignored and does not unblock the live +/// waiter; the correct id then authorizes exactly once. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stale_id_ignored_then_real_id_authorizes() { + let log = call_log_path("staleid"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let p = h + .send( + "session/prompt", + json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": "go" }] }), + ) + .await; + + // Wait for the ask. + let req = h + .recv_until(|v| v.get("method") == Some(&json!("session/request_permission"))) + .await; + + // Feed several ignorable responses first: a minted-but-never-issued id, a + // foreign numeric id, and a null id. None may unblock the real waiter. + h.write(resp_selected(&json!("perm-9999"), "allow_once")) + .await; + h.write(resp_selected(&json!(7), "allow_once")).await; + h.write(resp_selected(&Value::Null, "allow_once")).await; + // Give the agent a beat to (wrongly) act on any of them. + tokio::time::sleep(Duration::from_millis(150)).await; + assert!( + call_log_lines(&log).is_empty(), + "stale/foreign ids must not authorize the pending call" + ); + + // The real id resolves it exactly once. + h.write(approve_permission(&req)).await; + let resp = h.recv_until(|v| v["id"] == json!(p)).await; + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!( + call_log_lines(&log), + vec!["shell"], + "only the correct id authorizes, exactly once" + ); + h.shutdown().await; +} + +/// `session/cancel` while a permission ask is outstanding terminates the turn +/// promptly, executes nothing, and needs no `cancelled` permission response — +/// a Buzz client always answers, but a non-Buzz ACP client may violate the spec +/// by staying silent, and cancellation must not depend on that answer. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_cancel_while_waiting_executes_nothing_without_client_answer() { + let log = call_log_path("cancelwait"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let p = h + .send( + "session/prompt", + json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": "go" }] }), + ) + .await; + + // Wait for the ask, then cancel WITHOUT ever answering the permission. + let _req = h + .recv_until(|v| v.get("method") == Some(&json!("session/request_permission"))) + .await; + h.notify("session/cancel", json!({ "sessionId": sid })) + .await; + + let resp = h.recv_until(|v| v["id"] == json!(p)).await; + assert_eq!( + stop_reason(&resp), + "cancelled", + "cancel resolves the turn without a client permission answer" + ); + assert!( + call_log_lines(&log).is_empty(), + "a cancelled ask must never reach the tool" + ); + h.shutdown().await; +} + +/// Two parallel calls each get their own ask (distinct ids); crossed decisions — +/// deny the first-asked, allow the second-asked — authorize only the allowed +/// call. Serial admission (`max_parallel_tools=1`) serializes the asks: the +/// second ask fires only after the first resolves. Which tool is admitted first +/// is a tokio scheduling detail, so the test denies whichever is asked first and +/// proves only the allowed (second) call reached MCP — order-agnostic. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_crossed_parallel_decisions_authorize_only_matching_call() { + let log = call_log_path("crossed"); + // Two distinct registered tools so the call log distinguishes them by name. + let llm = spawn_capturing_llm(vec![ + openai_tool_calls(&[ + ("tc-alpha", "fake__alpha", json!({})), + ("tc-bravo", "fake__bravo", json!({})), + ]), + openai_text("done"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_MAX_PARALLEL_TOOLS", "1")]).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[ + ("FAKE_MCP_NAMED_TOOLS", "alpha,bravo"), + ("FAKE_MCP_CALL_LOG", &log), + ], + ) + .await; + + // Deny whichever tool is asked first; allow the second. Ids are distinct. + let mut seen_ids: Vec = Vec::new(); + let mut allowed_title = String::new(); + let (resp, requests) = drive(&mut h, &sid, "go", |req| { + assert!( + !seen_ids.contains(&req["id"]), + "each parallel call must get a distinct request id" + ); + seen_ids.push(req["id"].clone()); + if seen_ids.len() == 1 { + Some(resp_selected(&req["id"], "reject_once")) // deny the first-asked + } else { + allowed_title = perm_title(req); + Some(approve_permission(req)) // allow the second-asked + } + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!(requests.len(), 2, "one ask per parallel call"); + // The call log records bare tool names; the title carries the qualified + // `__`. Only the allowed (second-asked) call reached MCP. + let allowed_bare = allowed_title + .strip_prefix("fake__") + .expect("qualified title") + .to_owned(); + assert_eq!( + call_log_lines(&log), + vec![allowed_bare], + "only the allowed (second-asked) call reached MCP; the denied one did not" + ); + h.shutdown().await; +} + +/// The built-in `load_skill` tool is not an MCP call and is exempt from the +/// permission boundary: it executes with no `session/request_permission`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_load_skill_emits_no_permission_request() { + let tmp = tempfile::TempDir::new().unwrap(); + let cwd = tmp.path(); + let skill_dir = cwd.join(".agents/skills/my-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: my-skill\ndescription: A skill\n---\nSKILL_BODY_77\n", + ) + .unwrap(); + + let llm = spawn_capturing_llm(vec![ + openai_tool_calls(&[("tc-ls", "load_skill", json!({ "name": "my-skill" }))]), + openai_text("done"), + ]) + .await; + let mut h = Harness::spawn(&llm.url).await; + // No MCP server: `load_skill` is a built-in, and skills come from `cwd`. + let sid = init(&mut h, 1, cwd.to_str().unwrap(), &[]).await; + + let (resp, requests) = drive(&mut h, &sid, "use my-skill", |_| { + panic!("load_skill must not trigger a permission request") + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert!(requests.is_empty(), "built-in load_skill is exempt"); + h.shutdown().await; +} + +/// Lifecycle hooks (`_Stop`, `_PostCompact`) invoke MCP through `call_hooks`, +/// not the model-issued tool path, so they are exempt: the hook reaches the +/// server (call log records it) with no permission ask. Here the `_Stop` hook +/// objects once, forcing a hook invocation the test can observe. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stop_hook_reaches_mcp_without_permission_ask() { + let log = call_log_path("stophook"); + // Text turn triggers the _Stop gate → hook objects once → agent loops → + // second text turn, hook silent → end_turn. No model-issued tool call. + let llm = spawn_capturing_llm(vec![ + openai_text("premature"), + openai_text("really done"), + openai_text("unexpected"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("MCP_HOOK_SERVERS", "fake"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"), + ], + ) + .await; + let sid = init( + &mut h, + 1, + "/tmp", + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "you have open work"), + ("FAKE_MCP_STOP_COUNT", "1"), + ("FAKE_MCP_CALL_LOG", &log), + ], + ) + .await; + + let (resp, requests) = drive(&mut h, &sid, "go", |_| { + panic!("a lifecycle hook must not trigger a permission request") + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert!(requests.is_empty(), "_Stop hook is exempt from the ask"); + assert!( + call_log_lines(&log).contains(&"_Stop".to_owned()), + "the _Stop hook still reached MCP without an ask; log={:?}", + call_log_lines(&log) + ); + h.shutdown().await; +} + +/// Under a v2-negotiated connection, the emitted `session/request_permission` +/// carries the v2 shape (`subject.toolCall`, top-level `title`), never the v1 +/// legacy top-level `toolCall`. Complements the pure v1/v2 builder unit tests +/// with an end-to-end proof that the negotiated version reaches the wire. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_v2_negotiation_emits_v2_request_shape() { + let log = call_log_path("v2shape"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 2, // negotiate v2 + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let (resp, requests) = drive(&mut h, &sid, "go", |req| { + let params = &req["params"]; + // v2: tool context under `subject.toolCall`, with top-level `title`. + assert_eq!(params["subject"]["type"], "tool_call", "v2 uses subject"); + assert_eq!(params["subject"]["toolCall"]["title"], "fake__shell"); + assert_eq!(params["title"], "fake__shell", "v2 has top-level title"); + assert!( + params.get("toolCall").is_none(), + "v2 must not carry the v1 top-level toolCall" + ); + Some(approve_permission(req)) + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!(requests.len(), 1); + assert_eq!(call_log_lines(&log), vec!["shell"]); + h.shutdown().await; +} diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index 6a4f347f6b..edee9090d8 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -5,220 +5,16 @@ //! - cancellation leaves history valid for the next prompt //! - empty-content assistant turn doesn't poison OpenAI history -use std::collections::VecDeque; use std::process::Stdio; -use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, Instant}; use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; -use tokio::sync::Mutex; -struct CapturingLlm { - url: String, - captured: Arc>>, -} - -async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { - spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await -} - -/// Like `spawn_capturing_llm` but each canned response carries its own HTTP -/// status, so a test can serve a real provider rejection (e.g. a context-window -/// 400) instead of only success bodies. -async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); - let cap2 = captured.clone(); - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let queue = queue.clone(); - let captured = cap2.clone(); - tokio::spawn(async move { - let mut buf = Vec::new(); - let mut tmp = [0u8; 8192]; - // Read until headers complete. - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 4_000_000 { - return; - } - } - // Parse Content-Length and read body. - let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - let headers = &buf[..header_end]; - let mut body_len = 0usize; - for line in headers.split(|b| *b == b'\n') { - let line = std::str::from_utf8(line).unwrap_or(""); - if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") { - body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0); - } - } - while buf.len() < header_end + body_len { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - } - if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { - captured.lock().await.push(req); - } - let (status, body) = queue - .lock() - .await - .pop_front() - .unwrap_or_else(|| (200, json!({ "error": "no canned response" }))); - let body_s = serde_json::to_string(&body).unwrap(); - let reason = match status { - 200 => "OK", - 400 => "Bad Request", - _ => "Error", - }; - let resp = format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body_s.len(), body_s, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - CapturingLlm { url, captured } -} - -struct Harness { - child: tokio::process::Child, - stdin: tokio::process::ChildStdin, - stdout: BufReader, - stderr: Arc>, - next_id: i64, -} - -impl Harness { - async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self { - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "fake-model") - .env("OPENAI_COMPAT_BASE_URL", base_url) - .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_MAX_ROUNDS", "8") - .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2"); - for (k, v) in extra { - cmd.env(k, v); - } - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - let mut child = cmd.spawn().expect("spawn buzz-agent"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let stderr = child.stderr.take().unwrap(); - let stderr_buf = Arc::new(StdMutex::new(String::new())); - let stderr_out = Arc::clone(&stderr_buf); - tokio::spawn(async move { - let mut reader = BufReader::new(stderr); - let mut line = String::new(); - loop { - line.clear(); - let n = match reader.read_line(&mut line).await { - Ok(n) => n, - Err(_) => break, - }; - if n == 0 { - break; - } - if let Ok(mut out) = stderr_out.lock() { - out.push_str(&line); - } - } - }); - Self { - child, - stdin, - stdout, - stderr: stderr_buf, - next_id: 1, - } - } - - async fn spawn(base_url: &str) -> Self { - Self::spawn_with_env(base_url, &[]).await - } - - async fn send(&mut self, method: &str, params: Value) -> i64 { - let id = self.next_id; - self.next_id += 1; - self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) - .await; - id - } - - async fn notify(&mut self, method: &str, params: Value) { - self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params })) - .await; - } - - async fn write(&mut self, msg: Value) { - let mut s = serde_json::to_string(&msg).unwrap(); - s.push('\n'); - self.stdin.write_all(s.as_bytes()).await.unwrap(); - self.stdin.flush().await.unwrap(); - } - - async fn recv(&mut self) -> Value { - let mut line = String::new(); - let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line)) - .await - .expect("recv timeout") - .expect("read line"); - assert!(n > 0, "agent EOF"); - serde_json::from_str(&line).expect("non-JSON line") - } - - async fn recv_until bool>(&mut self, mut pred: F) -> Value { - loop { - let v = self.recv().await; - if pred(&v) { - return v; - } - } - } - - async fn shutdown(mut self) { - drop(self.stdin); - let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; - let _ = self.child.start_kill(); - } - - fn stderr_text(&self) -> String { - self.stderr.lock().map(|s| s.clone()).unwrap_or_default() - } -} - -fn openai_text(content: &str) -> Value { - json!({ - "id": "cc-1", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": content }, - "finish_reason": "stop", - }], - }) -} +mod common; +use common::{ + approve_permission, openai_text, openai_tool_call, spawn_capturing_llm, + spawn_capturing_llm_with_status, Harness, +}; /// Like [`openai_text`] but attaches a `usage` block so tests can drive the /// token-based handoff gate. `prompt_tokens` is the input-token count the @@ -253,23 +49,6 @@ fn openai_max_tokens(content: &str, tool_calls: Value) -> Value { }) } -fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { - json!({ - "id": "cc-2", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", "content": null, - "tool_calls": [{ - "id": id, "type": "function", - "function": { "name": name, "arguments": args.to_string() }, - }], - }, - "finish_reason": "tool_calls", - }], - }) -} - async fn init_session(h: &mut Harness, mcp_servers: Value) -> String { h.send( "initialize", @@ -676,13 +455,7 @@ async fn per_turn_tool_call_cap_enforced() { loop { let v = h.recv().await; if v.get("method") == Some(&json!("session/request_permission")) { - let id = v["id"].clone(); - h.write(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&v)).await; continue; } if v.get("method") == Some(&json!("session/update")) @@ -858,7 +631,7 @@ async fn hook_stop_blocks_premature_end() { json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), ) .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; + let r = h.recv_until_approving(|v| v["id"] == json!(p)).await; assert!(r.get("result").is_some(), "errored: {r}"); assert_eq!(r["result"]["stopReason"], "end_turn"); @@ -936,7 +709,7 @@ async fn hook_stop_budget_exhausted() { json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), ) .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; + let r = h.recv_until_approving(|v| v["id"] == json!(p)).await; assert!(r.get("result").is_some(), "errored: {r}"); assert_eq!(r["result"]["stopReason"], "end_turn"); @@ -1517,7 +1290,7 @@ async fn stale_usage_plus_history_growth_triggers_handoff() { json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), ) .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; + let _ = h.recv_until_approving(|v| v["id"] == json!(p)).await; // req1 (tool_call) + summarize (handoff) + req2 (done) = 3. Without the // growth estimate we'd see only 2 (stale 8500 < 9000, no handoff). let captured = llm.captured.lock().await.len(); @@ -1788,7 +1561,7 @@ async fn cancel_sends_notifications_cancelled_to_any_mcp_server() { .await; // Wait for tool call to be in-progress. - h.recv_until(|v| { + h.recv_until_approving(|v| { v.get("params") .and_then(|p| p.get("update")) .and_then(|u| u.get("status")) @@ -1903,13 +1676,7 @@ async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value { loop { let v = h.recv().await; if v.get("method") == Some(&json!("session/request_permission")) { - let id = v["id"].clone(); - h.write(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&v)).await; continue; } if v["id"] == json!(p) { @@ -2664,7 +2431,9 @@ async fn max_tokens_recovery_can_proceed_to_tool_call() { json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), ) .await; - let reply = h.recv_until(|v| v["id"] == json!(prompt_id)).await; + let reply = h + .recv_until_approving(|v| v["id"] == json!(prompt_id)) + .await; assert_eq!(reply["result"]["stopReason"], "end_turn", "{reply}"); let requests = llm.captured.lock().await; assert_eq!(requests.len(), 3); @@ -3642,13 +3411,7 @@ async fn handoff_cap_binds_within_a_single_turn() { } if v.get("method") == Some(&json!("session/request_permission")) { - let id = v["id"].clone(); - h.write(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&v)).await; continue; } if v["id"] == json!(p2) { @@ -3795,13 +3558,7 @@ async fn failed_summarize_burns_handoff_attempt_budget() { loop { let v = h.recv().await; if v.get("method") == Some(&json!("session/request_permission")) { - let id = v["id"].clone(); - h.write(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&v)).await; continue; } if v["id"] == json!(p2) { diff --git a/crates/buzz-audit/Cargo.toml b/crates/buzz-audit/Cargo.toml index dfa73353de..766ade6505 100644 --- a/crates/buzz-audit/Cargo.toml +++ b/crates/buzz-audit/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } +metrics = { workspace = true } thiserror = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } diff --git a/crates/buzz-auth/src/rate_limit.rs b/crates/buzz-auth/src/rate_limit.rs index 8fd42c50fb..9e64627404 100644 --- a/crates/buzz-auth/src/rate_limit.rs +++ b/crates/buzz-auth/src/rate_limit.rs @@ -60,6 +60,8 @@ pub enum LimitType { Messages, /// HTTP REST API calls. ApiCalls, + /// Relay-proxied GIF metadata searches. + GifSearches, /// All WebSocket events (broader than `Messages`). WsEvents, /// Concurrent WebSocket connections from a single IP address. @@ -72,6 +74,7 @@ impl LimitType { match self { Self::Messages => "msg", Self::ApiCalls => "api", + Self::GifSearches => "gif", Self::WsEvents => "ws", Self::IpConnections => "conn", } @@ -87,6 +90,10 @@ pub struct RateLimitConfig { /// Maximum messages per minute for human users. Default: 60. #[serde(default = "default_human_msg")] pub human_messages_per_min: u64, + /// Maximum relay-proxied GIF searches per minute for each pubkey. + /// Default: 30. + #[serde(default = "default_gif_searches")] + pub gif_searches_per_min: u64, /// Maximum HTTP API calls per minute for human users. Default: 300. #[serde(default = "default_human_api")] pub human_api_calls_per_min: u64, @@ -110,6 +117,9 @@ pub struct RateLimitConfig { fn default_human_msg() -> u64 { 60 } +fn default_gif_searches() -> u64 { + 30 +} fn default_human_api() -> u64 { 300 } @@ -133,6 +143,7 @@ impl Default for RateLimitConfig { fn default() -> Self { Self { human_messages_per_min: default_human_msg(), + gif_searches_per_min: default_gif_searches(), human_api_calls_per_min: default_human_api(), human_ws_events_per_sec: default_human_ws(), agent_standard_messages_per_min: default_agent_std_msg(), @@ -272,6 +283,17 @@ mod tests { assert!(key.ends_with(":msg")); } + #[test] + fn gif_searches_have_an_independent_quota_key() { + let ctx = fixture_ctx("relay-a.example"); + let keys = Keys::generate(); + let gif_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::GifSearches); + let api_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::ApiCalls); + + assert!(gif_key.ends_with(":gif")); + assert_ne!(gif_key, api_key); + } + #[test] fn rate_limit_key_isolates_communities_for_same_pubkey() { // The S1 cross-community isolation fence at the rate-limit key layer: diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 81ed36f62b..b7fa06d203 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -428,7 +428,8 @@ buzz workflows delete --workflow "$WF_ID" | jq . ```bash buzz feed get | jq . buzz feed get --limit 5 | jq . -# Expected: [{id,pubkey,kind,content,created_at,tags}] — sig-stripped, sorted newest-first +# Expected: complete signed Nostr events with +# {id,pubkey,kind,content,created_at,sig,tags}, sorted newest-first ``` ### 6.11 Forum & Voting diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index ee8868ad92..71af043bd7 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1302,20 +1302,24 @@ fn to_ws_url(http_url: &str) -> String { .replace("http://", "ws://") } -/// Normalize raw event JSON array into consistent shape. -/// Each event becomes: {id, pubkey, kind, content, created_at, tags} +/// Normalize raw event JSON array into the canonical Nostr event shape. +/// String signatures are preserved; absent or non-string signatures remain absent. pub fn normalize_events(events: &[serde_json::Value]) -> String { let normalized: Vec = events .iter() .map(|e| { - serde_json::json!({ + let mut event = serde_json::json!({ "id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), "kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0), "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), "tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])), - }) + }); + if let Some(sig) = e.get("sig").and_then(|v| v.as_str()) { + event["sig"] = serde_json::json!(sig); + } + event }) .collect(); serde_json::to_string(&normalized).unwrap_or_default() @@ -2304,10 +2308,42 @@ mod retry_policy_tests { mod tests { use super::{ advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, - BuzzClient, + normalize_events, BuzzClient, }; use nostr::{EventBuilder, Keys, Kind, Tag}; + #[test] + fn normalize_events_preserves_the_complete_signed_event_shape() { + let signed_event = EventBuilder::new(Kind::TextNote, "signed content") + .tags([Tag::parse(["h", "channel-id"]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let mut event = serde_json::to_value(&signed_event).unwrap(); + event["relay_internal"] = serde_json::json!("excluded"); + + let output: Vec = + serde_json::from_str(&normalize_events(&[event])).unwrap(); + let normalized = &output[0]; + let round_tripped: nostr::Event = serde_json::from_value(normalized.clone()).unwrap(); + + assert_eq!(round_tripped, signed_event); + round_tripped.verify().unwrap(); + assert!(normalized.get("sig").is_some()); + assert!(normalized.get("relay_internal").is_none()); + } + + #[test] + fn normalize_events_omits_missing_or_non_string_signatures() { + let output: Vec = serde_json::from_str(&normalize_events(&[ + serde_json::json!({}), + serde_json::json!({"sig": 42}), + ])) + .unwrap(); + + assert!(output[0].get("sig").is_none()); + assert!(output[1].get("sig").is_none()); + } + #[test] fn query_cursor_uses_last_events_composite_sort_key() { let mut filter = serde_json::json!({"kinds": [39000], "limit": 500}); diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index d3d5c7f81a..d5e1dae4b2 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -5,6 +5,27 @@ use crate::error::CliError; const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"]; +fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { + match format { + crate::OutputFormat::Compact => { + let events: Vec = + serde_json::from_str(normalized).unwrap_or_default(); + let compact: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "id": e.get("id").cloned().unwrap_or_default(), + "content": e.get("content").cloned().unwrap_or_default(), + "created_at": e.get("created_at").cloned().unwrap_or_default(), + }) + }) + .collect(); + serde_json::to_string(&compact).unwrap_or_default() + } + crate::OutputFormat::Json => normalized.to_string(), + } +} + /// Get activity feed — query events mentioning our pubkey (via p-tag). pub async fn cmd_get_feed( client: &BuzzClient, @@ -42,25 +63,7 @@ pub async fn cmd_get_feed( let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); events.sort_by_key(|e| Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0))); let normalized = normalize_events(&events); - let output = match format { - crate::OutputFormat::Compact => { - let evts: Vec = - serde_json::from_str(&normalized).unwrap_or_default(); - let compact: Vec = evts - .iter() - .map(|e| { - serde_json::json!({ - "id": e.get("id").cloned().unwrap_or_default(), - "content": e.get("content").cloned().unwrap_or_default(), - "created_at": e.get("created_at").cloned().unwrap_or_default(), - }) - }) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() - } - crate::OutputFormat::Json => normalized, - }; - println!("{output}"); + println!("{}", format_events(&normalized, format)); Ok(()) } @@ -78,3 +81,35 @@ pub async fn dispatch( } => cmd_get_feed(client, since, limit, types.as_deref(), format).await, } } + +#[cfg(test)] +mod tests { + use super::format_events; + + #[test] + fn compact_event_format_remains_the_three_key_contract() { + let normalized = serde_json::json!([{ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "kind": 9, + "content": "compact content", + "created_at": 1_787_754_972_u64, + "tags": [["p", "c".repeat(64)]], + "sig": "d".repeat(128), + }]) + .to_string(); + + let output: Vec = + serde_json::from_str(&format_events(&normalized, &crate::OutputFormat::Compact)) + .unwrap(); + + assert_eq!( + output[0], + serde_json::json!({ + "id": "a".repeat(64), + "content": "compact content", + "created_at": 1_787_754_972_u64, + }) + ); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index ea273336e3..9f41fbf751 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1057,7 +1057,7 @@ pub async fn dispatch( mod tests { use super::{ channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags, - match_profiles_by_name, merge_message_mentions, missing_members, + 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, @@ -1078,6 +1078,33 @@ mod tests { const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05"; const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68"; + #[test] + fn compact_event_format_remains_the_three_key_contract() { + let normalized = serde_json::json!([{ + "id": ID_A, + "pubkey": PUBKEY, + "kind": 9, + "content": "compact content", + "created_at": 1_787_754_972_u64, + "tags": [["h", "channel-id"]], + "sig": "d".repeat(128), + }]) + .to_string(); + + let output: Vec = + serde_json::from_str(&format_events(&normalized, &crate::OutputFormat::Compact)) + .unwrap(); + + assert_eq!( + output[0], + serde_json::json!({ + "id": ID_A, + "content": "compact content", + "created_at": 1_787_754_972_u64, + }) + ); + } + #[tokio::test] async fn malformed_channel_is_rejected_before_thread_fetch() { let client = diff --git a/crates/buzz-datastore-tracing/Cargo.toml b/crates/buzz-datastore-tracing/Cargo.toml index e93900c54c..fb7ba6f37d 100644 --- a/crates/buzz-datastore-tracing/Cargo.toml +++ b/crates/buzz-datastore-tracing/Cargo.toml @@ -16,6 +16,8 @@ quote = "1" syn = { version = "2", features = ["full"] } [dev-dependencies] +metrics = { workspace = true } +metrics-util = { workspace = true } opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["testing"] } tokio = { workspace = true } diff --git a/crates/buzz-datastore-tracing/src/lib.rs b/crates/buzz-datastore-tracing/src/lib.rs index f2645cb8f3..217f2335de 100644 --- a/crates/buzz-datastore-tracing/src/lib.rs +++ b/crates/buzz-datastore-tracing/src/lib.rs @@ -70,6 +70,9 @@ impl Parse for DatastoreArgs { /// PostgreSQL spans always omit function arguments, use the `buzz_datastore` /// target, and expose only canonical semantic fields plus explicitly supplied /// safe fields. An `Err` sets `otel.status_code` without inspecting the error. +/// The literal `name` also labels a logical-operation duration histogram. Slow +/// completions are sampled and logged with only that name, outcome, and elapsed +/// time; arguments, error values, and return values are never formatted. #[proc_macro_attribute] pub fn datastore_span(args: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(args as DatastoreArgs); @@ -129,9 +132,46 @@ pub fn datastore_span(args: TokenStream, item: TokenStream) -> TokenStream { } } }); + let outcome = if returns_result { + quote! { + if #result.is_err() { "error" } else { "success" } + } + } else { + quote!("success") + }; function.block = Box::new(syn::parse_quote!({ + let __buzz_datastore_started_7f3a9c = ::std::time::Instant::now(); let #result: #return_type = (async #original_body).await; #record_error + let __buzz_datastore_outcome_7f3a9c = #outcome; + let __buzz_datastore_elapsed_7f3a9c = __buzz_datastore_started_7f3a9c.elapsed(); + ::metrics::histogram!( + "buzz_db_operation_duration_seconds", + "operation" => #name, + "outcome" => __buzz_datastore_outcome_7f3a9c, + ) + .record(__buzz_datastore_elapsed_7f3a9c.as_secs_f64()); + if __buzz_datastore_elapsed_7f3a9c >= ::std::time::Duration::from_millis(500) { + static __BUZZ_DATASTORE_SLOW_SAMPLE_7F3A9C: + ::std::sync::atomic::AtomicU64 = ::std::sync::atomic::AtomicU64::new(0); + if __BUZZ_DATASTORE_SLOW_SAMPLE_7F3A9C.fetch_add( + 1, + ::std::sync::atomic::Ordering::Relaxed, + ) % 100 == 0 { + let __buzz_datastore_elapsed_ms_7f3a9c = + __buzz_datastore_elapsed_7f3a9c + .as_millis() + .min(::std::primitive::u64::MAX as u128) as u64; + ::tracing::warn!( + target: "buzz_datastore", + parent: None, + operation = #name, + outcome = __buzz_datastore_outcome_7f3a9c, + elapsed_ms = __buzz_datastore_elapsed_ms_7f3a9c, + "slow datastore operation" + ); + } + } #result })); diff --git a/crates/buzz-datastore-tracing/tests/runtime.rs b/crates/buzz-datastore-tracing/tests/runtime.rs index b58dca8715..3355190956 100644 --- a/crates/buzz-datastore-tracing/tests/runtime.rs +++ b/crates/buzz-datastore-tracing/tests/runtime.rs @@ -1,6 +1,12 @@ use buzz_datastore_tracing::datastore_span; +use metrics_util::debugging::{DebugValue, DebuggingRecorder}; use opentelemetry::trace::{SpanKind, Status, TracerProvider as _}; use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use tracing::field::{Field, Visit}; +use tracing::{Event, Subscriber}; +use tracing_subscriber::layer::{Context, Layer}; use tracing_subscriber::prelude::*; const DIRECT_ERROR: &str = "raw-secret-direct-error"; @@ -27,8 +33,48 @@ async fn operation( Ok(limit) } +#[datastore_span(name = "slow_test_operation", system = "postgresql")] +async fn slow_operation(delay: std::time::Duration) -> Result<(), &'static str> { + tokio::time::sleep(delay).await; + Err(DIRECT_ERROR) +} + +#[derive(Default)] +struct EventFields(BTreeMap); + +impl Visit for EventFields { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name().to_owned(), format!("{value:?}")); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.0.insert(field.name().to_owned(), value.to_owned()); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.0.insert(field.name().to_owned(), value.to_string()); + } +} + +#[derive(Clone, Default)] +struct EventCapture(Arc>>); + +impl Layer for EventCapture +where + S: Subscriber, +{ + fn on_event(&self, event: &Event<'_>, _context: Context<'_, S>) { + let mut fields = EventFields::default(); + event.record(&mut fields); + self.0.lock().expect("capture lock").push(fields); + } +} + #[tokio::test(flavor = "current_thread")] async fn exports_policy_fields_without_error_or_argument_data() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _metrics_guard = metrics::set_default_local_recorder(&recorder); let exporter = InMemorySpanExporter::default(); let provider = SdkTracerProvider::builder() .with_simple_exporter(exporter.clone()) @@ -41,6 +87,37 @@ async fn exports_policy_fields_without_error_or_argument_data() { assert_eq!(operation(8, true, false).await, Err(DIRECT_ERROR)); assert_eq!(operation(9, false, true).await, Err(QUESTION_ERROR)); + let operation_samples = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_db_operation_duration_seconds") + .map(|(key, _, _, value)| { + let DebugValue::Histogram(samples) = value else { + panic!("operation duration must be a histogram"); + }; + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + (labels, samples) + }) + .collect::>(); + assert_eq!(operation_samples.len(), 2); + for (labels, samples) in operation_samples { + assert_eq!( + labels.get("operation").map(String::as_str), + Some("test_operation") + ); + assert!(matches!( + labels.get("outcome").map(String::as_str), + Some("success" | "error") + )); + assert!(!samples.is_empty()); + assert!(samples.iter().all(|sample| sample.into_inner() >= 0.0)); + } + provider.force_flush().expect("spans flush"); let spans = exporter.get_finished_spans().expect("exported spans"); assert_eq!(spans.len(), 3); @@ -78,3 +155,55 @@ async fn exports_policy_fields_without_error_or_argument_data() { } } } + +#[tokio::test(flavor = "current_thread")] +async fn slow_operation_logging_is_guarded_sampled_and_redacted() { + let capture = EventCapture::default(); + let subscriber = tracing_subscriber::registry().with(capture.clone()); + let _subscriber_guard = tracing::subscriber::set_default(subscriber); + + assert_eq!( + slow_operation(std::time::Duration::from_millis(1)).await, + Err(DIRECT_ERROR) + ); + assert_eq!( + slow_operation(std::time::Duration::from_millis(510)).await, + Err(DIRECT_ERROR) + ); + assert_eq!( + slow_operation(std::time::Duration::from_millis(510)).await, + Err(DIRECT_ERROR) + ); + + let events = capture.0.lock().expect("capture lock"); + let slow = events + .iter() + .filter(|event| { + event + .0 + .get("message") + .is_some_and(|message| message.contains("slow datastore operation")) + }) + .collect::>(); + assert_eq!( + slow.len(), + 1, + "first slow call is logged, next 99 are sampled out" + ); + let fields = &slow[0].0; + assert_eq!( + fields.get("operation").map(String::as_str), + Some("slow_test_operation") + ); + assert_eq!(fields.get("outcome").map(String::as_str), Some("error")); + assert!(fields + .get("elapsed_ms") + .and_then(|value| value.parse::().ok()) + .is_some_and(|elapsed| elapsed >= 500)); + assert_eq!( + fields.len(), + 4, + "only message and fixed safe fields are logged" + ); + assert!(!format!("{fields:?}").contains(DIRECT_ERROR)); +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index a1890adb56..98790e3d62 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -478,14 +478,17 @@ async fn acquire_channel_membership_lock( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") - .bind(format!( - "{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}", - community_id.as_uuid(), - channel_id - )) - .execute(&mut **tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}", + community_id.as_uuid(), + channel_id + )) + .execute(&mut **tx), + ) + .await?; Ok(()) } @@ -631,10 +634,13 @@ pub async fn lock_member_snapshot( relay_pubkey, Some(channel_id.as_bytes()), ); - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(replacement_lock) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(replacement_lock) + .execute(&mut *tx), + ) + .await?; acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; let rows = sqlx::query( r#" diff --git a/crates/buzz-db/src/community.rs b/crates/buzz-db/src/community.rs new file mode 100644 index 0000000000..5df896a350 --- /dev/null +++ b/crates/buzz-db/src/community.rs @@ -0,0 +1,992 @@ +//! Community lifecycle and host-map persistence. + +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use sqlx::Row; +use uuid::Uuid; + +use crate::{relay_members, Db, DbError, Result}; + +/// Community host-map row returned by [`Db::lookup_community_by_host`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, +} + +/// Community row returned by idempotent community ensure/create operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnsuredCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// True only when this call inserted the `communities` row. + pub created: bool, +} + +/// Community row returned by an atomic create-with-owner operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreatedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host stored for the community. + pub host: String, +} + +/// Result of atomically creating a community with its initial owner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreateCommunityWithOwnerResult { + /// The community was created, or an identical retried create found it. + Created(CreatedCommunityRecord), + /// The host already belongs to another owner. + HostExists, + /// The intended owner already owns the maximum number of communities. + LimitReached, +} + +/// Community row returned by operator-plane ownership reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// When the community row was created. + pub created_at: DateTime, + /// When the community was archived; absent while active. + pub archived_at: Option>, +} + +/// Community row returned by an owner-authorized archive operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArchivedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Reserved canonical host. + pub host: String, + /// Durable first-archive timestamp. + pub archived_at: DateTime, +} + +/// Community row returned by an owner-authorized unarchive operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnarchivedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Reserved canonical host restored to active admission. + pub host: String, +} + +impl Db { + /// Returns the community mapped to a normalized request host, if one exists. + /// + /// The caller owns host normalization and turns `None` into the fail-closed + /// request/connection error. buzz-db only reads the durable host map. + #[datastore_span(name = "lookup_community_by_host", system = "postgresql")] + pub async fn lookup_community_by_host( + &self, + normalized_host: &str, + ) -> Result> { + let row = sqlx::query( + r#" + SELECT id, host + FROM communities + WHERE lower(host) = lower($1) + AND archived_at IS NULL + AND deleted_at IS NULL + AND deletion_state = 'active' + "#, + ) + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + Ok(CommunityRecord { + id: CommunityId::from_uuid(id), + host, + }) + }) + .transpose() + } + + /// 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 { + 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) + .await?; + Ok(active) + } + + /// Returns a community by host regardless of lifecycle state. Operator-plane only. + #[datastore_span( + name = "lookup_community_by_host_for_management", + system = "postgresql" + )] + pub async fn lookup_community_by_host_for_management( + &self, + normalized_host: &str, + ) -> Result> { + let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(CommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .transpose() + } + + /// Lists communities where `owner_pubkey` currently holds the `owner` role. + /// + /// This is an operator-plane helper, not a tenant-scoped data-plane read: + /// callers must gate it on deployment-level operator auth before exposing it. + #[datastore_span(name = "list_communities_owned_by", system = "postgresql")] + pub async fn list_communities_owned_by( + &self, + owner_pubkey: &str, + ) -> Result> { + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let rows = sqlx::query( + r#" + SELECT c.id, c.host, c.created_at, c.archived_at + FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE rm.pubkey = $1 + AND rm.role = 'owner' + ORDER BY c.created_at ASC, c.host ASC + "#, + ) + .bind(owner_pubkey) + .fetch_all(&self.pool) + .await?; + + rows.into_iter() + .map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + let created_at: DateTime = row.try_get("created_at")?; + let archived_at: Option> = row.try_get("archived_at")?; + Ok(OwnedCommunityRecord { + id: CommunityId::from_uuid(id), + host, + created_at, + archived_at, + }) + }) + .collect() + } + + /// Returns the normalized host mapped to a community id, if the community + /// exists. + /// + /// The reverse of [`lookup_community_by_host`]: used by side-effect + /// producers that already hold a server-resolved `CommunityId` (e.g. the + /// workflow action sink running a run owned by some community) and need a + /// fully-formed [`buzz_core::tenant::TenantContext`] — host included — to + /// fan out under *that* community rather than the deployment default. The + /// community is authoritative; the host is read back for labelling only and + /// 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 row = sqlx::query( + r#" + SELECT host + 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_optional(&self.pool) + .await?; + + row.map(|row| { + let host: String = row.try_get("host")?; + Ok(host) + }) + .transpose() + } + + /// Returns the community's workspace icon (NIP-11 `icon`), if set. + /// + /// Set by relay admins/owners via the kind:9033 command; the value is + /// validated and size-capped at that write path. + #[datastore_span(name = "get_community_icon", system = "postgresql")] + pub async fn get_community_icon(&self, community_id: CommunityId) -> Result> { + let row = sqlx::query( + r#" + SELECT icon + FROM communities + WHERE id = $1 + "#, + ) + .bind(community_id.as_uuid()) + .fetch_optional(&self.pool) + .await?; + + Ok(row + .map(|row| row.try_get::, _>("icon")) + .transpose()? + .flatten() + .filter(|icon| !icon.is_empty())) + } + + /// Sets or clears (`None`) the community's workspace icon. + #[datastore_span(name = "set_community_icon", system = "postgresql")] + pub async fn set_community_icon( + &self, + community_id: CommunityId, + icon: Option<&str>, + ) -> Result<()> { + sqlx::query( + r#" + UPDATE communities + SET icon = $2 + WHERE id = $1 + "#, + ) + .bind(community_id.as_uuid()) + .bind(icon) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Ensure a configured community host exists and return its row. + /// + /// This is the startup/config seeding path for N=1 deployments. Migrations + /// create the schema only; deployment-specific hosts are not hardcoded into + /// schema history. + #[datastore_span(name = "ensure_configured_community", system = "postgresql")] + pub async fn ensure_configured_community( + &self, + normalized_host: &str, + ) -> Result { + let row = sqlx::query( + r#" + INSERT INTO communities (host) + VALUES ($1) + ON CONFLICT (lower(host)) DO UPDATE SET host = communities.host + WHERE communities.deletion_state = 'active' + AND communities.deleted_at IS NULL + RETURNING id, host, (xmax = 0) AS created + "#, + ) + .bind(normalized_host) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| { + DbError::AccessDenied(format!( + "community host {normalized_host:?} is permanently tombstoned" + )) + })?; + + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + let created: bool = row.try_get("created")?; + + Ok(EnsuredCommunityRecord { + id: CommunityId::from_uuid(id), + host, + created, + }) + } + + /// Atomically creates a community and its initial owner. + /// + /// Holds a per-owner advisory lock while enforcing the ownership limit. + /// Identical create retries return the original record; host collisions and + /// limit failures remain distinguishable to the operator API. + #[datastore_span(name = "create_community_with_owner", system = "postgresql")] + pub async fn create_community_with_owner( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result { + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let mut tx = self.pool.begin().await?; + + // Serialize on the owner pubkey so concurrent creates to the same + // owner cannot both pass the ownership count check. + crate::observability::observe_advisory_lock( + crate::observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) + .execute(&mut *tx), + ) + .await?; + + let row = sqlx::query( + r#" + INSERT INTO communities (host) + VALUES ($1) + ON CONFLICT (lower(host)) DO NOTHING + RETURNING id, host + "#, + ) + .bind(normalized_host) + .fetch_optional(&mut *tx) + .await?; + + let (id, host) = if let Some(row) = row { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + // Enforce the limit before inserting the new owner row. + let owned_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'", + ) + .bind(&owner_pubkey) + .fetch_one(&mut *tx) + .await?; + + if owned_count >= relay_members::max_communities_per_owner() { + tx.rollback().await?; + return Ok(CreateCommunityWithOwnerResult::LimitReached); + } + + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)", + ) + .bind(id) + .bind(&owner_pubkey) + .execute(&mut *tx) + .await?; + (id, host) + } else { + let existing = sqlx::query( + r#" + SELECT c.id, c.host + FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE lower(c.host) = lower($1) + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + AND c.archived_at IS NULL + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + "#, + ) + .bind(normalized_host) + .bind(&owner_pubkey) + .fetch_optional(&mut *tx) + .await?; + let Some(existing) = existing else { + tx.rollback().await?; + return Ok(CreateCommunityWithOwnerResult::HostExists); + }; + (existing.try_get("id")?, existing.try_get("host")?) + }; + + tx.commit().await?; + Ok(CreateCommunityWithOwnerResult::Created( + CreatedCommunityRecord { + id: CommunityId::from_uuid(id), + host, + }, + )) + } + + /// Idempotently archives a community when the asserted pubkey is its current owner. + #[datastore_span(name = "archive_community_owned_by", system = "postgresql")] + pub async fn archive_community_owned_by( + &self, + normalized_host: &str, + owner_pubkey: &str, + protected_deployment_host: &str, + ) -> Result> { + let row = sqlx::query( + r#"UPDATE communities c + SET archived_at = COALESCE(c.archived_at, now()) + FROM relay_members rm + WHERE lower(c.host) = lower($1) + AND rm.community_id = c.id + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + AND lower(c.host) <> lower($3) + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + RETURNING c.id, c.host, c.archived_at"#, + ) + .bind(normalized_host) + .bind(owner_pubkey) + .bind(protected_deployment_host) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(ArchivedCommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + archived_at: row.try_get("archived_at")?, + }) + }) + .transpose() + } + + /// Idempotently restores a community when the asserted pubkey is its current owner. + #[datastore_span(name = "unarchive_community_owned_by", system = "postgresql")] + pub async fn unarchive_community_owned_by( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result> { + let row = sqlx::query( + r#"UPDATE communities c + SET archived_at = NULL + FROM relay_members rm + WHERE lower(c.host) = lower($1) + AND rm.community_id = c.id + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + RETURNING c.id, c.host"#, + ) + .bind(normalized_host) + .bind(owner_pubkey) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(UnarchivedCommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .transpose() + } + + /// Returns the community that owns a channel, if the channel exists. + /// + /// Internal relay producers use this to derive tenant context from the row + /// 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 row = sqlx::query( + r#" + SELECT community_id + FROM channels + WHERE id = $1 + AND deleted_at IS NULL + "#, + ) + .bind(channel_id) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let id: Uuid = row.try_get("community_id")?; + Ok(CommunityId::from_uuid(id)) + }) + .transpose() + } + + /// Batched version of [`Self::community_of_channel`]: given a list of + /// channel UUIDs, returns a map from channel id → owning community + /// for every channel that exists (soft-deletes excluded). + /// + /// Used by the runtime conformance read-seam emitters in `buzz-relay`: + /// after a `query_events`/`get_events_by_ids` returns N rows, the + /// emitter collects distinct `channel_id`s, calls this once, then + /// projects each row's true community label independently of the + /// fetch query's WHERE clause. That independence is what makes the + /// `Inv_NonInterference` / `Inv_ReadConfinement` gate non-vacuous — + /// a mutation that dropped `community_id = $X` from the fetch query + /// would still let this helper return the row's true label, and the + /// checker would see the mismatch. + /// + /// Channels missing from the result map (deleted or never existed) + /// are intentionally not present rather than mapped to a default — + /// callers MUST treat "channel-id not in map" as a coverage breach, + /// never as "use the resolved community". + #[datastore_span(name = "communities_of_channels", system = "postgresql")] + pub async fn communities_of_channels( + &self, + channel_ids: &[Uuid], + ) -> Result> { + if channel_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + let rows = sqlx::query( + r#" + SELECT id, community_id + FROM channels + WHERE id = ANY($1) + AND deleted_at IS NULL + "#, + ) + .bind(channel_ids) + .fetch_all(&self.pool) + .await?; + + let mut out = std::collections::HashMap::with_capacity(rows.len()); + for row in rows { + let ch: Uuid = row.try_get("id")?; + let cm: Uuid = row.try_get("community_id")?; + out.insert(ch, CommunityId::from_uuid(cm)); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + //! Pin the load-bearing contract for `Db::communities_of_channels`: + //! a channel id that does NOT exist MUST be absent from the result + //! map, never mapped to a default. The relay-side read-row emitter + //! relies on this — a missing entry triggers `MissingLookup → + //! ImplBug{row_community_lookup_missing} → CoverageBreach`. If this + //! helper ever started returning a default/zero entry for unknown + //! channels, that fail-closed chain would go blind. + use super::*; + use sqlx::PgPool; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_db() -> Db { + let database_url = + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + Db::from_pool(pool) + } + + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("communities-of-channels-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id + } + + async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) { + let creator: Vec = vec![0u8; 32]; + sqlx::query( + r#" + INSERT INTO channels + (id, community_id, name, channel_type, visibility, created_by) + VALUES + ($1, $2, $3, 'stream'::channel_type, 'open'::channel_visibility, $4) + "#, + ) + .bind(channel_id) + .bind(community_id) + .bind(format!("ch-{}", channel_id.simple())) + .bind(&creator) + .execute(pool) + .await + .expect("insert channel"); + } + + #[test] + fn community_implementation_tests_and_spans_have_single_owners() { + let community_source = include_str!("community.rs"); + let lib_source = include_str!("lib.rs"); + let operations = [ + "lookup_community_by_host", + "is_community_active", + "lookup_community_by_host_for_management", + "list_communities_owned_by", + "lookup_community_host", + "get_community_icon", + "set_community_icon", + "ensure_configured_community", + "create_community_with_owner", + "archive_community_owned_by", + "unarchive_community_owned_by", + "community_of_channel", + "communities_of_channels", + ]; + for operation in operations { + let method = format!("pub async fn {operation}("); + assert_eq!( + community_source.matches(&method).count(), + 1, + "{operation} implementation must live exactly once in community.rs", + ); + assert!( + !lib_source.contains(&method), + "{operation} implementation must not remain in lib.rs", + ); + + let span = format!("name = \"{operation}\""); + assert_eq!( + community_source.matches(&span).count(), + 1, + "{operation} must have exactly one datastore span", + ); + assert!( + !lib_source.contains(&span), + "{operation} datastore span must not remain in lib.rs", + ); + } + + let records = [ + "CommunityRecord", + "EnsuredCommunityRecord", + "CreatedCommunityRecord", + "OwnedCommunityRecord", + "ArchivedCommunityRecord", + "UnarchivedCommunityRecord", + ]; + for record in records { + let declaration = format!("pub struct {record}"); + assert_eq!(community_source.matches(&declaration).count(), 1); + assert!(!lib_source.contains(&declaration)); + } + let result_declaration = format!("pub {} {}", "enum", "CreateCommunityWithOwnerResult"); + assert_eq!(community_source.matches(&result_declaration).count(), 1); + assert!(!lib_source.contains(&result_declaration)); + + let moved_tests = [ + "lookup_community_by_host_matches_case_insensitive_host_index", + "create_community_with_owner_is_atomic_and_create_only", + "unarchive_community_owned_by_restores_admission_idempotently", + "create_community_with_owner_enforces_per_owner_limit", + "concurrent_same_owner_create_returns_the_winning_row_to_both_callers", + "ensure_configured_community_reports_insert_winner", + "list_communities_owned_by_returns_only_owner_rows", + "communities_of_channels_present_for_existing_absent_for_missing", + ]; + for test in moved_tests { + let declaration = format!("async fn {test}"); + assert_eq!(community_source.matches(&declaration).count(), 1); + assert!(!lib_source.contains(&declaration)); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lookup_community_by_host_matches_case_insensitive_host_index() { + let db = setup_db().await; + let id = Uuid::new_v4(); + let lower_host = format!("lookup-community-{}.example", id.simple()); + let stored_host = lower_host.to_uppercase(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(&stored_host) + .execute(&db.pool) + .await + .expect("insert mixed-case community host"); + + let found = db + .lookup_community_by_host(&lower_host) + .await + .expect("lookup lower-case host") + .expect("community found by lower-case host"); + assert_eq!(found.id, CommunityId::from_uuid(id)); + assert_eq!(found.host, stored_host); + + let found = db + .lookup_community_by_host(&stored_host) + .await + .expect("lookup stored-case host") + .expect("community found by stored-case host"); + assert_eq!(found.id, CommunityId::from_uuid(id)); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_community_with_owner_is_atomic_and_create_only() { + let db = setup_db().await; + let host = format!("create-only-{}.example", Uuid::new_v4().simple()); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + let created = db + .create_community_with_owner(&host, owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + assert_eq!(created.host, host); + let owner_role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", + ) + .bind(created.id.as_uuid()) + .bind(owner) + .fetch_optional(&db.pool) + .await + .expect("owner role"); + assert_eq!(owner_role.as_deref(), Some("owner")); + + let retry = db + .create_community_with_owner(&host.to_ascii_uppercase(), owner) + .await + .expect("same-owner retry"); + assert_eq!( + retry, + CreateCommunityWithOwnerResult::Created(created.clone()), + "retry returns the original row" + ); + + let collision = db + .create_community_with_owner(&host, other) + .await + .expect("collision result"); + assert_eq!(collision, CreateCommunityWithOwnerResult::HostExists); + let roles: Vec<(String, String)> = sqlx::query_as( + "SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey", + ) + .bind(created.id.as_uuid()) + .fetch_all(&db.pool) + .await + .expect("community roles"); + assert_eq!(roles, vec![(owner.to_string(), "owner".to_string())]); + + db.bootstrap_owner(created.id, other) + .await + .expect("rotate owner"); + let post_rotation_retry = db + .create_community_with_owner(&host, owner) + .await + .expect("post-rotation retry"); + assert_eq!( + post_rotation_retry, + CreateCommunityWithOwnerResult::HostExists + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unarchive_community_owned_by_restores_admission_idempotently() { + let db = setup_db().await; + let host = format!("unarchive-{}.example", Uuid::new_v4().simple()); + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let outsider = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let created = db + .create_community_with_owner(&host, &owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + + let archived = db + .archive_community_owned_by(&host, &owner, "protected.example") + .await + .expect("archive community") + .expect("owned community"); + assert_eq!(archived.id, created.id); + assert!( + db.lookup_community_by_host(&host) + .await + .expect("active lookup") + .is_none(), + "archived communities must fail admission" + ); + assert!(db + .unarchive_community_owned_by(&host, &outsider) + .await + .expect("wrong-owner unarchive") + .is_none()); + assert!(db + .unarchive_community_owned_by("missing.example", &owner) + .await + .expect("unknown-host unarchive") + .is_none()); + + let restored = db + .unarchive_community_owned_by(&host.to_ascii_uppercase(), &owner) + .await + .expect("unarchive community") + .expect("owned community"); + assert_eq!(restored.id, created.id); + assert_eq!(restored.host, host); + assert_eq!( + db.lookup_community_by_host(&host) + .await + .expect("restored lookup") + .expect("active community") + .id, + created.id + ); + assert_eq!( + db.get_relay_member(created.id, &owner) + .await + .expect("owner lookup") + .expect("owner remains") + .role, + "owner" + ); + + let retry = db + .unarchive_community_owned_by(&host, &owner) + .await + .expect("idempotent retry") + .expect("owned community"); + assert_eq!(retry, restored); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_community_with_owner_enforces_per_owner_limit() { + let db = setup_db().await; + let owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + // Create 3 communities for this owner (the max). + for i in 0..3 { + let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); + assert!(matches!( + db.create_community_with_owner(&host, &owner) + .await + .expect("create community"), + CreateCommunityWithOwnerResult::Created(_) + )); + } + + let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); + assert_eq!( + db.create_community_with_owner(&host, &owner) + .await + .expect("create community call"), + CreateCommunityWithOwnerResult::LimitReached + ); + assert!( + db.lookup_community_by_host(&host) + .await + .expect("look up rolled-back fresh host") + .is_none(), + "limit rejection must roll back the fresh community row" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_same_owner_create_returns_the_winning_row_to_both_callers() { + let db = setup_db().await; + let host = format!("concurrent-create-{}.example", Uuid::new_v4().simple()); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + let (first, second) = tokio::join!( + db.create_community_with_owner(&host, owner), + db.create_community_with_owner(&host, owner), + ); + let first = first.expect("first concurrent create"); + let second = second.expect("second concurrent create"); + + assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_))); + assert_eq!(first, second, "conflict loser re-reads the winning row"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ensure_configured_community_reports_insert_winner() { + let db = setup_db().await; + let host = format!("ensure-community-{}.example", Uuid::new_v4().simple()); + + let first = db + .ensure_configured_community(&host) + .await + .expect("first ensure"); + assert!(first.created, "first ensure should report created"); + assert_eq!(first.host, host); + + let second = db + .ensure_configured_community(&host) + .await + .expect("second ensure"); + assert!(!second.created, "second ensure should report existed"); + assert_eq!(second.id, first.id); + assert_eq!(second.host, host); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_communities_owned_by_returns_only_owner_rows() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + let community_c = CommunityId::from_uuid(make_community(&db.pool).await); + // Unique per run: `list_communities_owned_by` is keyed only by pubkey, + // so a shared fixed pubkey picks up communities leaked by sibling + // ignored tests running against the same database. + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let owner = owner.as_str(); + let other = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let other = other.as_str(); + + db.bootstrap_owner(community_a, owner) + .await + .expect("owner A"); + db.bootstrap_owner(community_b, other) + .await + .expect("other owner B"); + db.add_relay_member(community_c, owner, "admin", None) + .await + .expect("admin C"); + + let owned = db + .list_communities_owned_by(owner) + .await + .expect("list owned communities"); + + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].id, community_a); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn communities_of_channels_present_for_existing_absent_for_missing() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let existing = Uuid::new_v4(); + insert_channel(&db.pool, community, existing).await; + + // Channel that is NOT inserted — the load-bearing case. + let missing = Uuid::new_v4(); + + let result = db + .communities_of_channels(&[existing, missing]) + .await + .expect("communities_of_channels"); + + // (1) Existing channel → present with its true community. + assert_eq!( + result.get(&existing).copied(), + Some(CommunityId::from_uuid(community)), + "existing channel must map to its true community", + ); + + // (2) Missing channel → ABSENT from the map (never defaulted). + // This is the contract the relay-side `MissingLookup → ImplBug` + // fail-closed guard-rail depends on. If this assertion ever + // weakens to `result.get(&missing) != Some(community)`, the + // mutate-bite below stops biting. + assert!( + !result.contains_key(&missing), + "missing channel must be absent from the result map, got {:?}", + result.get(&missing), + ); + + // (3) Map size matches: exactly one entry, the existing one. + assert_eq!( + result.len(), + 1, + "result map must contain only existing channels" + ); + } +} diff --git a/crates/buzz-db/src/deletion.rs b/crates/buzz-db/src/deletion.rs index fbe69f22a6..98a039d62f 100644 --- a/crates/buzz-db/src/deletion.rs +++ b/crates/buzz-db/src/deletion.rs @@ -310,11 +310,11 @@ pub struct StorageManifest { pub struct PrefixManifest { /// Exact community-scoped listing prefix. pub prefix: String, - /// Objects under the prefix at enumeration time. + /// Object versions and delete markers under the prefix at enumeration time. pub object_count: u64, - /// Total object bytes under the prefix at enumeration time. + /// Total object-version bytes under the prefix at enumeration time. pub total_bytes: u64, - /// Hex SHA-256 of the newline-terminated ascending key stream. + /// Hex SHA-256 of the newline-terminated ascending version-entry stream. pub keys_digest: String, } @@ -325,10 +325,88 @@ pub struct ManifestKeyChunk { pub chunk_no: i64, /// The tenant prefix every key in this chunk lives under. pub prefix: String, - /// Strictly ascending keys. + /// Strictly ascending serialized manifest entries. pub keys: Vec, } +/// One immutable object-store manifest entry. +/// +/// Version 5 storage manifests serialize entries as +/// `key\u{1f}version_id\u{1f}kind`, where kind is `object` or +/// `delete_marker`. Version 4 manifests used bare keys. Keeping the side-table +/// column name unchanged avoids a database migration while making the stream +/// explicitly version-aware. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StorageManifestEntry { + /// Object key. + pub key: String, + /// S3 version id. + pub version_id: String, + /// Either `object` or `delete_marker`. + pub kind: String, +} + +impl StorageManifestEntry { + /// Create a manifest entry. + pub fn new( + key: impl Into, + version_id: impl Into, + kind: impl Into, + ) -> Self { + Self { + key: key.into(), + version_id: version_id.into(), + kind: kind.into(), + } + } + + /// Serialize this entry into the chunk stream. + pub fn encode(&self) -> Result { + validate_manifest_component("key", &self.key)?; + validate_manifest_component("version id", &self.version_id)?; + validate_manifest_component("kind", &self.kind)?; + if self.kind != "object" && self.kind != "delete_marker" { + return Err(DbError::DeletionSafety(format!( + "unsupported storage manifest entry kind {}", + self.kind + ))); + } + Ok(format!( + "{}\u{1f}{}\u{1f}{}", + self.key, self.version_id, self.kind + )) + } + + /// Decode a manifest stream entry. + pub fn decode(value: &str) -> Result { + let mut parts = value.split('\u{1f}'); + let key = parts.next().unwrap_or_default(); + let version_id = parts.next().ok_or_else(|| { + DbError::DeletionSafety("storage manifest entry is missing version id".to_string()) + })?; + let kind = parts.next().ok_or_else(|| { + DbError::DeletionSafety("storage manifest entry is missing kind".to_string()) + })?; + if parts.next().is_some() { + return Err(DbError::DeletionSafety( + "storage manifest entry has too many fields".to_string(), + )); + } + let entry = Self::new(key, version_id, kind); + entry.encode()?; + Ok(entry) + } +} + +fn validate_manifest_component(name: &str, value: &str) -> Result<()> { + if value.is_empty() || value.contains(['\n', '\u{1f}']) { + return Err(DbError::DeletionSafety(format!( + "storage manifest {name} is empty or contains a reserved delimiter" + ))); + } + Ok(()) +} + /// One durable fleet-wide object-store taxonomy sweep record. #[derive(Debug, Clone, Serialize)] pub struct TaxonomySweep { @@ -358,11 +436,11 @@ type TaxonomySweepRow = ( i64, ); -/// Streaming SHA-256 over a strictly ascending key stream. +/// Streaming SHA-256 over a strictly ascending storage manifest stream. /// /// The executor's prefix enumeration and the destructive freeze's chunk -/// validation both fold keys through this, so "the chunk rows are exactly -/// the frozen enumeration" reduces to digest equality. Each key is hashed +/// validation both fold entries through this, so "the chunk rows are exactly +/// the frozen enumeration" reduces to digest equality. Each entry is hashed /// with a trailing newline so concatenation cannot alias two streams. pub struct KeyStreamDigest { hasher: Sha256, @@ -395,6 +473,17 @@ impl KeyStreamDigest { "storage key stream is not strictly ascending at {key}" ))); } + self.fold_unordered(key) + } + + /// Fold an already-canonical manifest entry whose source ordering is owned + /// by the object store, not by key lexicographic order. + /// + /// S3 `ListObjectVersions` sorts by key but orders multiple versions of one + /// key by recency with opaque version ids, so version-aware manifests cannot + /// require strictly ascending serialized entries. Digest equality still + /// binds the exact stream that was listed and chunked. + pub fn fold_unordered(&mut self, key: &str) -> Result<()> { self.hasher.update(key.as_bytes()); self.hasher.update(b"\n"); self.last = Some(key.to_owned()); @@ -1121,12 +1210,15 @@ impl DeletionStore { /// Already-acquired leases remain renewable, verifiable, and releasable so /// admitted remote effects retain their exclusion proof until completion. pub async fn begin_quiescing(&self, token: &LeaseToken) -> Result<()> { - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = crate::observability::begin_transaction( + &self.pool, + crate::observability::TransactionOperation::BeginCommunityDeletionQuiescing, + ) + .await?; + transaction_timer + .observe(async { verify_lease(&mut tx, token, DeletionStage::Approved).await?; - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(token.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion(&mut tx, token.community_id).await?; verify_lease(&mut tx, token, DeletionStage::Approved).await?; let (generation, archived_at): (i64, Option>) = sqlx::query_as( "SELECT deletion_fence_generation, archived_at FROM communities WHERE id = $1 FOR UPDATE", @@ -1170,16 +1262,21 @@ impl DeletionStore { .await?; tx.commit().await?; Ok(()) + }) + .await } /// Acquire the universal durable fence after all pre-quiesce serving leases drain. pub async fn fence(&self, token: &LeaseToken) -> Result { - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = crate::observability::begin_transaction( + &self.pool, + crate::observability::TransactionOperation::FenceCommunityDeletion, + ) + .await?; + transaction_timer + .observe(async { verify_lease(&mut tx, token, DeletionStage::Approved).await?; - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(token.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion(&mut tx, token.community_id).await?; verify_lease(&mut tx, token, DeletionStage::Approved).await?; let active_serving_writes = sqlx::query( "SELECT count(*)::BIGINT AS active_count, \ @@ -1242,6 +1339,8 @@ impl DeletionStore { .await?; tx.commit().await?; Ok(generation) + }) + .await } /// Freeze the exact post-fence storage binding manifest. @@ -1878,10 +1977,7 @@ impl DeletionStore { .ok_or_else(|| DbError::NotFound(format!("community deletion {request_id}")))?; // Every lifecycle transition takes the community lock before any row lock. // Inverting this order lets abort and the executor deadlock each other. - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion(&mut tx, community_id).await?; let row = sqlx::query("SELECT * FROM community_deletion_requests WHERE id = $1 FOR UPDATE") .bind(request_id) .fetch_optional(&mut *tx) @@ -2165,10 +2261,7 @@ impl DeletionStore { tx: &mut Transaction<'_, Postgres>, community: CommunityId, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(community.as_uuid()) - .execute(&mut **tx) - .await?; + lock_community_deletion_shared(tx, community).await?; let state: Option = sqlx::query_scalar( "SELECT deletion_state FROM communities WHERE id = $1 AND deleted_at IS NULL", ) @@ -2197,10 +2290,7 @@ impl DeletionStore { tx: &mut Transaction<'_, Postgres>, lease: &ServingWriteLease, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(lease.community_id.as_uuid()) - .execute(&mut **tx) - .await?; + lock_community_deletion_shared(tx, lease.community_id).await?; let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ JOIN communities community ON community.id = lease.community_id \ @@ -2318,10 +2408,7 @@ impl DeletionStore { ) -> Result<()> { let lease_seconds = i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX); let mut tx = self.pool.begin().await?; - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(lease.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion_shared(&mut tx, lease.community_id).await?; let lease_until: Option> = sqlx::query_scalar( "UPDATE community_serving_write_leases lease \ SET lease_until = now() + make_interval(secs => $6), heartbeat_at = now() \ @@ -2376,10 +2463,7 @@ impl DeletionStore { /// admitted remote effect. pub async fn verify_serving_write_lease(&self, lease: &ServingWriteLease) -> Result<()> { let mut tx = self.pool.begin().await?; - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(lease.community_id.as_uuid()) - .execute(&mut *tx) - .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 \ JOIN communities community ON community.id = lease.community_id \ @@ -2483,6 +2567,34 @@ impl DeletionStore { } } +async fn lock_community_deletion( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, +) -> Result<()> { + crate::observability::observe_advisory_lock( + crate::observability::LockType::Deletion, + sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") + .bind(community.as_uuid()) + .execute(&mut **tx), + ) + .await?; + Ok(()) +} + +async fn lock_community_deletion_shared( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, +) -> Result<()> { + crate::observability::observe_advisory_lock( + crate::observability::LockType::Deletion, + sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") + .bind(community.as_uuid()) + .execute(&mut **tx), + ) + .await?; + Ok(()) +} + /// Take the shared schema/destruction advisory lock for the current /// transaction. /// @@ -2491,10 +2603,13 @@ impl DeletionStore { /// whole run (see [`crate::migration::run_migrations`]); shared holders do /// not block each other, so concurrent deletion executors are unaffected. async fn lock_schema_destruction_shared(conn: &mut PgConnection) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock_shared($1)") - .bind(SCHEMA_DESTRUCTION_LOCK_KEY) - .execute(conn) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::MigrationSchemaSafety, + sqlx::query("SELECT pg_advisory_xact_lock_shared($1)") + .bind(SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(conn), + ) + .await?; Ok(()) } @@ -2594,7 +2709,7 @@ async fn live_fenced_tables_on(conn: &mut PgConnection) -> Result Result<()> { - if manifest.version != 4 { + if !matches!(manifest.version, 4 | 5) { return Err(DbError::DeletionSafety(format!( "unsupported storage manifest version {}", manifest.version @@ -2682,12 +2797,21 @@ fn validate_manifest_key_chunks( )); } for key in &keys.0 { - if !key.starts_with(chunk_prefix.as_str()) { + let prefix_key = if manifest.version >= 5 { + StorageManifestEntry::decode(key)?.key + } else { + key.clone() + }; + if !prefix_key.starts_with(chunk_prefix.as_str()) { return Err(DbError::DeletionSafety(format!( - "frozen key {key} is outside its chunk prefix {chunk_prefix}" + "frozen key {prefix_key} is outside its chunk prefix {chunk_prefix}" ))); } - digest.fold(key)?; + if manifest.version >= 5 { + digest.fold_unordered(key)?; + } else { + digest.fold(key)?; + } } } if let Some(summary) = current { @@ -3039,6 +3163,36 @@ mod tests { assert!(validate_storage_manifest(&malformed_digest).is_err()); } + #[test] + fn frozen_inventory_digest_is_canonical_for_v5_manifest_entries() { + let entry = StorageManifestEntry::new("_meta/c/a.json", "null", "object") + .encode() + .expect("entry"); + let mut digest = KeyStreamDigest::new(); + digest.fold_unordered(&entry).expect("fold entry"); + let (keys_digest, object_count) = digest.finish(); + let inventory = FrozenInventory { + schema: SchemaManifest { + scoped_tables: vec!["events".to_string()], + row_counts: BTreeMap::from([("events".to_string(), 1)]), + fenced_tables: vec!["events".to_string()], + }, + storage: StorageManifest { + version: 5, + prefixes: vec![PrefixManifest { + prefix: "_meta/c/".to_string(), + object_count, + total_bytes: 4, + keys_digest, + }], + }, + }; + let digest = inventory.digest().unwrap(); + let round_tripped: FrozenInventory = + serde_json::from_slice(&serde_json::to_vec(&inventory).unwrap()).unwrap(); + assert_eq!(digest, round_tripped.digest().unwrap()); + } + #[test] fn key_stream_digest_requires_strict_order_and_is_chunking_invariant() { let keys = ["a/1", "a/2", "a/3"]; @@ -3100,6 +3254,49 @@ mod tests { assert!(validate_manifest_key_chunks(&storage_manifest(), &[]).is_ok()); } + #[test] + fn versioned_manifest_entries_decode_and_validate_chunks() { + let entries = vec![ + StorageManifestEntry::new("_meta/c/1", "v2", "object") + .encode() + .expect("entry 1"), + StorageManifestEntry::new("_meta/c/1", "v1", "delete_marker") + .encode() + .expect("entry 2"), + ]; + let mut digest = KeyStreamDigest::new(); + for entry in &entries { + digest.fold_unordered(entry).expect("fold version entry"); + } + let (hex_digest, count) = digest.finish(); + let mut manifest = storage_manifest(); + manifest.version = 5; + manifest.prefixes[0].object_count = count; + manifest.prefixes[0].keys_digest = hex_digest; + + let chunk = |entries: &[String]| { + vec![( + 0, + "_meta/c/".to_string(), + sqlx::types::Json(entries.to_vec()), + )] + }; + // v5 freeze validation is retry-stable: a retried freeze with the + // same canonical version-entry stream is accepted, while a drifted + // stream is rejected. + assert!(validate_manifest_key_chunks(&manifest, &chunk(&entries)).is_ok()); + assert!(validate_manifest_key_chunks(&manifest, &chunk(&entries)).is_ok()); + + let foreign = vec![StorageManifestEntry::new("_uploads/c/1", "v1", "object") + .encode() + .expect("foreign entry")]; + assert!(validate_manifest_key_chunks(&manifest, &chunk(&foreign)).is_err()); + assert!(StorageManifestEntry::decode("_meta/c/1").is_err()); + assert!(StorageManifestEntry::new("_meta/c/1", "v1", "unknown") + .encode() + .is_err()); + } + #[test] fn frozen_inventory_digest_is_stable() { let inventory = FrozenInventory { @@ -3131,7 +3328,7 @@ mod postgres_tests { async fn store() -> (Db, DeletionStore) { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 -- local test-only credentials let db = Db::new(&DbConfig { database_url, max_connections: 5, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index cbce6d3753..b37dcedff8 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -32,6 +32,8 @@ pub mod api_token; pub mod archived_identities; /// Channel and membership persistence. pub mod channel; +/// Community lifecycle and host-map persistence. +pub mod community; /// Durable whole-community deletion lifecycle and PostgreSQL adapter. pub mod deletion; /// Direct message channel persistence. @@ -48,6 +50,7 @@ pub mod git_repo; pub mod migration; /// Community moderation: reports, bans/timeouts, audit actions. pub mod moderation; +mod observability; /// Monthly table partition management. pub mod partition; /// Buzz product-feedback sidecar persistence. @@ -73,6 +76,11 @@ pub mod user; /// Workflow, run, and approval persistence. pub mod workflow; +pub use community::{ + ArchivedCommunityRecord, CommunityRecord, CreateCommunityWithOwnerResult, + CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, + UnarchivedCommunityRecord, +}; pub use error::{DbError, Result}; pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; @@ -566,79 +574,6 @@ impl Default for DbConfig { } } -/// Community host-map row returned by [`Db::lookup_community_by_host`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Normalized host that maps to this community. - pub host: String, -} - -/// Community row returned by idempotent community ensure/create operations. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EnsuredCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Normalized host that maps to this community. - pub host: String, - /// True only when this call inserted the `communities` row. - pub created: bool, -} - -/// Community row returned by an atomic create-with-owner operation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CreatedCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Normalized host stored for the community. - pub host: String, -} - -/// Result of atomically creating a community with its initial owner. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CreateCommunityWithOwnerResult { - /// The community was created, or an identical retried create found it. - Created(CreatedCommunityRecord), - /// The host already belongs to another owner. - HostExists, - /// The intended owner already owns the maximum number of communities. - LimitReached, -} - -/// Community row returned by operator-plane ownership reads. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OwnedCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Normalized host that maps to this community. - pub host: String, - /// When the community row was created. - pub created_at: DateTime, - /// When the community was archived; absent while active. - pub archived_at: Option>, -} - -/// Community row returned by an owner-authorized archive operation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ArchivedCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Reserved canonical host. - pub host: String, - /// Durable first-archive timestamp. - pub archived_at: DateTime, -} - -/// Community row returned by an owner-authorized unarchive operation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnarchivedCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Reserved canonical host restored to active admission. - pub host: String, -} - /// Token summary returned by [`Db::list_active_tokens`]. #[derive(Debug, Clone)] pub struct TokenSummary { @@ -778,7 +713,7 @@ impl Db { }; let aurora_identity = self.reader_aurora_identity.clone(); tokio::spawn(async move { - match read_pool.acquire().await { + 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 { @@ -920,7 +855,7 @@ 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 read_pool.acquire().await { + let conn = match observability::acquire(read_pool, observability::PoolRole::Reader).await { Ok(conn) => conn, Err(sqlx::Error::PoolTimedOut) => { tracing::warn!("reader pool acquire timed out; routing to writer"); @@ -1096,7 +1031,8 @@ impl Db { &self, lock_key: i64, ) -> Result> { - let mut connection = self.pool.acquire().await?; + let mut connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") .bind(lock_key) .fetch_one(&mut *connection) @@ -1243,466 +1179,11 @@ 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> { - self.pool.begin().await.map_err(Into::into) - } - - /// Returns the community mapped to a normalized request host, if one exists. - /// - /// The caller owns host normalization and turns `None` into the fail-closed - /// request/connection error. buzz-db only reads the durable host map. - #[datastore_span(name = "lookup_community_by_host", system = "postgresql")] - pub async fn lookup_community_by_host( - &self, - normalized_host: &str, - ) -> Result> { - let row = sqlx::query( - r#" - SELECT id, host - FROM communities - WHERE lower(host) = lower($1) - AND archived_at IS NULL - AND deleted_at IS NULL - AND deletion_state = 'active' - "#, - ) - .bind(normalized_host) - .fetch_optional(&self.pool) - .await?; - - row.map(|row| { - let id: Uuid = row.try_get("id")?; - let host: String = row.try_get("host")?; - - Ok(CommunityRecord { - id: CommunityId::from_uuid(id), - host, - }) - }) - .transpose() - } - - /// 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 { - 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) - .await?; - Ok(active) - } - - /// Returns a community by host regardless of lifecycle state. Operator-plane only. - #[datastore_span( - name = "lookup_community_by_host_for_management", - system = "postgresql" - )] - pub async fn lookup_community_by_host_for_management( - &self, - normalized_host: &str, - ) -> Result> { - let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") - .bind(normalized_host) - .fetch_optional(&self.pool) - .await?; - row.map(|row| { - Ok(CommunityRecord { - id: CommunityId::from_uuid(row.try_get("id")?), - host: row.try_get("host")?, - }) - }) - .transpose() - } - - /// Lists communities where `owner_pubkey` currently holds the `owner` role. - /// - /// This is an operator-plane helper, not a tenant-scoped data-plane read: - /// callers must gate it on deployment-level operator auth before exposing it. - #[datastore_span(name = "list_communities_owned_by", system = "postgresql")] - pub async fn list_communities_owned_by( - &self, - owner_pubkey: &str, - ) -> Result> { - let owner_pubkey = owner_pubkey.to_ascii_lowercase(); - let rows = sqlx::query( - r#" - SELECT c.id, c.host, c.created_at, c.archived_at - FROM communities c - JOIN relay_members rm ON rm.community_id = c.id - WHERE rm.pubkey = $1 - AND rm.role = 'owner' - ORDER BY c.created_at ASC, c.host ASC - "#, - ) - .bind(owner_pubkey) - .fetch_all(&self.pool) - .await?; - - rows.into_iter() - .map(|row| { - let id: Uuid = row.try_get("id")?; - let host: String = row.try_get("host")?; - let created_at: DateTime = row.try_get("created_at")?; - let archived_at: Option> = row.try_get("archived_at")?; - Ok(OwnedCommunityRecord { - id: CommunityId::from_uuid(id), - host, - created_at, - archived_at, - }) - }) - .collect() - } - - /// Returns the normalized host mapped to a community id, if the community - /// exists. - /// - /// The reverse of [`lookup_community_by_host`]: used by side-effect - /// producers that already hold a server-resolved `CommunityId` (e.g. the - /// workflow action sink running a run owned by some community) and need a - /// fully-formed [`buzz_core::tenant::TenantContext`] — host included — to - /// fan out under *that* community rather than the deployment default. The - /// community is authoritative; the host is read back for labelling only and - /// 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 row = sqlx::query( - r#" - SELECT host - 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_optional(&self.pool) - .await?; - - row.map(|row| { - let host: String = row.try_get("host")?; - Ok(host) - }) - .transpose() - } - - /// Returns the community's workspace icon (NIP-11 `icon`), if set. - /// - /// Set by relay admins/owners via the kind:9033 command; the value is - /// validated and size-capped at that write path. - #[datastore_span(name = "get_community_icon", system = "postgresql")] - pub async fn get_community_icon(&self, community_id: CommunityId) -> Result> { - let row = sqlx::query( - r#" - SELECT icon - FROM communities - WHERE id = $1 - "#, - ) - .bind(community_id.as_uuid()) - .fetch_optional(&self.pool) - .await?; - - Ok(row - .map(|row| row.try_get::, _>("icon")) - .transpose()? - .flatten() - .filter(|icon| !icon.is_empty())) - } - - /// Sets or clears (`None`) the community's workspace icon. - #[datastore_span(name = "set_community_icon", system = "postgresql")] - pub async fn set_community_icon( - &self, - community_id: CommunityId, - icon: Option<&str>, - ) -> Result<()> { - sqlx::query( - r#" - UPDATE communities - SET icon = $2 - WHERE id = $1 - "#, - ) - .bind(community_id.as_uuid()) - .bind(icon) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Ensure a configured community host exists and return its row. - /// - /// This is the startup/config seeding path for N=1 deployments. Migrations - /// create the schema only; deployment-specific hosts are not hardcoded into - /// schema history. - #[datastore_span(name = "ensure_configured_community", system = "postgresql")] - pub async fn ensure_configured_community( - &self, - normalized_host: &str, - ) -> Result { - let row = sqlx::query( - r#" - INSERT INTO communities (host) - VALUES ($1) - ON CONFLICT (lower(host)) DO UPDATE SET host = communities.host - WHERE communities.deletion_state = 'active' - AND communities.deleted_at IS NULL - RETURNING id, host, (xmax = 0) AS created - "#, - ) - .bind(normalized_host) - .fetch_optional(&self.pool) - .await? - .ok_or_else(|| { - DbError::AccessDenied(format!( - "community host {normalized_host:?} is permanently tombstoned" - )) - })?; - - let id: Uuid = row.try_get("id")?; - let host: String = row.try_get("host")?; - let created: bool = row.try_get("created")?; - - Ok(EnsuredCommunityRecord { - id: CommunityId::from_uuid(id), - host, - created, - }) - } - - /// Atomically creates a community and its initial owner. - /// - /// Holds a per-owner advisory lock while enforcing the ownership limit. - /// Identical create retries return the original record; host collisions and - /// limit failures remain distinguishable to the operator API. - #[datastore_span(name = "create_community_with_owner", system = "postgresql")] - pub async fn create_community_with_owner( - &self, - normalized_host: &str, - owner_pubkey: &str, - ) -> Result { - let owner_pubkey = owner_pubkey.to_ascii_lowercase(); - let mut tx = self.pool.begin().await?; - - // Serialize on the owner pubkey so concurrent creates to the same - // owner cannot both pass the ownership count check. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) - .execute(&mut *tx) - .await?; - - let row = sqlx::query( - r#" - INSERT INTO communities (host) - VALUES ($1) - ON CONFLICT (lower(host)) DO NOTHING - RETURNING id, host - "#, - ) - .bind(normalized_host) - .fetch_optional(&mut *tx) - .await?; - - let (id, host) = if let Some(row) = row { - let id: Uuid = row.try_get("id")?; - let host: String = row.try_get("host")?; - - // Enforce the limit before inserting the new owner row. - let owned_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'", - ) - .bind(&owner_pubkey) - .fetch_one(&mut *tx) - .await?; - - if owned_count >= relay_members::max_communities_per_owner() { - tx.rollback().await?; - return Ok(CreateCommunityWithOwnerResult::LimitReached); - } - - sqlx::query( - "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)", - ) - .bind(id) - .bind(&owner_pubkey) - .execute(&mut *tx) - .await?; - (id, host) - } else { - let existing = sqlx::query( - r#" - SELECT c.id, c.host - FROM communities c - JOIN relay_members rm ON rm.community_id = c.id - WHERE lower(c.host) = lower($1) - AND lower(rm.pubkey) = lower($2) - AND rm.role = 'owner' - AND c.archived_at IS NULL - AND c.deletion_state = 'active' - AND c.deleted_at IS NULL - "#, - ) - .bind(normalized_host) - .bind(&owner_pubkey) - .fetch_optional(&mut *tx) - .await?; - let Some(existing) = existing else { - tx.rollback().await?; - return Ok(CreateCommunityWithOwnerResult::HostExists); - }; - (existing.try_get("id")?, existing.try_get("host")?) - }; - - tx.commit().await?; - Ok(CreateCommunityWithOwnerResult::Created( - CreatedCommunityRecord { - id: CommunityId::from_uuid(id), - host, - }, - )) - } - - /// Idempotently archives a community when the asserted pubkey is its current owner. - #[datastore_span(name = "archive_community_owned_by", system = "postgresql")] - pub async fn archive_community_owned_by( - &self, - normalized_host: &str, - owner_pubkey: &str, - protected_deployment_host: &str, - ) -> Result> { - let row = sqlx::query( - r#"UPDATE communities c - SET archived_at = COALESCE(c.archived_at, now()) - FROM relay_members rm - WHERE lower(c.host) = lower($1) - AND rm.community_id = c.id - AND lower(rm.pubkey) = lower($2) - AND rm.role = 'owner' - AND lower(c.host) <> lower($3) - AND c.deletion_state = 'active' - AND c.deleted_at IS NULL - RETURNING c.id, c.host, c.archived_at"#, - ) - .bind(normalized_host) - .bind(owner_pubkey) - .bind(protected_deployment_host) - .fetch_optional(&self.pool) - .await?; - row.map(|row| { - Ok(ArchivedCommunityRecord { - id: CommunityId::from_uuid(row.try_get("id")?), - host: row.try_get("host")?, - archived_at: row.try_get("archived_at")?, - }) - }) - .transpose() - } - - /// Idempotently restores a community when the asserted pubkey is its current owner. - #[datastore_span(name = "unarchive_community_owned_by", system = "postgresql")] - pub async fn unarchive_community_owned_by( - &self, - normalized_host: &str, - owner_pubkey: &str, - ) -> Result> { - let row = sqlx::query( - r#"UPDATE communities c - SET archived_at = NULL - FROM relay_members rm - WHERE lower(c.host) = lower($1) - AND rm.community_id = c.id - AND lower(rm.pubkey) = lower($2) - AND rm.role = 'owner' - AND c.deletion_state = 'active' - AND c.deleted_at IS NULL - RETURNING c.id, c.host"#, - ) - .bind(normalized_host) - .bind(owner_pubkey) - .fetch_optional(&self.pool) - .await?; - row.map(|row| { - Ok(UnarchivedCommunityRecord { - id: CommunityId::from_uuid(row.try_get("id")?), - host: row.try_get("host")?, - }) - }) - .transpose() - } - - /// Returns the community that owns a channel, if the channel exists. - /// - /// Internal relay producers use this to derive tenant context from the row - /// 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 row = sqlx::query( - r#" - SELECT community_id - FROM channels - WHERE id = $1 - AND deleted_at IS NULL - "#, - ) - .bind(channel_id) - .fetch_optional(&self.pool) - .await?; - - row.map(|row| { - let id: Uuid = row.try_get("community_id")?; - Ok(CommunityId::from_uuid(id)) - }) - .transpose() - } - - /// Batched version of [`Self::community_of_channel`]: given a list of - /// channel UUIDs, returns a map from channel id → owning community - /// for every channel that exists (soft-deletes excluded). - /// - /// Used by the runtime conformance read-seam emitters in `buzz-relay`: - /// after a `query_events`/`get_events_by_ids` returns N rows, the - /// emitter collects distinct `channel_id`s, calls this once, then - /// projects each row's true community label independently of the - /// fetch query's WHERE clause. That independence is what makes the - /// `Inv_NonInterference` / `Inv_ReadConfinement` gate non-vacuous — - /// a mutation that dropped `community_id = $X` from the fetch query - /// would still let this helper return the row's true label, and the - /// checker would see the mismatch. - /// - /// Channels missing from the result map (deleted or never existed) - /// are intentionally not present rather than mapped to a default — - /// callers MUST treat "channel-id not in map" as a coverage breach, - /// never as "use the resolved community". - #[datastore_span(name = "communities_of_channels", system = "postgresql")] - pub async fn communities_of_channels( - &self, - channel_ids: &[Uuid], - ) -> Result> { - if channel_ids.is_empty() { - return Ok(std::collections::HashMap::new()); - } - let rows = sqlx::query( - r#" - SELECT id, community_id - FROM channels - WHERE id = ANY($1) - AND deleted_at IS NULL - "#, - ) - .bind(channel_ids) - .fetch_all(&self.pool) - .await?; - - let mut out = std::collections::HashMap::with_capacity(rows.len()); - for row in rows { - let ch: Uuid = row.try_get("id")?; - let cm: Uuid = row.try_get("community_id")?; - out.insert(ch, CommunityId::from_uuid(cm)); - } - Ok(out) + let connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + sqlx::Transaction::begin(connection, None) + .await + .map_err(Into::into) } /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. @@ -4846,139 +4327,6 @@ impl Db { Ok(result.rows_affected()) } - /// Atomically replace a replaceable event: NIP-16 kinds (0, 3, 41, 10000–19999) - /// and NIP-29 discovery state (39000–39002, called from side_effects.rs). - /// - /// Keeps only the event with the highest `created_at` per (kind, pubkey, channel_id). - /// Same-second ties are broken by lowest event `id` (NIP-16 deterministic ordering). - /// Returns `(event, false)` for stale writes and duplicate IDs — callers should - /// skip fan-out/dispatch when `was_inserted` is false. - #[datastore_span(name = "replace_addressable_event", system = "postgresql")] - pub async fn replace_addressable_event( - &self, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, - ) -> Result<(StoredEvent, bool)> { - let kind_i32 = buzz_core::kind::event_kind_i32(event); - let pubkey_bytes = event.pubkey.to_bytes(); - let created_at_secs = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) - .ok_or(DbError::InvalidTimestamp(created_at_secs))?; - - // Collisions only cause extra serialization; they cannot change behavior. - let lock_key = replaceable::event_replacement_lock_key( - community_id, - kind_i32, - pubkey_bytes.as_slice(), - channel_id.as_ref().map(|id| id.as_bytes().as_slice()), - ); - - let mut tx = self.pool.begin().await?; - - // Serialize all writers for the same (kind, pubkey, channel_id) tuple. - // Advisory lock is transaction-scoped — released on commit/rollback. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx) - .await?; - - // Check for the newest existing event. ORDER BY + LIMIT 1 is defensive against - // historical data where prior bugs may have left multiple live rows. - let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( - "SELECT created_at, id FROM events \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ - AND channel_id IS NOT DISTINCT FROM $4 \ - AND deleted_at IS NULL \ - ORDER BY created_at DESC, id ASC LIMIT 1", - ) - .bind(community_id.as_uuid()) - .bind(kind_i32) - .bind(pubkey_bytes.as_slice()) - .bind(channel_id) - .fetch_optional(&mut *tx) - .await?; - - // Stale-write protection: reject if incoming is not newer. - // NIP-16: created_at is second-resolution. On same-second tie, lowest - // event id (lexicographic) wins — deterministic across relays. - let incoming_id = event.id.as_bytes().as_slice(); - if let Some((existing_ts, existing_id)) = existing { - let dominated = created_at < existing_ts - || (created_at == existing_ts && incoming_id >= existing_id.as_slice()); - if dominated { - tx.rollback().await?; - let received_at = chrono::Utc::now(); - return Ok(( - StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), - false, - )); - } - } - - // Soft-delete the old event (if any). IS NOT DISTINCT FROM for NULL safety. - sqlx::query( - "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ - AND channel_id IS NOT DISTINCT FROM $4 \ - AND deleted_at IS NULL", - ) - .bind(community_id.as_uuid()) - .bind(kind_i32) - .bind(pubkey_bytes.as_slice()) - .bind(channel_id) - .execute(&mut *tx) - .await?; - - // Insert the new event inside the same transaction. - let sig_bytes = event.sig.serialize(); - let tags_json = serde_json::to_value(&event.tags)?; - let received_at = chrono::Utc::now(); - let d_tag = crate::event::extract_d_tag(event); - - let insert_result = sqlx::query( - "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ - ON CONFLICT DO NOTHING", - ) - .bind(community_id.as_uuid()) - .bind(event.id.as_bytes().as_slice()) - .bind(pubkey_bytes.as_slice()) - .bind(created_at) - .bind(kind_i32) - .bind(&tags_json) - .bind(&event.content) - .bind(sig_bytes.as_slice()) - .bind(received_at) - .bind(channel_id) - .bind(d_tag.as_deref()) - .execute(&mut *tx) - .await?; - - let was_inserted = insert_result.rows_affected() > 0; - if !was_inserted { - // ON CONFLICT fired — the event ID already exists. Rollback the - // soft-delete so we don't lose the previous replaceable event. - tx.rollback().await?; - return Ok(( - StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), - false, - )); - } - - // The replaceable event and its denormalized mention index are one - // authoritative discovery write. An indexing error must roll back the - // new event and restore the previously-live event. - crate::insert_mentions_in_transaction(&mut tx, community_id, event, channel_id).await?; - - tx.commit().await?; - - Ok(( - StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), - true, - )) - } - /// Returns whether the relay-authored NIP-43 snapshot is absent or differs /// from the canonical membership rows for `community_id`. /// @@ -5057,16 +4405,25 @@ impl Db { None, ); - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + observability::TransactionOperation::PublishNip43MembershipLocked, + ) + .await?; + let (event, received_at, was_inserted, member_count) = transaction_timer + .observe(async { // Acquire the per-community snapshot lock BEFORE reading members. // This serializes the entire read-build-write cycle: a concurrent // publication will block here until our transaction commits, then // read the updated membership state. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx) - .await?; + observability::observe_advisory_lock( + observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx), + ) + .await?; // Read current members inside the locked transaction. let rows = sqlx::query( @@ -5141,24 +4498,24 @@ impl Db { .await?; let was_inserted = insert_result.rows_affected() > 0; - if !was_inserted { + if was_inserted { + tx.commit().await?; + } else { tx.rollback().await?; - return Ok(( - StoredEvent::with_received_at(event, received_at, None, false), - false, - member_count, - )); } + Ok::<_, DbError>((event, received_at, was_inserted, member_count)) + }) + .await?; - tx.commit().await?; - - if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + if was_inserted { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } } Ok(( - StoredEvent::with_received_at(event, received_at, None, true), - true, + StoredEvent::with_received_at(event, received_at, None, was_inserted), + was_inserted, member_count, )) } @@ -5239,17 +4596,10 @@ fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result { #[cfg(test)] mod tests { - //! Pin the load-bearing contract for `Db::communities_of_channels`: - //! a channel id that does NOT exist MUST be absent from the result - //! map, never mapped to a default. The relay-side read-row emitter - //! relies on this — a missing entry triggers `MissingLookup → - //! ImplBug{row_community_lookup_missing} → CoverageBreach`. If this - //! helper ever started returning a default/zero entry for unknown - //! channels, that fail-closed chain would go blind. use super::*; use buzz_core::CommunityId; use sqlx::postgres::PgPoolOptions; - use sqlx::{Acquire, PgPool}; + use sqlx::PgPool; use uuid::Uuid; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; @@ -5382,226 +4732,6 @@ mod tests { drop_scratch_db(&admin, pool, &scratch_name).await; } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn addressable_replacement_rolls_back_when_mention_indexing_fails() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (pool, scratch_name) = create_scratch_db(&admin, "atomic_addressable").await; - let db = Db::from_pool(pool.clone()); - let community_uuid = Uuid::new_v4(); - let channel = Uuid::new_v4(); - let keys = Keys::generate(); - let owner_keys = Keys::generate(); - seed_community_channel(&pool, community_uuid, channel, &owner_keys).await; - let community = CommunityId::from_uuid(community_uuid); - let member = owner_keys.public_key().to_hex(); - let tags = || { - vec![ - Tag::parse(["d", channel.to_string().as_str()]).expect("d tag"), - Tag::parse(["p", member.as_str(), "", "owner"]).expect("p tag"), - ] - }; - let base = Timestamp::now().as_secs(); - let old = EventBuilder::new(Kind::Custom(39002), "old") - .tags(tags()) - .custom_created_at(Timestamp::from(base)) - .sign_with_keys(&keys) - .expect("sign old"); - db.replace_addressable_event(community, &old, Some(channel)) - .await - .expect("insert old roster"); - - sqlx::query( - "CREATE FUNCTION reject_test_mention() RETURNS trigger AS $$ \ - BEGIN RAISE EXCEPTION 'injected mention failure'; END; \ - $$ LANGUAGE plpgsql", - ) - .execute(&pool) - .await - .expect("create failure function"); - sqlx::query( - "CREATE TRIGGER reject_test_mention BEFORE INSERT ON event_mentions \ - FOR EACH ROW EXECUTE FUNCTION reject_test_mention()", - ) - .execute(&pool) - .await - .expect("install failure injection"); - - let new = EventBuilder::new(Kind::Custom(39002), "new") - .tags(tags()) - .custom_created_at(Timestamp::from(base + 1)) - .sign_with_keys(&keys) - .expect("sign new"); - let error = db - .replace_addressable_event(community, &new, Some(channel)) - .await - .expect_err("mention failure must fail replacement"); - assert!(error.to_string().contains("injected mention failure")); - - let live_id: Vec = sqlx::query_scalar( - "SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \ - AND kind=39002 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(channel) - .fetch_one(&pool) - .await - .expect("query live roster"); - assert_eq!(live_id, old.id.as_bytes(), "old roster must remain live"); - let new_rows: i64 = - sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") - .bind(community.as_uuid()) - .bind(new.id.as_bytes().as_slice()) - .fetch_one(&pool) - .await - .expect("count rolled-back event"); - assert_eq!(new_rows, 0, "new roster must roll back with its index"); - - drop_scratch_db(&admin, pool, &scratch_name).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn stale_legacy_roster_cannot_replace_new_locked_snapshot() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (setup_pool, scratch_name) = create_scratch_db(&admin, "mixed_roster_writer").await; - let base_url = admin_url().await; - let slash = base_url.rfind('/').expect("database URL has path segment"); - let scratch_url = format!("{}/{}", &base_url[..slash], scratch_name); - let pool = PgPoolOptions::new() - .max_connections(1) - .acquire_timeout(Duration::from_secs(1)) - .connect(&scratch_url) - .await - .expect("connect one-connection scratch pool"); - setup_pool.close().await; - let db = Db::from_pool(pool.clone()); - let community_uuid = Uuid::new_v4(); - let community = CommunityId::from_uuid(community_uuid); - let channel = Uuid::new_v4(); - let relay_keys = Keys::generate(); - let owner_keys = Keys::generate(); - let owner = owner_keys.public_key().to_bytes(); - seed_community_channel(&pool, community_uuid, channel, &owner_keys).await; - - // This is the old pod's unlocked capture A. It remains in process memory - // while a role-only canonical mutation advances and the new pod publishes B. - let base = Timestamp::now().as_secs(); - let roster = |members: &[(&[u8], &str)], timestamp| { - let tags = - std::iter::once(Tag::parse(["d", channel.to_string().as_str()]).expect("d tag")) - .chain(members.iter().map(|(member, role)| { - Tag::parse(["p", hex::encode(member).as_str(), "", *role]).expect("p tag") - })) - .collect::>(); - EventBuilder::new(Kind::Custom(39002), "") - .tags(tags) - .custom_created_at(Timestamp::from(timestamp)) - .sign_with_keys(&relay_keys) - .expect("sign roster") - }; - - let newcomer = Keys::generate().public_key().to_bytes(); - sqlx::query( - "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ - VALUES ($1, $2, $3, 'member', $4)", - ) - .bind(community_uuid) - .bind(channel) - .bind(newcomer.as_slice()) - .bind(owner.as_slice()) - .execute(&pool) - .await - .expect("seed member before legacy capture"); - let stale_a = roster( - &[(owner.as_slice(), "owner"), (newcomer.as_slice(), "member")], - base + 2, - ); - - sqlx::query( - "UPDATE channel_members SET role = 'admin' \ - WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", - ) - .bind(community_uuid) - .bind(channel) - .bind(newcomer.as_slice()) - .execute(&pool) - .await - .expect("commit newer canonical role"); - - let relay_pubkey = relay_keys.public_key().to_bytes(); - let mut snapshot = db - .lock_member_snapshot(community, channel, &relay_pubkey) - .await - .expect("new writer captures locked roster B"); - let fresh_b = roster( - &[(owner.as_slice(), "owner"), (newcomer.as_slice(), "admin")], - base + 1, - ); - assert!( - snapshot - .replace_member_event(community, channel, &fresh_b) - .await - .expect("new writer publishes B") - .1 - ); - snapshot - .release() - .await - .expect("commit B and release locks"); - - // The legacy canonical path takes the replacement key, soft-deletes B, - // then attempts its newer-timestamp stale A. Migration 0032 rejects the - // INSERT; transaction rollback must restore B. A one-connection pool - // proves the lock order does not turn this compatibility path into a - // self-deadlock. - let error = tokio::time::timeout( - Duration::from_secs(3), - db.replace_addressable_event(community, &stale_a, Some(channel)), - ) - .await - .expect("legacy replacement must not deadlock") - .expect_err("stale captured roster A must be rejected"); - assert!( - matches!( - error, - DbError::Sqlx(sqlx::Error::Database(ref db_error)) - if db_error.code().as_deref() == Some("23514") - ), - "expected roster fence check violation, got {error:?}" - ); - - let live_ids: Vec> = sqlx::query_scalar( - "SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \ - AND kind=39002 AND pubkey=$3 AND deleted_at IS NULL", - ) - .bind(community_uuid) - .bind(channel) - .bind(relay_pubkey.as_slice()) - .fetch_all(&pool) - .await - .expect("load live roster heads"); - assert_eq!(live_ids, vec![fresh_b.id.as_bytes().to_vec()]); - let stale_rows: i64 = - sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") - .bind(community_uuid) - .bind(stale_a.id.as_bytes().as_slice()) - .fetch_one(&pool) - .await - .expect("count rejected stale roster"); - assert_eq!(stale_rows, 0, "stale roster insert must roll back"); - - drop_scratch_db(&admin, pool, &scratch_name).await; - } - #[tokio::test] #[ignore = "requires Postgres"] async fn desired_schema_rejects_stale_legacy_roster_role() { @@ -5696,602 +4826,6 @@ mod tests { drop_scratch_db(&admin, pool, &scratch_name).await; } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn nip_rs_replacement_hard_deletes_payload_and_watermark_rejects_replay() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let d_tag = format!("read-state:{}", "a".repeat(32)); - let tags = vec![ - Tag::parse(["d", d_tag.as_str()]).expect("d tag"), - Tag::parse(["t", "read-state"]).expect("t tag"), - ]; - let base = Timestamp::now().as_secs(); - let old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "old") - .tags(tags.clone()) - .custom_created_at(Timestamp::from(base)) - .sign_with_keys(&keys) - .expect("sign old"); - let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "new") - .tags(tags) - .custom_created_at(Timestamp::from(base + 1)) - .sign_with_keys(&keys) - .expect("sign new"); - - assert!( - db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("insert old") - .1 - ); - assert!( - db.replace_parameterized_event(community, &new, &d_tag, None) - .await - .expect("replace with new") - .1 - ); - - let rows: i64 = sqlx::query_scalar( - "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("count NIP-RS rows"); - assert_eq!(rows, 1, "superseded payload must be physically deleted"); - - sqlx::query( - "UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .execute(&db.pool) - .await - .expect("simulate NIP-09 coordinate deletion"); - - assert!( - !db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("replay old") - .1 - ); - let live: i64 = sqlx::query_scalar( - "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("count live NIP-RS rows"); - assert_eq!(live, 0, "watermark must block stale resurrection"); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn nip_rs_transaction_operation_restores_hard_delete_opt_in() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let base = Timestamp::now().as_secs(); - let replace_d_tag = format!("read-state:{}", "b".repeat(32)); - let victim_d_tag = format!("read-state:{}", "c".repeat(32)); - let event = |d_tag: &str, content: &str, timestamp: u64| { - EventBuilder::new( - Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), - content, - ) - .tags(vec![ - Tag::parse(["d", d_tag]).expect("d tag"), - Tag::parse(["t", "read-state"]).expect("t tag"), - ]) - .custom_created_at(Timestamp::from(timestamp)) - .sign_with_keys(&keys) - .expect("sign read state") - }; - let old = event(&replace_d_tag, "old", base); - let new = event(&replace_d_tag, "new", base + 1); - let victim = event(&victim_d_tag, "victim", base); - - assert!( - db.replace_parameterized_event(community, &old, &replace_d_tag, None) - .await - .expect("insert old head") - .1 - ); - assert!( - db.replace_parameterized_event(community, &victim, &victim_d_tag, None) - .await - .expect("insert victim head") - .1 - ); - - let mut tx = db - .begin_transaction() - .await - .expect("begin caller transaction"); - let result = db - .replace_parameterized_event_in_transaction( - &mut tx, - community, - &new, - &replace_d_tag, - None, - replaceable::ParameterizedReplacePrecondition::Unconditional, - ) - .await - .expect("replace inside caller transaction"); - assert_eq!( - result.status, - replaceable::ParameterizedReplaceStatus::Inserted - ); - - let leaked: Option = sqlx::query_scalar( - "SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')", - ) - .fetch_one(&mut *tx) - .await - .expect("read hard-delete opt-in after replacement"); - assert_ne!(leaked.as_deref(), Some("on")); - - let unauthorized = sqlx::query( - "DELETE FROM events WHERE community_id=$1 AND kind=30078 \ - AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&victim_d_tag) - .execute(&mut *tx) - .await; - assert!( - unauthorized.is_err(), - "replacement opt-in must not authorize later caller SQL" - ); - tx.rollback().await.expect("roll back caller transaction"); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn parameterized_replacement_in_existing_transaction_honors_revision_and_rollback() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let d_tag = format!("transactional-project-{}", Uuid::new_v4().simple()); - let base = Timestamp::now().as_secs(); - let event = |content: &str, timestamp: u64| { - EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) - .tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")]) - .custom_created_at(Timestamp::from(timestamp)) - .sign_with_keys(&keys) - .expect("sign project") - }; - let old = event("old", base); - let new = event("new", base + 1); - - assert!( - db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("insert old head") - .1 - ); - - let mut tx = db.begin_transaction().await.expect("begin replacement tx"); - let outcome = db - .replace_parameterized_event_in_transaction( - &mut tx, - community, - &new, - &d_tag, - None, - replaceable::ParameterizedReplacePrecondition::ExpectedRevision( - old.id.as_bytes().as_slice(), - ), - ) - .await - .expect("replace inside caller transaction"); - assert_eq!( - outcome.status, - replaceable::ParameterizedReplaceStatus::Inserted - ); - tx.rollback().await.expect("roll back replacement tx"); - - let live_id: Vec = sqlx::query_scalar( - "SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \ - AND d_tag=$4 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(buzz_core::kind::KIND_PROJECT as i32) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("load live head after rollback"); - assert_eq!(live_id, old.id.as_bytes().to_vec()); - - let mut tx = db - .begin_transaction() - .await - .expect("begin stale revision tx"); - let mismatch = db - .replace_parameterized_event_in_transaction( - &mut tx, - community, - &new, - &d_tag, - None, - replaceable::ParameterizedReplacePrecondition::ExpectedRevision( - [0x42; 32].as_slice(), - ), - ) - .await - .expect("evaluate stale revision"); - assert_eq!( - mismatch.status, - replaceable::ParameterizedReplaceStatus::RevisionMismatch - ); - tx.rollback().await.expect("roll back stale revision tx"); - - let missing_d_tag = format!("missing-project-{}", Uuid::new_v4().simple()); - let missing = EventBuilder::new( - Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), - "missing", - ) - .tags(vec![ - Tag::parse(["d", missing_d_tag.as_str()]).expect("missing d tag") - ]) - .custom_created_at(Timestamp::from(base + 2)) - .sign_with_keys(&keys) - .expect("sign missing project"); - let mut tx = db - .begin_transaction() - .await - .expect("begin missing revision tx"); - let missing_result = db - .replace_parameterized_event_in_transaction( - &mut tx, - community, - &missing, - &missing_d_tag, - None, - replaceable::ParameterizedReplacePrecondition::ExpectedRevision( - [0x24; 32].as_slice(), - ), - ) - .await - .expect("evaluate missing revision"); - assert_eq!( - missing_result.status, - replaceable::ParameterizedReplaceStatus::RevisionMissing - ); - tx.rollback().await.expect("roll back missing revision tx"); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn parameterized_replacement_rolls_back_when_mention_indexing_fails() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (pool, scratch_name) = create_scratch_db(&admin, "atomic_parameterized").await; - let db = Db::from_pool(pool.clone()); - let community = CommunityId::from_uuid(make_community(&pool).await); - let keys = Keys::generate(); - let mentioned = Keys::generate().public_key().to_hex(); - let d_tag = format!("mention-project-{}", Uuid::new_v4().simple()); - let tags = || { - vec![ - Tag::parse(["d", d_tag.as_str()]).expect("d tag"), - Tag::parse(["p", mentioned.as_str()]).expect("p tag"), - ] - }; - let base = Timestamp::now().as_secs(); - let old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), "old") - .tags(tags()) - .custom_created_at(Timestamp::from(base)) - .sign_with_keys(&keys) - .expect("sign old project"); - let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), "new") - .tags(tags()) - .custom_created_at(Timestamp::from(base + 1)) - .sign_with_keys(&keys) - .expect("sign new project"); - - assert!( - db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("insert old project") - .1 - ); - sqlx::query( - "CREATE FUNCTION reject_test_mention() RETURNS trigger AS $$ \ - BEGIN RAISE EXCEPTION 'injected mention failure'; END; \ - $$ LANGUAGE plpgsql", - ) - .execute(&pool) - .await - .expect("create failure function"); - sqlx::query( - "CREATE TRIGGER reject_test_mention BEFORE INSERT ON event_mentions \ - FOR EACH ROW EXECUTE FUNCTION reject_test_mention()", - ) - .execute(&pool) - .await - .expect("install failure injection"); - - let mut tx = db - .begin_transaction() - .await - .expect("begin caller transaction"); - let error = db - .replace_parameterized_event_in_transaction( - &mut tx, - community, - &new, - &d_tag, - None, - replaceable::ParameterizedReplacePrecondition::Unconditional, - ) - .await - .expect_err("mention failure must fail replacement"); - assert!(error.to_string().contains("injected mention failure")); - - let probe: i32 = sqlx::query_scalar("SELECT 1") - .fetch_one(&mut *tx) - .await - .expect("inner failure must leave caller transaction usable"); - assert_eq!(probe, 1); - - let live_id: Vec = sqlx::query_scalar( - "SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \ - AND d_tag=$4 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(buzz_core::kind::KIND_PROJECT as i32) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&mut *tx) - .await - .expect("load live project after failed indexing"); - assert_eq!(live_id, old.id.as_bytes().to_vec()); - let new_rows: i64 = - sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") - .bind(community.as_uuid()) - .bind(new.id.as_bytes().as_slice()) - .fetch_one(&mut *tx) - .await - .expect("count rolled-back project"); - assert_eq!(new_rows, 0); - tx.commit().await.expect("commit usable caller transaction"); - - drop_scratch_db(&admin, pool, &scratch_name).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn parameterized_duplicate_restores_live_head_inside_caller_transaction() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let d_tag = format!("duplicate-project-{}", Uuid::new_v4().simple()); - let base = Timestamp::now().as_secs(); - let event = |content: &str, timestamp: u64| { - EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) - .tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")]) - .custom_created_at(Timestamp::from(timestamp)) - .sign_with_keys(&keys) - .expect("sign project") - }; - let old = event("old-live-head", base); - let duplicate = event("soft-deleted-duplicate", base + 1); - - assert!( - db.replace_parameterized_event(community, &duplicate, &d_tag, None) - .await - .expect("insert future duplicate") - .1 - ); - sqlx::query("UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND id=$2") - .bind(community.as_uuid()) - .bind(duplicate.id.as_bytes().as_slice()) - .execute(&db.pool) - .await - .expect("soft-delete duplicate row"); - - let mut seed_tx = db - .begin_transaction() - .await - .expect("begin seed transaction"); - let (_, was_inserted) = - event::insert_event_in_transaction(&mut seed_tx, community, &old, None) - .await - .expect("insert older live head"); - assert!(was_inserted); - seed_tx.commit().await.expect("commit older live head"); - - let mut tx = db - .begin_transaction() - .await - .expect("begin caller transaction"); - let result = db - .replace_parameterized_event_in_transaction( - &mut tx, - community, - &duplicate, - &d_tag, - None, - replaceable::ParameterizedReplacePrecondition::Unconditional, - ) - .await - .expect("evaluate soft-deleted duplicate"); - assert_eq!( - result.status, - replaceable::ParameterizedReplaceStatus::Duplicate - ); - - let live_id: Vec = sqlx::query_scalar( - "SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \ - AND d_tag=$4 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(buzz_core::kind::KIND_PROJECT as i32) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&mut *tx) - .await - .expect("caller transaction remains usable after duplicate"); - assert_eq!(live_id, old.id.as_bytes().to_vec()); - tx.rollback().await.expect("roll back caller transaction"); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn concurrent_parameterized_replacement_keeps_deterministic_head() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let d_tag = format!("concurrent-project-{}", Uuid::new_v4().simple()); - let created_at = Timestamp::now().as_secs(); - let event = |content: &str, timestamp: u64| { - EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) - .tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")]) - .custom_created_at(Timestamp::from(timestamp)) - .sign_with_keys(&keys) - .expect("sign project") - }; - let first = event("first", created_at); - let second = event("second", created_at); - let expected = if first.id.as_bytes() < second.id.as_bytes() { - &first - } else { - &second - }; - - let (first_result, second_result) = tokio::join!( - db.replace_parameterized_event(community, &first, &d_tag, None), - db.replace_parameterized_event(community, &second, &d_tag, None), - ); - let first_inserted = first_result.expect("first concurrent writer").1; - let second_inserted = second_result.expect("second concurrent writer").1; - assert!( - first_inserted || second_inserted, - "at least one concurrent writer must insert", - ); - - let live_ids: Vec> = sqlx::query_scalar( - "SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \ - AND d_tag=$4 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(buzz_core::kind::KIND_PROJECT as i32) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_all(&db.pool) - .await - .expect("load concurrent live head"); - assert_eq!(live_ids, vec![expected.id.as_bytes().to_vec()]); - - assert!( - !db.replace_parameterized_event(community, expected, &d_tag, None) - .await - .expect("replay winning event") - .1, - "replaying the live event must be idempotent", - ); - let stale = event("stale", created_at.saturating_sub(1)); - assert!( - !db.replace_parameterized_event(community, &stale, &d_tag, None) - .await - .expect("submit stale event") - .1, - "an older event must not replace the live head", - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn mesh_status_replacement_keeps_one_physical_row() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let d_tag = "buzz-mesh-member-status:owner-test"; - let tags = vec![ - Tag::parse(["d", d_tag]).expect("d tag"), - Tag::parse(["k", "buzz-mesh-status"]).expect("k tag"), - ]; - let base = Timestamp::now().as_secs(); - for (offset, content) in [(0, "running"), (1, "running-again"), (2, "stopped")] { - let event = EventBuilder::new( - Kind::Custom(buzz_core::kind::KIND_BOOKMARK_SET as u16), - content, - ) - .tags(tags.clone()) - .custom_created_at(Timestamp::from(base + offset)) - .sign_with_keys(&keys) - .expect("sign mesh status"); - assert!( - db.replace_parameterized_event(community, &event, d_tag, None) - .await - .expect("replace mesh status") - .1 - ); - } - - let (rows, live): (i64, i64) = sqlx::query_as( - "SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \ - WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(d_tag) - .fetch_one(&db.pool) - .await - .expect("count mesh status rows"); - assert_eq!((rows, live), (1, 1)); - - sqlx::query( - "UPDATE events SET deleted_at=NOW() \ - WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(d_tag) - .execute(&db.pool) - .await - .expect("simulate old relay soft delete"); - let rows_after_legacy_delete: i64 = sqlx::query_scalar( - "SELECT count(*) FROM events \ - WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(d_tag) - .fetch_one(&db.pool) - .await - .expect("count rows after old relay soft delete"); - assert_eq!( - rows_after_legacy_delete, 0, - "migration trigger must purge soft-deleted mesh status" - ); - } - #[tokio::test] #[ignore = "requires Postgres"] async fn coordinate_delete_spares_head_newer_than_the_deletion() { @@ -6361,246 +4895,6 @@ mod tests { ); } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let base = Timestamp::now().as_secs(); - - for (case, tags) in [ - ( - "duplicate-d", - vec![ - Tag::parse(["d", &format!("read-state:{}", "c".repeat(32))]) - .expect("first d tag"), - Tag::parse(["d", &format!("read-state:{}", "d".repeat(32))]) - .expect("second d tag"), - Tag::parse(["t", "read-state"]).expect("t tag"), - ], - ), - ( - "duplicate-t", - vec![ - Tag::parse(["d", &format!("read-state:{}", "e".repeat(32))]).expect("d tag"), - Tag::parse(["t", "read-state"]).expect("first t tag"), - Tag::parse(["t", "read-state"]).expect("second t tag"), - ], - ), - ] { - let d_tag = tags - .iter() - .find_map(|tag| { - let parts = tag.as_slice(); - (parts.first().is_some_and(|part| part == "d") && parts.len() >= 2) - .then(|| parts[1].clone()) - }) - .expect("first d-tag value"); - let old = EventBuilder::new( - Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), - format!("{case}-old"), - ) - .tags(tags.clone()) - .custom_created_at(Timestamp::from(base)) - .sign_with_keys(&keys) - .expect("sign old event"); - let new = EventBuilder::new( - Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), - format!("{case}-new"), - ) - .tags(tags) - .custom_created_at(Timestamp::from(base + 1)) - .sign_with_keys(&keys) - .expect("sign new event"); - - assert!( - db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("insert old event") - .1 - ); - assert!( - db.replace_parameterized_event(community, &new, &d_tag, None) - .await - .expect("replace with new event") - .1 - ); - - let (rows, live): (i64, i64) = sqlx::query_as( - "SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \ - WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("count retained rows"); - assert_eq!((rows, live), (2, 1), "{case} must retain legacy history"); - - let watermarks: i64 = sqlx::query_scalar( - "SELECT count(*) FROM parameterized_event_watermarks \ - WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("count watermarks"); - assert_eq!(watermarks, 0, "{case} must not create a watermark"); - } - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let base = Timestamp::now().as_secs(); - let conforming_d = format!("read-state:{}", "6".repeat(32)); - let conforming = EventBuilder::new( - Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), - "fenced-conforming", - ) - .tags(vec![ - Tag::parse(["d", conforming_d.as_str()]).expect("d tag"), - Tag::parse(["t", "read-state"]).expect("t tag"), - ]) - .custom_created_at(Timestamp::from(base)) - .sign_with_keys(&keys) - .expect("sign conforming event"); - assert!( - db.replace_parameterized_event(community, &conforming, &conforming_d, None) - .await - .expect("insert conforming event") - .1 - ); - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078)", - ) - .bind(community.as_uuid()) - .bind("6".repeat(64)) - .bind(conforming.id.as_bytes().as_slice()) - .bind(conforming.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("insert mention"); - - // Model ce10's first destructive statement. RAISE aborts the transaction, - // so its later mention delete and incoming insert can never commit. - let mut old_writer = db.pool.begin().await.expect("begin old-writer tx"); - let rejected = sqlx::query( - "DELETE FROM events WHERE community_id=$1 AND kind=30078 \ - AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&conforming_d) - .execute(&mut *old_writer) - .await; - assert!(rejected.is_err(), "old-writer hard delete must be rejected"); - old_writer.rollback().await.expect("rollback rejected tx"); - let preserved: (i64, i64) = sqlx::query_as( - "SELECT (SELECT count(*) FROM events WHERE community_id=$1 AND id=$2), \ - (SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2)", - ) - .bind(community.as_uuid()) - .bind(conforming.id.as_bytes().as_slice()) - .fetch_one(&db.pool) - .await - .expect("count preserved payload and mention"); - assert_eq!(preserved, (1, 1)); - - let nonconforming_d = format!("read-state:{}", "7".repeat(32)); - let nonconforming = EventBuilder::new( - Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), - "fenced-nonconforming", - ) - .tags(vec![ - Tag::parse(["d", nonconforming_d.as_str()]).expect("first d tag"), - Tag::parse(["d", "other"]).expect("second d tag"), - Tag::parse(["t", "read-state"]).expect("t tag"), - ]) - .custom_created_at(Timestamp::from(base + 1)) - .sign_with_keys(&keys) - .expect("sign nonconforming event"); - assert!( - db.replace_parameterized_event(community, &nonconforming, &nonconforming_d, None,) - .await - .expect("insert nonconforming event") - .1 - ); - let rejected_nonconforming = sqlx::query( - "DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)", - ) - .bind(community.as_uuid()) - .bind(nonconforming.id.as_bytes().as_slice()) - .bind(nonconforming.created_at.as_secs() as f64) - .execute(&db.pool) - .await; - assert!( - rejected_nonconforming.is_err(), - "fence must cover a nonconforming OLD row at a regex coordinate" - ); - - let unrelated_d = format!("read-state:{}", "8".repeat(32)); - let unrelated = EventBuilder::new(Kind::Custom(30023), "unrelated") - .tags(vec![Tag::parse(["d", unrelated_d.as_str()]).expect("d tag")]) - .custom_created_at(Timestamp::from(base + 2)) - .sign_with_keys(&keys) - .expect("sign unrelated event"); - assert!( - db.replace_parameterized_event(community, &unrelated, &unrelated_d, None) - .await - .expect("insert unrelated event") - .1 - ); - let unrelated_delete = sqlx::query( - "DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)", - ) - .bind(community.as_uuid()) - .bind(unrelated.id.as_bytes().as_slice()) - .bind(unrelated.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("delete unrelated event"); - assert_eq!(unrelated_delete.rows_affected(), 1); - - // Check both transaction exits on one physical session; pool selection - // cannot accidentally hide a leaked session-local authorization value. - let mut conn = db.pool.acquire().await.expect("acquire dedicated session"); - for commit in [true, false] { - let mut tx = conn.begin().await.expect("begin GUC transaction"); - let value: String = - sqlx::query_scalar("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)") - .fetch_one(&mut *tx) - .await - .expect("set transaction-local GUC"); - assert_eq!(value, "on"); - if commit { - tx.commit().await.expect("commit GUC transaction"); - } else { - tx.rollback().await.expect("rollback GUC transaction"); - } - let leaked: Option = sqlx::query_scalar( - "SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')", - ) - .fetch_one(&mut *conn) - .await - .expect("read GUC after transaction"); - assert_ne!(leaked.as_deref(), Some("on")); - } - } - #[tokio::test] #[ignore = "requires Postgres"] async fn database_guard_covers_legacy_writer_and_nip09_deletion() { @@ -6962,297 +5256,6 @@ mod tests { drop_scratch_db(&admin, pool, &scratch_name).await; } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn lookup_community_by_host_matches_case_insensitive_host_index() { - let db = setup_db().await; - let id = Uuid::new_v4(); - let lower_host = format!("lookup-community-{}.example", id.simple()); - let stored_host = lower_host.to_uppercase(); - - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(id) - .bind(&stored_host) - .execute(&db.pool) - .await - .expect("insert mixed-case community host"); - - let found = db - .lookup_community_by_host(&lower_host) - .await - .expect("lookup lower-case host") - .expect("community found by lower-case host"); - assert_eq!(found.id, CommunityId::from_uuid(id)); - assert_eq!(found.host, stored_host); - - let found = db - .lookup_community_by_host(&stored_host) - .await - .expect("lookup stored-case host") - .expect("community found by stored-case host"); - assert_eq!(found.id, CommunityId::from_uuid(id)); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn create_community_with_owner_is_atomic_and_create_only() { - let db = setup_db().await; - let host = format!("create-only-{}.example", Uuid::new_v4().simple()); - let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - - let created = db - .create_community_with_owner(&host, owner) - .await - .expect("create community"); - let CreateCommunityWithOwnerResult::Created(created) = created else { - panic!("expected new community"); - }; - assert_eq!(created.host, host); - let owner_role: Option = sqlx::query_scalar( - "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", - ) - .bind(created.id.as_uuid()) - .bind(owner) - .fetch_optional(&db.pool) - .await - .expect("owner role"); - assert_eq!(owner_role.as_deref(), Some("owner")); - - let retry = db - .create_community_with_owner(&host.to_ascii_uppercase(), owner) - .await - .expect("same-owner retry"); - assert_eq!( - retry, - CreateCommunityWithOwnerResult::Created(created.clone()), - "retry returns the original row" - ); - - let collision = db - .create_community_with_owner(&host, other) - .await - .expect("collision result"); - assert_eq!(collision, CreateCommunityWithOwnerResult::HostExists); - let roles: Vec<(String, String)> = sqlx::query_as( - "SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey", - ) - .bind(created.id.as_uuid()) - .fetch_all(&db.pool) - .await - .expect("community roles"); - assert_eq!(roles, vec![(owner.to_string(), "owner".to_string())]); - - db.bootstrap_owner(created.id, other) - .await - .expect("rotate owner"); - let post_rotation_retry = db - .create_community_with_owner(&host, owner) - .await - .expect("post-rotation retry"); - assert_eq!( - post_rotation_retry, - CreateCommunityWithOwnerResult::HostExists - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn unarchive_community_owned_by_restores_admission_idempotently() { - let db = setup_db().await; - let host = format!("unarchive-{}.example", Uuid::new_v4().simple()); - let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - let outsider = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - let created = db - .create_community_with_owner(&host, &owner) - .await - .expect("create community"); - let CreateCommunityWithOwnerResult::Created(created) = created else { - panic!("expected new community"); - }; - - let archived = db - .archive_community_owned_by(&host, &owner, "protected.example") - .await - .expect("archive community") - .expect("owned community"); - assert_eq!(archived.id, created.id); - assert!( - db.lookup_community_by_host(&host) - .await - .expect("active lookup") - .is_none(), - "archived communities must fail admission" - ); - assert!(db - .unarchive_community_owned_by(&host, &outsider) - .await - .expect("wrong-owner unarchive") - .is_none()); - assert!(db - .unarchive_community_owned_by("missing.example", &owner) - .await - .expect("unknown-host unarchive") - .is_none()); - - let restored = db - .unarchive_community_owned_by(&host.to_ascii_uppercase(), &owner) - .await - .expect("unarchive community") - .expect("owned community"); - assert_eq!(restored.id, created.id); - assert_eq!(restored.host, host); - assert_eq!( - db.lookup_community_by_host(&host) - .await - .expect("restored lookup") - .expect("active community") - .id, - created.id - ); - assert_eq!( - db.get_relay_member(created.id, &owner) - .await - .expect("owner lookup") - .expect("owner remains") - .role, - "owner" - ); - - let retry = db - .unarchive_community_owned_by(&host, &owner) - .await - .expect("idempotent retry") - .expect("owned community"); - assert_eq!(retry, restored); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn create_community_with_owner_enforces_per_owner_limit() { - let db = setup_db().await; - let owner = format!("{:064x}", Uuid::new_v4().as_u128()); - - // Create 3 communities for this owner (the max). - for i in 0..3 { - let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); - assert!(matches!( - db.create_community_with_owner(&host, &owner) - .await - .expect("create community"), - CreateCommunityWithOwnerResult::Created(_) - )); - } - - let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); - assert_eq!( - db.create_community_with_owner(&host, &owner) - .await - .expect("create community call"), - CreateCommunityWithOwnerResult::LimitReached - ); - assert!( - db.lookup_community_by_host(&host) - .await - .expect("look up rolled-back fresh host") - .is_none(), - "limit rejection must roll back the fresh community row" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn concurrent_same_owner_create_returns_the_winning_row_to_both_callers() { - let db = setup_db().await; - let host = format!("concurrent-create-{}.example", Uuid::new_v4().simple()); - let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - let (first, second) = tokio::join!( - db.create_community_with_owner(&host, owner), - db.create_community_with_owner(&host, owner), - ); - let first = first.expect("first concurrent create"); - let second = second.expect("second concurrent create"); - - assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_))); - assert_eq!(first, second, "conflict loser re-reads the winning row"); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn ensure_configured_community_reports_insert_winner() { - let db = setup_db().await; - let host = format!("ensure-community-{}.example", Uuid::new_v4().simple()); - - let first = db - .ensure_configured_community(&host) - .await - .expect("first ensure"); - assert!(first.created, "first ensure should report created"); - assert_eq!(first.host, host); - - let second = db - .ensure_configured_community(&host) - .await - .expect("second ensure"); - assert!(!second.created, "second ensure should report existed"); - assert_eq!(second.id, first.id); - assert_eq!(second.host, host); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn list_communities_owned_by_returns_only_owner_rows() { - let db = setup_db().await; - let community_a = CommunityId::from_uuid(make_community(&db.pool).await); - let community_b = CommunityId::from_uuid(make_community(&db.pool).await); - let community_c = CommunityId::from_uuid(make_community(&db.pool).await); - // Unique per run: `list_communities_owned_by` is keyed only by pubkey, - // so a shared fixed pubkey picks up communities leaked by sibling - // ignored tests running against the same database. - let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - let owner = owner.as_str(); - let other = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - let other = other.as_str(); - - db.bootstrap_owner(community_a, owner) - .await - .expect("owner A"); - db.bootstrap_owner(community_b, other) - .await - .expect("other owner B"); - db.add_relay_member(community_c, owner, "admin", None) - .await - .expect("admin C"); - - let owned = db - .list_communities_owned_by(owner) - .await - .expect("list owned communities"); - - assert_eq!(owned.len(), 1); - assert_eq!(owned[0].id, community_a); - } - - async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) { - let creator: Vec = vec![0u8; 32]; - sqlx::query( - r#" - INSERT INTO channels - (id, community_id, name, channel_type, visibility, created_by) - VALUES - ($1, $2, $3, 'stream'::channel_type, 'open'::channel_visibility, $4) - "#, - ) - .bind(channel_id) - .bind(community_id) - .bind(format!("ch-{}", channel_id.simple())) - .bind(&creator) - .execute(pool) - .await - .expect("insert channel"); - } - #[tokio::test] #[ignore = "requires Postgres"] async fn allowlist_is_scoped_to_community() { @@ -7319,48 +5322,6 @@ mod tests { .expect("A not allowed after remove")); } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn communities_of_channels_present_for_existing_absent_for_missing() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let existing = Uuid::new_v4(); - insert_channel(&db.pool, community, existing).await; - - // Channel that is NOT inserted — the load-bearing case. - let missing = Uuid::new_v4(); - - let result = db - .communities_of_channels(&[existing, missing]) - .await - .expect("communities_of_channels"); - - // (1) Existing channel → present with its true community. - assert_eq!( - result.get(&existing).copied(), - Some(CommunityId::from_uuid(community)), - "existing channel must map to its true community", - ); - - // (2) Missing channel → ABSENT from the map (never defaulted). - // This is the contract the relay-side `MissingLookup → ImplBug` - // fail-closed guard-rail depends on. If this assertion ever - // weakens to `result.get(&missing) != Some(community)`, the - // mutate-bite below stops biting. - assert!( - !result.contains_key(&missing), - "missing channel must be absent from the result map, got {:?}", - result.get(&missing), - ); - - // (3) Map size matches: exactly one entry, the existing one. - assert_eq!( - result.len(), - 1, - "result map must contain only existing channels" - ); - } - /// BUG-5 regression: the `reactions` table is community-scoped /// (`PK (community_id, event_created_at, event_id, pubkey, emoji)`), so a /// reaction added under community A must be invisible and unremovable from diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 2afc418010..3ab5a93077 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -82,11 +82,16 @@ where F: FnOnce(PgConnection) -> Fut, Fut: Future)>, { - let mut lock_conn = pool.acquire().await?.detach(); - sqlx::query("SELECT pg_advisory_lock($1)") - .bind(SCHEMA_DESTRUCTION_LOCK_KEY) - .execute(&mut lock_conn) - .await?; + let mut lock_conn = crate::observability::acquire(pool, crate::observability::PoolRole::Writer) + .await? + .detach(); + crate::observability::observe_advisory_lock( + crate::observability::LockType::MigrationSchemaSafety, + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(&mut lock_conn), + ) + .await?; let (mut lock_conn, outcome) = op(lock_conn).await; let unlock = sqlx::query("SELECT pg_advisory_unlock($1)") .bind(SCHEMA_DESTRUCTION_LOCK_KEY) diff --git a/crates/buzz-db/src/observability.rs b/crates/buzz-db/src/observability.rs new file mode 100644 index 0000000000..afe1d20b30 --- /dev/null +++ b/crates/buzz-db/src/observability.rs @@ -0,0 +1,636 @@ +//! Bounded-cardinality database pressure instrumentation primitives. +//! +//! Label values come only from the closed enums in this module. Callers must +//! never derive labels from tenant data, events, SQL text, or query identifiers. + +use std::future::Future; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PoolRole { + Writer, + Reader, +} + +impl PoolRole { + #[cfg(test)] + pub(crate) const ALL: [Self; 2] = [Self::Writer, Self::Reader]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Writer => "writer", + Self::Reader => "reader", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LockType { + Replacement, + Membership, + PushGate, + Deletion, + MigrationSchemaSafety, +} + +impl LockType { + #[cfg(test)] + pub(crate) const ALL: [Self; 5] = [ + Self::Replacement, + Self::Membership, + Self::PushGate, + Self::Deletion, + Self::MigrationSchemaSafety, + ]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Replacement => "replacement", + Self::Membership => "membership", + Self::PushGate => "push_gate", + Self::Deletion => "deletion", + Self::MigrationSchemaSafety => "migration_schema_safety", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Outcome { + Success, + Error, + Timeout, +} + +impl Outcome { + #[cfg(test)] + pub(crate) const ALL: [Self; 3] = [Self::Success, Self::Error, Self::Timeout]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Success => "success", + Self::Error => "error", + Self::Timeout => "timeout", + } + } + + fn from_sqlx_error(error: &sqlx::Error) -> Self { + match error { + sqlx::Error::PoolTimedOut => Self::Timeout, + sqlx::Error::Database(database) if database.code().as_deref() == Some("55P03") => { + Self::Timeout + } + _ => Self::Error, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TransactionOperation { + ReplaceParameterizedEvent, + ReplaceAddressableEvent, + PublishNip43MembershipLocked, + AcceptPushLeaseEvent, + BeginCommunityDeletionQuiescing, + FenceCommunityDeletion, +} + +impl TransactionOperation { + #[cfg(test)] + pub(crate) const ALL: [Self; 6] = [ + Self::ReplaceParameterizedEvent, + Self::ReplaceAddressableEvent, + Self::PublishNip43MembershipLocked, + Self::AcceptPushLeaseEvent, + Self::BeginCommunityDeletionQuiescing, + Self::FenceCommunityDeletion, + ]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::ReplaceParameterizedEvent => "replace_parameterized_event", + Self::ReplaceAddressableEvent => "replace_addressable_event", + Self::PublishNip43MembershipLocked => "publish_nip43_membership_locked", + Self::AcceptPushLeaseEvent => "accept_push_lease_event", + Self::BeginCommunityDeletionQuiescing => "begin_community_deletion_quiescing", + Self::FenceCommunityDeletion => "fence_community_deletion", + } + } +} + +pub(crate) fn record_pool_acquire(role: PoolRole, outcome: Outcome, elapsed: Duration) { + metrics::histogram!( + "buzz_db_pool_acquire_wait_seconds", + "pool_role" => role.as_str(), + "outcome" => outcome.as_str(), + ) + .record(elapsed.as_secs_f64()); + metrics::counter!( + "buzz_db_pool_acquisitions_total", + "pool_role" => role.as_str(), + "outcome" => outcome.as_str(), + ) + .increment(1); +} + +pub(crate) async fn acquire( + pool: &sqlx::PgPool, + role: PoolRole, +) -> sqlx::Result> { + let started = Instant::now(); + 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()); + 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 transaction = sqlx::Transaction::begin(connection, None).await?; + Ok((transaction, TransactionTimer::start(operation))) +} + +pub(crate) async fn observe_advisory_lock(lock_type: LockType, future: F) -> sqlx::Result +where + F: Future>, +{ + let started = Instant::now(); + let result = future.await; + let outcome = result + .as_ref() + .map(|_| Outcome::Success) + .unwrap_or_else(Outcome::from_sqlx_error); + metrics::histogram!( + "buzz_db_advisory_lock_wait_seconds", + "lock_type" => lock_type.as_str(), + "outcome" => outcome.as_str(), + ) + .record(started.elapsed().as_secs_f64()); + metrics::counter!( + "buzz_db_advisory_lock_acquisitions_total", + "lock_type" => lock_type.as_str(), + "outcome" => outcome.as_str(), + ) + .increment(1); + result +} + +pub(crate) struct TransactionTimer { + operation: TransactionOperation, + started: Instant, + outcome: Outcome, +} + +impl TransactionTimer { + pub(crate) fn start(operation: TransactionOperation) -> Self { + Self { + operation, + started: Instant::now(), + outcome: Outcome::Error, + } + } + + pub(crate) async fn observe(mut self, future: F) -> Result + where + F: Future>, + { + let result = future.await; + if result.is_ok() { + self.outcome = Outcome::Success; + } + result + } +} + +impl Drop for TransactionTimer { + fn drop(&mut self) { + metrics::histogram!( + "buzz_db_transaction_duration_seconds", + "operation" => self.operation.as_str(), + "outcome" => self.outcome.as_str(), + ) + .record(self.started.elapsed().as_secs_f64()); + } +} + +#[cfg(test)] +mod tests { + use super::{ + acquire, observe_advisory_lock, record_pool_acquire, LockType, Outcome, PoolRole, + TransactionOperation, TransactionTimer, + }; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use std::collections::{BTreeMap, BTreeSet}; + use std::time::Duration; + + #[test] + fn label_vocabularies_are_closed_and_documented() { + assert_eq!(PoolRole::ALL.map(PoolRole::as_str), ["writer", "reader"]); + assert_eq!( + LockType::ALL.map(LockType::as_str), + [ + "replacement", + "membership", + "push_gate", + "deletion", + "migration_schema_safety", + ] + ); + assert_eq!( + Outcome::ALL.map(Outcome::as_str), + ["success", "error", "timeout"] + ); + assert_eq!( + TransactionOperation::ALL.map(TransactionOperation::as_str), + [ + "replace_parameterized_event", + "replace_addressable_event", + "publish_nip43_membership_locked", + "accept_push_lease_event", + "begin_community_deletion_quiescing", + "fence_community_deletion", + ] + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn transaction_timer_observe_classifies_result_outcomes() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let success = TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent) + .observe(async { Ok::<_, &str>("committed") }) + .await; + assert_eq!(success, Ok("committed")); + + let error = TransactionTimer::start(TransactionOperation::AcceptPushLeaseEvent) + .observe(async { Err::<(), _>("rollback") }) + .await; + assert_eq!(error, Err("rollback")); + + let keys = snapshotter + .snapshot() + .into_vec() + .into_iter() + .map(|(key, ..)| { + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + (key.key().name().to_owned(), labels) + }) + .collect::>(); + + for (operation, outcome) in [ + ("replace_parameterized_event", "success"), + ("accept_push_lease_event", "error"), + ] { + assert!(keys.contains(&( + "buzz_db_transaction_duration_seconds".to_owned(), + [ + ("operation".to_owned(), operation.to_owned()), + ("outcome".to_owned(), outcome.to_owned()), + ] + .into_iter() + .collect(), + ))); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn primitives_record_fixed_success_error_and_timeout_labels() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + record_pool_acquire( + PoolRole::Writer, + Outcome::Success, + Duration::from_millis(12), + ); + record_pool_acquire( + PoolRole::Reader, + Outcome::Timeout, + Duration::from_millis(34), + ); + let lock_ok: sqlx::Result<()> = + observe_advisory_lock(LockType::Replacement, async { Ok(()) }).await; + assert!(lock_ok.is_ok()); + let lock_error: sqlx::Result<()> = + observe_advisory_lock(LockType::Membership, async { Err(sqlx::Error::PoolClosed) }) + .await; + assert!(lock_error.is_err()); + + let committed: Result<(), ()> = + TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent) + .observe(async { Ok(()) }) + .await; + assert!(committed.is_ok()); + let rolled_back: Result<(), ()> = + TransactionTimer::start(TransactionOperation::AcceptPushLeaseEvent) + .observe(async { Err(()) }) + .await; + assert!(rolled_back.is_err()); + + let snapshot = snapshotter.snapshot().into_vec(); + let keys = snapshot + .iter() + .map(|(key, ..)| { + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + (key.key().name().to_owned(), labels) + }) + .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")], + ), + ( + "buzz_db_advisory_lock_wait_seconds", + [("lock_type", "replacement"), ("outcome", "success")], + ), + ( + "buzz_db_advisory_lock_wait_seconds", + [("lock_type", "membership"), ("outcome", "error")], + ), + ( + "buzz_db_advisory_lock_acquisitions_total", + [("lock_type", "replacement"), ("outcome", "success")], + ), + ( + "buzz_db_advisory_lock_acquisitions_total", + [("lock_type", "membership"), ("outcome", "error")], + ), + ( + "buzz_db_transaction_duration_seconds", + [ + ("operation", "replace_parameterized_event"), + ("outcome", "success"), + ], + ), + ( + "buzz_db_transaction_duration_seconds", + [ + ("operation", "accept_push_lease_event"), + ("outcome", "error"), + ], + ), + ] { + let expected_labels = expected + .1 + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + assert!( + keys.contains(&(expected.0.to_owned(), expected_labels)), + "missing metric series {expected:?}; got {keys:?}" + ); + } + + for (key, _, _, value) in snapshot { + if key.key().name().ends_with("_seconds") { + let DebugValue::Histogram(samples) = value else { + panic!("seconds metrics must be histograms"); + }; + assert!(samples.iter().all(|sample| sample.into_inner() >= 0.0)); + } else if key.key().name().ends_with("_total") { + let DebugValue::Counter(value) = value else { + panic!("total metrics must be counters"); + }; + assert_eq!(value, 1); + } + } + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { + 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_millis(75)) + .connect(&database_url) + .await + .expect("connect size-one test pool"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let held = acquire(&pool, PoolRole::Writer) + .await + .expect("writer acquire succeeds"); + let timeout = acquire(&pool, PoolRole::Reader) + .await + .expect_err("reader-labeled checkout times out while pool is saturated"); + assert!(matches!(timeout, sqlx::Error::PoolTimedOut)); + drop(held); + pool.close().await; + let closed = acquire(&pool, PoolRole::Writer) + .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 + .iter() + .find(|label| label.key() == name) + .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(), + ); + } + assert!(outcomes.contains_key(&("writer".to_owned(), "success".to_owned()))); + assert!(outcomes.contains_key(&("writer".to_owned(), "error".to_owned()))); + let timeout_samples = outcomes + .get(&("reader".to_owned(), "timeout".to_owned())) + .expect("reader timeout series"); + assert!( + timeout_samples.iter().any(|sample| *sample >= 0.05), + "timeout wait must include the saturated checkout delay: {timeout_samples:?}" + ); + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + 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 pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(4) + .connect(&database_url) + .await + .expect("connect advisory-lock test pool"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let mut success_tx = pool.begin().await.expect("begin success transaction"); + observe_advisory_lock( + LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(0x62757a7a6f627331_i64) + .execute(&mut *success_tx), + ) + .await + .expect("uncontended lock succeeds"); + success_tx + .rollback() + .await + .expect("rollback success transaction"); + + let contention_key = 0x62757a7a6f627332_i64; + let mut holder = pool.begin().await.expect("begin lock holder"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(contention_key) + .execute(&mut *holder) + .await + .expect("holder acquires contention key"); + let mut waiter = pool.begin().await.expect("begin lock waiter"); + let waiter_task = tokio::spawn(async move { + let result = observe_advisory_lock( + LockType::Deletion, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(contention_key) + .execute(&mut *waiter), + ) + .await; + (waiter, result) + }); + tokio::time::sleep(Duration::from_millis(60)).await; + assert!( + !waiter_task.is_finished(), + "waiter must be blocked by holder" + ); + holder.commit().await.expect("release contention key"); + let (waiter, waited) = waiter_task.await.expect("join lock waiter"); + waited.expect("contended lock succeeds after release"); + waiter.rollback().await.expect("rollback waiter"); + + let timeout_key = 0x62757a7a6f627333_i64; + let mut timeout_holder = pool.begin().await.expect("begin timeout holder"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(timeout_key) + .execute(&mut *timeout_holder) + .await + .expect("holder acquires timeout key"); + let mut timeout_waiter = pool.begin().await.expect("begin timeout waiter"); + sqlx::query("SET LOCAL lock_timeout = '30ms'") + .execute(&mut *timeout_waiter) + .await + .expect("set test-only lock timeout"); + let timed_out = observe_advisory_lock( + LockType::MigrationSchemaSafety, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(timeout_key) + .execute(&mut *timeout_waiter), + ) + .await + .expect_err("lock wait times out"); + assert_eq!( + timed_out + .as_database_error() + .and_then(|error| error.code()) + .as_deref(), + Some("55P03") + ); + timeout_holder + .rollback() + .await + .expect("release timeout key"); + + let mut aborted = pool.begin().await.expect("begin error transaction"); + sqlx::query("SELECT 1 / 0") + .execute(&mut *aborted) + .await + .expect_err("abort transaction before lock"); + observe_advisory_lock( + LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(0x62757a7a6f627334_i64) + .execute(&mut *aborted), + ) + .await + .expect_err("lock statement fails in aborted transaction"); + aborted + .rollback() + .await + .expect("rollback aborted transaction"); + + let mut outcomes = BTreeMap::<(String, String), Vec>::new(); + for (key, _, _, value) in snapshotter.snapshot().into_vec() { + if key.key().name() != "buzz_db_advisory_lock_wait_seconds" { + continue; + } + let DebugValue::Histogram(samples) = value else { + panic!("lock wait must be a histogram"); + }; + let labels = key.key().labels().collect::>(); + let label = |name: &str| { + labels + .iter() + .find(|label| label.key() == name) + .map(|label| label.value().to_owned()) + .unwrap_or_default() + }; + outcomes.insert( + (label("lock_type"), label("outcome")), + samples + .into_iter() + .map(|sample| sample.into_inner()) + .collect(), + ); + } + assert!(outcomes.contains_key(&("replacement".to_owned(), "success".to_owned()))); + assert!(outcomes.contains_key(&("membership".to_owned(), "error".to_owned()))); + assert!( + outcomes.contains_key(&("migration_schema_safety".to_owned(), "timeout".to_owned())) + ); + let contention = outcomes + .get(&("deletion".to_owned(), "success".to_owned())) + .expect("deletion contention series"); + assert!( + contention.iter().any(|sample| *sample >= 0.04), + "lock timer must include the holder wait: {contention:?}" + ); + } +} diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/push.rs index 263ef6ee64..b00e8e4074 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/push.rs @@ -25,10 +25,13 @@ async fn acquire_push_gate_lock( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, community: CommunityId, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") - .bind(format!("{PUSH_GATE_LOCK_NAMESPACE}{}", community.as_uuid())) - .execute(&mut **tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::PushGate, + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!("{PUSH_GATE_LOCK_NAMESPACE}{}", community.as_uuid())) + .execute(&mut **tx), + ) + .await?; Ok(()) } @@ -222,7 +225,13 @@ pub async fn accept_lease_event( max_active_leases: i64, ) -> Result { let author = event.pubkey.as_bytes(); - let mut tx = pool.begin().await?; + let (mut tx, transaction_timer) = crate::observability::begin_transaction( + pool, + crate::observability::TransactionOperation::AcceptPushLeaseEvent, + ) + .await?; + transaction_timer + .observe(async { let mut address_lock = Vec::with_capacity(16 + author.len() + installation_id.len()); address_lock.extend_from_slice(community.as_uuid().as_bytes()); address_lock.extend_from_slice(author); @@ -238,14 +247,20 @@ pub async fn accept_lease_event( let mut author_lock_bytes = [0_u8; 8]; author_lock_bytes.copy_from_slice(&author_digest[..8]); let author_lock = i64::from_le_bytes(author_lock_bytes); - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(address_lock) - .execute(&mut *tx) - .await?; - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(author_lock) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::PushGate, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(address_lock) + .execute(&mut *tx), + ) + .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::PushGate, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(author_lock) + .execute(&mut *tx), + ) + .await?; // T1b: an activation can flip the community from "no eligible lease" to // "eligible", so it must serialize against the trigger's shared gate lock. // Acquired after the address/author locks to keep one global lock order. @@ -400,6 +415,8 @@ pub async fn accept_lease_event( } tx.commit().await?; Ok(AcceptLeaseOutcome::Accepted) + }) + .await } fn constraint_acceptance_outcome(error: &sqlx::Error) -> Option { @@ -1282,7 +1299,7 @@ mod tests { async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); // sadscan:disable np.postgres.1 -- local test-only credentials let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 402229cdec..3cb86e8a43 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -473,10 +473,13 @@ pub async fn transfer_ownership( // 1. Serialize on the transferee so concurrent transfers to the same // recipient cannot both pass the ownership count check. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(owner_count_advisory_lock_key(&pubkey)) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(owner_count_advisory_lock_key(&pubkey)) + .execute(&mut *tx), + ) + .await?; // 2. Lock the current owner row FOR UPDATE and verify the expected owner. // FOR UPDATE prevents the stale-owner race: a concurrent transfer that @@ -637,7 +640,7 @@ mod tests { use super::*; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/replaceable.rs b/crates/buzz-db/src/replaceable.rs index aa57a51666..9b575b6ea1 100644 --- a/crates/buzz-db/src/replaceable.rs +++ b/crates/buzz-db/src/replaceable.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Utc}; use sqlx::{Acquire, Postgres, Transaction}; use uuid::Uuid; +use crate::observability::{self, LockType, TransactionOperation}; use crate::{Db, DbError, Result}; /// Result category for a parameterized-replaceable event write. @@ -122,10 +123,13 @@ async fn replace_parameterized_event_in_transaction_impl( pubkey_bytes.as_slice(), Some(d_tag.as_bytes()), ); - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut **tx) - .await?; + observability::observe_advisory_lock( + LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut **tx), + ) + .await?; let d_tag_count = event .tags @@ -359,6 +363,163 @@ async fn replace_parameterized_event_in_transaction_impl( } impl Db { + /// Atomically replace a replaceable event: NIP-16 kinds (0, 3, 41, 10000–19999) + /// and NIP-29 discovery state (39000–39002, called from side_effects.rs). + /// + /// Keeps only the event with the highest `created_at` per (kind, pubkey, channel_id). + /// Same-second ties are broken by lowest event `id` (NIP-16 deterministic ordering). + /// Returns `(event, false)` for stale writes and duplicate IDs — callers should + /// skip fan-out/dispatch when `was_inserted` is false. + #[datastore_span(name = "replace_addressable_event", system = "postgresql")] + pub async fn replace_addressable_event( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + ) -> Result<(StoredEvent, bool)> { + let kind_i32 = buzz_core::kind::event_kind_i32(event); + let pubkey_bytes = event.pubkey.to_bytes(); + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + + // Collisions only cause extra serialization; they cannot change behavior. + let lock_key = event_replacement_lock_key( + community_id, + kind_i32, + pubkey_bytes.as_slice(), + channel_id.as_ref().map(|id| id.as_bytes().as_slice()), + ); + + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + observability::TransactionOperation::ReplaceAddressableEvent, + ) + .await?; + + transaction_timer + .observe(async { + // Serialize all writers for the same (kind, pubkey, channel_id) tuple. + // Advisory lock is transaction-scoped — released on commit/rollback. + observability::observe_advisory_lock( + observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx), + ) + .await?; + + // Check for the newest existing event. ORDER BY + LIMIT 1 is defensive against + // historical data where prior bugs may have left multiple live rows. + let existing: Option<(chrono::DateTime, Vec)> = + sqlx::query_as( + "SELECT created_at, id FROM events \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NOT DISTINCT FROM $4 \ + AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .bind(channel_id) + .fetch_optional(&mut *tx) + .await?; + + // Stale-write protection: reject if incoming is not newer. + // NIP-16: created_at is second-resolution. On same-second tie, lowest + // event id (lexicographic) wins — deterministic across relays. + let incoming_id = event.id.as_bytes().as_slice(); + if let Some((existing_ts, existing_id)) = existing { + let dominated = created_at < existing_ts + || (created_at == existing_ts + && incoming_id >= existing_id.as_slice()); + if dominated { + tx.rollback().await?; + let received_at = chrono::Utc::now(); + return Ok(( + StoredEvent::with_received_at( + event.clone(), + received_at, + channel_id, + false, + ), + false, + )); + } + } + + // Soft-delete the old event (if any). IS NOT DISTINCT FROM for NULL safety. + sqlx::query( + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NOT DISTINCT FROM $4 \ + AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .bind(channel_id) + .execute(&mut *tx) + .await?; + + // Insert the new event inside the same transaction. + let sig_bytes = event.sig.serialize(); + let tags_json = serde_json::to_value(&event.tags)?; + let received_at = chrono::Utc::now(); + let d_tag = crate::event::extract_d_tag(event); + + let insert_result = sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(pubkey_bytes.as_slice()) + .bind(created_at) + .bind(kind_i32) + .bind(&tags_json) + .bind(&event.content) + .bind(sig_bytes.as_slice()) + .bind(received_at) + .bind(channel_id) + .bind(d_tag.as_deref()) + .execute(&mut *tx) + .await?; + + let was_inserted = insert_result.rows_affected() > 0; + if !was_inserted { + // ON CONFLICT fired — the event ID already exists. Rollback the + // soft-delete so we don't lose the previous replaceable event. + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at( + event.clone(), + received_at, + channel_id, + false, + ), + false, + )); + } + + // The replaceable event and its denormalized mention index are one + // authoritative discovery write. An indexing error must roll back the + // new event and restore the previously-live event. + crate::insert_mentions_in_transaction(&mut tx, community_id, event, channel_id) + .await?; + + tx.commit().await?; + + Ok(( + StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), + true, + )) + }) + .await + } + /// Replace a NIP-33 event inside a caller-owned transaction. /// /// The caller owns commit or rollback. Requiring [`Transaction`] here and @@ -395,23 +556,1198 @@ impl Db { d_tag: &str, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let mut tx = self.pool.begin().await?; - let result = self - .replace_parameterized_event_in_transaction( - &mut tx, - community_id, - event, - d_tag, - channel_id, - ParameterizedReplacePrecondition::Unconditional, - ) - .await?; - let was_inserted = result.status == ParameterizedReplaceStatus::Inserted; - if was_inserted { - tx.commit().await?; - } else { - tx.rollback().await?; - } - Ok((result.event, was_inserted)) + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + TransactionOperation::ReplaceParameterizedEvent, + ) + .await?; + transaction_timer + .observe(async { + let result = self + .replace_parameterized_event_in_transaction( + &mut tx, + community_id, + event, + d_tag, + channel_id, + ParameterizedReplacePrecondition::Unconditional, + ) + .await?; + let was_inserted = result.status == ParameterizedReplaceStatus::Inserted; + if was_inserted { + tx.commit().await?; + } else { + tx.rollback().await?; + } + Ok((result.event, was_inserted)) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{event, migration, replaceable}; + use sqlx::postgres::PgPoolOptions; + use sqlx::{Acquire, PgPool}; + use std::time::Duration; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + + async fn setup_db() -> Db { + let database_url = + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + Db::from_pool(pool) + } + + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("communities-of-channels-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id + } + + async fn admin_url() -> String { + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) + } + + /// Create a fresh scratch database on the same server and optionally run migrations. + async fn create_scratch_db_through( + admin: &PgPool, + prefix: &str, + target: Option, + ) -> (PgPool, String) { + let name = format!("{}_{}", prefix, Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(admin) + .await + .expect("create scratch db"); + let base = admin_url().await; + // Swap the database path segment of the admin URL for the scratch name. + let scratch_url = { + let idx = base.rfind('/').expect("db url has a path segment"); + format!("{}/{}", &base[..idx], name) + }; + let pool = PgPool::connect(&scratch_url) + .await + .expect("connect scratch db"); + match target { + Some(target) => migration::run_migrations_through(&pool, target) + .await + .expect("migrate scratch db through target"), + None => migration::run_migrations(&pool) + .await + .expect("migrate scratch db"), + } + (pool, name) + } + + /// Create a fresh scratch database on the same server and run all migrations. + /// Returns (pool, db_name); callers should `drop_scratch_db` when done. + async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) { + create_scratch_db_through(admin, prefix, None).await + } + + async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(admin) + .await; + } + + /// Insert identical community + channel rows into a database so the same + /// (community, channel) ids resolve in both writer and replica. + async fn seed_community_channel( + pool: &PgPool, + community: Uuid, + channel: Uuid, + author: &nostr::Keys, + ) { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("replica-routing-{}.example", community.simple())) + .execute(pool) + .await + .expect("insert community"); + crate::channel::create_channel_with_id( + pool, + CommunityId::from_uuid(community), + channel, + &format!("replica-routing-{channel}"), + crate::channel::ChannelType::Stream, + crate::channel::ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn addressable_replacement_rolls_back_when_mention_indexing_fails() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, scratch_name) = create_scratch_db(&admin, "atomic_addressable").await; + let db = Db::from_pool(pool.clone()); + let community_uuid = Uuid::new_v4(); + let channel = Uuid::new_v4(); + let keys = Keys::generate(); + let owner_keys = Keys::generate(); + seed_community_channel(&pool, community_uuid, channel, &owner_keys).await; + let community = CommunityId::from_uuid(community_uuid); + let member = owner_keys.public_key().to_hex(); + let tags = || { + vec![ + Tag::parse(["d", channel.to_string().as_str()]).expect("d tag"), + Tag::parse(["p", member.as_str(), "", "owner"]).expect("p tag"), + ] + }; + let base = Timestamp::now().as_secs(); + let old = EventBuilder::new(Kind::Custom(39002), "old") + .tags(tags()) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign old"); + db.replace_addressable_event(community, &old, Some(channel)) + .await + .expect("insert old roster"); + + sqlx::query( + "CREATE FUNCTION reject_test_mention() RETURNS trigger AS $$ \ + BEGIN RAISE EXCEPTION 'injected mention failure'; END; \ + $$ LANGUAGE plpgsql", + ) + .execute(&pool) + .await + .expect("create failure function"); + sqlx::query( + "CREATE TRIGGER reject_test_mention BEFORE INSERT ON event_mentions \ + FOR EACH ROW EXECUTE FUNCTION reject_test_mention()", + ) + .execute(&pool) + .await + .expect("install failure injection"); + + let new = EventBuilder::new(Kind::Custom(39002), "new") + .tags(tags()) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign new"); + let error = db + .replace_addressable_event(community, &new, Some(channel)) + .await + .expect_err("mention failure must fail replacement"); + assert!(error.to_string().contains("injected mention failure")); + + let live_id: Vec = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \ + AND kind=39002 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel) + .fetch_one(&pool) + .await + .expect("query live roster"); + assert_eq!(live_id, old.id.as_bytes(), "old roster must remain live"); + let new_rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(new.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rolled-back event"); + assert_eq!(new_rows, 0, "new roster must roll back with its index"); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn stale_legacy_roster_cannot_replace_new_locked_snapshot() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (setup_pool, scratch_name) = create_scratch_db(&admin, "mixed_roster_writer").await; + let base_url = admin_url().await; + let slash = base_url.rfind('/').expect("database URL has path segment"); + let scratch_url = format!("{}/{}", &base_url[..slash], scratch_name); + let pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(1)) + .connect(&scratch_url) + .await + .expect("connect one-connection scratch pool"); + setup_pool.close().await; + let db = Db::from_pool(pool.clone()); + let community_uuid = Uuid::new_v4(); + let community = CommunityId::from_uuid(community_uuid); + let channel = Uuid::new_v4(); + let relay_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_bytes(); + seed_community_channel(&pool, community_uuid, channel, &owner_keys).await; + + // This is the old pod's unlocked capture A. It remains in process memory + // while a role-only canonical mutation advances and the new pod publishes B. + let base = Timestamp::now().as_secs(); + let roster = |members: &[(&[u8], &str)], timestamp| { + let tags = + std::iter::once(Tag::parse(["d", channel.to_string().as_str()]).expect("d tag")) + .chain(members.iter().map(|(member, role)| { + Tag::parse(["p", hex::encode(member).as_str(), "", *role]).expect("p tag") + })) + .collect::>(); + EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .custom_created_at(Timestamp::from(timestamp)) + .sign_with_keys(&relay_keys) + .expect("sign roster") + }; + + let newcomer = Keys::generate().public_key().to_bytes(); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $4)", + ) + .bind(community_uuid) + .bind(channel) + .bind(newcomer.as_slice()) + .bind(owner.as_slice()) + .execute(&pool) + .await + .expect("seed member before legacy capture"); + let stale_a = roster( + &[(owner.as_slice(), "owner"), (newcomer.as_slice(), "member")], + base + 2, + ); + + sqlx::query( + "UPDATE channel_members SET role = 'admin' \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_uuid) + .bind(channel) + .bind(newcomer.as_slice()) + .execute(&pool) + .await + .expect("commit newer canonical role"); + + let relay_pubkey = relay_keys.public_key().to_bytes(); + let mut snapshot = db + .lock_member_snapshot(community, channel, &relay_pubkey) + .await + .expect("new writer captures locked roster B"); + let fresh_b = roster( + &[(owner.as_slice(), "owner"), (newcomer.as_slice(), "admin")], + base + 1, + ); + assert!( + snapshot + .replace_member_event(community, channel, &fresh_b) + .await + .expect("new writer publishes B") + .1 + ); + snapshot + .release() + .await + .expect("commit B and release locks"); + + // The legacy canonical path takes the replacement key, soft-deletes B, + // then attempts its newer-timestamp stale A. Migration 0032 rejects the + // INSERT; transaction rollback must restore B. A one-connection pool + // proves the lock order does not turn this compatibility path into a + // self-deadlock. + let error = tokio::time::timeout( + Duration::from_secs(3), + db.replace_addressable_event(community, &stale_a, Some(channel)), + ) + .await + .expect("legacy replacement must not deadlock") + .expect_err("stale captured roster A must be rejected"); + assert!( + matches!( + error, + DbError::Sqlx(sqlx::Error::Database(ref db_error)) + if db_error.code().as_deref() == Some("23514") + ), + "expected roster fence check violation, got {error:?}" + ); + + let live_ids: Vec> = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \ + AND kind=39002 AND pubkey=$3 AND deleted_at IS NULL", + ) + .bind(community_uuid) + .bind(channel) + .bind(relay_pubkey.as_slice()) + .fetch_all(&pool) + .await + .expect("load live roster heads"); + assert_eq!(live_ids, vec![fresh_b.id.as_bytes().to_vec()]); + let stale_rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(community_uuid) + .bind(stale_a.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rejected stale roster"); + assert_eq!(stale_rows, 0, "stale roster insert must roll back"); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_rs_replacement_hard_deletes_payload_and_watermark_rejects_replay() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = format!("read-state:{}", "a".repeat(32)); + let tags = vec![ + Tag::parse(["d", d_tag.as_str()]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]; + let base = Timestamp::now().as_secs(); + let old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "old") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign old"); + let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "new") + .tags(tags) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign new"); + + assert!( + db.replace_parameterized_event(community, &old, &d_tag, None) + .await + .expect("insert old") + .1 + ); + assert!( + db.replace_parameterized_event(community, &new, &d_tag, None) + .await + .expect("replace with new") + .1 + ); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count NIP-RS rows"); + assert_eq!(rows, 1, "superseded payload must be physically deleted"); + + sqlx::query( + "UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .execute(&db.pool) + .await + .expect("simulate NIP-09 coordinate deletion"); + + assert!( + !db.replace_parameterized_event(community, &old, &d_tag, None) + .await + .expect("replay old") + .1 + ); + let live: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count live NIP-RS rows"); + assert_eq!(live, 0, "watermark must block stale resurrection"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_rs_transaction_operation_restores_hard_delete_opt_in() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let base = Timestamp::now().as_secs(); + let replace_d_tag = format!("read-state:{}", "b".repeat(32)); + let victim_d_tag = format!("read-state:{}", "c".repeat(32)); + let event = |d_tag: &str, content: &str, timestamp: u64| { + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + content, + ) + .tags(vec![ + Tag::parse(["d", d_tag]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]) + .custom_created_at(Timestamp::from(timestamp)) + .sign_with_keys(&keys) + .expect("sign read state") + }; + let old = event(&replace_d_tag, "old", base); + let new = event(&replace_d_tag, "new", base + 1); + let victim = event(&victim_d_tag, "victim", base); + + assert!( + db.replace_parameterized_event(community, &old, &replace_d_tag, None) + .await + .expect("insert old head") + .1 + ); + assert!( + db.replace_parameterized_event(community, &victim, &victim_d_tag, None) + .await + .expect("insert victim head") + .1 + ); + + let mut tx = db + .begin_transaction() + .await + .expect("begin caller transaction"); + let result = db + .replace_parameterized_event_in_transaction( + &mut tx, + community, + &new, + &replace_d_tag, + None, + replaceable::ParameterizedReplacePrecondition::Unconditional, + ) + .await + .expect("replace inside caller transaction"); + assert_eq!( + result.status, + replaceable::ParameterizedReplaceStatus::Inserted + ); + + let leaked: Option = sqlx::query_scalar( + "SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')", + ) + .fetch_one(&mut *tx) + .await + .expect("read hard-delete opt-in after replacement"); + assert_ne!(leaked.as_deref(), Some("on")); + + let unauthorized = sqlx::query( + "DELETE FROM events WHERE community_id=$1 AND kind=30078 \ + AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&victim_d_tag) + .execute(&mut *tx) + .await; + assert!( + unauthorized.is_err(), + "replacement opt-in must not authorize later caller SQL" + ); + tx.rollback().await.expect("roll back caller transaction"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn parameterized_replacement_in_existing_transaction_honors_revision_and_rollback() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = format!("transactional-project-{}", Uuid::new_v4().simple()); + let base = Timestamp::now().as_secs(); + let event = |content: &str, timestamp: u64| { + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) + .tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")]) + .custom_created_at(Timestamp::from(timestamp)) + .sign_with_keys(&keys) + .expect("sign project") + }; + let old = event("old", base); + let new = event("new", base + 1); + + assert!( + db.replace_parameterized_event(community, &old, &d_tag, None) + .await + .expect("insert old head") + .1 + ); + + let mut tx = db.begin_transaction().await.expect("begin replacement tx"); + let outcome = db + .replace_parameterized_event_in_transaction( + &mut tx, + community, + &new, + &d_tag, + None, + replaceable::ParameterizedReplacePrecondition::ExpectedRevision( + old.id.as_bytes().as_slice(), + ), + ) + .await + .expect("replace inside caller transaction"); + assert_eq!( + outcome.status, + replaceable::ParameterizedReplaceStatus::Inserted + ); + tx.rollback().await.expect("roll back replacement tx"); + + let live_id: Vec = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \ + AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_PROJECT as i32) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("load live head after rollback"); + assert_eq!(live_id, old.id.as_bytes().to_vec()); + + let mut tx = db + .begin_transaction() + .await + .expect("begin stale revision tx"); + let mismatch = db + .replace_parameterized_event_in_transaction( + &mut tx, + community, + &new, + &d_tag, + None, + replaceable::ParameterizedReplacePrecondition::ExpectedRevision( + [0x42; 32].as_slice(), + ), + ) + .await + .expect("evaluate stale revision"); + assert_eq!( + mismatch.status, + replaceable::ParameterizedReplaceStatus::RevisionMismatch + ); + tx.rollback().await.expect("roll back stale revision tx"); + + let missing_d_tag = format!("missing-project-{}", Uuid::new_v4().simple()); + let missing = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), + "missing", + ) + .tags(vec![ + Tag::parse(["d", missing_d_tag.as_str()]).expect("missing d tag") + ]) + .custom_created_at(Timestamp::from(base + 2)) + .sign_with_keys(&keys) + .expect("sign missing project"); + let mut tx = db + .begin_transaction() + .await + .expect("begin missing revision tx"); + let missing_result = db + .replace_parameterized_event_in_transaction( + &mut tx, + community, + &missing, + &missing_d_tag, + None, + replaceable::ParameterizedReplacePrecondition::ExpectedRevision( + [0x24; 32].as_slice(), + ), + ) + .await + .expect("evaluate missing revision"); + assert_eq!( + missing_result.status, + replaceable::ParameterizedReplaceStatus::RevisionMissing + ); + tx.rollback().await.expect("roll back missing revision tx"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn parameterized_replacement_rolls_back_when_mention_indexing_fails() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, scratch_name) = create_scratch_db(&admin, "atomic_parameterized").await; + let db = Db::from_pool(pool.clone()); + let community = CommunityId::from_uuid(make_community(&pool).await); + let keys = Keys::generate(); + let mentioned = Keys::generate().public_key().to_hex(); + let d_tag = format!("mention-project-{}", Uuid::new_v4().simple()); + let tags = || { + vec![ + Tag::parse(["d", d_tag.as_str()]).expect("d tag"), + Tag::parse(["p", mentioned.as_str()]).expect("p tag"), + ] + }; + let base = Timestamp::now().as_secs(); + let old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), "old") + .tags(tags()) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign old project"); + let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), "new") + .tags(tags()) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign new project"); + + assert!( + db.replace_parameterized_event(community, &old, &d_tag, None) + .await + .expect("insert old project") + .1 + ); + sqlx::query( + "CREATE FUNCTION reject_test_mention() RETURNS trigger AS $$ \ + BEGIN RAISE EXCEPTION 'injected mention failure'; END; \ + $$ LANGUAGE plpgsql", + ) + .execute(&pool) + .await + .expect("create failure function"); + sqlx::query( + "CREATE TRIGGER reject_test_mention BEFORE INSERT ON event_mentions \ + FOR EACH ROW EXECUTE FUNCTION reject_test_mention()", + ) + .execute(&pool) + .await + .expect("install failure injection"); + + let mut tx = db + .begin_transaction() + .await + .expect("begin caller transaction"); + let error = db + .replace_parameterized_event_in_transaction( + &mut tx, + community, + &new, + &d_tag, + None, + replaceable::ParameterizedReplacePrecondition::Unconditional, + ) + .await + .expect_err("mention failure must fail replacement"); + assert!(error.to_string().contains("injected mention failure")); + + let probe: i32 = sqlx::query_scalar("SELECT 1") + .fetch_one(&mut *tx) + .await + .expect("inner failure must leave caller transaction usable"); + assert_eq!(probe, 1); + + let live_id: Vec = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \ + AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_PROJECT as i32) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&mut *tx) + .await + .expect("load live project after failed indexing"); + assert_eq!(live_id, old.id.as_bytes().to_vec()); + let new_rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(new.id.as_bytes().as_slice()) + .fetch_one(&mut *tx) + .await + .expect("count rolled-back project"); + assert_eq!(new_rows, 0); + tx.commit().await.expect("commit usable caller transaction"); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn parameterized_duplicate_restores_live_head_inside_caller_transaction() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = format!("duplicate-project-{}", Uuid::new_v4().simple()); + let base = Timestamp::now().as_secs(); + let event = |content: &str, timestamp: u64| { + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) + .tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")]) + .custom_created_at(Timestamp::from(timestamp)) + .sign_with_keys(&keys) + .expect("sign project") + }; + let old = event("old-live-head", base); + let duplicate = event("soft-deleted-duplicate", base + 1); + + assert!( + db.replace_parameterized_event(community, &duplicate, &d_tag, None) + .await + .expect("insert future duplicate") + .1 + ); + sqlx::query("UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(duplicate.id.as_bytes().as_slice()) + .execute(&db.pool) + .await + .expect("soft-delete duplicate row"); + + let mut seed_tx = db + .begin_transaction() + .await + .expect("begin seed transaction"); + let (_, was_inserted) = + event::insert_event_in_transaction(&mut seed_tx, community, &old, None) + .await + .expect("insert older live head"); + assert!(was_inserted); + seed_tx.commit().await.expect("commit older live head"); + + let mut tx = db + .begin_transaction() + .await + .expect("begin caller transaction"); + let result = db + .replace_parameterized_event_in_transaction( + &mut tx, + community, + &duplicate, + &d_tag, + None, + replaceable::ParameterizedReplacePrecondition::Unconditional, + ) + .await + .expect("evaluate soft-deleted duplicate"); + assert_eq!( + result.status, + replaceable::ParameterizedReplaceStatus::Duplicate + ); + + let live_id: Vec = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \ + AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_PROJECT as i32) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&mut *tx) + .await + .expect("caller transaction remains usable after duplicate"); + assert_eq!(live_id, old.id.as_bytes().to_vec()); + tx.rollback().await.expect("roll back caller transaction"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_parameterized_replacement_keeps_deterministic_head() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = format!("concurrent-project-{}", Uuid::new_v4().simple()); + let created_at = Timestamp::now().as_secs(); + let event = |content: &str, timestamp: u64| { + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) + .tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")]) + .custom_created_at(Timestamp::from(timestamp)) + .sign_with_keys(&keys) + .expect("sign project") + }; + let first = event("first", created_at); + let second = event("second", created_at); + let expected = if first.id.as_bytes() < second.id.as_bytes() { + &first + } else { + &second + }; + + let (first_result, second_result) = tokio::join!( + db.replace_parameterized_event(community, &first, &d_tag, None), + db.replace_parameterized_event(community, &second, &d_tag, None), + ); + let first_inserted = first_result.expect("first concurrent writer").1; + let second_inserted = second_result.expect("second concurrent writer").1; + assert!( + first_inserted || second_inserted, + "at least one concurrent writer must insert", + ); + + let live_ids: Vec> = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \ + AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_PROJECT as i32) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_all(&db.pool) + .await + .expect("load concurrent live head"); + assert_eq!(live_ids, vec![expected.id.as_bytes().to_vec()]); + + assert!( + !db.replace_parameterized_event(community, expected, &d_tag, None) + .await + .expect("replay winning event") + .1, + "replaying the live event must be idempotent", + ); + let stale = event("stale", created_at.saturating_sub(1)); + assert!( + !db.replace_parameterized_event(community, &stale, &d_tag, None) + .await + .expect("submit stale event") + .1, + "an older event must not replace the live head", + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn mesh_status_replacement_keeps_one_physical_row() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = "buzz-mesh-member-status:owner-test"; + let tags = vec![ + Tag::parse(["d", d_tag]).expect("d tag"), + Tag::parse(["k", "buzz-mesh-status"]).expect("k tag"), + ]; + let base = Timestamp::now().as_secs(); + for (offset, content) in [(0, "running"), (1, "running-again"), (2, "stopped")] { + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_BOOKMARK_SET as u16), + content, + ) + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base + offset)) + .sign_with_keys(&keys) + .expect("sign mesh status"); + assert!( + db.replace_parameterized_event(community, &event, d_tag, None) + .await + .expect("replace mesh status") + .1 + ); + } + + let (rows, live): (i64, i64) = sqlx::query_as( + "SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \ + WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(d_tag) + .fetch_one(&db.pool) + .await + .expect("count mesh status rows"); + assert_eq!((rows, live), (1, 1)); + + sqlx::query( + "UPDATE events SET deleted_at=NOW() \ + WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(d_tag) + .execute(&db.pool) + .await + .expect("simulate old relay soft delete"); + let rows_after_legacy_delete: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(d_tag) + .fetch_one(&db.pool) + .await + .expect("count rows after old relay soft delete"); + assert_eq!( + rows_after_legacy_delete, 0, + "migration trigger must purge soft-deleted mesh status" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let base = Timestamp::now().as_secs(); + + for (case, tags) in [ + ( + "duplicate-d", + vec![ + Tag::parse(["d", &format!("read-state:{}", "c".repeat(32))]) + .expect("first d tag"), + Tag::parse(["d", &format!("read-state:{}", "d".repeat(32))]) + .expect("second d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ], + ), + ( + "duplicate-t", + vec![ + Tag::parse(["d", &format!("read-state:{}", "e".repeat(32))]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("first t tag"), + Tag::parse(["t", "read-state"]).expect("second t tag"), + ], + ), + ] { + let d_tag = tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().is_some_and(|part| part == "d") && parts.len() >= 2) + .then(|| parts[1].clone()) + }) + .expect("first d-tag value"); + let old = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + format!("{case}-old"), + ) + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign old event"); + let new = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + format!("{case}-new"), + ) + .tags(tags) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign new event"); + + assert!( + db.replace_parameterized_event(community, &old, &d_tag, None) + .await + .expect("insert old event") + .1 + ); + assert!( + db.replace_parameterized_event(community, &new, &d_tag, None) + .await + .expect("replace with new event") + .1 + ); + + let (rows, live): (i64, i64) = sqlx::query_as( + "SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count retained rows"); + assert_eq!((rows, live), (2, 1), "{case} must retain legacy history"); + + let watermarks: i64 = sqlx::query_scalar( + "SELECT count(*) FROM parameterized_event_watermarks \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count watermarks"); + assert_eq!(watermarks, 0, "{case} must not create a watermark"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let base = Timestamp::now().as_secs(); + let conforming_d = format!("read-state:{}", "6".repeat(32)); + let conforming = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + "fenced-conforming", + ) + .tags(vec![ + Tag::parse(["d", conforming_d.as_str()]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign conforming event"); + assert!( + db.replace_parameterized_event(community, &conforming, &conforming_d, None) + .await + .expect("insert conforming event") + .1 + ); + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("6".repeat(64)) + .bind(conforming.id.as_bytes().as_slice()) + .bind(conforming.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert mention"); + + // Model ce10's first destructive statement. RAISE aborts the transaction, + // so its later mention delete and incoming insert can never commit. + let mut old_writer = db.pool.begin().await.expect("begin old-writer tx"); + let rejected = sqlx::query( + "DELETE FROM events WHERE community_id=$1 AND kind=30078 \ + AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&conforming_d) + .execute(&mut *old_writer) + .await; + assert!(rejected.is_err(), "old-writer hard delete must be rejected"); + old_writer.rollback().await.expect("rollback rejected tx"); + let preserved: (i64, i64) = sqlx::query_as( + "SELECT (SELECT count(*) FROM events WHERE community_id=$1 AND id=$2), \ + (SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2)", + ) + .bind(community.as_uuid()) + .bind(conforming.id.as_bytes().as_slice()) + .fetch_one(&db.pool) + .await + .expect("count preserved payload and mention"); + assert_eq!(preserved, (1, 1)); + + let nonconforming_d = format!("read-state:{}", "7".repeat(32)); + let nonconforming = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + "fenced-nonconforming", + ) + .tags(vec![ + Tag::parse(["d", nonconforming_d.as_str()]).expect("first d tag"), + Tag::parse(["d", "other"]).expect("second d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign nonconforming event"); + assert!( + db.replace_parameterized_event(community, &nonconforming, &nonconforming_d, None,) + .await + .expect("insert nonconforming event") + .1 + ); + let rejected_nonconforming = sqlx::query( + "DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)", + ) + .bind(community.as_uuid()) + .bind(nonconforming.id.as_bytes().as_slice()) + .bind(nonconforming.created_at.as_secs() as f64) + .execute(&db.pool) + .await; + assert!( + rejected_nonconforming.is_err(), + "fence must cover a nonconforming OLD row at a regex coordinate" + ); + + let unrelated_d = format!("read-state:{}", "8".repeat(32)); + let unrelated = EventBuilder::new(Kind::Custom(30023), "unrelated") + .tags(vec![Tag::parse(["d", unrelated_d.as_str()]).expect("d tag")]) + .custom_created_at(Timestamp::from(base + 2)) + .sign_with_keys(&keys) + .expect("sign unrelated event"); + assert!( + db.replace_parameterized_event(community, &unrelated, &unrelated_d, None) + .await + .expect("insert unrelated event") + .1 + ); + let unrelated_delete = sqlx::query( + "DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)", + ) + .bind(community.as_uuid()) + .bind(unrelated.id.as_bytes().as_slice()) + .bind(unrelated.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("delete unrelated event"); + assert_eq!(unrelated_delete.rows_affected(), 1); + + // Check both transaction exits on one physical session; pool selection + // cannot accidentally hide a leaked session-local authorization value. + let mut conn = db.pool.acquire().await.expect("acquire dedicated session"); + for commit in [true, false] { + let mut tx = conn.begin().await.expect("begin GUC transaction"); + let value: String = + sqlx::query_scalar("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)") + .fetch_one(&mut *tx) + .await + .expect("set transaction-local GUC"); + assert_eq!(value, "on"); + if commit { + tx.commit().await.expect("commit GUC transaction"); + } else { + tx.rollback().await.expect("rollback GUC transaction"); + } + let leaked: Option = sqlx::query_scalar( + "SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')", + ) + .fetch_one(&mut *conn) + .await + .expect("read GUC after transaction"); + assert_ne!(leaked.as_deref(), Some("on")); + } } } diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs new file mode 100644 index 0000000000..724a9b47ed --- /dev/null +++ b/crates/buzz-db/tests/observability_source.rs @@ -0,0 +1,40 @@ +#[test] +fn database_metrics_and_slow_logs_exclude_sensitive_or_unbounded_fields() { + let implementation = include_str!("../src/observability.rs"); + let datastore_macro = include_str!("../../buzz-datastore-tracing/src/lib.rs"); + let instrumentation = format!("{implementation}\n{datastore_macro}"); + + for forbidden in [ + "\"community\" =>", + "\"event_id\" =>", + "\"event_kind\" =>", + "\"kind\" =>", + "\"sql\" =>", + "\"query\" =>", + "\"query_id\" =>", + "\"d_tag\" =>", + "\"coordinate\" =>", + "community =", + "event_id =", + "event_kind =", + "sql =", + "query_id =", + "d_tag =", + "coordinate =", + ] { + assert!( + !instrumentation.contains(forbidden), + "database instrumentation must not expose {forbidden}" + ); + } + + assert!(datastore_macro.contains("name: LitStr")); + assert!(datastore_macro.contains("\"operation\" => #name")); + assert!(datastore_macro.contains("elapsed_ms =")); + assert!( + datastore_macro.contains("parent: None"), + "slow warnings must not inherit dynamic datastore span fields" + ); + // The runtime tracing-layer assertion covers field names because a source + // search would also match ordinary local variables such as `record_error`. +} diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index ae3dbe4f39..f13b7d507a 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -2,6 +2,7 @@ #![warn(missing_docs)] //! Shared durable whole-community deletion engine and store adapters. +use std::future::Future; #[cfg(test)] use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -10,10 +11,14 @@ use std::time::Duration; use anyhow::{Context, Result}; use buzz_db::deletion::{ ClaimedDeletion, DeletionRequest, DeletionStage, DeletionStore, FrozenInventory, - KeyStreamDigest, LeaseToken, PrefixManifest, StorageManifest, DEFAULT_LEASE_DURATION, + KeyStreamDigest, LeaseToken, PrefixManifest, StorageManifest, StorageManifestEntry, + DEFAULT_LEASE_DURATION, }; use buzz_db::{Db, DbConfig}; -use buzz_media::{is_tenant_owned_key, tenant_prefixes, MediaStorage}; +use buzz_media::{ + is_tenant_owned_key, tenant_prefixes, BulkDeleteOutcome, MediaStorage, ObjectVersionKind, + ObjectVersionRef, +}; use clap::Subcommand; use serde::Serialize; use tokio_util::sync::CancellationToken; @@ -700,6 +705,26 @@ async fn flush_chunk(services: &Services, sink: &mut ChunkSink<'_>, prefix: &str Ok(()) } +fn manifest_kind_name(kind: ObjectVersionKind) -> &'static str { + match kind { + ObjectVersionKind::Object => "object", + ObjectVersionKind::DeleteMarker => "delete_marker", + } +} + +fn manifest_chunk_deleted_detail( + prefix: &str, + key_count: usize, + outcome: &buzz_media::BulkDeleteOutcome, +) -> serde_json::Value { + serde_json::json!({ + "prefix": prefix, + "keys": key_count, + "deleted": outcome.deleted, + "already_missing": outcome.already_missing, + }) +} + /// Enumerate the target's three tenant prefixes into per-prefix summaries. /// /// Cost is O(tenant objects) regardless of fleet size. Unknown shapes inside @@ -715,36 +740,44 @@ async fn enumerate_tenant_prefixes( heartbeat_lost: Option<&CancellationToken>, mut sink: Option<&mut ChunkSink<'_>>, ) -> Result { - if services.media.bucket_versioning_detected().await? { - return Err(permanent( - "bucket versioning detected; deletion cannot prove logical absence with delete markers", - )); - } let community = *request.community_id.as_uuid(); let chunk_keys = manifest_chunk_keys(); let mut prefixes = Vec::new(); for prefix in tenant_prefixes(community) { let mut digest = KeyStreamDigest::new(); let mut total_bytes: u64 = 0; - let mut continuation = None; + let mut key_marker = None; + let mut version_id_marker = None; loop { if heartbeat_lost.is_some_and(CancellationToken::is_cancelled) { return Err(DeletionLeaseLost.into()); } let page = services .media - .list_prefix_page(&prefix, continuation.take(), LIST_PAGE_SIZE) + .list_prefix_versions_page( + &prefix, + key_marker.take(), + version_id_marker.take(), + LIST_PAGE_SIZE, + ) .await?; - for (key, size) in page.objects { - if !is_tenant_owned_key(community, &key) { + for entry in page.entries { + if !is_tenant_owned_key(community, &entry.key) { return Err(permanent(format!( - "key under a tenant prefix is outside the exact writer taxonomy: {key}" + "key under a tenant prefix is outside the exact writer taxonomy: {}", + entry.key ))); } - digest.fold(&key)?; - total_bytes = total_bytes.saturating_add(size); + let encoded = StorageManifestEntry::new( + entry.key, + entry.version_id, + manifest_kind_name(entry.kind), + ) + .encode()?; + digest.fold_unordered(&encoded)?; + total_bytes = total_bytes.saturating_add(entry.size); if let Some(sink) = sink.as_deref_mut() { - sink.buffered.push(key); + sink.buffered.push(encoded); if sink.buffered.len() >= chunk_keys { flush_chunk(services, sink, &prefix).await?; } @@ -753,12 +786,12 @@ async fn enumerate_tenant_prefixes( if !page.is_truncated { break; } - continuation = page.next_continuation_token; - if continuation.is_none() { - return Err(transient( - "truncated tenant listing page has no continuation token", - )); - } + let (next_key_marker, next_version_id_marker) = require_truncated_version_markers( + page.next_key_marker, + page.next_version_id_marker, + )?; + key_marker = Some(next_key_marker); + version_id_marker = Some(next_version_id_marker); } if let Some(sink) = sink.as_deref_mut() { flush_chunk(services, sink, &prefix).await?; @@ -772,13 +805,113 @@ async fn enumerate_tenant_prefixes( }); } let manifest = StorageManifest { - version: 4, + version: 5, prefixes, }; buzz_db::deletion::validate_storage_manifest(&manifest)?; Ok(manifest) } +fn require_truncated_version_markers( + next_key_marker: Option, + next_version_id_marker: Option, +) -> Result<(String, String)> { + match (next_key_marker, next_version_id_marker) { + (Some(key_marker), Some(version_id_marker)) => Ok((key_marker, version_id_marker)), + (None, Some(_)) => Err(transient( + "truncated tenant version listing page has no key marker", + )), + (Some(_), None) => Err(transient( + "truncated tenant version listing page has no version id marker", + )), + (None, None) => Err(transient( + "truncated tenant version listing page has no key marker or version id marker", + )), + } +} + +async fn delete_manifest_chunk_with( + chunk: &buzz_db::deletion::ManifestKeyChunk, + storage_version: i32, + delete: F, +) -> Result +where + F: FnOnce(Vec) -> Fut, + Fut: Future>, +{ + let versions = object_versions_from_manifest_chunk(chunk, storage_version)?; + delete(versions).await +} + +fn object_versions_from_manifest_chunk( + chunk: &buzz_db::deletion::ManifestKeyChunk, + storage_version: i32, +) -> Result> { + if storage_version >= 5 { + chunk + .keys + .iter() + .map(|entry| { + let entry = StorageManifestEntry::decode(entry)?; + Ok(ObjectVersionRef { + key: entry.key, + version_id: entry.version_id, + }) + }) + .collect() + } else { + Ok(chunk + .keys + .iter() + .map(|key| ObjectVersionRef { + key: key.clone(), + version_id: String::new(), + }) + .collect()) + } +} + +fn manifest_chunk_deleted_checkpoint_detail( + chunk: &buzz_db::deletion::ManifestKeyChunk, + outcome: &BulkDeleteOutcome, +) -> Result { + validate_manifest_chunk_delete_outcome(chunk, outcome)?; + Ok(manifest_chunk_deleted_detail( + &chunk.prefix, + chunk.keys.len(), + outcome, + )) +} + +fn validate_manifest_chunk_delete_outcome( + chunk: &buzz_db::deletion::ManifestKeyChunk, + outcome: &BulkDeleteOutcome, +) -> Result<()> { + if !outcome.versioned_keys.is_empty() { + return Err(transient(format!( + "bulk delete returned version metadata for {} explicit versions: {}", + outcome.versioned_keys.len(), + outcome.versioned_keys.join(",") + ))); + } + if !outcome.failed.is_empty() { + let (key, code, message) = &outcome.failed[0]; + return Err(transient(format!( + "bulk delete failed for {} key(s); first: {key}: {code}: {message}", + outcome.failed.len() + ))); + } + let acknowledged = outcome.deleted.saturating_add(outcome.already_missing); + if acknowledged != chunk.keys.len() as u64 { + return Err(transient(format!( + "bulk delete acknowledged {acknowledged} of {} keys in chunk {}", + chunk.keys.len(), + chunk.chunk_no + ))); + } + Ok(()) +} + /// Freeze the post-fence, post-drain destructive enumeration: stream the /// tenant prefixes into side-table chunks, then bind the chunk stream to the /// request row's digests atomically. @@ -1071,6 +1204,35 @@ async fn execute_stage( } match request.stage { DeletionStage::Approved => { + // Fail closed on missing version-list permission before we take the + // durable write fence. Exact-version delete permission cannot be + // proven safely here: S3 has no dry-run DeleteObjectVersion, and a + // fabricated-version delete would still be a destructive API call + // while proving less than the real tenant-prefix operation. + run_guarded_external_step( + services, + &token, + DeletionStage::Approved, + heartbeat_lost, + || async { + for prefix in tenant_prefixes(*request.community_id.as_uuid()) { + services + .media + .preflight_version_listing(&prefix) + .await + .with_context(|| { + format!( + "S3 version-list preflight failed for prefix {prefix}; \ + verify s3:ListBucketVersions and s3:DeleteObjectVersion \ + on the relay bucket before fencing" + ) + })?; + } + Ok(()) + }, + ) + .await?; + // Approval binds immutable catalog + community-prefix ownership. // Live row counts and tenant binding keys are deliberately not // equality-bound until the durable fence closes all writers. @@ -1150,51 +1312,38 @@ async fn execute_stage( let mut removed: u64 = 0; let mut already_missing: u64 = 0; while let Some(chunk) = services.store.next_pending_manifest_chunk(&token).await? { + let chunk_no = chunk.chunk_no; let outcome = run_guarded_external_step( services, &token, DeletionStage::Drained, heartbeat_lost, - || async { Ok(services.media.delete_objects(&chunk.keys).await?) }, + || async { + delete_manifest_chunk_with(&chunk, storage.version, |versions| async { + if storage.version >= 5 { + Ok(services.media.delete_object_versions(&versions).await?) + } else { + let keys = versions + .into_iter() + .map(|version| version.key) + .collect::>(); + Ok(services.media.delete_objects(&keys).await?) + } + }) + .await + }, ) .await?; - if !outcome.versioned_keys.is_empty() { - return Err(permanent(format!( - "bulk delete produced version artifacts; bucket versioning blocks \ - deletion: {}", - outcome.versioned_keys.join(",") - ))); + if heartbeat_lost.is_cancelled() { + return Err(DeletionLeaseLost.into()); } - if !outcome.failed.is_empty() { - let (key, code, message) = &outcome.failed[0]; - return Err(transient(format!( - "bulk delete failed for {} key(s); first: {key}: {code}: {message}", - outcome.failed.len() - ))); - } - let acknowledged = outcome.deleted.saturating_add(outcome.already_missing); - if acknowledged != chunk.keys.len() as u64 { - return Err(transient(format!( - "bulk delete acknowledged {acknowledged} of {} keys in chunk {}", - chunk.keys.len(), - chunk.chunk_no - ))); - } - removed += outcome.deleted; - already_missing += outcome.already_missing; + let detail = manifest_chunk_deleted_checkpoint_detail(&chunk, &outcome)?; services .store - .mark_manifest_chunk_deleted( - &token, - chunk.chunk_no, - serde_json::json!({ - "prefix": chunk.prefix, - "keys": chunk.keys.len(), - "deleted": outcome.deleted, - "already_missing": outcome.already_missing, - }), - ) + .mark_manifest_chunk_deleted(&token, chunk_no, detail) .await?; + removed += outcome.deleted; + already_missing += outcome.already_missing; } let frozen_keys: u64 = storage .prefixes @@ -1277,10 +1426,16 @@ fn token_with_current_fence(token: &LeaseToken, request: &DeletionRequest) -> Le /// empty — O(1) requests per prefix, independent of fleet size. async fn verify_storage_absence(services: &Services, request: &DeletionRequest) -> Result<()> { for prefix in tenant_prefixes(*request.community_id.as_uuid()) { - let page = services.media.list_prefix_page(&prefix, None, 1).await?; - if let Some((key, _)) = page.objects.first() { + let page = services + .media + .list_prefix_versions_page(&prefix, None, None, 1) + .await?; + if let Some(entry) = page.entries.first() { return Err(transient(format!( - "logical verification found a live target object binding: {key}" + "logical verification found a retained target object version: {}@{} ({})", + entry.key, + entry.version_id, + manifest_kind_name(entry.kind) ))); } } @@ -1821,6 +1976,99 @@ mod tests { } } + #[test] + fn truncated_version_listing_requires_key_marker() { + let error = require_truncated_version_markers(None, Some("v1".to_string())) + .expect_err("missing key marker must fail closed"); + + assert!(format!("{error:#}").contains("no key marker")); + } + + #[test] + fn truncated_version_listing_requires_version_id_marker() { + let error = require_truncated_version_markers(Some("key".to_string()), None) + .expect_err("missing version id marker must fail closed"); + + assert!(format!("{error:#}").contains("no version id marker")); + } + + #[test] + fn legacy_v4_manifest_chunk_decodes_bare_keys_for_resume_delete() { + let chunk = buzz_db::deletion::ManifestKeyChunk { + chunk_no: 3, + prefix: "_meta/community/".to_string(), + keys: vec![ + "_meta/community/a.json".to_string(), + "_meta/community/b.json".to_string(), + ], + }; + + let versions = object_versions_from_manifest_chunk(&chunk, 4).expect("decode v4 chunk"); + assert_eq!( + versions, + vec![ + ObjectVersionRef { + key: "_meta/community/a.json".to_string(), + version_id: String::new(), + }, + ObjectVersionRef { + key: "_meta/community/b.json".to_string(), + version_id: String::new(), + }, + ] + ); + } + + #[tokio::test] + async fn partial_delete_ack_fails_before_checkpoint_detail() { + let chunk = buzz_db::deletion::ManifestKeyChunk { + chunk_no: 7, + prefix: "_meta/community/".to_string(), + keys: vec![ + StorageManifestEntry::new("_meta/community/a.json", "v1", "object") + .encode() + .expect("encode manifest entry"), + StorageManifestEntry::new("_meta/community/b.json", "v2", "object") + .encode() + .expect("encode manifest entry"), + ], + }; + let delete = delete_manifest_chunk_with(&chunk, 5, |versions| async move { + assert_eq!(versions.len(), 2); + Ok(BulkDeleteOutcome { + deleted: 1, + already_missing: 0, + versioned_keys: Vec::new(), + failed: Vec::new(), + }) + }) + .await + .expect("delete call returns partial acknowledgement"); + let checkpoint = manifest_chunk_deleted_checkpoint_detail(&chunk, &delete); + + let error = checkpoint.expect_err("partial acknowledgement must be transient"); + assert!(format!("{error:#}").contains("bulk delete acknowledged 1 of 2 keys in chunk 7")); + } + + #[test] + fn manifest_chunk_checkpoint_detail_records_partial_delete_response_counts() { + let detail = manifest_chunk_deleted_detail( + "_meta/community/", + 3, + &buzz_media::BulkDeleteOutcome { + deleted: 2, + already_missing: 1, + versioned_keys: Vec::new(), + failed: Vec::new(), + }, + ); + + assert_eq!(detail["prefix"], "_meta/community/"); + assert_eq!(detail["keys"], 3); + assert_eq!(detail["deleted"], 2); + assert_eq!(detail["already_missing"], 1); + } + #[test] fn permanent_failures_are_typed_not_string_classified() { let permanent_error = permanent("catalog drift"); diff --git a/crates/buzz-media/Cargo.toml b/crates/buzz-media/Cargo.toml index 530ce69c90..7808ecaff4 100644 --- a/crates/buzz-media/Cargo.toml +++ b/crates/buzz-media/Cargo.toml @@ -32,6 +32,7 @@ tempfile = "3" tokio-util = { version = "0.7", features = ["io"] } futures-util = "0.3" futures-core = "0.3" +quick-xml = { version = "0.38", features = ["serialize"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index b2ff12c16e..3198e1f830 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -20,7 +20,10 @@ pub use bucket_index::{ }; pub use config::{MediaConfig, S3AddressingStyle}; pub use error::MediaError; -pub use storage::{BlobHeadMeta, BlobMeta, BulkDeleteOutcome, ByteStream, MediaStorage}; +pub use storage::{ + BlobHeadMeta, BlobMeta, BulkDeleteOutcome, ByteStream, MediaStorage, ObjectVersionEntry, + ObjectVersionKind, ObjectVersionRef, ObjectVersionsPage, +}; pub use types::BlobDescriptor; pub use upload::{process_file_upload, process_upload, process_video_upload}; pub use upload_record::{ diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 0f0aa7af62..abe6bdd40e 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -1,5 +1,6 @@ //! S3/MinIO storage client. +use std::collections::HashMap; use std::path::Path; use std::pin::Pin; @@ -8,13 +9,198 @@ use buzz_core::tenant::{CommunityId, TenantContext}; use crate::config::{MediaConfig, S3AddressingStyle}; use crate::error::MediaError; use bytes::Bytes; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::Reader; use s3::creds::Credentials; +use s3::request::Request as _; use s3::{Bucket, Region}; use serde::{Deserialize, Serialize}; /// A stream of byte chunks from S3, usable with `axum::body::Body::from_stream()`. pub type ByteStream = Pin> + Send>>; +/// The kind of versioned S3 object-store entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ObjectVersionKind { + /// A concrete object version with bytes. + Object, + /// A delete-marker version hiding older bytes from live-object listing. + DeleteMarker, +} + +/// One S3 object version or delete marker under a tenant prefix. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectVersionEntry { + /// Object key. + pub key: String, + /// Concrete S3 version id. + pub version_id: String, + /// Whether this entry is a byte-bearing object or delete marker. + pub kind: ObjectVersionKind, + /// Byte size for object versions; zero for delete markers. + pub size: u64, +} + +/// Exact version identifier used for permanent deletion. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectVersionRef { + /// Object key. + pub key: String, + /// Concrete S3 version id. + pub version_id: String, +} + +/// One `ListObjectVersions` page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObjectVersionsPage { + /// Object versions and delete markers returned by this page. + pub entries: Vec, + /// Next key marker for truncated listings. + pub next_key_marker: Option, + /// Next version-id marker for truncated listings. + pub next_version_id_marker: Option, + /// Whether more pages remain. + pub is_truncated: bool, +} + +#[derive(Debug, Default)] +struct ListVersionFields { + key: Option, + version_id: Option, + size: Option, +} + +fn local_name(name: &[u8]) -> &[u8] { + name.rsplit(|byte| *byte == b':').next().unwrap_or(name) +} + +fn xml_error(error: impl std::fmt::Display) -> MediaError { + MediaError::StorageError(error.to_string()) +} + +fn read_element_text( + reader: &mut Reader<&[u8]>, + start: &BytesStart<'_>, +) -> Result { + reader + .read_text(start.to_end().name()) + .map(|text| text.into_owned()) + .map_err(xml_error) +} + +fn skip_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<(), MediaError> { + reader + .read_to_end(start.to_end().name()) + .map_err(xml_error)?; + Ok(()) +} + +fn parse_list_version_entry( + reader: &mut Reader<&[u8]>, + start: &BytesStart<'_>, + kind: ObjectVersionKind, +) -> Result { + let mut fields = ListVersionFields::default(); + loop { + match reader.read_event().map_err(xml_error)? { + Event::Start(child) => match local_name(child.local_name().as_ref()) { + b"Key" => fields.key = Some(read_element_text(reader, &child)?), + b"VersionId" => fields.version_id = Some(read_element_text(reader, &child)?), + b"Size" => { + let size = read_element_text(reader, &child)?; + fields.size = Some(size.parse::().map_err(xml_error)?); + } + _ => skip_element(reader, &child)?, + }, + Event::Empty(child) => match local_name(child.local_name().as_ref()) { + b"Key" => fields.key = Some(String::new()), + b"VersionId" => fields.version_id = Some(String::new()), + b"Size" => fields.size = Some(0), + _ => {} + }, + Event::End(end) if end.name().as_ref() == start.to_end().name().as_ref() => { + let key = fields.key.ok_or_else(|| { + MediaError::StorageError("ListObjectVersions entry missing Key".to_string()) + })?; + let version_id = fields.version_id.ok_or_else(|| { + MediaError::StorageError( + "ListObjectVersions entry missing VersionId".to_string(), + ) + })?; + return Ok(ObjectVersionEntry { + key, + version_id, + kind, + size: if kind == ObjectVersionKind::Object { + fields.size.unwrap_or(0) + } else { + 0 + }, + }); + } + Event::Eof => { + return Err(MediaError::StorageError( + "unexpected EOF inside ListObjectVersions entry".to_string(), + )); + } + _ => {} + } + } +} + +fn parse_object_versions_page(xml: &[u8]) -> Result { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(true); + let mut entries = Vec::new(); + let mut next_key_marker = None; + let mut next_version_id_marker = None; + let mut is_truncated = false; + + loop { + match reader.read_event().map_err(xml_error)? { + Event::Start(start) => match local_name(start.local_name().as_ref()) { + b"Version" => entries.push(parse_list_version_entry( + &mut reader, + &start, + ObjectVersionKind::Object, + )?), + b"DeleteMarker" => entries.push(parse_list_version_entry( + &mut reader, + &start, + ObjectVersionKind::DeleteMarker, + )?), + b"IsTruncated" => { + let value = read_element_text(&mut reader, &start)?; + is_truncated = value.eq_ignore_ascii_case("true"); + } + b"NextKeyMarker" => { + next_key_marker = Some(read_element_text(&mut reader, &start)?); + } + b"NextVersionIdMarker" => { + next_version_id_marker = Some(read_element_text(&mut reader, &start)?); + } + b"ListVersionsResult" => {} + _ => skip_element(&mut reader, &start)?, + }, + Event::Empty(start) => match local_name(start.local_name().as_ref()) { + b"NextKeyMarker" => next_key_marker = Some(String::new()), + b"NextVersionIdMarker" => next_version_id_marker = Some(String::new()), + _ => {} + }, + Event::Eof => break, + _ => {} + } + } + + Ok(ObjectVersionsPage { + entries, + next_key_marker, + next_version_id_marker, + is_truncated, + }) +} + /// S3-compatible object storage client. pub struct MediaStorage { bucket: Box, @@ -177,24 +363,6 @@ impl MediaStorage { } } - /// Detect whether the bucket has ever had versioning enabled. - /// - /// rust-s3 exposes no GetBucketVersioning, so this writes and inspects a - /// short-lived fleet probe object instead: versioning-enabled (and - /// versioning-suspended) buckets stamp new writes with a version id. - /// Deletion refuses versioned buckets because bulk deletes without a - /// VersionId would only insert delete markers, not prove logical absence. - pub async fn bucket_versioning_detected(&self) -> Result { - let key = format!("probe/deletion-versioning-{}", uuid::Uuid::new_v4()); - self.put(&key, b"buzz deletion versioning probe", "text/plain") - .await?; - let inspected = self.bucket.head_object(&key).await; - let removed = self.bucket.delete_object(&key).await; - let (head, _) = inspected.map_err(|e| MediaError::StorageError(e.to_string()))?; - removed.map_err(|e| MediaError::StorageError(e.to_string()))?; - Ok(head.version_id.is_some()) - } - /// Bulk-delete up to one manifest chunk of keys via S3 `DeleteObjects`. /// /// Never fails on per-key outcomes: they are folded into @@ -210,6 +378,57 @@ impl MediaStorage { .iter() .map(|key| s3::serde_types::ObjectIdentifier::new(key.clone())) .collect::>(); + self.delete_object_identifiers(identifiers).await + } + + /// Non-destructively verify that versioned bucket APIs are reachable. + /// + /// `ListObjectVersions` can be proven without mutation. S3 has no equivalent + /// dry-run for `DeleteObjectVersion`: `DeleteObjects` is always destructive, + /// even for exact versions, and deleting a fabricated version id does not + /// prove permission when policies can be prefix- or tag-constrained. + /// Operators must still provision `s3:DeleteObjectVersion`; the first exact + /// version deletion remains the destructive proof. + pub async fn preflight_version_listing(&self, prefix: &str) -> Result<(), MediaError> { + self.list_prefix_versions_page(prefix, None, None, 1) + .await + .map(|_| ()) + } + + /// Bulk-delete exact object versions via S3 `DeleteObjects`. + /// + /// Every identifier includes a version id, so this removes historical + /// versions and delete markers permanently instead of adding another + /// delete marker to a versioned bucket. + pub async fn delete_object_versions( + &self, + versions: &[ObjectVersionRef], + ) -> Result { + self.delete_object_versions_with_folding(versions, fold_version_delete_result) + .await + } + + async fn delete_object_versions_with_folding( + &self, + versions: &[ObjectVersionRef], + fold: fn(s3::serde_types::DeleteObjectsResult) -> BulkDeleteOutcome, + ) -> Result { + if versions.is_empty() { + return Ok(BulkDeleteOutcome::default()); + } + let identifiers = object_version_identifiers(versions); + let result = self + .bucket + .delete_objects(identifiers) + .await + .map_err(|e| MediaError::StorageError(e.to_string()))?; + Ok(fold(result)) + } + + async fn delete_object_identifiers( + &self, + identifiers: Vec, + ) -> Result { let result = self .bucket .delete_objects(identifiers) @@ -330,6 +549,57 @@ impl MediaStorage { is_truncated: result.is_truncated, }) } + + /// One page of object versions and delete markers under a prefix. + /// + /// This uses S3 `ListObjectVersions` (`?versions`) instead of + /// `ListObjectsV2`: versioned buckets can be logically empty while still + /// retaining historical versions or delete markers, and permanent deletion + /// must enumerate both. Pagination must carry both `KeyMarker` and + /// `VersionIdMarker`; carrying only the key marker can skip siblings when a + /// key has multiple versions on a page boundary. + pub async fn list_prefix_versions_page( + &self, + prefix: &str, + key_marker: Option, + version_id_marker: Option, + max_keys: usize, + ) -> Result { + let mut query = HashMap::from([ + ("versions".to_string(), String::new()), + ("prefix".to_string(), prefix.to_string()), + ("max-keys".to_string(), max_keys.to_string()), + ]); + if let Some(marker) = key_marker { + query.insert("key-marker".to_string(), marker); + } + if let Some(marker) = version_id_marker { + query.insert("version-id-marker".to_string(), marker); + } + let bucket = self + .bucket + .with_extra_query(query) + .map_err(|e| MediaError::StorageError(e.to_string()))?; + let request = s3::request::tokio_backend::ReqwestRequest::new( + &bucket, + "/", + s3::command::Command::GetObject, + ) + .await + .map_err(|e| MediaError::StorageError(e.to_string()))?; + let response = request + .response_data(false) + .await + .map_err(|e| MediaError::StorageError(e.to_string()))?; + if response.status_code() >= 300 { + return Err(MediaError::StorageError(format!( + "list object versions failed with status {}: {}", + response.status_code(), + response.as_str().unwrap_or("") + ))); + } + parse_object_versions_page(response.as_slice()) + } } /// Per-key outcomes of one bulk `DeleteObjects` call. @@ -349,16 +619,49 @@ pub struct BulkDeleteOutcome { pub failed: Vec<(String, String, String)>, } +fn object_version_identifiers( + versions: &[ObjectVersionRef], +) -> Vec { + versions + .iter() + .map(|version| { + s3::serde_types::ObjectIdentifier::with_version( + version.key.clone(), + version.version_id.clone(), + ) + }) + .collect() +} + fn fold_bulk_delete_result(result: s3::serde_types::DeleteObjectsResult) -> BulkDeleteOutcome { + fold_delete_result(result, DeleteMode::Unversioned) +} + +fn fold_version_delete_result(result: s3::serde_types::DeleteObjectsResult) -> BulkDeleteOutcome { + fold_delete_result(result, DeleteMode::ExplicitVersion) +} + +enum DeleteMode { + Unversioned, + ExplicitVersion, +} + +fn fold_delete_result( + result: s3::serde_types::DeleteObjectsResult, + mode: DeleteMode, +) -> BulkDeleteOutcome { let mut outcome = BulkDeleteOutcome::default(); for deleted in result.deleted { - if deleted.delete_marker == Some(true) + let has_version_artifact = deleted.delete_marker == Some(true) || deleted.delete_marker_version_id.is_some() - || deleted.version_id.is_some() - { - outcome.versioned_keys.push(deleted.key); - } else { - outcome.deleted += 1; + || deleted.version_id.is_some(); + match mode { + DeleteMode::Unversioned if has_version_artifact => { + outcome.versioned_keys.push(deleted.key); + } + DeleteMode::Unversioned | DeleteMode::ExplicitVersion => { + outcome.deleted += 1; + } } } for error in result.errors { @@ -419,6 +722,228 @@ mod tests { ); } + #[test] + fn version_delete_fold_counts_explicit_version_artifacts_as_deleted() { + use s3::serde_types::{DeleteError, DeleteObjectsResult, DeletedObject}; + let result = DeleteObjectsResult { + deleted: vec![DeletedObject { + key: "versioned".to_string(), + version_id: Some("v1".to_string()), + delete_marker: Some(true), + delete_marker_version_id: Some("v1".to_string()), + }], + errors: vec![ + DeleteError { + key: "retried-version".to_string(), + code: "NoSuchVersion".to_string(), + message: "already absent".to_string(), + version_id: Some("v-gone".to_string()), + }, + DeleteError { + key: "denied-version".to_string(), + code: "AccessDenied".to_string(), + message: "denied".to_string(), + version_id: Some("v-denied".to_string()), + }, + ], + }; + + let outcome = fold_version_delete_result(result); + assert_eq!(outcome.deleted, 1); + assert_eq!(outcome.already_missing, 1); + assert!(outcome.versioned_keys.is_empty()); + assert_eq!( + outcome.failed, + vec![( + "denied-version".to_string(), + "AccessDenied".to_string(), + "denied".to_string() + )] + ); + } + + #[test] + fn object_version_identifiers_include_explicit_version_ids() { + let identifiers = object_version_identifiers(&[ + ObjectVersionRef { + key: "_meta/tenant/a.json".to_string(), + version_id: "v-object".to_string(), + }, + ObjectVersionRef { + key: "uploads/tenant/event/blob".to_string(), + version_id: "v-delete-marker".to_string(), + }, + ]); + + assert_eq!(identifiers.len(), 2); + assert_eq!(identifiers[0].key, "_meta/tenant/a.json"); + assert_eq!(identifiers[0].version_id.as_deref(), Some("v-object")); + assert_eq!(identifiers[1].key, "uploads/tenant/event/blob"); + assert_eq!( + identifiers[1].version_id.as_deref(), + Some("v-delete-marker") + ); + } + + #[tokio::test] + async fn delete_object_versions_empty_input_short_circuits_before_folding() { + let storage = MediaStorage::new(&storage_config("buzz_dev", "buzz_dev_secret")) + .expect("static client"); + let outcome = storage + .delete_object_versions_with_folding(&[], |_| BulkDeleteOutcome { + deleted: 0, + already_missing: 0, + versioned_keys: vec!["wrong-fold".to_string()], + failed: Vec::new(), + }) + .await + .expect("empty delete short-circuits before fold"); + assert_eq!(outcome, BulkDeleteOutcome::default()); + } + + #[test] + fn parse_object_versions_page_includes_objects_delete_markers_and_dual_markers() { + let page = parse_object_versions_page( + br#" + + buzz-media + _meta/tenant/ + _meta/tenant/a.json + v-old + 2 + true + _meta/tenant/a.json + v-new + + _meta/tenant/a.json + v-delete + true + + + _meta/tenant/a.json + v-new + false + 42 + +"#, + ) + .expect("parse versions page"); + + assert!(page.is_truncated); + assert_eq!(page.next_key_marker.as_deref(), Some("_meta/tenant/a.json")); + assert_eq!(page.next_version_id_marker.as_deref(), Some("v-new")); + assert_eq!( + page.entries, + vec![ + ObjectVersionEntry { + key: "_meta/tenant/a.json".to_string(), + version_id: "v-delete".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "_meta/tenant/a.json".to_string(), + version_id: "v-new".to_string(), + kind: ObjectVersionKind::Object, + size: 42, + }, + ] + ); + } + + #[test] + fn parse_object_versions_page_preserves_repeated_interleaved_aws_ordering() { + let page = parse_object_versions_page( + br#" + k-av33 + k-av2 + k-av11 + k-bm2 + k-bm110 +"#, + ) + .expect("parse interleaved versions page"); + + assert_eq!( + page.entries, + vec![ + ObjectVersionEntry { + key: "k-a".to_string(), + version_id: "v3".to_string(), + kind: ObjectVersionKind::Object, + size: 3, + }, + ObjectVersionEntry { + key: "k-a".to_string(), + version_id: "v2".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "k-a".to_string(), + version_id: "v1".to_string(), + kind: ObjectVersionKind::Object, + size: 1, + }, + ObjectVersionEntry { + key: "k-b".to_string(), + version_id: "m2".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "k-b".to_string(), + version_id: "m1".to_string(), + kind: ObjectVersionKind::Object, + size: 10, + }, + ] + ); + } + + #[test] + fn parse_object_versions_page_handles_marker_only_key_before_versioned_key() { + let page = parse_object_versions_page( + br#" + k-marker-onlyd-only + k-versionedv220 + k-versionedd1 + k-versionedv110 +"#, + ) + .expect("parse marker-only and versioned keys"); + + assert_eq!( + page.entries, + vec![ + ObjectVersionEntry { + key: "k-marker-only".to_string(), + version_id: "d-only".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "k-versioned".to_string(), + version_id: "v2".to_string(), + kind: ObjectVersionKind::Object, + size: 20, + }, + ObjectVersionEntry { + key: "k-versioned".to_string(), + version_id: "d1".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "k-versioned".to_string(), + version_id: "v1".to_string(), + kind: ObjectVersionKind::Object, + size: 10, + }, + ] + ); + } + fn tenant(n: u128) -> TenantContext { TenantContext::resolved( CommunityId::from_uuid(uuid::Uuid::from_u128(n)), diff --git a/crates/buzz-media/tests/versioned_minio.rs b/crates/buzz-media/tests/versioned_minio.rs new file mode 100644 index 0000000000..1e0db4b481 --- /dev/null +++ b/crates/buzz-media/tests/versioned_minio.rs @@ -0,0 +1,363 @@ +//! Live destructive versioned-bucket deletion coverage against docker-compose MinIO. +//! +//! This exercises the S3-compatible path that community deletion relies on when +//! a bucket has versioning enabled: list object versions/delete markers with +//! dual markers, delete exact `(Key, VersionId)` identifiers, retry an already +//! deleted version, and prove final `ListObjectVersions` emptiness. +//! +//! Run it against the docker-compose MinIO (creds `buzz_dev`/`buzz_dev_secret`): +//! +//! ```bash +//! docker compose up -d minio minio-init +//! cargo test -p buzz-media --test versioned_minio -- --ignored --nocapture +//! ``` +//! +//! The test creates and removes its own bucket. The MinIO container name is +//! overridable with `BUZZ_MINIO_CONTAINER`; credentials/endpoint/region/addressing +//! use the same `BUZZ_S3_*` env vars as `static_creds_minio`. + +use std::process::Command; + +use buzz_media::config::MediaConfig; +use buzz_media::storage::{MediaStorage, ObjectVersionKind, ObjectVersionRef}; + +fn env_or(name: &str, default: &str) -> String { + std::env::var(name).unwrap_or_else(|_| default.to_string()) +} + +fn minio_config(bucket: String) -> MediaConfig { + MediaConfig { + s3_endpoint: env_or("BUZZ_S3_ENDPOINT", "http://localhost:9000"), + s3_access_key: env_or("BUZZ_S3_ACCESS_KEY", "buzz_dev"), + s3_secret_key: env_or("BUZZ_S3_SECRET_KEY", "buzz_dev_secret"), + s3_bucket: bucket, + s3_region: env_or("BUZZ_S3_REGION", "us-east-1"), + s3_addressing_style: env_or("BUZZ_S3_ADDRESSING_STYLE", "path") + .parse() + .expect("BUZZ_S3_ADDRESSING_STYLE must be path or virtual"), + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 524_288_000, + max_file_bytes: 104_857_600, + public_base_url: "http://localhost:3000/media".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + } +} + +fn run_mc(args: &[String]) -> Result<(), String> { + let container = env_or("BUZZ_MINIO_CONTAINER", "buzz-minio"); + let output = Command::new("docker") + .arg("exec") + .arg(container) + .arg("mc") + .args(args) + .output() + .map_err(|err| format!("failed to execute docker/mc: {err}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "mc {:?} failed with status {}\nstdout:\n{}\nstderr:\n{}", + args, + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) + } +} + +fn mc_alias(access_key: &str, secret_key: &str) -> Result<(), String> { + run_mc(&[ + "alias".to_string(), + "set".to_string(), + "local".to_string(), + "http://localhost:9000".to_string(), + access_key.to_string(), + secret_key.to_string(), + ]) +} + +async fn list_all_versions( + storage: &MediaStorage, + prefix: &str, + max_keys: usize, +) -> Vec { + let mut entries = Vec::new(); + let mut key_marker = None; + let mut version_id_marker = None; + loop { + let page = storage + .list_prefix_versions_page( + prefix, + key_marker.take(), + version_id_marker.take(), + max_keys, + ) + .await + .expect("list object versions page"); + if page.is_truncated { + assert!( + page.next_key_marker.is_some(), + "truncated ListObjectVersions page must include NextKeyMarker" + ); + assert!( + page.next_version_id_marker.is_some(), + "truncated ListObjectVersions page must include NextVersionIdMarker" + ); + } + entries.extend(page.entries); + if !page.is_truncated { + break; + } + key_marker = page.next_key_marker; + version_id_marker = page.next_version_id_marker; + } + entries +} + +fn refs_from(entries: &[buzz_media::storage::ObjectVersionEntry]) -> Vec { + entries + .iter() + .map(|entry| ObjectVersionRef { + key: entry.key.clone(), + version_id: entry.version_id.clone(), + }) + .collect() +} + +#[tokio::test] +#[ignore = "requires live docker-compose MinIO; permanently deletes exact test object versions"] +async fn never_versioned_bucket_lists_null_versions_and_exact_delete_empties_listing() { + let bucket = format!("buzz-media-never-versioned-{}", std::process::id()); + let bucket_path = format!("local/{bucket}"); + let config = minio_config(bucket.clone()); + mc_alias(&config.s3_access_key, &config.s3_secret_key).expect("configure mc alias"); + run_mc(&[ + "mb".to_string(), + "--ignore-existing".to_string(), + bucket_path.clone(), + ]) + .expect("create isolated never-versioned test bucket"); + + let storage = MediaStorage::new(&config).expect("static MinIO storage client"); + let prefix = format!("_test/never-versioned-{}/", uuid::Uuid::new_v4()); + let key = format!("{prefix}plain.bin"); + storage + .put(&key, b"plain", "application/octet-stream") + .await + .expect("put never-versioned object"); + + let listed = list_all_versions(&storage, &prefix, 2).await; + assert_eq!( + listed, + vec![buzz_media::storage::ObjectVersionEntry { + key: key.clone(), + version_id: "null".to_string(), + kind: ObjectVersionKind::Object, + size: 5, + }], + "never-versioned buckets must still enumerate exact null-version objects" + ); + + let delete = storage + .delete_object_versions(&refs_from(&listed)) + .await + .expect("delete exact null-version object"); + assert!(delete.failed.is_empty(), "{delete:?}"); + assert!(delete.versioned_keys.is_empty(), "{delete:?}"); + assert_eq!( + delete.deleted + delete.already_missing, + 1, + "exact null-version delete must account for the listed object" + ); + assert!( + list_all_versions(&storage, &prefix, 2).await.is_empty(), + "final ListObjectVersions must be empty after deleting the exact null version" + ); + + run_mc(&["rb".to_string(), "--force".to_string(), bucket_path.clone()]) + .expect("remove isolated never-versioned test bucket"); +} + +#[tokio::test] +#[ignore = "requires live docker-compose MinIO; permanently deletes exact test object versions"] +async fn versioned_bucket_exact_version_delete_reaches_final_list_versions_emptiness() { + let bucket = format!("buzz-media-versioned-{}", std::process::id()); + let bucket_path = format!("local/{bucket}"); + let config = minio_config(bucket.clone()); + mc_alias(&config.s3_access_key, &config.s3_secret_key).expect("configure mc alias"); + run_mc(&[ + "mb".to_string(), + "--ignore-existing".to_string(), + bucket_path.clone(), + ]) + .expect("create isolated versioned test bucket"); + run_mc(&[ + "version".to_string(), + "enable".to_string(), + bucket_path.clone(), + ]) + .expect("enable bucket versioning"); + + let storage = MediaStorage::new(&config).expect("static MinIO storage client"); + let prefix = format!("_test/versioned-{}/", uuid::Uuid::new_v4()); + let historical_key = format!("{prefix}historical.bin"); + let marker_only_key = format!("{prefix}marker-only.bin"); + let paginated_key = format!("{prefix}paginated.bin"); + + storage + .put(&historical_key, b"v1", "application/octet-stream") + .await + .expect("put historical v1"); + storage + .put(&historical_key, b"v2", "application/octet-stream") + .await + .expect("put historical v2"); + storage + .delete(&historical_key) + .await + .expect("delete historical current version creates delete marker"); + storage + .put(&marker_only_key, b"marker-base", "application/octet-stream") + .await + .expect("put marker-only base version"); + storage + .delete(&marker_only_key) + .await + .expect("delete current version creates marker-only delete marker"); + let marker_versions = list_all_versions(&storage, &marker_only_key, 2).await; + let marker_objects: Vec = marker_versions + .iter() + .filter(|entry| entry.kind == ObjectVersionKind::Object) + .map(|entry| ObjectVersionRef { + key: entry.key.clone(), + version_id: entry.version_id.clone(), + }) + .collect(); + assert_eq!( + marker_objects.len(), + 1, + "marker-only setup should have one object version: {marker_versions:?}" + ); + let marker_object_delete = storage + .delete_object_versions(&marker_objects) + .await + .expect("delete marker-only base object version"); + assert!( + marker_object_delete.failed.is_empty(), + "{marker_object_delete:?}" + ); + assert!( + marker_object_delete.versioned_keys.is_empty(), + "{marker_object_delete:?}" + ); + storage + .put(&paginated_key, b"page-a", "application/octet-stream") + .await + .expect("put paginated v1"); + storage + .put(&paginated_key, b"page-b", "application/octet-stream") + .await + .expect("put paginated v2"); + + let listed = list_all_versions(&storage, &prefix, 2).await; + assert!( + listed.len() >= 6, + "expected multiple versions/delete markers across small pages, got {listed:?}" + ); + assert!(listed.iter().any(|entry| { + entry.key == historical_key && entry.kind == ObjectVersionKind::Object && entry.size == 2 + })); + assert!(listed.iter().any(|entry| { + entry.key == historical_key && entry.kind == ObjectVersionKind::DeleteMarker + })); + assert!(listed.iter().any(|entry| { + entry.key == marker_only_key && entry.kind == ObjectVersionKind::DeleteMarker + })); + + let refs = refs_from(&listed); + let first_chunk = &refs[..2.min(refs.len())]; + let first_delete = storage + .delete_object_versions(first_chunk) + .await + .expect("delete first explicit version chunk"); + assert!(first_delete.failed.is_empty(), "{first_delete:?}"); + assert!(first_delete.versioned_keys.is_empty(), "{first_delete:?}"); + assert_eq!( + first_delete.deleted + first_delete.already_missing, + first_chunk.len() as u64, + "explicit version delete should account for every requested identifier" + ); + + let retry = storage + .delete_object_versions(&first_chunk[..1]) + .await + .expect("retry already-deleted explicit version"); + assert!(retry.failed.is_empty(), "{retry:?}"); + assert!(retry.versioned_keys.is_empty(), "{retry:?}"); + assert_eq!( + retry.deleted + retry.already_missing, + 1, + "retry should be idempotently accounted as deleted/already missing" + ); + + let rest_delete = storage + .delete_object_versions(&refs[first_chunk.len()..]) + .await + .expect("delete remaining explicit versions"); + assert!(rest_delete.failed.is_empty(), "{rest_delete:?}"); + assert!(rest_delete.versioned_keys.is_empty(), "{rest_delete:?}"); + assert_eq!( + rest_delete.deleted + rest_delete.already_missing, + (refs.len() - first_chunk.len()) as u64, + "remaining explicit version delete should account for every requested identifier" + ); + + let remaining = list_all_versions(&storage, &prefix, 2).await; + assert!( + remaining.is_empty(), + "final ListObjectVersions must be empty after exact-version deletion: {remaining:?}" + ); + + if run_mc(&[ + "version".to_string(), + "suspend".to_string(), + bucket_path.clone(), + ]) + .is_ok() + { + let suspended_key = format!("{prefix}suspended.bin"); + storage + .put(&suspended_key, b"suspended", "application/octet-stream") + .await + .expect("put suspended-versioning object"); + storage + .delete(&suspended_key) + .await + .expect("delete suspended-versioning object"); + let suspended_entries = list_all_versions(&storage, &prefix, 2).await; + assert!( + suspended_entries + .iter() + .any(|entry| entry.key == suspended_key), + "suspended-versioning write/delete should be visible to ListObjectVersions" + ); + let suspended_delete = storage + .delete_object_versions(&refs_from(&suspended_entries)) + .await + .expect("delete suspended-versioning entries by explicit version id"); + assert!(suspended_delete.failed.is_empty(), "{suspended_delete:?}"); + assert!( + suspended_delete.versioned_keys.is_empty(), + "{suspended_delete:?}" + ); + assert!(list_all_versions(&storage, &prefix, 2).await.is_empty()); + } else { + eprintln!("MinIO mc did not support version suspend; enabled-versioning coverage passed"); + } + + run_mc(&["rb".to_string(), "--force".to_string(), bucket_path.clone()]) + .expect("remove isolated versioned test bucket"); +} diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs new file mode 100644 index 0000000000..a8848af295 --- /dev/null +++ b/crates/buzz-relay/src/api/gifs.rs @@ -0,0 +1,613 @@ +//! Relay-owned KLIPY GIF metadata/search proxy. +//! +//! KLIPY requires a provider credential, but desktop applications cannot keep +//! build-time credentials secret. These narrow endpoints keep the key on the +//! operator's relay while returning only KLIPY-hosted media URLs and metadata; +//! GIF bytes are never downloaded, cached, or stored by Buzz. +//! +//! Search and share reporting are the only relay endpoints. Sending a selected +//! GIF is a normal message containing its CDN URL, and clients render that URL +//! through the existing image path. No GIF bytes transit the relay. + +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + extract::State, + http::{header, HeaderMap, StatusCode}, + response::Json, +}; +use futures_util::StreamExt; +use serde::Deserialize; +use serde_json::Value; + +use crate::state::AppState; + +use buzz_auth::LimitType; + +use super::{api_error, bridge, internal_error, relay_members}; + +const KLIPY_API_ROOT: &str = "https://api.klipy.com/api/v1/"; +pub(crate) const SEARCH_PATH: &str = "/gifs/search"; +pub(crate) const SHARE_PATH: &str = "/gifs/share"; +const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_UPSTREAM_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +/// Build the dedicated KLIPY client. Redirects are disabled: the API key rides +/// in the request path, so following a provider 3xx could replay a key-bearing +/// URL to an attacker-chosen host. With no redirect policy, a 3xx comes back as +/// a non-success status that the handlers map to a generic `502`, and the +/// `Location` target is never read or forwarded. +pub fn build_gif_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(UPSTREAM_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("static GIF HTTP client configuration") +} + +#[derive(Debug, Deserialize)] +/// Client-owned search context forwarded to KLIPY by the relay. +pub struct SearchRequest { + /// Empty means trending; otherwise this is the user's search text. + query: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, + /// Desktop locale used to localize provider results. + locale: String, +} + +#[derive(Debug, Deserialize)] +/// Client-owned share context forwarded to KLIPY by the relay. +pub struct ShareRequest { + /// Provider slug for the selected GIF. + slug: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, +} + +fn validate_text( + name: &str, + value: &str, + max_chars: usize, + allow_empty: bool, +) -> Result<(), (StatusCode, Json)> { + let count = value.chars().count(); + if (!allow_empty && value.trim().is_empty()) || count > max_chars { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "{name} must be {} through {max_chars} characters", + if allow_empty { 0 } else { 1 } + ), + )); + } + Ok(()) +} + +fn klipy_url( + api_key: &str, + path: &[&str], + query: &[(&str, &str)], +) -> Result)> { + let mut url = url::Url::parse(KLIPY_API_ROOT) + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + segments.pop_if_empty().push(api_key); + for segment in path { + segments.push(segment); + } + } + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().copied()); + } + Ok(url) +} + +fn klipy_share_request( + client: &reqwest::Client, + api_key: &str, + request: &ShareRequest, +) -> Result)> { + let url = klipy_url(api_key, &["gifs", "share", request.slug.trim()], &[])?; + Ok(client + .post(url) + .json(&serde_json::json!({ "customer_id": request.customer_id }))) +} + +async fn authenticate( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { + let raw_host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + "POST", + &expected_url, + Some(body), + true, + true, + )?; + bridge::enforce_http_admission(state, &tenant, &pubkey).await?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey.to_bytes(), + headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()), + ) + .await?; + + Ok((tenant, pubkey)) +} + +async fn send_upstream( + request: reqwest::RequestBuilder, +) -> Result)> { + request + .timeout(UPSTREAM_TIMEOUT) + .send() + .await + .map_err(|error| { + tracing::warn!( + timeout = error.is_timeout(), + "KLIPY upstream request failed" + ); + api_error(StatusCode::BAD_GATEWAY, "GIF provider is unavailable") + }) +} + +async fn enforce_search_admission( + state: &AppState, + tenant: &buzz_core::TenantContext, + pubkey: &nostr::PublicKey, +) -> Result<(), (StatusCode, Json)> { + let limit = state.auth.config().rate_limits.gif_searches_per_min; + match crate::admission::check_principal( + state.admission_rate_limiter.as_ref(), + tenant, + pubkey, + LimitType::GifSearches, + 60, + limit, + ) + .await + { + Ok(()) => Ok(()), + Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => { + metrics::counter!("buzz_gif_search_rejections_total", "reason" => "quota").increment(1); + Err(api_error( + StatusCode::TOO_MANY_REQUESTS, + &format!("rate-limited: GIF search quota exceeded; retry in {reset_in_secs}s"), + )) + } + Err(crate::admission::AdmissionError::Unavailable) => Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "rate-limited: GIF search admission unavailable", + )), + } +} + +async fn limited_json(response: reqwest::Response) -> Result)> { + if response + .content_length() + .is_some_and(|length| length > MAX_UPSTREAM_RESPONSE_BYTES as u64) + { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response could not be read", + ) + })?; + if body.len().saturating_add(chunk.len()) > MAX_UPSTREAM_RESPONSE_BYTES { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + body.extend_from_slice(&chunk); + } + + serde_json::from_slice(&body).map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider returned an invalid response", + ) + }) +} + +fn successful_search_payload(upstream: &Value) -> Result)> { + if upstream.get("result").and_then(Value::as_bool) != Some(true) { + tracing::warn!("KLIPY search returned an unsuccessful result"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + let data = upstream.get("data").cloned().unwrap_or(Value::Null); + Ok(serde_json::json!({ "result": true, "data": data })) +} + +/// Search or browse trending KLIPY GIF metadata for an authenticated member. +pub async fn search( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?; + let request: SearchRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON"))?; + validate_text("query", &request.query, 200, true)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + validate_text("locale", &request.locale, 32, false)?; + enforce_search_admission(&state, &tenant, &pubkey).await?; + + let endpoint = if request.query.trim().is_empty() { + "trending" + } else { + "search" + }; + let mut query = vec![ + ("page", "1"), + ("per_page", "24"), + ("customer_id", request.customer_id.as_str()), + ("locale", request.locale.as_str()), + ]; + if !request.query.trim().is_empty() { + query.push(("q", request.query.trim())); + } + let url = klipy_url(config.api_key(), &["gifs", endpoint], &query)?; + let response = send_upstream(state.gif_http_client.get(url)).await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY search failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + + // Never forward the provider response wholesale. KLIPY may report an + // application-level failure with HTTP 200 and include request details in + // its error fields. Allowlist only successful result data so credentials + // and provider diagnostics cannot cross the relay boundary. + let upstream = limited_json(response).await?; + Ok(Json(successful_search_payload(&upstream)?)) +} + +/// Report a selected GIF to KLIPY so the provider can update Recents. +pub async fn share( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + authenticate(&state, &headers, SHARE_PATH, &body).await?; + let request: ShareRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON"))?; + validate_text("slug", &request.slug, 200, false)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + + let response = send_upstream(klipy_share_request( + &state.gif_http_client, + config.api_key(), + &request, + )?) + .await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY share failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the share request", + )); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use tower::ServiceExt; + + async fn unconfigured_test_state() -> Arc { + let mut config = crate::config::Config::from_env().expect("test config"); + config.klipy = None; + config.redis_url = "redis://127.0.0.1:1".to_string(); + + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://buzz:buzz_dev@127.0.0.1:1/buzz") // sadscan:disable np.postgres.1 + .expect("lazy test database pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("lazy test Redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("test pubsub"), + ); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("test media storage config"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + None::, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + #[tokio::test] + async fn search_route_returns_not_found_before_auth_when_unconfigured() { + let state = unconfigured_test_state().await; + let response = Router::new() + .route(SEARCH_PATH, axum::routing::post(search)) + .with_state(state) + .oneshot( + Request::post(SEARCH_PATH) + .body(Body::from("{}")) + .expect("search request"), + ) + .await + .expect("search response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn limited_json_rejects_oversized_streamed_bodies() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route( + "/oversized", + get(|| async { + ( + [(header::CONTENT_TYPE, "application/json")], + "x".repeat(MAX_UPSTREAM_RESPONSE_BYTES + 1), + ) + }), + ), + ) + .await + .expect("serve oversized response"); + }); + let response = reqwest::get(format!("http://{address}/oversized")) + .await + .expect("test upstream response"); + let (status, _) = limited_json(response) + .await + .expect_err("oversized body must be rejected"); + + server.abort(); + let _ = server.await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + } + + #[test] + fn klipy_url_encodes_credentials_as_a_path_segment() { + let url = klipy_url( + "key/with spaces", + &["gifs", "search"], + &[("customer_id", "customer")], + ) + .expect("static URL is valid"); + + assert_eq!( + url.as_str(), + "https://api.klipy.com/api/v1/key%2Fwith%20spaces/gifs/search?customer_id=customer" + ); + } + + #[test] + fn klipy_share_request_uses_slug_path_and_customer_body() { + let request = ShareRequest { + slug: " ship/it ".to_string(), + customer_id: "customer-123".to_string(), + }; + let built = klipy_share_request(&reqwest::Client::new(), "secret-key", &request) + .expect("share request builds") + .build() + .expect("share request is valid"); + + assert_eq!(built.method(), reqwest::Method::POST); + assert_eq!( + built.url().as_str(), + "https://api.klipy.com/api/v1/secret-key/gifs/share/ship%2Fit" + ); + assert_eq!( + built.body().and_then(reqwest::Body::as_bytes), + Some(br#"{"customer_id":"customer-123"}"#.as_slice()) + ); + } + + #[test] + fn validation_bounds_provider_control_fields() { + assert!(validate_text("query", "", 200, true).is_ok()); + assert!(validate_text("customer_id", "", 128, false).is_err()); + assert!(validate_text("query", &"x".repeat(201), 200, true).is_err()); + } + + #[test] + fn successful_payload_strips_provider_errors_and_unknown_fields() { + let payload = successful_search_payload(&serde_json::json!({ + "result": true, + "data": { "data": [] }, + "errors": { "message": ["request used secret-key"] }, + "debug": "secret-key" + })) + .expect("successful payload"); + + assert_eq!( + payload, + serde_json::json!({ "result": true, "data": { "data": [] } }) + ); + } + + #[test] + fn unsuccessful_payload_is_rejected_without_provider_details() { + let (status, body) = successful_search_payload(&serde_json::json!({ + "result": false, + "errors": { "message": ["request used secret-key"] } + })) + .expect_err("unsuccessful provider payload must be rejected"); + + assert_eq!(status, StatusCode::BAD_GATEWAY); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert!(!serialized.contains("secret-key")); + } + + /// A provider 3xx must never cause a second connection, and the error + /// surfaced past the shared send/reject path must leak neither the API key + /// (carried in the request path) nor the redirect target. + /// + /// Mutation check: swapping `build_gif_http_client`'s redirect policy back + /// to the default makes the client follow the 302, the redirect listener + /// records a request, and this test fails on the `redirect_hits` assertion. + #[tokio::test] + async fn gif_client_refuses_provider_redirects_without_leaking_secrets() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + const SECRET_KEY: &str = "super-secret-klipy-key"; + + // Second listener: the redirect target. It must never be reached. + let redirect_hits = Arc::new(AtomicUsize::new(0)); + let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind redirect target"); + let redirect_addr = redirect_listener.local_addr().expect("redirect address"); + let redirect_hits_server = redirect_hits.clone(); + let redirect_server = tokio::spawn(async move { + axum::serve( + redirect_listener, + Router::new().route( + "/leaked", + get(move || { + redirect_hits_server.fetch_add(1, Ordering::SeqCst); + async { "reached the redirect target" } + }), + ), + ) + .await + .expect("serve redirect target"); + }); + + // Fake upstream: answers the key-bearing path with a 302 whose Location + // points at the second listener, exactly the disclosure vector. + let redirect_location = format!("http://{redirect_addr}/leaked"); + let upstream_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake upstream"); + let upstream_addr = upstream_listener.local_addr().expect("upstream address"); + let location_header = redirect_location.clone(); + let upstream_server = tokio::spawn(async move { + axum::serve( + upstream_listener, + Router::new().route( + &format!("/{SECRET_KEY}/gifs/search"), + get(move || { + let location = location_header.clone(); + async move { + ( + StatusCode::FOUND, + [(header::LOCATION, location)], + "provider body naming the secret-key", + ) + } + }), + ), + ) + .await + .expect("serve fake upstream"); + }); + + let client = build_gif_http_client(); + let response = + send_upstream(client.get(format!("http://{upstream_addr}/{SECRET_KEY}/gifs/search"))) + .await + .expect("request completes without following the redirect"); + + // The redirect was not followed: the client surfaces the 3xx itself. + assert!(response.status().is_redirection()); + assert!(!response.status().is_success()); + assert_eq!(redirect_hits.load(Ordering::SeqCst), 0); + + // The shared reject path (both handlers gate on `!is_success`) returns a + // static generic error carrying no key and no redirect target. + let (status, body) = api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + ); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert!(!serialized.contains(SECRET_KEY)); + assert!(!serialized.contains(&redirect_location)); + + upstream_server.abort(); + redirect_server.abort(); + let _ = upstream_server.await; + let _ = redirect_server.await; + } +} diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 3b2241046a..ec7af3aac6 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -2424,6 +2424,104 @@ mod track_c_tests { } } + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { + let (state, pool) = finalize_test_state().await; + let host = format!( + "git-announce-lease-{}.example", + uuid::Uuid::new_v4().simple() + ); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let (request, claim) = approved_deletion(&state, &host).await; + let tenant = TenantContext::resolved(community, host.clone()); + let owner_keys = Keys::generate(); + let repo = format!("repo-{}", uuid::Uuid::new_v4().simple()); + let event = EventBuilder::new(Kind::Custom(30_617), "") + .tags([Tag::parse(["d", &repo]).expect("d tag")]) + .sign_with_keys(&owner_keys) + .expect("sign announcement"); + let gate = Arc::new(crate::handlers::side_effects::GitRepoAnnouncementGate::default()); + let hooks = crate::handlers::side_effects::GitRepoAnnouncementHooks { + post_lease_gate: Some(Arc::clone(&gate)), + }; + let announce_state = Arc::clone(&state); + let announce_tenant = tenant.clone(); + let announce = tokio::spawn(async move { + let result = crate::handlers::side_effects::handle_git_repo_announcement_inner( + &announce_tenant, + &event, + &announce_state, + &hooks, + ) + .await; + let owner_hex = hex::encode(owner_keys.public_key().to_bytes()); + (result, owner_hex) + }); + + gate.reached.notified().await; + state + .db + .deletion_store() + .begin_quiescing(&claim.lease) + .await + .expect("quiesce after announcement lease"); + let error = state + .db + .deletion_store() + .fence(&claim.lease) + .await + .expect_err("announcement serving lease must block fence"); + assert!(matches!( + error, + buzz_db::DbError::ServingWritesNotDrained { .. } + )); + + gate.resume.notify_one(); + let (announce_result, owner_hex) = announce.await.expect("announcement task"); + announce_result.expect("announcement completes"); + let pointer_key = crate::api::git::manifest::pointer_key(community, &owner_hex, &repo); + assert!( + state + .git_store + .get_pointer(&pointer_key) + .await + .expect("read pointer") + .is_some(), + "announcement pointer must be durable before lease release" + ); + assert!(state + .db + .deletion_store() + .serving_writes_drained(community) + .await + .expect("serving lease released")); + let generation = state + .db + .deletion_store() + .fence(&claim.lease) + .await + .expect("fence after pointer seed"); + assert_eq!(generation, 1); + assert_eq!( + state + .db + .deletion_store() + .get(request.id) + .await + .expect("fenced request") + .stage, + buzz_db::deletion::DeletionStage::Fenced + ); + drop(state); + pool.close().await; + } + #[tokio::test] #[ignore = "requires Postgres and MinIO"] async fn finalize_push_holds_serving_lease_through_post_cas_publication() { diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 2a942bc803..204ec360c3 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod bridge; pub mod events; +pub mod gifs; pub mod git; pub mod invites; pub mod media; diff --git a/crates/buzz-relay/src/build_info.rs b/crates/buzz-relay/src/build_info.rs new file mode 100644 index 0000000000..c7073505b3 --- /dev/null +++ b/crates/buzz-relay/src/build_info.rs @@ -0,0 +1,16 @@ +//! Build-time identity compiled into the relay binary. + +/// Full source commit SHA, or `unknown` outside a provenance-aware build. +pub(crate) fn source_sha() -> &'static str { + option_env!("BUZZ_SOURCE_SHA").unwrap_or("unknown") +} + +/// Stable build identifier, or `local` outside CI. +pub(crate) fn build_id() -> &'static str { + option_env!("BUZZ_BUILD_ID").unwrap_or("local") +} + +/// Build details URL, or `unknown` outside CI. +pub(crate) fn build_url() -> &'static str { + option_env!("BUZZ_BUILD_URL").unwrap_or("unknown") +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index c8034e87c1..f43d459b9e 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -46,6 +46,30 @@ pub struct JoinPolicyConfig { pub version: String, } +/// Optional KLIPY GIF-search integration owned by the relay operator. +/// +/// The API key deliberately stays private and its [`Debug`] implementation is +/// redacted so dumping [`Config`] cannot disclose it. +#[derive(Clone)] +pub struct KlipyConfig { + api_key: String, +} + +impl KlipyConfig { + /// Return the key only to the outbound KLIPY client. + pub(crate) fn api_key(&self) -> &str { + &self.api_key + } +} + +impl std::fmt::Debug for KlipyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KlipyConfig") + .field("api_key", &"[REDACTED]") + .finish() + } +} + /// Maximum configured jitter, leaving ten seconds of the hard-drain budget for /// WebSocket close-frame delivery after the final delayed cancellation. pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; @@ -218,6 +242,10 @@ pub struct Config { /// Default: `false`. Set via `BUZZ_ALLOW_NIP_OA_AUTH=true`. pub allow_nip_oa_auth: bool, + /// Relay-owned KLIPY integration. Unset means GIF search is not advertised + /// and its proxy routes return 404. + pub klipy: Option, + /// Media storage configuration (S3/MinIO). pub media: buzz_media::MediaConfig, /// Maximum concurrent media uploads handled by one relay process. @@ -323,6 +351,10 @@ fn rate_limit_config_from_env() -> Result, ) -> anyhow::Result<()> { + handle_git_repo_announcement_inner(tenant, event, state, &GitRepoAnnouncementHooks::default()) + .await +} + +#[derive(Default)] +pub(crate) struct GitRepoAnnouncementHooks { + #[cfg(test)] + pub(crate) post_lease_gate: Option>, +} + +#[cfg(test)] +#[derive(Default)] +pub(crate) struct GitRepoAnnouncementGate { + pub(crate) reached: tokio::sync::Notify, + pub(crate) resume: tokio::sync::Notify, +} + +pub(crate) async fn handle_git_repo_announcement_inner( + tenant: &TenantContext, + event: &Event, + state: &Arc, + hooks: &GitRepoAnnouncementHooks, +) -> anyhow::Result<()> { + #[cfg(not(test))] + let _ = hooks; // Extract repo identifier from d tag (required for NIP-33 parameterized replaceable events). let repo_id = extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?; @@ -2677,6 +2702,32 @@ async fn handle_git_repo_announcement( // other attempt already established. let reserved_by_this_attempt = matches!(outcome, ReserveOutcome::Reserved); + // The event row and name registry are ordinary database state: if deletion + // quiescing wins before they commit, the DB write fence rejects them; if + // they committed first, the destructive DB stage purges them. The manifest + // and pointer below are external S3 effects, so acquire the durable + // serving-write lease immediately before that sequence. Once acquired, + // deletion must drain this lease before it can freeze the final object list. + let serving_write = buzz_deletion::acquire_serving_write( + &state.db, + tenant.community(), + "git_repo_announcement", + ) + .await + .map_err(|e| anyhow::anyhow!("repo announcement rejected by community deletion fence: {e}"))?; + + #[cfg(test)] + if let Some(gate) = &hooks.post_lease_gate { + gate.reached.notify_one(); + gate.resume.notified().await; + } + + if let Err(error) = serving_write.verify().await { + return Err(anyhow::anyhow!( + "repo announcement lost community serving lease: {error}" + )); + } + // Establish/confirm the manifest pointer, keeping the invariant // "repo announced ⟺ pointer exists" so the read path can rely on // pointer-absent meaning never-announced (keeping `info_refs`'s fail-closed @@ -2691,10 +2742,16 @@ async fn handle_git_repo_announcement( // re-announce must accept it untouched; only an absent pointer is // repaired by seeding. Using the strict seed here would wrongly reject // every re-announce after the first push. - let pointer_result = if reserved_by_this_attempt { - seed_manifest_pointer(state, tenant, &owner_hex, &repo_id).await - } else { - ensure_manifest_pointer(state, tenant, &owner_hex, &repo_id).await + let pointer_operation = async { + if reserved_by_this_attempt { + seed_manifest_pointer(state, tenant, &owner_hex, &repo_id).await + } else { + ensure_manifest_pointer(state, tenant, &owner_hex, &repo_id).await + } + }; + let pointer_result = match serving_write.protect(pointer_operation).await { + Ok(result) => result, + Err(error) => Err(error), }; if let Err(pointer_err) = pointer_result { // A reserved name without a clone-able pointer is exactly the broken @@ -2743,7 +2800,9 @@ async fn handle_git_repo_announcement( // initial empty signal is a one-time seeding notification, not something a // re-announce should replay. if reserved_by_this_attempt { - if let Err(e) = emit_initial_ref_state(tenant, state, &owner_hex, &repo_id).await { + if let Err(e) = + emit_initial_ref_state(tenant, state, serving_write.lease(), &owner_hex, &repo_id).await + { // Non-fatal: the manifest is the source of truth; this is just the // derived notification. A failure here means subscribers miss the // "repo now exists" event, but clone/push still works. @@ -2756,6 +2815,9 @@ async fn handle_git_repo_announcement( } } + serving_write.finish().await.map_err(|e| { + anyhow::anyhow!("repo announcement lost community serving lease on release: {e}") + })?; Ok(()) } @@ -2897,6 +2959,7 @@ async fn ensure_manifest_pointer( async fn emit_initial_ref_state( tenant: &TenantContext, state: &Arc, + lease: &buzz_db::deletion::ServingWriteLease, owner_hex: &str, repo_id: &str, ) -> anyhow::Result<()> { @@ -2914,7 +2977,7 @@ async fn emit_initial_ref_state( .map_err(|e| anyhow::anyhow!("build_ref_state_event: {e}"))?; let (stored, was_inserted) = state .db - .insert_event(tenant.community(), &event, None) + .insert_event_with_serving_write_guard(lease, &event, None) .await .map_err(|e| anyhow::anyhow!("insert kind:30618: {e}"))?; if was_inserted { diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314adad92e..800433a849 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -3,6 +3,7 @@ //! NIP-01 WebSocket relay for Buzz private team communication. mod admission; +mod build_info; /// REST API route handlers. pub mod api; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 77462b3f28..06bdf673b3 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -35,6 +35,16 @@ fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool { }) } +fn relay_keypair_from_config(relay_private_key: Option<&str>) -> anyhow::Result { + let hex = relay_private_key.ok_or_else(|| { + anyhow::anyhow!( + "BUZZ_RELAY_PRIVATE_KEY must be set. Run `just bootstrap` for local \ + development or configure a stable 32-byte hex private key." + ) + })?; + nostr::Keys::parse(hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}")) +} + /// Controls how many per-community gauge series the usage poller emits. /// /// Datadog cost is proportional to the number of unique time-series. With ~25 @@ -143,6 +153,7 @@ async fn main() -> anyhow::Result<()> { error!("Invalid configuration: {e}"); anyhow::anyhow!("Configuration error: {e}") })?; + let relay_keypair = relay_keypair_from_config(config.relay_private_key.as_deref())?; info!( bind_addr = %config.bind_addr, relay_url = %config.relay_url, @@ -424,29 +435,6 @@ async fn main() -> anyhow::Result<()> { let workflow_config = buzz_workflow::WorkflowConfig::default(); let workflow_engine = Arc::new(WorkflowEngine::new(db.clone(), workflow_config)); - let relay_keypair = if let Some(hex) = &config.relay_private_key { - nostr::Keys::parse(hex) - .map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))? - } else if !config.require_auth_token { - // Dev mode: use a deterministic keypair so addressable events (kind:39000/39001/39002) - // replace correctly across restarts. Without this, each restart generates a new pubkey - // and replace_addressable_event inserts duplicates instead of replacing. - const DEV_RELAY_PRIVKEY: &str = - "0000000000000000000000000000000000000000000000000000000000000001"; - let keys = nostr::Keys::parse(DEV_RELAY_PRIVKEY).expect("hardcoded dev key is valid"); - tracing::warn!( - pubkey = %keys.public_key().to_hex(), - "Using hardcoded dev relay keypair (BUZZ_REQUIRE_AUTH_TOKEN=false). \ - Set BUZZ_RELAY_PRIVATE_KEY for production." - ); - keys - } else { - panic!( - "BUZZ_RELAY_PRIVATE_KEY must be set when BUZZ_REQUIRE_AUTH_TOKEN=true. \ - A stable relay identity is required for production." - ); - }; - config .media .validate() @@ -2040,8 +2028,8 @@ mod tests { use super::{ buzz_auto_migrate_enabled, dropped_in_memory_keys, idle_timeout_secs, - refresh_legacy_active_gauge_recency, run_periodic_until_cancelled, EmissionScope, - InMemoryMetricKey, + refresh_legacy_active_gauge_recency, relay_keypair_from_config, + run_periodic_until_cancelled, EmissionScope, InMemoryMetricKey, }; use metrics::GaugeFn; use metrics_util::{ @@ -2089,6 +2077,23 @@ mod tests { assert!(buzz_auto_migrate_enabled(Some("on"))); } + #[test] + fn configured_relay_identity_is_preserved() { + let configured = nostr::Keys::generate(); + let secret = configured.secret_key().to_secret_hex(); + + let selected = relay_keypair_from_config(Some(&secret)).expect("configured key"); + + assert_eq!(selected.public_key(), configured.public_key()); + } + + #[test] + fn missing_relay_identity_is_rejected() { + let result = relay_keypair_from_config(None); + + assert!(result.is_err()); + } + #[test] fn test_emission_scope_off_disallows_every_community() { assert!(EmissionScope::All.allows(&Uuid::new_v4())); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index c2b6c868ab..1802825863 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -52,11 +52,26 @@ pub struct RelayInfo { /// Public WebSocket URL of the dedicated NIP-AB device-pairing relay. #[serde(skip_serializing_if = "Option::is_none")] pub pairing_relay_url: Option, + /// Relay-owned GIF search integration. The descriptor is public and + /// provider-agnostic; provider credentials remain server-side. + #[serde(skip_serializing_if = "Option::is_none")] + pub gif: Option, /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, } +/// Public capability descriptor for relay-proxied GIF search. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GifDescriptor { + /// Provider identifier understood by Buzz clients. + pub provider: String, + /// Relay-relative authenticated metadata search endpoint. + pub search: String, + /// Relay-relative authenticated share-reporting endpoint. + pub share: String, +} + /// Protocol and resource limits advertised in the NIP-11 document. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayLimitation { @@ -138,12 +153,18 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. + /// + /// `gif_provider` is a config-derived provider identifier. When present, + /// `build` advertises the provider-agnostic `buzz-gif` extension and the + /// relay-relative metadata search endpoint. It must never contain a + /// provider credential. pub fn build( relay_self: Option<&str>, icon: Option<&str>, advertise_nip43: bool, max_message_length: usize, pairing_relay_url: Option<&str>, + gif_provider: Option<&str>, ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), @@ -155,6 +176,16 @@ impl RelayInfo { supported_nips.push(NIP_RELAY_MEMBERSHIP); } + let mut supported_extensions = vec!["nip-er".to_string()]; + let gif = gif_provider.map(|provider| { + supported_extensions.push("buzz-gif".to_string()); + GifDescriptor { + provider: provider.to_string(), + search: crate::api::gifs::SEARCH_PATH.to_string(), + share: crate::api::gifs::SHARE_PATH.to_string(), + } + }); + Self { name: "Buzz Relay".to_string(), description: "Buzz — private team communication relay".to_string(), @@ -162,12 +193,13 @@ impl RelayInfo { pubkey: None, contact: None, supported_nips, - supported_extensions: Some(vec!["nip-er".to_string()]), + supported_extensions: Some(supported_extensions), push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(relay_limitation(max_message_length)), pairing_relay_url: pairing_relay_url.map(str::to_string), + gif, relay_self: relay_self.map(|s| s.to_string()), } } @@ -232,7 +264,8 @@ fn push_descriptor( /// Centralised so the content-negotiated root handler and the dedicated /// `/info` endpoint can't drift apart. Every input to `RelayInfo::build` /// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the -/// host-scoped workspace icon. +/// host-scoped workspace icon. Optional provider capabilities are passed as +/// config-derived scalar identifiers; no provider credential enters NIP-11. pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo { let (relay_self, advertise_nip43) = nip11_facts(state); let icon = workspace_icon_for_host(state, raw_host).await; @@ -242,6 +275,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st advertise_nip43, state.config.max_frame_bytes, state.config.pairing_relay_url.as_deref(), + state.config.klipy.as_ref().map(|_| "klipy"), ); let tenant_host = if state.config.push_enabled { crate::tenant::bind_community(&state.db, raw_host) @@ -333,6 +367,7 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( bool, usize, Option<&str>, + Option<&str>, ) -> RelayInfo = RelayInfo::build; #[cfg(test)] @@ -387,7 +422,7 @@ mod tests { #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.software, "https://github.com/block/buzz"); } @@ -399,6 +434,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, Some("wss://pairing.buzz.xyz"), + None, ); let json = serde_json::to_value(&info).expect("serialize"); assert_eq!( @@ -407,11 +443,40 @@ mod tests { Some("wss://pairing.buzz.xyz") ); - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); let json = serde_json::to_value(&info).expect("serialize"); assert!(json.get("pairing_relay_url").is_none()); } + #[test] + fn gif_descriptor_and_extension_are_config_gated_and_credential_free() { + let info = RelayInfo::build( + None, + None, + false, + DEFAULT_MAX_FRAME_BYTES, + None, + Some("klipy"), + ); + + let json = serde_json::to_value(&info).expect("serialize"); + assert_eq!(json["gif"]["provider"], "klipy"); + assert_eq!(json["gif"]["search"], "/gifs/search"); + assert_eq!(json["gif"]["share"], "/gifs/share"); + assert!(json["supported_extensions"] + .as_array() + .expect("extensions") + .contains(&serde_json::json!("buzz-gif"))); + assert!(!json.to_string().contains("api_key")); + + let unconfigured = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); + assert!(unconfigured.gif.is_none()); + assert!(!unconfigured + .supported_extensions + .expect("extensions") + .contains(&"buzz-gif".to_string())); + } + /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard /// `icon` field; no icon (or a cleared, empty icon) omits the field /// entirely so the JSON matches pre-icon documents byte-for-byte. @@ -423,6 +488,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, None, + None, ); assert_eq!( info.icon.as_deref(), @@ -435,7 +501,7 @@ mod tests { ); for icon in [None, Some("")] { - let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.icon.is_none()); let json = serde_json::to_value(&info).expect("serialize"); assert!( @@ -455,7 +521,7 @@ mod tests { #[test] fn max_message_length_uses_configured_frame_limit() { - let info = RelayInfo::build(None, None, false, 262_144, None); + let info = RelayInfo::build(None, None, false, 262_144, None, None); let limitation = info.limitation.expect("limitation"); assert_eq!(limitation.max_message_length, Some(262_144)); } @@ -486,7 +552,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -499,7 +565,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -508,7 +574,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -519,6 +585,6 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None); + let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None, None); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 1dce66e91e..fc5396407c 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -72,6 +72,9 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + // Relay-owned third-party GIF metadata proxy (NIP-98 auth). + .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) + .route(api::gifs::SHARE_PATH, post(api::gifs::share)) .route( "/workflows/{workflow_id}/runs", get(api::workflows::workflow_runs), @@ -413,14 +416,22 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo } } -/// Status endpoint — service name, version, uptime. -async fn status_handler(State(state): State>) -> impl IntoResponse { - let uptime_secs = state.started_at.elapsed().as_secs(); - Json(json!({ +fn status_payload(uptime_secs: u64) -> serde_json::Value { + json!({ "service": "buzz-relay", "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": uptime_secs, - })) + "build": { + "source_sha": crate::build_info::source_sha(), + "id": crate::build_info::build_id(), + "url": crate::build_info::build_url(), + }, + }) +} + +/// Status endpoint — service name, version, uptime, and intrinsic build identity. +async fn status_handler(State(state): State>) -> impl IntoResponse { + Json(status_payload(state.started_at.elapsed().as_secs())) } /// `/_mesh` — live mesh status: peer table, connection/phi state, per-peer @@ -506,6 +517,23 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + #[test] + fn status_payload_exposes_source_and_build_identity() { + let payload = status_payload(42); + + assert_eq!(payload["service"], "buzz-relay"); + assert_eq!(payload["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(payload["uptime_seconds"], 42); + for field in ["source_sha", "id", "url"] { + assert!( + payload["build"][field] + .as_str() + .is_some_and(|value| !value.is_empty()), + "build.{field} must be a non-empty string" + ); + } + } + #[tokio::test(flavor = "current_thread")] async fn http_and_datastore_spans_are_exported_in_the_same_trace() { let exporter = InMemorySpanExporter::default(); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 2f544e188c..ab3f1d8c7e 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -722,6 +722,9 @@ pub struct AppState { /// replace this with process-local caching; replay freshness must survive /// cross-pod routing. pub nip98_replay: Arc, + /// Shared HTTP client for relay-proxied GIF provider requests. Reusing the + /// connection pool avoids a fresh TLS handshake for every search/share. + pub gif_http_client: reqwest::Client, /// Shared Redis-backed admission limits for ordinary HTTP and WebSocket work. pub admission_rate_limiter: Arc, @@ -852,6 +855,7 @@ impl AppState { ); let nip98_replay: Arc = Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone())); + let gif_http_client = crate::api::gifs::build_gif_http_client(); let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone())); let audit_enabled = audit_arc.is_some(); let state = Self { @@ -912,6 +916,7 @@ impl AppState { shutting_down: Arc::new(AtomicBool::new(false)), started_at: Instant::now(), nip98_replay, + gif_http_client, admission_rate_limiter, observer_rate_limiter: Arc::new(DashMap::new()), media_upload_rate_limiter: Arc::new(DashMap::new()), diff --git a/crates/buzz-search/Cargo.toml b/crates/buzz-search/Cargo.toml index e28c5b6840..6c6b9ada22 100644 --- a/crates/buzz-search/Cargo.toml +++ b/crates/buzz-search/Cargo.toml @@ -14,6 +14,7 @@ sqlx = { workspace = true } uuid = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +metrics = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 9309074895..49a6fafd19 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.7 +version: 0.1.8 appVersion: "0.1.0" home: https://github.com/block/buzz sources: @@ -24,7 +24,7 @@ maintainers: annotations: artifacthub.io/changes: | - kind: added - description: Generic init-container, volume, volume-mount, command, and args extension points for the relay Pod. + description: Optional immutable relay image digest pinning with backwards-compatible tag fallback. artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 30cee4f406..8a4b0c6d66 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -12,7 +12,7 @@ This chart has two operating profiles selected by values: ## Quickstart (eval only) ```sh -helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \ +helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.8 \ --create-namespace --namespace buzz \ --set quickstart=true \ --set postgresql.enabled=true \ @@ -29,12 +29,27 @@ intent marker surfaced in NOTES.txt; the bundled services are opted in via the four `*.enabled` flags above (see `ci/quickstart-values.yaml` for the exact set CI installs). Eval-only: every bundled service is a single replica with no HA. +For immutable delivery, pin the OCI digest instead of a tag. `image.digest` +overrides `image.tag` when both are present: + +```yaml +image: + repository: ghcr.io/block/buzz + digest: sha256:<64-lowercase-hex-characters> +``` + ## Production (GitOps) The chart is designed for ArgoCD and Flux. Both render charts with `helm template`, in which mode Helm's `lookup` function returns empty — any chart-side `randAlphaNum` call would regenerate secrets on every sync. The chart-managed Secret path is **only** safe for `helm install` / `helm upgrade`. Production deploys MUST use `secrets.existingSecret:`. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless. +To enable relay-proxied KLIPY search, add `BUZZ_KLIPY_API_KEY` to that Secret. +The key stays in the relay pod; clients discover the public `buzz-gif` +extension and `gif` descriptor in NIP-11, then receive KLIPY-hosted media URLs. +See [`docs/gif-search.md`](../../../docs/gif-search.md) for the protocol and +security boundaries. + See: - [`examples/argocd-app.yaml`](examples/argocd-app.yaml) — ArgoCD Application diff --git a/deploy/charts/buzz/examples/secret-sample.yaml b/deploy/charts/buzz/examples/secret-sample.yaml index 42d3254d48..c615a0de31 100644 --- a/deploy/charts/buzz/examples/secret-sample.yaml +++ b/deploy/charts/buzz/examples/secret-sample.yaml @@ -12,6 +12,7 @@ # REDIS_URL — redis://... (required when replicaCount > 1) # BUZZ_S3_ACCESS_KEY # BUZZ_S3_SECRET_KEY +# BUZZ_KLIPY_API_KEY — omit to disable relay-proxied GIF search apiVersion: v1 kind: Secret metadata: @@ -25,3 +26,4 @@ stringData: REDIS_URL: "redis://:REPLACE@redis.buzz.svc.cluster.local:6379" BUZZ_S3_ACCESS_KEY: "REPLACE" BUZZ_S3_SECRET_KEY: "REPLACE" + BUZZ_KLIPY_API_KEY: "REPLACE" diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl index ff070379eb..13efe0d1fd 100644 --- a/deploy/charts/buzz/templates/_helpers.tpl +++ b/deploy/charts/buzz/templates/_helpers.tpl @@ -53,9 +53,13 @@ app.kubernetes.io/component: relay {{- end -}} {{- define "buzz.image" -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else -}} {{- $tag := default .Chart.AppVersion .Values.image.tag -}} {{- printf "%s:%s" .Values.image.repository $tag -}} {{- end -}} +{{- end -}} {{/* Name of the chart-managed Secret holding relay-identity material and any diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 451ebb1cde..319ec7f159 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -215,6 +215,12 @@ spec: name: {{ include "buzz.envSecretName" . }} key: BUZZ_S3_SECRET_KEY optional: true + - name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_KLIPY_API_KEY + optional: true - name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: {{ include "buzz.huddleAudioAvailable" . | quote }} diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 10a1a34d1f..f288f8df2d 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -195,6 +195,24 @@ tests: path: spec.template.spec.containers[0].args template: templates/deployment.yaml + - it: renders an immutable digest instead of a configured tag + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + image.repository: ghcr.io/block/buzz + image.tag: sha-deadbee + image.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/block/buzz@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + template: templates/deployment.yaml + - it: appends generic Pod extensions and overrides the relay command set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/tests/secrets_test.yaml b/deploy/charts/buzz/tests/secrets_test.yaml index dca83ce27f..d313caf4d4 100644 --- a/deploy/charts/buzz/tests/secrets_test.yaml +++ b/deploy/charts/buzz/tests/secrets_test.yaml @@ -80,6 +80,27 @@ tests: optional: true template: templates/deployment.yaml + - it: Deployment env points BUZZ_KLIPY_API_KEY at existingSecret as optional + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + secrets.existingSecret: "buzz-secrets" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: buzz-secrets + key: BUZZ_KLIPY_API_KEY + optional: true + template: templates/deployment.yaml + - it: READ_DATABASE_URL stays optional against the chart-managed Secret set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index a5a0050a86..3498189c8a 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -2,6 +2,16 @@ suite: validation templates: - templates/deployment.yaml tests: + - it: rejects a malformed image digest + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + image.digest: sha256:not-a-digest + asserts: + - failedTemplate: + errorPattern: "image.digest: Does not match pattern" + - it: fails when relayUrl is missing set: relayUrl: "" diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 94d369c890..aaab848fd3 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -15,6 +15,11 @@ "properties": { "repository": { "type": "string", "minLength": 1 }, "tag": { "type": "string" }, + "digest": { + "type": "string", + "pattern": "^$|^sha256:[0-9a-f]{64}$", + "description": "Optional immutable OCI image digest. When set, the chart renders repository@digest and ignores tag." + }, "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }, "pullSecrets": { "type": "array", diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index ca3403a633..6c57a5c8ac 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -25,6 +25,7 @@ quickstart: false image: repository: ghcr.io/block/buzz tag: "" # empty → .Chart.AppVersion + digest: "" # optional sha256:...; when set, overrides tag pullPolicy: IfNotPresent pullSecrets: [] @@ -93,6 +94,7 @@ ownerPubkey: "" # REDIS_URL — full Redis URL with auth # BUZZ_S3_ACCESS_KEY — S3 access key # BUZZ_S3_SECRET_KEY — S3 secret key +# BUZZ_KLIPY_API_KEY — KLIPY GIF search key; omit to disable GIF search secrets: existingSecret: "" # Inline overrides (NOT recommended for production; they land in values). @@ -333,6 +335,15 @@ externalRedis: # first sweep fails AccessDenied and buzz_storage_sweep_ok stays 0 — no other # media functionality is affected. Set BUZZ_STORAGE_METRICS=off to disable # the sweep entirely on a deployment that can't grant it. +# +# Whole-community deletion (`buzz-admin deletions ...`) permanently removes +# tenant-owned object versions through the v5 deletion path. In addition to the +# ordinary media permissions, every deployment that enables community deletion +# needs bucket-level `s3:ListBucketVersions` and object-level +# `s3:DeleteObjectVersion` on this bucket before exercising deletion — including +# never-versioned buckets, which S3 exposes through `ListObjectVersions` with the +# `null` version id. +# # Note: buzz_storage_sweep_failures is a process-local gauge — on leader # failover it resets to the new leader's local count, not a global total. # Note: on a failed sweep attempt, the next retry fires on the next usage tick diff --git a/desktop/package.json b/desktop/package.json index 4a6bdcd7f5..1e93fd76a8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.18", + "version": "0.5.20", "type": "module", "scripts": { "dev": "vite", @@ -66,6 +66,7 @@ "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", + "linkifyjs": "^4.3.2", "lucide-react": "^1.0.0", "mdast-util-from-markdown": "^2.0.3", "motion": "^12.38.0", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index fb60a35189..68b702431a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.18" +version = "0.5.20" dependencies = [ "anyhow", "arboard", @@ -1191,6 +1191,7 @@ dependencies = [ "infer", "mp4", "nostr 0.44.7", + "quick-xml 0.38.4", "rust-s3", "serde", "serde_json", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3f7189deea..2255a41860 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.18" +version = "0.5.20" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index 5bfce3adad..66c80f5d6c 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -44,6 +44,9 @@ const SPEAKER_LEVEL_TICK_MS: u64 = 50; /// Per-peer arrival window for the TTS interrupt frame counter. const FRAME_WINDOW: std::time::Duration = std::time::Duration::from_millis(500); const REMOTE_RELEASE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(500); +/// Match Mobile's speaking treatment: an open microphone can emit continuous +/// non-DTX Opus for room tone, so packet type alone is not evidence of speech. +const REMOTE_SPEECH_LEVEL_DBOV: i8 = -55; /// Playout clock: NetEq emits 10 ms frames, so we tick at 10 ms. const PLAYOUT_TICK_MS: u64 = 10; @@ -86,19 +89,30 @@ fn normalized_speaker_level(level_dbov: i8) -> f32 { ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) } +fn is_remote_speech_frame(is_dtx: bool, level_dbov: i8) -> bool { + !is_dtx && level_dbov >= REMOTE_SPEECH_LEVEL_DBOV +} + fn update_remote_release_deadline( peer: u8, - is_dtx: bool, + is_speech: bool, remote_floor_owners: &std::collections::HashSet, deadlines: &mut std::collections::HashMap, now: tokio::time::Instant, ) { - if !is_dtx { - deadlines.remove(&peer); - } else if remote_floor_owners.contains(&peer) { - deadlines - .entry(peer) - .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + if remote_floor_owners.contains(&peer) { + if is_speech { + // Refresh from audible speech itself. Some mobile capture paths + // stop producing packets once speech ends, so waiting for a DTX + // or quiet packet can otherwise hold the human floor forever. + deadlines.insert(peer, now + REMOTE_RELEASE_DEBOUNCE); + } else { + // Preserve the deadline from the last audible frame. Continuous + // room-tone packets must not keep extending the human floor. + deadlines + .entry(peer) + .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + } } } @@ -437,19 +451,20 @@ pub(crate) async fn run_playout_recv_loop( continue; } let is_dtx = (header.flags & FLAG_DTX) != 0; - // Only count non-DTX arrivals toward the UI's - // active-speaker set. DTX/comfort packets are emitted - // by an idle peer to keep the codec alive — they - // don't mean the peer is speaking, and shouldn't - // make their tile flash for the 500 ms speaker tick. + let is_remote_speech = + is_remote_speech_frame(is_dtx, header.level_dbov); + // Only count audible arrivals toward the UI's + // active-speaker set. An open mobile microphone can + // continuously emit non-DTX room tone, so require an + // audible level before treating a packet as speech. update_remote_release_deadline( peer_idx, - is_dtx, + is_remote_speech, &remote_floor_owners, &mut remote_release_deadlines, tokio::time::Instant::now(), ); - if !is_dtx { + if is_remote_speech { active_indices.insert(peer_idx); let level = normalized_speaker_level(header.level_dbov); speaker_levels @@ -497,7 +512,7 @@ pub(crate) async fn run_playout_recv_loop( // Count only remote-human speech toward floor onset. // Agent audio still plays, but it must not acquire the // human floor or suppress another agent's response. - if !is_dtx && remote_human { + if is_remote_speech && remote_human { if last_frame_reset.elapsed() >= FRAME_WINDOW { frame_counts.clear(); last_frame_reset = tokio::time::Instant::now(); @@ -507,6 +522,14 @@ pub(crate) async fn run_playout_recv_loop( if *count >= REMOTE_SPEECH_THRESHOLD { human_floor.enter_remote(peer_idx); remote_floor_owners.insert(peer_idx); + // The threshold-crossing frame is processed + // before this peer becomes an owner. Arm its + // release here so silence need not arrive in a + // later packet to let queued TTS continue. + remote_release_deadlines.insert( + peer_idx, + tokio::time::Instant::now() + REMOTE_RELEASE_DEBOUNCE, + ); if tts_active.load(Ordering::Acquire) { tts_cancel.store(true, Ordering::Release); } @@ -647,18 +670,18 @@ mod tests { use super::*; #[test] - fn continuous_dtx_does_not_extend_remote_floor_deadline() { + fn continuous_silence_does_not_extend_remote_floor_deadline() { let peer = 7; let started = tokio::time::Instant::now(); let owners = std::collections::HashSet::from([peer]); let mut deadlines = std::collections::HashMap::new(); - update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + update_remote_release_deadline(peer, false, &owners, &mut deadlines, started); let armed = deadlines[&peer]; for elapsed_ms in [100, 200, 300, 400] { update_remote_release_deadline( peer, - true, + false, &owners, &mut deadlines, started + std::time::Duration::from_millis(elapsed_ms), @@ -678,11 +701,32 @@ mod tests { } #[test] - fn dtx_from_non_owner_does_not_arm_remote_floor_deadline() { + fn last_speech_frame_arms_remote_floor_release_without_follow_up_audio() { + let peer = 7; + let started = tokio::time::Instant::now(); + let owners = std::collections::HashSet::from([peer]); + let mut deadlines = std::collections::HashMap::new(); + + update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + let armed = started + REMOTE_RELEASE_DEBOUNCE; + assert_eq!(deadlines[&peer], armed); + + let human_floor = HumanFloor::new(); + human_floor.enter_remote(peer); + let mut owners = owners; + release_expired_remote_floors(armed, &mut owners, &mut deadlines, &human_floor); + + assert!(!human_floor.is_blocked()); + assert!(owners.is_empty()); + assert!(deadlines.is_empty()); + } + + #[test] + fn silence_from_non_owner_does_not_arm_remote_floor_deadline() { let mut deadlines = std::collections::HashMap::new(); update_remote_release_deadline( 7, - true, + false, &std::collections::HashSet::new(), &mut deadlines, tokio::time::Instant::now(), @@ -690,6 +734,15 @@ mod tests { assert!(deadlines.is_empty()); } + #[test] + fn remote_speech_requires_non_dtx_audio_above_the_activity_floor() { + assert!(!is_remote_speech_frame(true, 0)); + assert!(!is_remote_speech_frame(false, -127)); + assert!(!is_remote_speech_frame(false, -56)); + assert!(is_remote_speech_frame(false, -55)); + assert!(is_remote_speech_frame(false, -12)); + } + #[test] fn speaker_level_maps_conversational_range() { assert_eq!(normalized_speaker_level(-127), 0.0); diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 72cf466427..3d191926a3 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -48,7 +48,7 @@ const BUZZ_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); /// Template content version for AGENTS.md static content (above managed markers). /// Bump this when changing `nest_agents.md` to trigger refresh on existing installs. /// Version 1 is implicitly "before this mechanism existed" (no version file). -const NEST_AGENTS_VERSION: u32 = 4; +const NEST_AGENTS_VERSION: u32 = 5; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index bc67a5b69e..9aa1eeb098 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -41,6 +41,21 @@ fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); } +#[test] +fn nest_agents_template_separates_commit_attribution_claims() { + assert_eq!(AGENTS_MD.matches("## Git Commit Attribution").count(), 1); + assert!(AGENTS_MD.contains( + "Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims" + )); + assert!(AGENTS_MD + .contains("Request, approval, review, or accountability alone is not co-authorship")); + assert!(AGENTS_MD.contains("A sign-off is not an approval marker")); + assert!(AGENTS_MD.contains("Never use another person's signing key")); + assert!(AGENTS_MD.contains("inspect every outgoing commit against the actual upstream or base")); + assert!(AGENTS_MD.contains("An agent-owned repository may use the agent as author")); + assert!(!AGENTS_MD.contains("every commit MUST include a `Signed-off-by`")); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); @@ -431,6 +446,34 @@ fn refresh_agents_md_writes_version_file() { assert_eq!(version.trim(), NEST_AGENTS_VERSION.to_string()); } +#[test] +fn refresh_agents_md_upgrades_attribution_and_preserves_owned_content() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + + let agents_md = root.join("AGENTS.md"); + fs::write( + &agents_md, + "# Buzz Nest\n\n## Git Commit Identity\n\n\ + - **Human sign-off (required):** every commit MUST include a `Signed-off-by`.\n\n\ + \n\ + ## Active Agents\n\n| Name | Persona | How to address |\n\ + |------|---------|----------------|\n| Kit | Builder | @Kit |\n\ + \n\n## Local Notes\n\nKeep me.\n", + ) + .unwrap(); + fs::write(root.join(".nest-agents-version"), "4\n").unwrap(); + + ensure_nest_at(&root).unwrap(); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert_eq!(content.matches("## Git Commit Attribution").count(), 1); + assert!(!content.contains("**Human sign-off (required):**")); + assert!(content.contains("| Kit | Builder | @Kit |")); + assert!(content.contains("## Local Notes\n\nKeep me.")); +} + #[test] fn refresh_skill_md_writes_version_file() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 7cb7489b85..dbba2624db 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -44,15 +44,18 @@ created: 2026-01-15 - **`.scratch/` is disposable** — don't rely on it across sessions - **Stay on task** — only stage files relevant to your current work -## Git Commit Identity +## Git Commit Attribution -The human operator signs off for accountability. +Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims. Follow repository-local rules and the authorizing human's explicit directions; do not infer attribution from repository ownership or from who requested, approved, or reviewed the work. -- **Human sign-off (required):** every commit MUST include a `Signed-off-by` trailer for the human operator who is responsible for the agent's work. Add via `git commit --trailer "Signed-off-by: Human Name "`. One blank line must separate trailers from the commit body. -- **Human credit (`Co-authored-by`):** every commit MUST also include a `Co-authored-by` trailer for the same human operator, with identical name and email to the `Signed-off-by` line. GitHub parses `Co-authored-by` for contribution-graph credit; `Signed-off-by` alone does not grant it. Add via `git commit --trailer "Co-authored-by: Human Name "`. Place `Co-authored-by` before `Signed-off-by` in the trailer block. -- **Discovering the human's identity:** read `git config user.name` and `git config user.email` from the working repository. These reflect the human operator's configured identity for that repo (which may differ from their global config). Use these exact values for both trailers. Do NOT hardcode, guess, or prompt for the email — the repo config is the source of truth. If `git config user.email` returns empty, STOP and ask the human operator for their name and email before committing. -- **Signing:** if the agent has a registered signing key, sign commits. If not, commits will land unverified — this is acceptable until agent SSH keys are provisioned. Do NOT use the human's signing key. -- **Verify before pushing:** `git log -1` should show the human's `Signed-off-by` trailer. +- **Author:** use the person or agent required by the applicable policy. If no policy specifies an author, use the identity that actually authored the change. +- **Co-authors:** add `Co-authored-by` only for other people or agents who materially authored the change. Request, approval, review, or accountability alone is not co-authorship. +- **DCO:** add `Signed-off-by` only when repository policy requires that identity's DCO certification. A sign-off is not an approval marker. +- **Identity:** resolve required identities from trusted local configuration or explicit verified direction; never hard-code or guess them. A managed runtime may make effective `git config user.*` values identify the agent. Stop and ask if a required identity cannot be established. +- **Signing:** use only the signing key configured for the committing identity. Never use another person's signing key. +- **Verify before pushing:** inspect every outgoing commit against the actual upstream or base and confirm its attribution matches the applicable policy. + +A repository may require an accountable human as author and the implementing agent as co-author. An agent-owned repository may use the agent as author and require no human trailer. In both cases, repository-local policy controls. ## Active Agents diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 79a5ea301d..01f7622915 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -51,7 +51,7 @@ Manage your repository's enforced branch and tag rules with `repos protect list| Output varies by command group — `--help` shows flags but not response shapes. -**Read commands** (messages, channels, users, feed, workflows): normalized JSON arrays with `sig` stripped. Fields: `{id, pubkey, kind, content, created_at, tags}` for events; command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), workflows (`{workflow_id, content, created_at, pubkey}`). +**Read commands** return JSON arrays. Event reads (`messages get/thread/search`, `feed get`) return normalized, complete signed Nostr events with `{id, pubkey, kind, content, created_at, tags, sig}`. Other reads use command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), and workflows (`{workflow_id, content, created_at, pubkey}`). **Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`. Agent draft commands add `{request_id, action, saved: false}` because they only open an owner-reviewed Desktop draft. diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 4a73c78064..05dc555339 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.18", + "version": "0.5.20", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e111f93ca0..468435e15e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -648,8 +648,8 @@ export function AppShell() { ); const handleOpenSearchResult = React.useCallback( - (hit: SearchHit) => { - void openSearchHit(hit); + (hit: SearchHit, query: string) => { + void openSearchHit(hit, { query }); }, [openSearchHit], ); diff --git a/desktop/src/app/navigation/searchHighlightNavigation.test.mjs b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs new file mode 100644 index 0000000000..1e05a62669 --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { createSearchHighlightNavigation, parseSearchHighlightNavigation } = + await import("./searchHighlightNavigation.ts"); + +test("creates trimmed transient state with a unique activation id", () => { + const first = createSearchHighlightNavigation("message", " Mentions "); + const second = createSearchHighlightNavigation("message", "Mentions"); + + assert.deepEqual( + { messageId: first.messageId, query: first.query }, + { messageId: "message", query: "Mentions" }, + ); + assert.notEqual(first.activationId, second.activationId); +}); + +test("does not create highlight state for an empty query", () => { + assert.equal(createSearchHighlightNavigation("message", " "), undefined); + assert.equal( + createSearchHighlightNavigation("message", undefined), + undefined, + ); +}); + +test("parses only complete highlight navigation state", () => { + const state = { + activationId: "activation", + messageId: "message", + query: "mentions", + }; + + assert.deepEqual(parseSearchHighlightNavigation(state), state); + assert.equal( + parseSearchHighlightNavigation({ messageId: "message", query: "mentions" }), + null, + ); + assert.equal(parseSearchHighlightNavigation(null), null); +}); diff --git a/desktop/src/app/navigation/searchHighlightNavigation.ts b/desktop/src/app/navigation/searchHighlightNavigation.ts new file mode 100644 index 0000000000..43d3506ec8 --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.ts @@ -0,0 +1,47 @@ +export type SearchHighlightNavigation = { + activationId: string; + messageId: string; + query: string; +}; + +export function createSearchHighlightNavigation( + messageId: string, + query: string | undefined, +): SearchHighlightNavigation | undefined { + const trimmedQuery = query?.trim(); + if (!trimmedQuery) { + return undefined; + } + + return { + activationId: crypto.randomUUID(), + messageId, + query: trimmedQuery, + }; +} + +export function parseSearchHighlightNavigation( + value: unknown, +): SearchHighlightNavigation | null { + if (!value || typeof value !== "object") { + return null; + } + + const candidate = value as Partial; + if ( + typeof candidate.activationId !== "string" || + candidate.activationId.length === 0 || + typeof candidate.messageId !== "string" || + candidate.messageId.length === 0 || + typeof candidate.query !== "string" || + candidate.query.length === 0 + ) { + return null; + } + + return { + activationId: candidate.activationId, + messageId: candidate.messageId, + query: candidate.query, + }; +} diff --git a/desktop/src/app/navigation/searchHitNavigation.test.mjs b/desktop/src/app/navigation/searchHitNavigation.test.mjs index 74e5f108af..02276d9eb3 100644 --- a/desktop/src/app/navigation/searchHitNavigation.test.mjs +++ b/desktop/src/app/navigation/searchHitNavigation.test.mjs @@ -51,6 +51,7 @@ test("search-hit navigation preserves forced message routing while active", asyn options: { force: true, messageId: "message", + searchHighlight: undefined, threadRootId: "thread-root", }, }, @@ -58,6 +59,45 @@ test("search-hit navigation preserves forced message routing while active", asyn assert.equal(getCachedSearchHitEvent("message")?.id, "message"); }); +test("search-hit navigation carries trimmed highlight state and forces repeated activations", async () => { + clearSearchHitEventCache(); + const calls = []; + + await openSearchHitWithNavigation(plainMessage, { + goChannel: async (channelId, options) => { + calls.push({ channelId, options }); + return true; + }, + goForumPost: async () => false, + query: " Mentions ", + }); + + assert.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "message"); + assert.equal(calls[0].options.searchHighlight.query, "Mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); +}); + +test("forum-post search navigation carries transient same-route activation state", async () => { + clearSearchHitEventCache(); + const forumPost = { ...forumComment, eventId: "post", kind: 45001 }; + const calls = []; + + await openSearchHitWithNavigation(forumPost, { + goChannel: async () => false, + goForumPost: async (channelId, postId, options) => { + calls.push({ channelId, postId, options }); + return true; + }, + query: "mentions", + }); + + assert.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "post"); + assert.equal(calls[0].options.searchHighlight.query, "mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); +}); + test("cancelled search-hit navigation cannot repopulate cache or route", async () => { clearSearchHitEventCache(); let resolveLookup; diff --git a/desktop/src/app/navigation/searchHitNavigation.ts b/desktop/src/app/navigation/searchHitNavigation.ts index 8523340d48..6b180c33f0 100644 --- a/desktop/src/app/navigation/searchHitNavigation.ts +++ b/desktop/src/app/navigation/searchHitNavigation.ts @@ -1,21 +1,28 @@ import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { createSearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import type { SearchHit } from "@/shared/api/types"; type SearchHitNavigationActions = { force?: boolean; + query?: string; goChannel: ( channelId: string, options?: { force?: boolean; messageId?: string; + searchHighlight?: ReturnType; threadRootId?: string | null; }, ) => Promise; goForumPost: ( channelId: string, postId: string, - options?: { force?: boolean; replyId?: string }, + options?: { + force?: boolean; + replyId?: string; + searchHighlight?: ReturnType; + }, ) => Promise; signal?: AbortSignal; }; @@ -30,6 +37,10 @@ export async function openSearchHitWithNavigation( } const isLifecycleBound = Boolean(actions.signal); + const searchHighlight = createSearchHighlightNavigation( + hit.eventId, + actions.query, + ); if (!isLifecycleBound) { cacheSearchHitEvent(hit); } @@ -47,14 +58,16 @@ export async function openSearchHitWithNavigation( if (destination.kind === "forum-post") { return actions.goForumPost(destination.channelId, destination.postId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), replyId: destination.replyId, + searchHighlight, }); } return actions.goChannel(destination.channelId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), messageId: destination.messageId, + searchHighlight, threadRootId: destination.threadRootId, }); } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 7a21f0dfbe..c82c4d96ee 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,6 +6,7 @@ import { useRouter, } from "@tanstack/react-router"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; import { allowNavigation, @@ -32,14 +33,23 @@ export function useAppNavigation() { to: string; params?: Record; search?: Record; - state?: Record; + state?: + | Record + | (( + previousState: Record, + ) => Record); }, behavior: NavigationBehavior = {}, guardedTarget?: GuardedNavigation, ) => { const nextLocation = router.buildLocation(next as never); + const hasStateUpdate = next.state !== undefined; - if (location.href === nextLocation.href && !behavior.force) { + if ( + location.href === nextLocation.href && + !behavior.force && + !hasStateUpdate + ) { return false; } @@ -265,6 +275,9 @@ export function useAppNavigation() { * silently swallowed (block/buzz#3509). */ force?: boolean; messageId?: string; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; @@ -290,6 +303,12 @@ export function useAppNavigation() { ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, @@ -329,6 +348,9 @@ export function useAppNavigation() { force?: boolean; replace?: boolean; replyId?: string; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; }, ) => { return commitNavigation( @@ -338,7 +360,15 @@ export function useAppNavigation() { channelId, postId, }, - search: options?.replyId ? { replyId: options.replyId } : {}, + search: { + ...(options?.replyId ? { replyId: options.replyId } : {}), + }, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, @@ -406,6 +436,8 @@ export function useAppNavigation() { * Used by desktop-notification activation so a click is never * silently swallowed (block/buzz#3509). */ force?: boolean; + /** Search text to highlight after opening this result. */ + query?: string; /** Stop notification-driven routing when its owning lifecycle ends. */ signal?: AbortSignal; }, @@ -414,6 +446,7 @@ export function useAppNavigation() { force: behavior?.force, goChannel, goForumPost, + query: behavior?.query, signal: behavior?.signal, }), [goChannel, goForumPost], diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d4626d2c6f..03b08196af 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; @@ -20,6 +21,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteScreenProps = { autoSendDraftKey: string | null; channelId: string; + searchHighlight: SearchHighlightNavigation | null | undefined; selectedPostId: string | null; targetMessageId: string | null; targetReplyId: string | null; @@ -100,6 +102,7 @@ async function fetchRouteTargetEvents( export function ChannelRouteScreen({ autoSendDraftKey, channelId, + searchHighlight, selectedPostId, targetMessageId, targetReplyId, @@ -132,23 +135,67 @@ export function ChannelRouteScreen({ const cachedTarget = getCachedSearchHitEvent(targetMessageId); return cachedTarget ? [cachedTarget] : []; }); - - // Reset spliced target events when the channel context changes (channel - // switch or entering/leaving a forum post). Tied to channel identity rather - // than the route target so clearing the `messageId` param mid-channel keeps - // the deep-linked row in view. Seeded with the mount key so the initial - // cache-seeded events survive first commit; only a genuine channel change - // clears them. Declared before the fetch effect so a channel switch clears - // stale events before the new target is fetched. - const previousResetKeyRef = React.useRef( - `${channelId}::${selectedPostId ?? ""}`, + const [activeSearchHighlight, setActiveSearchHighlight] = + React.useState(searchHighlight ?? null); + const appliedSearchActivationIdRef = React.useRef( + searchHighlight?.activationId ?? null, ); + + // Router state is transient and can be cleared by the target URL cleanup. + // Retain the applied activation locally until an ordinary route transition + // explicitly arrives without search state. React.useEffect(() => { - const resetKey = `${channelId}::${selectedPostId ?? ""}`; - if (previousResetKeyRef.current === resetKey) return; - previousResetKeyRef.current = resetKey; + if (searchHighlight === null) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + return; + } + if (!searchHighlight) { + const ordinaryTargetIds = [ + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ].filter((targetId): targetId is string => targetId !== null); + if ( + ordinaryTargetIds.length > 0 && + activeSearchHighlight && + !ordinaryTargetIds.includes(activeSearchHighlight.messageId) + ) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + } + return; + } + if (appliedSearchActivationIdRef.current === searchHighlight.activationId) { + return; + } + + appliedSearchActivationIdRef.current = searchHighlight.activationId; + setActiveSearchHighlight(searchHighlight); + }, [ + activeSearchHighlight, + searchHighlight, + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ]); + + // Reset spliced target events when the channel changes. Tied to channel + // identity rather than the route target so clearing the `messageId` param + // mid-channel keeps the deep-linked row in view. Seeded with the mount key so + // the initial cache-seeded events survive first commit; only a genuine + // channel change clears them. Declared before the fetch effect so a channel + // switch clears stale events before the new target is fetched. + const previousResetKeyRef = React.useRef(channelId); + React.useEffect(() => { + if (previousResetKeyRef.current === channelId) return; + previousResetKeyRef.current = channelId; + appliedSearchActivationIdRef.current = null; setTargetMessageEvents([]); - }, [channelId, selectedPostId]); + setActiveSearchHighlight(null); + }, [channelId]); React.useEffect(() => { let isCancelled = false; @@ -234,6 +281,8 @@ export function ChannelRouteScreen({ targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} + targetSearchMessageId={activeSearchHighlight?.messageId} + targetSearchQuery={activeSearchHighlight?.query} /> ); } diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx index 1025cc1e89..8fcab41817 100644 --- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx +++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { selectSearchHighlightRouteState } from "@/app/routes/searchHighlightRouteState"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -33,6 +34,9 @@ function ForumPostRouteComponent() { usePreviewFeatureWarning("forum"); const { channelId, postId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); return ( { function ChannelRouteComponent() { const { channelId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); const isHuddleTranscript = huddleWindowChannelId() !== null; return ( @@ -74,6 +79,7 @@ function ChannelRouteComponent() { { + assert.deepEqual( + selectSearchHighlightRouteState({ state: { searchHighlight } }), + searchHighlight, + ); +}); + +test("target cleanup without highlight state preserves the selection", () => { + assert.equal(selectSearchHighlightRouteState({ state: {} }), undefined); +}); + +test("ordinary navigation explicitly clears the selection", () => { + assert.equal( + selectSearchHighlightRouteState({ state: { searchHighlight: null } }), + null, + ); +}); + +test("ignores malformed router state", () => { + assert.equal( + selectSearchHighlightRouteState({ + state: { searchHighlight: { messageId: "message", query: "mentions" } }, + }), + undefined, + ); +}); diff --git a/desktop/src/app/routes/searchHighlightRouteState.ts b/desktop/src/app/routes/searchHighlightRouteState.ts new file mode 100644 index 0000000000..4fcc01c8d3 --- /dev/null +++ b/desktop/src/app/routes/searchHighlightRouteState.ts @@ -0,0 +1,17 @@ +import { + parseSearchHighlightNavigation, + type SearchHighlightNavigation, +} from "@/app/navigation/searchHighlightNavigation"; + +export function selectSearchHighlightRouteState(location: { + state: unknown; +}): SearchHighlightNavigation | null | undefined { + const state = location.state as { searchHighlight?: unknown } | undefined; + if (!(state && "searchHighlight" in state)) { + return undefined; + } + if (state.searchHighlight === null) { + return null; + } + return parseSearchHighlightNavigation(state.searchHighlight) ?? undefined; +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index b4a139eb0e..c8cfd30088 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -103,6 +103,54 @@ test("buildTranscript renders Prompt context + user message for a multi-block se assert.equal(userMessage.messageId, PROMPT_EVENT_ID); }); +test("buildTranscript preserves a slash-command preamble before semantic prompt blocks", () => { + const authorPubkey = "a".repeat(64); + const event = { + ...baseEvent, + payload: { + method: "session/prompt", + params: { + sessionId: "sess-1", + prompt: [ + { type: "text", text: "/goal ship it" }, + { + type: "text", + text: "\nScope: channel\n", + }, + { + type: "text", + text: [ + '', + `Event ID: ${PROMPT_EVENT_ID.toUpperCase()}`, + "Channel: agents", + "Kind: 40002", + `From: Eva (hex: ${authorPubkey})`, + "Content: @Eva /goal ship it", + "", + ].join("\n"), + }, + ], + }, + }, + }; + + const items = buildTranscript([event]); + const userMessage = items.find((item) => item.type === "message"); + assert.equal(userMessage?.text, "@Eva /goal ship it"); + assert.equal(userMessage?.title, "@Mention"); + assert.equal(userMessage?.authorPubkey, authorPubkey); + assert.equal(userMessage?.messageId, PROMPT_EVENT_ID); + + const promptContext = items.find( + (item) => item.type === "metadata" && item.title === "Prompt context", + ); + assert.deepEqual( + promptContext?.sections.map((section) => section.title), + ["Prompt", "Context", "Buzz event: @mention"], + ); + assert.equal(promptContext?.sections[0]?.body, "/goal ship it"); +}); + test("buildTranscript falls back to a single turn trigger id for older prompt frames", () => { const promptEvent = { ...baseEvent, diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index dfb8eb22fb..63bf1597cb 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -19,12 +19,12 @@ import { extractBlockText, extractContentText, extractPlanText, - extractPromptText, + extractPromptBlocks, extractTriggeringEventIds, extractToolArgs, extractToolIdentity, extractToolResult, - parsePromptText, + parsePromptBlocks, parseSystemPromptSections, } from "./agentSessionTranscriptHelpers"; import { friendlyTurnErrorCopy } from "../lib/friendlyAgentLastError"; @@ -839,9 +839,9 @@ export function processTranscriptEvent( } } } else if (event.kind === "acp_write" && method === "session/prompt") { - const promptText = extractPromptText(payload); - if (promptText) { - const parsedPrompt = parsePromptText(promptText); + const promptBlocks = extractPromptBlocks(payload); + if (promptBlocks.length > 0) { + const parsedPrompt = parsePromptBlocks(promptBlocks); if (parsedPrompt.userText) { upsertMessage( d, @@ -871,7 +871,7 @@ export function processTranscriptEvent( } } else if (event.kind === "acp_write" && method === "session/new") { // The base + persona prompts ride session/new's systemPrompt, framed by - // the harness as [Base]/[Agent Instructions]/[Agent Memory — core]/[Channel Canvas]. + // the harness as ///. // claude-agent-acp uses _meta.systemPrompt.append instead; both paths // produce the same standalone card (turnId: null, acpSource "session/new"); // the bare field takes precedence when both are present. @@ -898,9 +898,9 @@ export function processTranscriptEvent( event.kind === "acp_write" && method === "_goose/unstable/session/steer" ) { - const promptText = extractPromptText(payload); - if (promptText) { - const parsedPrompt = parsePromptText(promptText); + const promptBlocks = extractPromptBlocks(payload); + if (promptBlocks.length > 0) { + const parsedPrompt = parsePromptBlocks(promptBlocks); if (parsedPrompt.userText) { upsertMessage( d, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index 23df1e5f2b..aa91391922 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -136,6 +136,111 @@ test("parsePromptText leading text before a header becomes a Prompt section", () ); }); +test("parsePromptText splits a legacy tagged standing prefix from the dynamic turn", () => { + const text = [ + "", + "platform context", + "", + "", + "", + "persona context", + "", + "", + "[Context]", + "Scope: channel", + "", + "[Buzz event: @mention]", + "Event ID: abc123", + "From: Alice (hex: AABBCC)", + "Content: ship it", + ].join("\n"); + + const parsed = parsePromptText(text); + + assert.equal(parsed.userText, "ship it"); + assert.deepEqual( + parsed.sections.map((section) => section.title), + ["Base", "System", "Context", "Buzz event: @mention"], + ); +}); + +test("parsePromptText splits paired top-level turn sections and preserves inner framing", () => { + const text = [ + "", + "Scope: thread", + "", + "", + '', + "[1] Alice (2026-08-25T12:00:00Z): prior message", + "", + "", + '', + "Event ID: abc123", + "From: Alice (hex: AABBCC)", + "Content: ship it", + "", + ].join("\n"); + + const parsed = parsePromptText(text); + + assert.equal(parsed.userText, "ship it"); + assert.deepEqual(parsed.sections, [ + { title: "Context", body: "Scope: thread" }, + { + title: "Thread Context (1 of 3 messages, truncated)", + body: "[1] Alice (2026-08-25T12:00:00Z): prior message", + }, + { + title: "Buzz event: @mention", + body: "Event ID: abc123\nFrom: Alice (hex: AABBCC)\nContent: ship it", + }, + ]); +}); + +test("parsePromptText preserves batched steer and interrupt counts in section titles", () => { + const cases = [ + { + tag: "new-message-arrived-while-you-were-working", + count: "2", + title: "New messages — arrived while you were working — 2 events", + }, + { + tag: "new-request-supersedes-previous", + count: "3", + title: "New request — supersedes previous — 3 events", + }, + ]; + + for (const { tag, count, title } of cases) { + const text = [ + `<${tag} count="${count}">`, + "--- Event 1 (message) ---", + "Content: update", + ``, + ].join("\n"); + + const parsed = parsePromptText(text); + + assert.equal(parsed.sections[0]?.title, title); + } +}); + +test("parsePromptText falls back to the complete prompt for ambiguous turn tags", () => { + const text = [ + "", + "literal authored boundary: ", + "", + '', + "Content: hello", + "", + ].join("\n"); + + const parsed = parsePromptText(text); + + assert.deepEqual(parsed.sections, [{ title: "Prompt", body: text }]); + assert.equal(parsed.userText, ""); +}); + test("extractPromptText joins text blocks from params.prompt", () => { const payload = { params: { @@ -201,6 +306,118 @@ test("parseSystemPromptSections splits both prompts into Base and System", () => ]); }); +test("parseSystemPromptSections reads paired standing-context tags", () => { + const framed = [ + "", + "base text", + "", + "", + "", + "Current working directory: /workspace", + "", + "", + "", + "persona text", + "", + "", + "", + "team text", + "", + "", + "", + "memory text", + "", + "", + "", + "reply now", + "", + "", + "", + "canvas text", + "", + ].join("\n"); + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "Base", body: "base text" }, + { + title: "Workspace", + body: "Current working directory: /workspace", + }, + { title: "System", body: "persona text" }, + { title: "Team Instructions", body: "team text" }, + { title: "Core Memory", body: "memory text" }, + { title: "Huddle Instructions", body: "reply now" }, + { title: "Channel Canvas", body: "canvas text" }, + ]); +}); + +test("parseSystemPromptSections keeps paired-tag examples literal in legacy personas", () => { + const framed = [ + "[Base]", + "platform rules", + "", + "[System]", + "Teach users this example:", + "", + "untrusted text", + "", + "Then continue following the real persona.", + ].join("\n"); + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "Base", body: "platform rules" }, + { + title: "System", + body: [ + "Teach users this example:", + "", + "untrusted text", + "", + "Then continue following the real persona.", + ].join("\n"), + }, + ]); +}); + +test("parseSystemPromptSections shows the complete prompt when semantic framing has trailing text", () => { + const framed = [ + "", + "base text", + "", + "unframed trailing text", + ].join("\n"); + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "Prompt", body: framed }, + ]); +}); + +test("parseSystemPromptSections preserves literal entity text in standing-context bodies", () => { + const framed = + "\nliteral </system> & <policy>\n"; + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "System", body: "literal </system> & <policy>" }, + ]); +}); + +test("parseSystemPromptSections shows the captured prompt literally when paired tags are ambiguous", () => { + const framed = + "\nkeep , , ", & \n"; + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "Prompt", body: framed }, + ]); +}); + +test("parseSystemPromptSections preserves authored boundary whitespace", () => { + const framed = "\n\n keep this \n\n"; + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "System", body: "\n keep this \n" }, + ]); +}); + test("parseSystemPromptSections splits current Base and Agent Instructions framing", () => { const framed = "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace\n\n[Agent Instructions]\npersona text"; diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 87cf8ec2df..01feb69d82 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -6,11 +6,46 @@ import { } from "./agentSessionToolCatalog"; import { asRecord, asString, titleCase } from "./agentSessionUtils"; -export function extractPromptText(payload: Record): string { +export function extractPromptBlocks( + payload: Record, +): string[] { const params = asRecord(payload.params); const prompt = params.prompt; - if (!Array.isArray(prompt)) return ""; - return prompt.map(extractBlockText).filter(Boolean).join("\n"); + if (!Array.isArray(prompt)) return []; + return prompt.map(extractBlockText).filter(Boolean); +} + +export function extractPromptText(payload: Record): string { + return extractPromptBlocks(payload).join("\n"); +} + +const SEMANTIC_PROMPT_SECTION_START = + /^\s*<(?:workspace|base|system|team-instructions|core-memory|huddle-instructions|channel-canvas|context|thread-context|conversation-context|buzz-event|buzz-events|what-you-were-working-on|new-message-arrived-while-you-were-working|previous-request-interrupted-before-completion|new-request-supersedes-previous)(?:\s[^>]*)?>/; + +/** + * Parse ACP prompt blocks without losing the connector-facing slash-command + * boundary. The harness emits that command as block zero and semantic prompt + * sections in subsequent blocks; arbitrary leading text remains on the normal + * parsing path. + */ +export function parsePromptBlocks( + blocks: readonly string[], +): ReturnType { + const [firstBlock, ...remainingBlocks] = blocks; + const hasSlashCommandPreamble = + /^\/[A-Za-z0-9]/.test(firstBlock?.trimStart() ?? "") && + remainingBlocks.length > 0 && + SEMANTIC_PROMPT_SECTION_START.test(remainingBlocks[0]); + + if (!hasSlashCommandPreamble) { + return parsePromptText(blocks.join("\n")); + } + + const parsed = parsePromptText(remainingBlocks.join("\n")); + return { + ...parsed, + sections: [{ title: "Prompt", body: firstBlock }, ...parsed.sections], + }; } export function parsePromptText(text: string): { @@ -20,9 +55,13 @@ export function parsePromptText(text: string): { userPubkey: string | null; userEventId: string | null; } { - const sections = parsePromptSections(text).filter( - (s) => s.body.trim().length > 0, - ); + const semanticPrefix = splitSemanticStandingPrefix(text); + const semanticTurn = splitSemanticTurnSections(semanticPrefix.remainder); + const sections = [ + ...semanticPrefix.sections, + ...semanticTurn.sections, + ...parsePromptSections(semanticTurn.remainder), + ].filter((s) => s.body.trim().length > 0); if (sections.length === 0) { return { sections: [], @@ -56,11 +95,11 @@ export function parsePromptText(text: string): { } /** - * Split the framed `session/new` `systemPrompt` into its `Base`/`Agent Instructions`/ - * `Team Instructions`/`Core Memory`/`Channel Canvas` sub-sections - * deterministically. + * Split `session/new`'s paired standing-context tags into transcript sections. + * The bracket parser is retained below for observer history captured before + * the framing experiment. * - * The harness composes the value in order: + * Archived harness versions composed the value in order: * `[Base]\n{base}\n\n[Agent Instructions]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}` * with any section omitted when absent. Extraction runs in reverse producer * order so that each `lastIndexOf` search operates on the full input and each @@ -99,6 +138,9 @@ export function parsePromptText(text: string): { export function parseSystemPromptSections( systemPrompt: string, ): PromptSection[] { + const semantic = parseSemanticStandingSections(systemPrompt); + if (semantic) return semantic; + const sections: PromptSection[] = []; // ── 1. Extract [Channel Canvas] ─────────────────────────────────────────── @@ -277,6 +319,183 @@ export function parseSystemPromptSections( return sections; } +/** + * Split current paired-tag standing context while retaining the bracket parser + * below for observer history captured before the framing experiment. + */ +function parseSemanticStandingSections( + systemPrompt: string, +): PromptSection[] | null { + const titles: Record = { + workspace: "Workspace", + base: "Base", + system: "System", + "team-instructions": "Team Instructions", + "core-memory": "Core Memory", + "huddle-instructions": "Huddle Instructions", + "channel-canvas": "Channel Canvas", + }; + const tags = Object.keys(titles).join("|"); + // Archived bracket-framed personas may contain literal balanced tag examples. + // Only classify a capture as semantic when its framing starts at the input boundary. + if (!new RegExp(`^\\s*<(${tags})>`).test(systemPrompt)) return null; + + const parsed = splitSemanticStandingPrefix(systemPrompt); + // Current producers emit only paired sections separated by whitespace. Any + // other text makes the boundary ambiguous, so show the complete capture. + if (parsed.sections.length > 0 && parsed.remainder.trim().length === 0) { + return parsed.sections; + } + + return [{ title: "Prompt", body: systemPrompt }]; +} + +function splitSemanticStandingPrefix(text: string): { + sections: PromptSection[]; + remainder: string; +} { + const sections: PromptSection[] = []; + let remainder = text; + const tags = [ + "workspace", + "base", + "system", + "team-instructions", + "core-memory", + "huddle-instructions", + "channel-canvas", + ].join("|"); + const titles: Record = { + workspace: "Workspace", + base: "Base", + system: "System", + "team-instructions": "Team Instructions", + "core-memory": "Core Memory", + "huddle-instructions": "Huddle Instructions", + "channel-canvas": "Channel Canvas", + }; + if (hasAmbiguousSemanticBoundary(text, Object.keys(titles))) { + return { sections, remainder: text }; + } + const leadingSection = new RegExp(`^\\s*<(${tags})>([\\s\\S]*?)<\\/\\1>\\s*`); + + for (;;) { + const match = remainder.match(leadingSection); + if (!match) break; + sections.push({ + title: titles[match[1]], + body: stripSemanticBoundaryNewlines(match[2]), + }); + remainder = remainder.slice(match[0].length); + } + return { sections, remainder }; +} + +function hasAmbiguousSemanticBoundary(value: string, tags: string[]): boolean { + return tags.some((tag) => { + const openingCount = Array.from( + value.matchAll(new RegExp(`<${tag}(?:\\s[^>]*)?>`, "g")), + ).length; + const closingCount = value.split(``).length - 1; + return openingCount !== closingCount || openingCount > 1; + }); +} + +function splitSemanticTurnSections(text: string): { + sections: PromptSection[]; + remainder: string; +} { + const sections: PromptSection[] = []; + let remainder = text; + const tags = [ + "context", + "thread-context", + "conversation-context", + "buzz-event", + "buzz-events", + "what-you-were-working-on", + "new-message-arrived-while-you-were-working", + "previous-request-interrupted-before-completion", + "new-request-supersedes-previous", + ]; + if (hasAmbiguousSemanticBoundary(text, tags)) { + return { sections, remainder: text }; + } + const leadingSection = new RegExp( + `^\\s*<(${tags.join("|")})([^>]*)>([\\s\\S]*?)<\\/\\1>\\s*`, + ); + + for (;;) { + const match = remainder.match(leadingSection); + if (!match) break; + sections.push({ + title: semanticTurnTitle(match[1], parseSemanticAttributes(match[2])), + body: stripSemanticBoundaryNewlines(match[3]), + }); + remainder = remainder.slice(match[0].length); + } + return { sections, remainder }; +} + +function parseSemanticAttributes(raw: string): Record { + return Object.fromEntries( + Array.from(raw.matchAll(/([a-z-]+)="([^"]*)"/g), ([, name, value]) => [ + name, + decodeSemanticAttribute(value), + ]), + ); +} + +function decodeSemanticAttribute(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +function semanticTurnTitle( + tag: string, + attributes: Record, +): string { + switch (tag) { + case "context": + return "Context"; + case "thread-context": + case "conversation-context": { + const label = + tag === "thread-context" ? "Thread Context" : "Conversation Context"; + const truncated = attributes.truncated === "true" ? ", truncated" : ""; + return `${label} (${attributes.included} of ${attributes.total} messages${truncated})`; + } + case "buzz-event": + return attributes.type ? `Buzz event: ${attributes.type}` : "Buzz event"; + case "buzz-events": + return `Buzz events — ${attributes.count} events`; + case "what-you-were-working-on": + return "What you were working on"; + case "new-message-arrived-while-you-were-working": + return attributes.count + ? `New messages — arrived while you were working — ${attributes.count} events` + : "New message — arrived while you were working"; + case "previous-request-interrupted-before-completion": + return "Previous request — interrupted before completion"; + case "new-request-supersedes-previous": + return attributes.count + ? `New request — supersedes previous — ${attributes.count} events` + : "New request — supersedes previous"; + default: + return tag; + } +} + +function stripSemanticBoundaryNewlines(value: string): string { + const withoutOpeningNewline = value.startsWith("\n") ? value.slice(1) : value; + return withoutOpeningNewline.endsWith("\n") + ? withoutOpeningNewline.slice(0, -1) + : withoutOpeningNewline; +} + function parsePromptSections(text: string): PromptSection[] { const sections: PromptSection[] = []; let current: PromptSection | null = null; diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 4086a4e1c1..89c61bdf60 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -49,6 +49,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; +import { useSearchHighlightProps } from "@/features/channels/ui/useSearchHighlightProps"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; @@ -152,6 +153,8 @@ export const ChannelPane = React.memo(function ChannelPane({ profilePanelTab, profilePanelView, targetMessageId, + targetSearchMessageId, + targetSearchQuery, threadAllMessages, threadHeadMessage, threadMessages, @@ -176,6 +179,10 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, ); const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); + const searchHighlightProps = useSearchHighlightProps( + targetSearchMessageId, + targetSearchQuery, + ); const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false); const isNonMemberView = @@ -194,8 +201,6 @@ export const ChannelPane = React.memo(function ChannelPane({ channelPaneMountedRef.current = false; }; }, []); - // Clear only the auto-send key so thread state survives deferred submission; - // older wrappers fall back to goChannel to prevent back-navigation replay. const handleAutoSubmitComplete = React.useCallback(() => { if (onAutoSendComplete) { onAutoSendComplete(); @@ -252,9 +257,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const timeoutState = useTimeoutState(); - // A moderation DM (1:1 with the relay identity) is read-only for the member; - // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → - // ordinary DM, composer enabled. const relaySelfQuery = useRelaySelfQuery(activeChannel?.channelType === "dm"); const isModerationDmChannel = isModerationDm( activeChannel ?? null, @@ -339,10 +341,6 @@ export const ChannelPane = React.memo(function ChannelPane({ !isMainDeferredEditPending && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; - // Unified working set for the composer bar: observer-derived turns primary, - // bot typing fallback (both folded together by agentWorkingSignal). This is - // what makes the bar show for an agent whose observer stream is live but - // whose typing signal never arrives — and vice versa. const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys( activeChannel?.id ?? null, ); @@ -373,7 +371,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [activeChannel, currentPubkey, profiles], ); - const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ beforeSend: () => @@ -389,13 +386,14 @@ export const ChannelPane = React.memo(function ChannelPane({ onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined, }); const channelIntro = isHuddleTranscript ? null : standardChannelIntro; - const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({ - activeChannel, - isHuddleTranscript, - messages, - profiles, - threadSummaries, - }); + const { mainTimelineEntries, recentMentions, visibleMessages } = + useChannelPaneMessages({ + activeChannel, + isHuddleTranscript, + messages, + profiles, + threadSummaries, + }); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, @@ -696,6 +694,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} + {...searchHighlightProps.timeline} targetMessageId={targetMessageId} splitThreadPanelOpen={ useSplitAuxiliaryPane && @@ -781,7 +780,7 @@ export const ChannelPane = React.memo(function ChannelPane({ : undefined } onSend={handleSendMessage} - profiles={profiles} + {...{ profiles, recentMentionPubkeys: recentMentions }} showBackgroundUploadProgress={false} placeholder={ timeoutState.active @@ -890,10 +889,11 @@ export const ChannelPane = React.memo(function ChannelPane({ onScrollTargetSettled={resolveScrollTarget} onToggleReaction={onToggleReaction} onUnfollowThread={onUnfollowThread} - profiles={profiles} + {...{ profiles, recentMentionPubkeys: recentMentions }} replyTargetMessage={threadReplyTargetMessage} scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} + {...searchHighlightProps.thread} threadHead={threadHeadMessage} videoReviewPresentation={threadVideoReviewPresentation} widthPx={threadPanelWidthPx} @@ -940,10 +940,6 @@ export const ChannelPane = React.memo(function ChannelPane({ })() ) : activeChannel && selectedAgent ? ( (() => { - // When the panel was opened from a different channel than the - // currently active one, re-scope it to the active channel so - // that both the content/header AND channel-backed actions (e.g. - // Stop current turn) operate on the same channel object. const effectiveAgentSessionChannelId = openAgentSessionChannelId && activeChannel.id !== openAgentSessionChannelId diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 0296822168..d1b88ae3a6 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -181,6 +181,10 @@ export type ChannelPaneProps = { threadReplyUnreadCounts?: ReadonlyMap; threadFirstUnreadReplyId?: string | null; targetMessageId: string | null; + /** Exact clicked result id, including a reply routed into the thread panel. */ + targetSearchMessageId?: string | null; + /** Search text to highlight within the clicked result. */ + targetSearchQuery?: string; typingPubkeys: string[]; isFollowingThread?: boolean; onFollowThread?: () => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 240a9ad70c..fe0c4d23b7 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -7,24 +7,14 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys"; import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles"; import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync"; -import { - useChannelMembersQuery, - useJoinChannelMutation, -} from "@/features/channels/hooks"; -import { - MSG_PREFIX, - THREAD_PREFIX, -} from "@/features/channels/readState/readStateFormat"; +import * as channelHooks from "@/features/channels/hooks"; +import * as readStateFormat from "@/features/channels/readState/readStateFormat"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog"; import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; -import { - useManagedAgentsQuery, - usePersonasQuery, - useRelayAgentsQuery, -} from "@/features/agents/hooks"; +import * as agentHooks from "@/features/agents/hooks"; import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide"; @@ -44,10 +34,7 @@ import { import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import { - getThreadReference, - isThreadReply, -} from "@/features/messages/lib/threading"; +import * as threading from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, @@ -62,10 +49,7 @@ import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import type { RelayEvent, RespondToMode } from "@/shared/api/types"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; -import { - useHuddleChannelMessages, - useIsHuddleTranscript, -} from "@/features/channels/ui/useHuddleChannelMessages"; +import * as huddleMessages from "@/features/channels/ui/useHuddleChannelMessages"; import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"; import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; @@ -89,6 +73,7 @@ import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; import { GuardedChannelPane } from "./GuardedChannelPane"; import { useNavigationGuard } from "./useNavigationGuard"; +import * as searchForwarding from "./searchTargetForwarding"; const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, @@ -101,6 +86,7 @@ export function ChannelScreen({ targetForumReplyId, targetMessageEvents, targetMessageId, + ...searchTarget }: ChannelScreenProps) { const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); @@ -171,7 +157,8 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; - const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); + const isHuddleTranscript = + huddleMessages.useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; const requireThreadEditResolutionRef = React.useRef<() => boolean>( () => true, @@ -214,7 +201,7 @@ export function ChannelScreen({ const messages = messagesQuery.data; if (!messages) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { - if (getThreadReference(messages[index].tags).parentId === null) + if (threading.getThreadReference(messages[index].tags).parentId === null) return messages[index]; } return null; @@ -233,7 +220,8 @@ export function ChannelScreen({ return; } setContextParentResolver((contextId) => - contextId.startsWith(THREAD_PREFIX) || contextId.startsWith(MSG_PREFIX) + contextId.startsWith(readStateFormat.THREAD_PREFIX) || + contextId.startsWith(readStateFormat.MSG_PREFIX) ? activeChannelId : null, ); @@ -253,13 +241,14 @@ export function ChannelScreen({ const toggleReactionMutation = useToggleReactionMutation(); const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); - const joinChannelMutation = useJoinChannelMutation(activeChannelId); + const joinChannelMutation = + channelHooks.useJoinChannelMutation(activeChannelId); const { resolvedMessages, threadSummaries, threadRepliesError: huddleThreadRepliesError, onRetryThreadReplies: onRetryHuddleThreadReplies, - } = useHuddleChannelMessages({ + } = huddleMessages.useHuddleChannelMessages({ activeChannel, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, @@ -305,9 +294,11 @@ export function ChannelScreen({ : [], [activeChannel], ); - const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); + const channelMembersQuery = channelHooks.useChannelMembersQuery( + activeChannel?.id ?? null, + ); const channelMembers = channelMembersQuery.data; - const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentsQuery = agentHooks.useManagedAgentsQuery(); const managedAgents = managedAgentsQuery.data ?? []; const welcomeGuideAgent = React.useMemo( () => pickWelcomeGuideAgent(managedAgents), @@ -318,7 +309,7 @@ export function ChannelScreen({ currentIdentity, welcomeGuideAgent, }); - const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgentsQuery = agentHooks.useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data ?? []; const knownAgentPubkeys = React.useMemo( () => @@ -385,7 +376,7 @@ export function ChannelScreen({ } return pubkeys; }, [knownAgentPubkeys, messageProfiles, communityAgentPubkeys]); - const personasQuery = usePersonasQuery(); + const personasQuery = agentHooks.usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personaById = new Map( @@ -502,7 +493,8 @@ export function ChannelScreen({ editMessageMutation, editTargetId, editTargetIsThreadReply: - editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []), + editTargetMessage !== null && + threading.isThreadReply(editTargetMessage.tags ?? []), expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -632,9 +624,6 @@ export function ChannelScreen({ isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, }, - // A persisted head only counts as hydrated when it has rows to paint - // (channelHeadCache.ts), so this bypass never settles onto an empty - // placeholder while the authoritative refresh is still in flight. hasSettledThisChannel || (activeChannelId !== null && hasPersistedHydratedChannel(queryClient, activeChannelId)), @@ -825,27 +814,30 @@ export function ChannelScreen({ > {activeChannel ? ( activeChannel.channelType === "forum" ? ( - + searchForwarding.renderSearchAwareForum( + , + searchTarget, + ) ) : ( } > - - knownAgentPubkeys.has(pubkey) || - !!messageProfiles?.[pubkey]?.isAgent, - ) - : null - } - followThreadById={followThread} - unfollowThreadById={unfollowThread} - isFollowingThreadById={isFollowingThread} - isMessageUnreadById={isMessageUnread} - isFollowingThread={isNotifiedForEffectiveThread} - isSending={sendMessageMutation.isPending} - isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} - threadSummaries={threadSummaries} - huddleThreadRepliesError={huddleThreadRepliesError} - onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} - onCancelEdit={handleCancelEdit} - onCancelThreadReply={handleCancelThreadReply} - onChannelManagementDeleted={handleChannelManagementDeleted} - onFollowThread={ - effectiveOpenThreadHeadId != null && - !isNotifiedForEffectiveThread - ? () => followThread(effectiveOpenThreadHeadId) - : undefined - } - onUnfollowThread={ - effectiveOpenThreadHeadId != null && - isNotifiedForEffectiveThread - ? () => unfollowThread(effectiveOpenThreadHeadId) - : undefined - } - onCloseAgentSession={handleCloseAgentSession} - onBackFromAgentSession={ - hasAgentSessionReturnTarget - ? handleBackFromAgentSession - : undefined - } - onCloseChannelManagement={handleCloseChannelManagement} - onCloseThread={handleCloseThread} - onDelete={ - activeChannel?.archivedAt ? undefined : handleDelete - } - onEdit={activeChannel?.archivedAt ? undefined : handleEdit} - onEditSave={ - activeChannel?.archivedAt ? undefined : handleEditSave - } - onMarkUnread={handleMessageMarkUnread} - onMarkRead={handleMessageMarkRead} - onExpandThreadReplies={handleExpandThreadReplies} - onOpenAgentSession={handleOpenAgentSession} - onOpenDm={handleOpenDm} - onOpenProfilePanel={handleOpenProfilePanel} - onResetThreadPanelWidth={handleThreadPanelWidthReset} - onCloseProfilePanel={handleCloseProfilePanel} - onOpenThread={handleOpenThreadAndCloseAgentSession} - onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessage} - onSendToChannel={handleSendToChannel} - onSendVideoReviewComment={effectiveSendVideoReviewComment} - onSendThreadReply={handleSendThreadReply} - onThreadScrollTargetResolved={() => - setThreadScrollTargetId(null) - } - onThreadPanelResizeStart={handleThreadPanelResizeStart} - onTargetReached={() => - clearMessageRouteTarget({ replace: true }) - } - onToggleReaction={effectiveToggleReaction} - openAgentSessionChannelId={openAgentSessionChannelId} - openAgentSessionPubkey={openAgentSessionPubkey} - openThreadHeadId={effectiveOpenThreadHeadId} - shouldShowThreadSkeleton={shouldShowThreadSkeleton} - onProfilePanelViewChange={setProfilePanelView} - onProfilePanelTabChange={setProfilePanelTab} - profilePanelPubkey={profilePanelPubkey} - profilePanelTab={profilePanelTab} - profilePanelView={profilePanelView} - personaLookup={personaLookup} - profiles={messageProfiles} - ownerProfiles={messageOwnerProfiles} - firstUnreadMessageId={firstUnreadMessageId} - unreadCount={unreadCount} - targetMessageId={mainTimelineTargetMessageId} - threadAllMessages={displayedThreadAllMessages} - threadHeadMessage={displayedThreadHeadMessage} - threadMessages={displayedThreadMessages} - threadMessagesPending={threadRepliesQuery.isPending} - threadMessagesError={threadRepliesQuery.isError} - onRetryThreadReplies={() => { - void threadRepliesQuery.refetch(); - }} - threadPanelWidthPx={threadPanelWidthPx} - threadTypingPubkeys={threadTypingPubkeys} - threadReplyTargetMessage={displayedThreadReplyTargetMessage} - threadScrollTargetId={threadScrollTargetId} - threadUnreadCounts={threadUnreadCounts} - threadReplyUnreadCounts={threadReplyUnreadCounts} - threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} - isJoining={joinChannelMutation.isPending} - onJoinChannel={joinChannelMutation.mutateAsync} - typingPubkeys={humanTypingPubkeys} - /> + {searchForwarding.renderSearchAwareChannel( + + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) + : null + } + followThreadById={followThread} + unfollowThreadById={unfollowThread} + isFollowingThreadById={isFollowingThread} + isMessageUnreadById={isMessageUnread} + isFollowingThread={isNotifiedForEffectiveThread} + isSending={sendMessageMutation.isPending} + isSinglePanelView={isSinglePanelView} + isTimelineLoading={isTimelineLoading} + messages={timelineMessages} + threadSummaries={threadSummaries} + huddleThreadRepliesError={huddleThreadRepliesError} + onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} + onCancelEdit={handleCancelEdit} + onCancelThreadReply={handleCancelThreadReply} + onChannelManagementDeleted={handleChannelManagementDeleted} + onFollowThread={ + effectiveOpenThreadHeadId != null && + !isNotifiedForEffectiveThread + ? () => followThread(effectiveOpenThreadHeadId) + : undefined + } + onUnfollowThread={ + effectiveOpenThreadHeadId != null && + isNotifiedForEffectiveThread + ? () => unfollowThread(effectiveOpenThreadHeadId) + : undefined + } + onCloseAgentSession={handleCloseAgentSession} + onBackFromAgentSession={ + hasAgentSessionReturnTarget + ? handleBackFromAgentSession + : undefined + } + onCloseChannelManagement={handleCloseChannelManagement} + onCloseThread={handleCloseThread} + onDelete={ + activeChannel?.archivedAt ? undefined : handleDelete + } + onEdit={activeChannel?.archivedAt ? undefined : handleEdit} + onEditSave={ + activeChannel?.archivedAt ? undefined : handleEditSave + } + onMarkUnread={handleMessageMarkUnread} + onMarkRead={handleMessageMarkRead} + onExpandThreadReplies={handleExpandThreadReplies} + onOpenAgentSession={handleOpenAgentSession} + onOpenDm={handleOpenDm} + onOpenProfilePanel={handleOpenProfilePanel} + onResetThreadPanelWidth={handleThreadPanelWidthReset} + onCloseProfilePanel={handleCloseProfilePanel} + onOpenThread={handleOpenThreadAndCloseAgentSession} + onSelectThreadReplyTarget={handleSelectThreadReplyTarget} + onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} + onSendVideoReviewComment={effectiveSendVideoReviewComment} + onSendThreadReply={handleSendThreadReply} + onThreadScrollTargetResolved={() => + setThreadScrollTargetId(null) + } + onThreadPanelResizeStart={handleThreadPanelResizeStart} + onTargetReached={() => + clearMessageRouteTarget({ replace: true }) + } + onToggleReaction={effectiveToggleReaction} + openAgentSessionChannelId={openAgentSessionChannelId} + openAgentSessionPubkey={openAgentSessionPubkey} + openThreadHeadId={effectiveOpenThreadHeadId} + shouldShowThreadSkeleton={shouldShowThreadSkeleton} + onProfilePanelViewChange={setProfilePanelView} + onProfilePanelTabChange={setProfilePanelTab} + profilePanelPubkey={profilePanelPubkey} + profilePanelTab={profilePanelTab} + profilePanelView={profilePanelView} + personaLookup={personaLookup} + profiles={messageProfiles} + ownerProfiles={messageOwnerProfiles} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} + threadHeadMessage={displayedThreadHeadMessage} + threadMessages={displayedThreadMessages} + threadMessagesPending={threadRepliesQuery.isPending} + threadMessagesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} + threadPanelWidthPx={threadPanelWidthPx} + threadTypingPubkeys={threadTypingPubkeys} + threadReplyTargetMessage={displayedThreadReplyTargetMessage} + threadScrollTargetId={threadScrollTargetId} + threadUnreadCounts={threadUnreadCounts} + threadReplyUnreadCounts={threadReplyUnreadCounts} + threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} + isJoining={joinChannelMutation.isPending} + onJoinChannel={joinChannelMutation.mutateAsync} + typingPubkeys={humanTypingPubkeys} + />, + searchTarget, + )} ) ) : ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 371af6faf5..a64937a74b 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -22,4 +22,8 @@ export type ChannelScreenProps = { targetForumReplyId: string | null; targetMessageEvents: RelayEvent[]; targetMessageId: string | null; + /** Exact clicked result id, retained after route target cleanup. */ + targetSearchMessageId?: string; + /** Search text to highlight within the opened result message. */ + targetSearchQuery?: string; }; diff --git a/desktop/src/features/channels/ui/ForumChannelContent.tsx b/desktop/src/features/channels/ui/ForumChannelContent.tsx index 3565502616..7826938665 100644 --- a/desktop/src/features/channels/ui/ForumChannelContent.tsx +++ b/desktop/src/features/channels/ui/ForumChannelContent.tsx @@ -42,6 +42,8 @@ type ForumChannelContentProps = { profilePanelView: ProfilePanelView; selectedPostId: string | null; targetReplyId: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; /** @@ -71,6 +73,8 @@ export function ForumChannelContent({ profilePanelView, selectedPostId, targetReplyId, + targetSearchMessageId, + targetSearchQuery, }: ForumChannelContentProps) { return ( <> @@ -88,6 +92,8 @@ export function ForumChannelContent({ onSelectPost={onSelectPost} selectedPostId={selectedPostId} targetReplyId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} /> diff --git a/desktop/src/features/channels/ui/searchTargetForwarding.tsx b/desktop/src/features/channels/ui/searchTargetForwarding.tsx new file mode 100644 index 0000000000..925156258c --- /dev/null +++ b/desktop/src/features/channels/ui/searchTargetForwarding.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +import type { ForumChannelContent } from "./ForumChannelContent"; +import type { GuardedChannelPane } from "./GuardedChannelPane"; +import type { ChannelScreenProps } from "./ChannelScreen.types"; + +type SearchTarget = Pick< + ChannelScreenProps, + "targetSearchMessageId" | "targetSearchQuery" +>; + +export const renderSearchAwareForum = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); + +export const renderSearchAwareChannel = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); diff --git a/desktop/src/features/channels/ui/useChannelPaneMessages.ts b/desktop/src/features/channels/ui/useChannelPaneMessages.ts index a2de4bbfe9..168ea66573 100644 --- a/desktop/src/features/channels/ui/useChannelPaneMessages.ts +++ b/desktop/src/features/channels/ui/useChannelPaneMessages.ts @@ -5,6 +5,7 @@ import { } from "@/features/channels/ui/ChannelPane.helpers"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import { getRecentMentionPubkeys } from "@/features/messages/lib/recentMentionPubkeys"; import { isWelcomeExperienceChannel } from "@/features/onboarding/welcome"; type ChannelPaneMessagesOptions = Pick< @@ -46,5 +47,14 @@ export function useChannelPaneMessages({ [isHuddleTranscript, profiles, threadSummaries, visibleMessages], ); - return { mainTimelineEntries, visibleMessages }; + const recentMentionPubkeys = React.useMemo( + () => getRecentMentionPubkeys(messages, activeChannel?.channelType), + [activeChannel?.channelType, messages], + ); + + return { + mainTimelineEntries, + recentMentions: recentMentionPubkeys, + visibleMessages, + }; } diff --git a/desktop/src/features/channels/ui/useSearchHighlightProps.ts b/desktop/src/features/channels/ui/useSearchHighlightProps.ts new file mode 100644 index 0000000000..b506fee182 --- /dev/null +++ b/desktop/src/features/channels/ui/useSearchHighlightProps.ts @@ -0,0 +1,18 @@ +import * as React from "react"; + +export function useSearchHighlightProps( + messageId: string | null | undefined, + query: string | undefined, +) { + const searchMatchingMessageIds = React.useMemo( + () => (messageId ? new Set([messageId]) : undefined), + [messageId], + ); + return React.useMemo( + () => ({ + thread: { searchMessageId: messageId, searchQuery: query }, + timeline: { searchMatchingMessageIds, searchQuery: query }, + }), + [messageId, query, searchMatchingMessageIds], + ); +} diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index 92d640afa9..ea7136db42 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -82,11 +82,14 @@ type EmojiPickerProps = { autoFocus?: boolean; /** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */ onSelect: (emoji: string) => void; + /** Number of emoji columns. Defaults to the compact picker used elsewhere. */ + perLine?: number; }; export const EmojiPicker = React.memo(function EmojiPicker({ autoFocus = false, onSelect, + perLine = 8, }: EmojiPickerProps) { const customEmoji = useCustomEmoji(); const custom = React.useMemo( @@ -116,7 +119,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({ onSelect(value); } }} - perLine={8} + perLine={perLine} previewPosition="bottom" set="native" skinTonePosition="search" diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index d79cce9c35..961716aaf4 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -610,6 +610,7 @@ export function ForumComposer({ ) : undefined } formattingDisabled={Boolean(disabled || isSubmissionPending)} + gifMediaController={media} isEmojiPickerOpen={isEmojiPickerOpen} isFormattingOpen={isFormattingOpen} isSending={Boolean(isSending || isSubmissionPending)} diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 7bf40fbffe..348fa15700 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -41,6 +41,8 @@ type ForumThreadPanelProps = { canDeletePost?: boolean; isDeletingPost?: boolean; targetEventId?: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDeleteReply( @@ -57,12 +59,14 @@ function ReplyRow({ profiles, channelNames, onDelete, + searchQuery, }: { reply: ThreadReply; currentPubkey?: string; profiles?: UserProfileLookup; channelNames?: string[]; onDelete?: (eventId: string) => void; + searchQuery?: string; }) { const replyAuthorLabel = resolveUserLabel({ pubkey: reply.pubkey, @@ -122,6 +126,7 @@ function ReplyRow({ imetaByUrl={parseImetaTags(reply.tags)} mentionNames={replyMentionNames} mentionPubkeysByName={replyMentionPubkeysByName} + searchQuery={searchQuery} /> @@ -143,6 +148,8 @@ export function ForumThreadPanel({ canDeletePost, isDeletingPost, targetEventId, + targetSearchMessageId, + targetSearchQuery, }: ForumThreadPanelProps) { const scrollRef = React.useRef(null); const { channels } = useChannelNavigation(); @@ -268,6 +275,11 @@ export function ForumThreadPanel({ imetaByUrl={parseImetaTags(post.tags)} mentionNames={postMentionNames} mentionPubkeysByName={postMentionPubkeysByName} + searchQuery={ + targetSearchMessageId === post.eventId + ? targetSearchQuery + : undefined + } /> @@ -286,6 +298,11 @@ export function ForumThreadPanel({ onDelete={onDeleteReply} profiles={profiles} reply={reply} + searchQuery={ + targetSearchMessageId === reply.eventId + ? targetSearchQuery + : undefined + } /> ))} diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index 9efada5549..670ee4e6df 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -30,6 +30,8 @@ type ForumViewProps = { onTargetReached?: (messageId: string) => void; selectedPostId: string | null; targetReplyId: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDelete(postPubkey: string, currentPubkey?: string): boolean { @@ -47,6 +49,8 @@ export function ForumView({ onTargetReached, selectedPostId, targetReplyId, + targetSearchMessageId, + targetSearchQuery, }: ForumViewProps) { const [isComposerOpen, setIsComposerOpen] = React.useState(false); const postsScrollRef = React.useRef(null); @@ -156,6 +160,8 @@ export function ForumView({ onTargetReached={onTargetReached} profiles={profiles} targetEventId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} thread={threadQuery.data} /> ); diff --git a/desktop/src/features/gifs/api.test.mjs b/desktop/src/features/gifs/api.test.mjs new file mode 100644 index 0000000000..60d44b29de --- /dev/null +++ b/desktop/src/features/gifs/api.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + klipyGifAttachment, + klipyGifFilename, + normalizeKlipyGifs, + relayKlipyCapability, +} from "./api.ts"; + +const GIF_ASSET = { + url: "https://static.klipy.com/example.gif", + width: 640, + height: 360, + size: 42, +}; + +test("normalizeKlipyGifs selects a compact preview and medium GIF", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 7, + title: " Ship it ", + slug: "ship-it", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + webp: { + url: "https://static.klipy.com/preview.webp", + width: 220, + height: 124, + size: 12, + }, + }, + }, + }, + ]); + + assert.equal(gif.title, "Ship it"); + assert.equal(gif.original.url, GIF_ASSET.url); + assert.equal(gif.preview.url, "https://static.klipy.com/preview.webp"); + assert.equal(gif.poster, null); +}); + +test("normalizeKlipyGifs carries a static jpg poster when present", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 8, + title: "Static poster", + slug: "static-poster", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + jpg: { + url: "https://static.klipy.com/poster.jpg", + width: 220, + height: 124, + size: 8, + }, + }, + }, + }, + ]); + + assert.equal(gif.poster?.url, "https://static.klipy.com/poster.jpg"); +}); + +test("normalizeKlipyGifs omits ads and malformed file records", () => { + const gifs = normalizeKlipyGifs([ + { id: 1, slug: "ad", type: "ad" }, + { id: 2, slug: "missing", type: "gif", file: {} }, + ]); + + assert.deepEqual(gifs, []); +}); + +test("relayKlipyCapability requires safe search and share endpoints", () => { + assert.deepEqual( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["nip-er", "buzz-gif"], + }), + { searchPath: "/gifs/search", sharePath: "/gifs/share" }, + ); + assert.equal(relayKlipyCapability({}), null); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "another", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { provider: "klipy", search: "/gifs/search" }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + for (const path of [ + "https://attacker.example/search", + "//attacker.example/search", + "/\\attacker.example/search", + "/%5c%5cattacker.example/search", + "/gifs/../admin", + "/gifs/%2e%2e/admin", + "/gifs/search?redirect=https://attacker.example", + "/gifs/search#fragment", + ]) { + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: path, + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: path, + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + } +}); + +test("klipyGifFilename sanitizes provider slugs", () => { + const gif = { + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: " That's a wrap! ", + title: "That's a wrap", + }; + + const filename = klipyGifFilename(gif); + + assert.equal(filename, "that-s-a-wrap.gif"); +}); + +test("klipyGifAttachment references KLIPY media without an uploaded copy", () => { + const attachment = klipyGifAttachment({ + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: "ship-it", + title: "Ship it", + }); + + assert.deepEqual(attachment, { + dim: "640x360", + displayLabel: "Ship it", + filename: "ship-it.gif", + sha256: "", + size: 42, + type: "image/gif", + uploaded: 0, + url: GIF_ASSET.url, + }); +}); diff --git a/desktop/src/features/gifs/api.ts b/desktop/src/features/gifs/api.ts new file mode 100644 index 0000000000..1408eaeb3c --- /dev/null +++ b/desktop/src/features/gifs/api.ts @@ -0,0 +1,185 @@ +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; + +type KlipyAsset = { + height?: number; + size?: number; + url?: string; + width?: number; +}; + +type KlipyFileSet = { + gif?: KlipyAsset; + jpg?: KlipyAsset; + webp?: KlipyAsset; +}; + +type KlipyRawGif = { + file?: { + hd?: KlipyFileSet; + md?: KlipyFileSet; + sm?: KlipyFileSet; + xs?: KlipyFileSet; + }; + id?: number; + slug?: string; + title?: string; + type?: string; +}; + +export type KlipyResponse = { + data?: { + data?: KlipyRawGif[]; + }; + result?: boolean; +}; + +export type KlipyGif = { + id: number | null; + original: Required; + /** + * Static (non-animated) poster for the GIF, when KLIPY exposes a `jpg` + * asset. Rendered in place of the animated preview under + * `prefers-reduced-motion: reduce`. + */ + poster: Required | null; + preview: Required; + slug: string; + title: string; +}; + +export type RelayGifSearchInfo = { + gif?: { + provider?: string; + search?: string; + share?: string; + }; + supported_extensions?: string[]; +}; + +export type RelayKlipyCapability = { + searchPath: string; + sharePath: string; +}; + +function safeRelayPath(path: unknown): path is string { + return ( + typeof path === "string" && + path.startsWith("/") && + !path.startsWith("//") && + !path.includes("\\") && + !path.includes("%") && + !path.includes("?") && + !path.includes("#") && + !path.split("/").some((segment) => segment === "." || segment === "..") + ); +} + +/** The safe relay-relative KLIPY endpoints advertised by NIP-11, if any. */ +export function relayKlipyCapability( + info: RelayGifSearchInfo, +): RelayKlipyCapability | null { + const searchPath = info.gif?.search; + const sharePath = info.gif?.share; + if ( + info.supported_extensions?.includes("buzz-gif") === true && + info.gif?.provider === "klipy" && + safeRelayPath(searchPath) && + safeRelayPath(sharePath) + ) { + return { searchPath, sharePath }; + } + return null; +} + +function isCompleteAsset( + asset: KlipyAsset | undefined, +): asset is Required { + return ( + typeof asset?.url === "string" && + asset.url.length > 0 && + typeof asset.width === "number" && + typeof asset.height === "number" && + typeof asset.size === "number" + ); +} + +function firstCompleteAsset( + ...assets: Array +): Required | null { + return assets.find(isCompleteAsset) ?? null; +} + +/** + * Normalize KLIPY's mixed media response to GIF-only results. The API can + * interleave ad/content records without a file payload; those are intentionally + * omitted until Buzz has an explicit third-party ad surface. + */ +export function normalizeKlipyGifs(items: KlipyRawGif[]): KlipyGif[] { + const gifs: KlipyGif[] = []; + + for (const item of items) { + if (item.type !== "gif" || !item.file || !item.slug) continue; + + const original = firstCompleteAsset( + item.file.md?.gif, + item.file.hd?.gif, + item.file.sm?.gif, + item.file.xs?.gif, + ); + const preview = firstCompleteAsset( + item.file.sm?.webp, + item.file.sm?.gif, + item.file.xs?.webp, + item.file.xs?.gif, + item.file.md?.webp, + original ?? undefined, + ); + if (!original || !preview) continue; + + const poster = firstCompleteAsset( + item.file.sm?.jpg, + item.file.xs?.jpg, + item.file.md?.jpg, + item.file.hd?.jpg, + ); + + gifs.push({ + id: item.id ?? null, + original, + poster, + preview, + slug: item.slug, + title: item.title?.trim() || "GIF", + }); + } + + return gifs; +} + +export function klipyGifFilename(gif: KlipyGif): string { + const safeSlug = gif.slug + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return `${safeSlug || "klipy-gif"}.gif`; +} + +/** + * Represent a selected KLIPY GIF as externally hosted media. The empty hash + * marks it as content-only media: the outgoing builder appends the image URL + * to the message body but deliberately omits an imeta tag, since Buzz relays + * only accept verified local `/media/` entries in imeta. + */ +export function klipyGifAttachment(gif: KlipyGif): ImetaMedia { + return { + dim: `${gif.original.width}x${gif.original.height}`, + displayLabel: gif.title, + filename: klipyGifFilename(gif), + sha256: "", + size: gif.original.size, + type: "image/gif", + uploaded: 0, + url: gif.original.url, + }; +} diff --git a/desktop/src/features/gifs/relay.ts b/desktop/src/features/gifs/relay.ts new file mode 100644 index 0000000000..bdcffd4f9f --- /dev/null +++ b/desktop/src/features/gifs/relay.ts @@ -0,0 +1,142 @@ +import { + type KlipyGif, + type KlipyResponse, + normalizeKlipyGifs, + relayKlipyCapability, + type RelayKlipyCapability, + type RelayGifSearchInfo, +} from "@/features/gifs/api"; +import { relayHttpFromWs } from "@/shared/api/inviteHelpers"; +import { signRelayEvent } from "@/shared/api/tauri"; + +const KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX = "buzz:klipy-customer-id:v1:"; +const NIP98_KIND = 27235; + +function customerId(relayUrl: string): string { + if (typeof window === "undefined") return globalThis.crypto.randomUUID(); + + try { + const storageKey = `${KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX}${relayUrl}`; + const existing = window.localStorage.getItem(storageKey); + if (existing) return existing; + + const created = globalThis.crypto.randomUUID(); + window.localStorage.setItem(storageKey, created); + return created; + } catch { + // Storage can be unavailable in hardened webviews. Prefer an ephemeral ID + // over a process-wide fallback that would correlate unrelated relays. + return globalThis.crypto.randomUUID(); + } +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(text), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +async function nip98PostHeader(url: string, body: string): Promise { + const authEvent = await signRelayEvent({ + kind: NIP98_KIND, + content: "", + tags: [ + ["u", url], + ["method", "POST"], + ["payload", await sha256Hex(body)], + ["nonce", crypto.randomUUID()], + ], + }); + return `Nostr ${btoa(JSON.stringify(authEvent))}`; +} + +const FRIENDLY_GIF_ERRORS: Record = { + relay_membership_required: "Join this community to search GIFs.", +}; + +function gifErrorMessage(error: string | undefined, status: number): string { + if (error && FRIENDLY_GIF_ERRORS[error]) return FRIENDLY_GIF_ERRORS[error]; + return error || `GIF request failed (${status})`; +} + +async function relayPost( + relayUrl: string, + path: string, + payload: Record, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}${path}`; + const body = JSON.stringify(payload); + const response = await fetch(url, { + body, + headers: { + Authorization: await nip98PostHeader(url, body), + "Content-Type": "application/json", + }, + method: "POST", + signal, + }); + if (!response.ok) { + const json = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(gifErrorMessage(json.error, response.status)); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +/** The selected relay's advertised KLIPY endpoints, when supported. */ +export async function relayKlipyEndpoints( + relayUrl: string, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}/info`; + const response = await fetch(url, { + headers: { Accept: "application/nostr+json" }, + signal, + }); + if (!response.ok) + throw new Error(`Could not read relay capabilities (${response.status})`); + const info = (await response.json()) as RelayGifSearchInfo; + return relayKlipyCapability(info); +} + +/** Search KLIPY through the selected relay without exposing its provider key. */ +export async function fetchKlipyGifs( + relayUrl: string, + searchPath: string, + query: string, + signal?: AbortSignal, +): Promise { + const response = await relayPost( + relayUrl, + searchPath, + { + customer_id: customerId(relayUrl), + locale: navigator.language || "en-US", + query: query.trim(), + }, + signal, + ); + if (response.result === false) { + throw new Error("GIF search failed"); + } + return normalizeKlipyGifs(response.data?.data ?? []); +} + +/** Report a selected GIF so KLIPY can update the anonymous user's Recents. */ +export async function reportKlipyShare( + relayUrl: string, + sharePath: string, + slug: string, +): Promise { + await relayPost(relayUrl, sharePath, { + customer_id: customerId(relayUrl), + slug, + }); +} diff --git a/desktop/src/features/gifs/ui/KlipyGifPicker.tsx b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx new file mode 100644 index 0000000000..6558cb24b8 --- /dev/null +++ b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx @@ -0,0 +1,161 @@ +import { useQuery } from "@tanstack/react-query"; +import { LoaderCircle, Search } from "lucide-react"; +import { useReducedMotion } from "motion/react"; +import * as React from "react"; + +import type { KlipyGif } from "@/features/gifs/api"; +import { fetchKlipyGifs } from "@/features/gifs/relay"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type KlipyGifPickerProps = { + onSelect: (gif: KlipyGif) => void; + relayUrl: string; + searchPath: string; +}; + +const LOADING_SKELETONS = [ + "tall-a", + "short-a", + "short-b", + "tall-b", + "short-c", + "short-d", + "tall-c", + "short-e", + "short-f", + "tall-d", +] as const; + +export const KlipyGifPicker = React.memo(function KlipyGifPicker({ + onSelect, + relayUrl, + searchPath, +}: KlipyGifPickerProps) { + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + const prefersReducedMotion = useReducedMotion() ?? false; + + React.useEffect(() => { + const timeout = window.setTimeout( + () => setDebouncedSearch(search.trim()), + 500, + ); + return () => window.clearTimeout(timeout); + }, [search]); + + const gifsQuery = useQuery({ + queryFn: ({ signal }) => + fetchKlipyGifs(relayUrl, searchPath, debouncedSearch, signal), + queryKey: ["klipy-gifs", relayUrl, searchPath, debouncedSearch], + retry: false, + staleTime: 5 * 60 * 1_000, + }); + + return ( +
+
+
+ + setSearch(event.target.value)} + placeholder="Search KLIPY" + type="search" + value={search} + /> + {gifsQuery.isFetching ? ( + + ) : null} +
+
+ +
+ {gifsQuery.isPending ? ( +
+ Loading GIFs + {LOADING_SKELETONS.map((id) => ( + + ))} +
+ ) : gifsQuery.isError ? ( +
+

+ {gifsQuery.error.message} +

+ +
+ ) : gifsQuery.data.length === 0 ? ( +
+ No GIFs found. +
+ ) : ( +
+ {gifsQuery.data.map((gif) => { + const staticPoster = prefersReducedMotion ? gif.poster : null; + const showAnimated = !prefersReducedMotion; + return ( + + ); + })} +
+ )} +
+ +
+ Powered by KLIPY +
+
+ ); +}); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs index d7516fe691..b30008109c 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs @@ -9,25 +9,25 @@ globalThis.localStorage = { const preference = await import("./autoPinMentionedAgentsPreference.ts"); -test("defaults missing and invalid values to keeping mentioned agents pinned", () => { - assert.equal(preference.parseKeepMentionedAgentsPinned(null), true); - assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), true); +test("defaults missing and invalid values to one-time agent mentions", () => { + assert.equal(preference.parseKeepMentionedAgentsPinned(null), false); + assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), false); assert.equal(preference.parseKeepMentionedAgentsPinned("true"), true); assert.equal(preference.parseKeepMentionedAgentsPinned("false"), false); }); test("persists changes to the post-mention pinning preference", () => { - preference.setKeepMentionedAgentsPinned(false); - assert.equal(preference.getKeepMentionedAgentsPinned(), false); - assert.equal( - values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), - "false", - ); - preference.setKeepMentionedAgentsPinned(true); assert.equal(preference.getKeepMentionedAgentsPinned(), true); assert.equal( values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), "true", ); + + preference.setKeepMentionedAgentsPinned(false); + assert.equal(preference.getKeepMentionedAgentsPinned(), false); + assert.equal( + values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), + "false", + ); }); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts index 0a3821b4e3..8f8e0b12d6 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts @@ -2,7 +2,7 @@ import * as React from "react"; export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = "buzz.messages.keepMentionedAgentsPinned"; -export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = true; +export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false; const listeners = new Set<() => void>(); let keepMentionedAgentsPinned = readStoredPreference(); diff --git a/desktop/src/features/messages/lib/composerLinkPastePipeline.test.mjs b/desktop/src/features/messages/lib/composerLinkPastePipeline.test.mjs new file mode 100644 index 0000000000..55d6a4d4c4 --- /dev/null +++ b/desktop/src/features/messages/lib/composerLinkPastePipeline.test.mjs @@ -0,0 +1,244 @@ +/** + * Composed-editor regression coverage for selected-text link paste. + * + * The unit tests in `composerMessageLinkNode.test.mjs` drive + * `createComposerLinkPasteHandler` against a hand-built schema and a mock + * view — they cannot see what happens *after* the handler declines. That gap + * hid a real bug: `false` from `editorProps.handleDOMEvents.paste` does not end + * paste handling, it hands the event to ProseMirror's built-in paste, which + * runs every plugin's `handlePaste`. TipTap's Link plugin recognised the same + * URLs one layer down and partially linked selections the composer had + * deliberately refused. + * + * So these tests build the *production* editor from `useRichTextEditor` and + * dispatch a real DOM `paste` event at `view.dom`. If someone re-enables + * `linkOnPaste` or slots another URL-aware plugin into the chain, this fails. + */ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; +import { find as findLinks } from "linkifyjs"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const CHANNEL_HREF = `buzz://channel/${CHANNEL_ID}`; +const MESSAGE_LINK_CHANNELS = [{ id: CHANNEL_ID, name: "general" }]; + +before(() => { + dom.window.HTMLElement.prototype.scrollIntoView = () => {}; + // `navigator` is a getter-only global from Node 21 on, so Object.assign + // throws on it. prosemirror-view reads userAgent for browser quirks. + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + Object.assign(globalThis, { + ClipboardEvent: dom.window.Event, + CustomEvent: dom.window.CustomEvent, + DOMParser: dom.window.DOMParser, + document: dom.window.document, + Element: dom.window.Element, + Event: dom.window.Event, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, + Range: dom.window.Range, + ResizeObserver: class { + disconnect() {} + observe() {} + unobserve() {} + }, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +/** + * Mounts the production composer editor and returns its Tiptap instance. + */ +async function mountComposerEditor() { + const React = await import("react"); + const { act, render, waitFor } = await import("@testing-library/react"); + const { EditorContent } = await import("@tiptap/react"); + const { useRichTextEditor } = await import("./useRichTextEditor.ts"); + + let editor = null; + function Harness() { + const instance = useRichTextEditor({ + messageLinkChannels: MESSAGE_LINK_CHANNELS, + }).editor; + editor = instance; + return instance + ? React.createElement(EditorContent, { editor: instance }) + : null; + } + + await act(async () => { + render(React.createElement(Harness)); + }); + // Tiptap emits `create` from a `setTimeout(…, 0)`, and Link's `onCreate` is + // what teaches linkify the `buzz` protocol. Paste before that lands and a + // `buzz://` assertion passes for the wrong reason. + await waitFor(() => + assert.ok(editor?.isInitialized, "composer editor never emitted `create`"), + ); + + // Check that precondition rather than trust the wait. It has to be checked + // *after* `create`, never as the poll itself: `find` initialises linkify's + // scanner on first call, and `registerCustomProtocol` after that only warns, + // so polling on `find` would break the registration it is watching for. + assert.equal( + findLinks(CHANNEL_HREF)[0]?.href, + CHANNEL_HREF, + "expected Link's onCreate to register the buzz protocol with linkify", + ); + return editor; +} + +const paragraph = (...content) => ({ type: "paragraph", content }); +const codeBlock = (text) => ({ + type: "codeBlock", + content: [{ type: "text", text }], +}); +const text = (value, marks) => ({ + type: "text", + text: value, + ...(marks && { marks }), +}); + +/** + * Seeds the document from ProseMirror JSON. Not an HTML string — + * `tiptap-markdown` parses `setContent` input as Markdown, so HTML arrives as + * literal text and the test silently exercises the wrong document. + */ +function seedDocument(editor, ...content) { + editor.commands.setContent({ type: "doc", content }); +} + +function selectAll(editor) { + editor.commands.setTextSelection({ + from: 0, + to: editor.state.doc.content.size, + }); +} + +function pasteText(editor, value) { + const event = new dom.window.Event("paste", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "clipboardData", { + value: { + types: ["text/plain"], + getData: (type) => (type === "text/plain" ? value : ""), + }, + }); + editor.view.dom.dispatchEvent(event); +} + +function linkHrefs(editor) { + const hrefs = []; + editor.state.doc.descendants((node) => { + for (const mark of node.marks) { + if (mark.type.name === "link") hrefs.push(mark.attrs.href); + } + }); + return hrefs; +} + +function nodeTypeNames(editor) { + const names = []; + editor.state.doc.descendants((node) => { + names.push(node.type.name); + }); + return names; +} + +test("mixed paragraph and code-block selection is replaced, never part-linked", async () => { + const editor = await mountComposerEditor(); + seedDocument( + editor, + paragraph(text("ordinary")), + codeBlock("const value = 1;"), + ); + selectAll(editor); + + pasteText(editor, "https://example.com"); + + // The whole selection goes, exactly as it would for any non-link paste. + assert.equal(editor.state.doc.textContent, "https://example.com"); + assert.ok(!nodeTypeNames(editor).includes("codeBlock")); + // Crucially, "ordinary" is not left behind wearing a link mark. + assert.ok(!editor.state.doc.textContent.includes("ordinary")); +}); + +test("mixed selection paste of a Buzz link becomes a chip, not a part-link", async () => { + const editor = await mountComposerEditor(); + seedDocument( + editor, + paragraph(text("ordinary")), + codeBlock("const value = 1;"), + ); + selectAll(editor); + + pasteText(editor, CHANNEL_HREF); + + const names = nodeTypeNames(editor); + assert.ok(names.includes("composerMessageLink")); + assert.ok(!names.includes("codeBlock")); + assert.ok(!editor.state.doc.textContent.includes("ordinary")); + assert.deepEqual(linkHrefs(editor), []); +}); + +test("mixed plain and inline-code selection is replaced, never part-linked", async () => { + const editor = await mountComposerEditor(); + seedDocument( + editor, + paragraph(text("plain "), text("inline", [{ type: "code" }])), + ); + selectAll(editor); + + pasteText(editor, "https://example.com"); + + assert.ok(!editor.state.doc.textContent.includes("plain ")); + assert.equal(editor.state.doc.textContent.trim(), "https://example.com"); +}); + +test("fully markable selection keeps its label and gains the link", async () => { + const editor = await mountComposerEditor(); + seedDocument(editor, paragraph(text("read this"))); + selectAll(editor); + + pasteText(editor, "https://example.com"); + + assert.equal(editor.state.doc.textContent, "read this"); + assert.deepEqual(linkHrefs(editor), ["https://example.com"]); +}); + +test("fully markable selection keeps its label for linkify-only URL shapes", async () => { + for (const [pasted, expectedHref] of [ + ["www.example.com", "http://www.example.com"], + ["foo@example.com", "mailto:foo@example.com"], + ]) { + const editor = await mountComposerEditor(); + seedDocument(editor, paragraph(text("read this"))); + selectAll(editor); + + pasteText(editor, pasted); + + assert.equal(editor.state.doc.textContent, "read this"); + assert.deepEqual(linkHrefs(editor), [expectedHref]); + } +}); diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs index 57212aeafe..9cd476af57 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -2,10 +2,16 @@ import assert from "node:assert/strict"; import { createRequire } from "node:module"; import test from "node:test"; +import { Schema } from "@tiptap/pm/model"; +import { AllSelection, EditorState, TextSelection } from "@tiptap/pm/state"; + import { ComposerMessageLinkNode, + createComposerLinkPasteHandler, registerComposerMessageLinkMarkdownIt, resolveComposerMessageLinkAttributes, + resolveExactLinkPaste, + resolveSelectionLinkPaste, } from "./composerMessageLinkNode.ts"; const requireFromTiptap = createRequire(import.meta.resolve("tiptap-markdown")); @@ -71,6 +77,408 @@ test("resolves channel and entity links as composer chips", () => { ); }); +const resolveKnownChannel = (channelId) => + channelId === CHANNEL_ID ? "general" : undefined; +const exactLinkPaste = (text) => + resolveExactLinkPaste(text, resolveKnownChannel); + +const EXACT_LINK_PASTE_ACCEPTED_CASES = [ + ["exact http", "https://example.com", "https://example.com"], + [ + "wrapped http", + "", + "https://example.com/docs?q=1", + ], + ["exact message", HREF, HREF], + ["wrapped message", `<${HREF}>`, HREF], + ["channel", CHANNEL_HREF, CHANNEL_HREF], + [ + "channel message", + CHANNEL_MESSAGE_HREF, + `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + ], + ["repo", REPO_HREF, REPO_HREF], + ["project", PROJECT_HREF, PROJECT_HREF], + ["pull request", PR_HREF, PR_HREF], + ["issue", ISSUE_HREF, ISSUE_HREF], +]; + +for (const [label, input, expectedHref] of EXACT_LINK_PASTE_ACCEPTED_CASES) { + test(`exact link paste resolves ${label}`, () => { + assert.deepEqual(exactLinkPaste(input), { href: expectedHref }); + }); +} + +test("exact link paste canonicalizes Buzz links", () => { + assert.deepEqual( + exactLinkPaste( + `BUZZ://channel/${CHANNEL_ID.toUpperCase()}/${CHANNEL_MESSAGE_ID.toUpperCase()}`, + ), + { + href: `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + }, + ); +}); + +for (const input of [ + "https://example.com and words", + " https://example.com", + "https://example.com ", + "https://example.com\n", + " trailing", + "www.example.com", + "ftp://example.com", + "not a url", + `See ${HREF}`, + `buzz://channel/${CHANNEL_ID}/not-a-message-id`, +]) { + test(`exact link paste rejects ${input}`, () => { + assert.equal(exactLinkPaste(input), null); + }); +} + +// The selection branch is the composer's alone now that `linkOnPaste` is off, +// so it has to accept everything linkify accepted — otherwise turning the +// second handler off would silently narrow which URLs preserve their label. +for (const [label, input, expectedHref] of [ + ["exact http", "https://example.com", "https://example.com"], + ["wrapped http", "", "https://example.com"], + [ + "canonical Buzz link", + CHANNEL_MESSAGE_HREF, + `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + ], + ["scheme-less host", "www.example.com", "http://www.example.com"], + ["bare host with path", "example.com/docs", "http://example.com/docs"], + ["email address", "foo@example.com", "mailto:foo@example.com"], + ["ftp", "ftp://example.com", "ftp://example.com"], +]) { + test(`selection link paste resolves ${label}`, () => { + assert.deepEqual(resolveSelectionLinkPaste(input, resolveKnownChannel), { + href: expectedHref, + }); + }); +} + +for (const input of [ + "https://example.com and words", + " https://example.com", + "read this", + "", +]) { + test(`selection link paste rejects ${JSON.stringify(input)}`, () => { + assert.equal(resolveSelectionLinkPaste(input, resolveKnownChannel), null); + }); +} + +const editorSchema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { content: "inline*", group: "block" }, + text: { group: "inline" }, + composerMessageLink: { + atom: true, + attrs: { channelName: { default: "" }, href: { default: "" } }, + group: "inline", + inline: true, + selectable: true, + }, + codeBlock: { content: "text*", group: "block", marks: "" }, + }, + marks: { + // `excludes: "_"` mirrors StarterKit's `code` mark: it silently drops any + // other mark added over it, so a link applied to code-marked text never + // lands even though the parent paragraph allows link marks. + code: { excludes: "_" }, + link: { attrs: { href: {} }, inclusive: false }, + }, +}); + +const paragraph = (...content) => + editorSchema.nodes.paragraph.create(null, content); +const codeBlock = (...content) => + editorSchema.nodes.codeBlock.create(null, content); +const document = (...content) => editorSchema.nodes.doc.create(null, content); +const text = (value, marks = []) => editorSchema.text(value, marks); +const composerChip = (href = HREF) => + editorSchema.nodes.composerMessageLink.create({ + channelName: "general", + href, + }); + +function createPasteEvent(value) { + let prevented = false; + return { + clipboardData: { getData: (type) => (type === "text/plain" ? value : "") }, + get defaultPrevented() { + return prevented; + }, + preventDefault() { + prevented = true; + }, + }; +} + +function createMockView(state) { + const view = { + dispatch(transaction) { + view.state = view.state.apply(transaction); + }, + focusCalled: false, + focus() { + view.focusCalled = true; + }, + state, + }; + return view; +} + +function stateFromDocument(doc, from, to = from) { + return EditorState.create({ + doc, + selection: TextSelection.create(doc, from, to), + }); +} + +function allSelectionStateFromDocument(doc) { + return EditorState.create({ + doc, + selection: new AllSelection(doc), + }); +} + +function toPlainJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +test("paste handler links selected text instead of replacing it", () => { + const doc = document(paragraph(text("read this"))); + const view = createMockView(stateFromDocument(doc, 1, 10)); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(event.defaultPrevented, true); + assert.equal(view.focusCalled, true); + assert.equal(view.state.doc.textContent, "read this"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { attrs: { href: "https://example.com" }, type: "link" }, + ]); + assert.equal(view.state.selection.empty, true); + assert.equal(view.state.selection.from, 10); + assert.deepEqual(view.state.storedMarks, []); +}); + +test("paste handler canonicalizes Buzz links over selected text", () => { + const doc = document(paragraph(text("selected"))); + const view = createMockView(stateFromDocument(doc, 1, 9)); + const event = createPasteEvent(CHANNEL_MESSAGE_HREF); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "selected"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { + attrs: { + href: `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + }, + type: "link", + }, + ]); +}); + +test("paste handler consumes idempotent wrapped link over selected text", () => { + const href = "https://example.com"; + const linkMark = editorSchema.marks.link.create({ href }); + const doc = document(paragraph(text("already linked", [linkMark]))); + const view = createMockView(stateFromDocument(doc, 1, 15)); + const event = createPasteEvent(`<${href}>`); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(event.defaultPrevented, true); + assert.equal(view.focusCalled, true); + assert.equal(view.state.doc.textContent, "already linked"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { attrs: { href }, type: "link" }, + ]); + assert.equal(view.state.selection.empty, true); + assert.equal(view.state.selection.from, 15); + assert.deepEqual(view.state.storedMarks, []); +}); + +test("paste handler falls through when selected text cannot carry link marks", () => { + const doc = document(codeBlock(text("const value = 1;"))); + const view = createMockView(stateFromDocument(doc, 1, 17)); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, false); + assert.equal(event.defaultPrevented, false); + assert.equal(view.focusCalled, false); + assert.equal(view.state.doc.textContent, "const value = 1;"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0], { + text: "const value = 1;", + type: "text", + }); +}); + +test("paste handler falls through when any selected text cannot carry link marks", () => { + const doc = document( + paragraph(text("ordinary")), + codeBlock(text("const value = 1;")), + ); + const view = createMockView(stateFromDocument(doc, 1, doc.content.size - 1)); + const initialDoc = toPlainJson(view.state.doc); + const initialSelection = view.state.selection.toJSON(); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, false); + assert.equal(event.defaultPrevented, false); + assert.equal(view.focusCalled, false); + assert.deepEqual(toPlainJson(view.state.doc), initialDoc); + assert.deepEqual(view.state.selection.toJSON(), initialSelection); +}); + +test("paste handler falls through when selected text mixes plain and inline code", () => { + const doc = document( + paragraph( + text("plain "), + text("inline", [editorSchema.marks.code.create()]), + ), + ); + const view = createMockView(stateFromDocument(doc, 1, doc.content.size - 1)); + const initialDoc = toPlainJson(view.state.doc); + const initialSelection = view.state.selection.toJSON(); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, false); + assert.equal(event.defaultPrevented, false); + assert.equal(view.focusCalled, false); + assert.deepEqual(toPlainJson(view.state.doc), initialDoc); + assert.deepEqual(view.state.selection.toJSON(), initialSelection); +}); + +test("paste handler links selected text for linkify-only URL shapes", () => { + for (const [pasted, expectedHref] of [ + ["www.example.com", "http://www.example.com"], + ["foo@example.com", "mailto:foo@example.com"], + ]) { + const doc = document(paragraph(text("read this"))); + const view = createMockView(stateFromDocument(doc, 1, 10)); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + createPasteEvent(pasted), + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "read this"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { attrs: { href: expectedHref }, type: "link" }, + ]); + } +}); + +test("caret paste stays plain for linkify-only URL shapes", () => { + // Only the selection branch widened. A caret paste of `www.example.com` must + // still fall through so it arrives as text for `autolink` to pick up, rather + // than being inserted as a pre-linked node. + const doc = document(paragraph(text("go "))); + const view = createMockView(stateFromDocument(doc, 4)); + const event = createPasteEvent("www.example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, false); + assert.equal(event.defaultPrevented, false); + assert.equal(view.state.doc.textContent, "go "); +}); + +test("paste handler collapses an all-selection to inline content", () => { + const doc = document(paragraph(text("select all"))); + const view = createMockView(allSelectionStateFromDocument(doc)); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "select all"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { attrs: { href: "https://example.com" }, type: "link" }, + ]); + assert.equal(view.state.selection.empty, true); + assert.equal(view.state.selection.from, 11); + assert.equal(view.state.selection.$from.parent.type.name, "paragraph"); +}); + +test("paste handler replaces selected text when it contains a composer chip", () => { + const doc = document( + paragraph(text("before "), composerChip(), text(" after")), + ); + const view = createMockView(stateFromDocument(doc, 1, doc.content.size - 1)); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "https://example.com "); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content, [ + { + marks: [{ attrs: { href: "https://example.com" }, type: "link" }], + text: "https://example.com", + type: "text", + }, + { text: " ", type: "text" }, + ]); +}); + +test("paste handler preserves caret paste behavior", () => { + const doc = document(paragraph(text("go "))); + const view = createMockView(stateFromDocument(doc, 4)); + const event = createPasteEvent(CHANNEL_HREF); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "go "); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content, [ + { text: "go ", type: "text" }, + { + attrs: { channelName: "general", href: CHANNEL_HREF }, + type: "composerMessageLink", + }, + { text: " ", type: "text" }, + ]); +}); + function captureMarkdownRule() { let capturedAnchor = null; let capturedRule = null; diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index 59b15c78ba..bd6a9067a9 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -1,7 +1,8 @@ import { mergeAttributes, Node } from "@tiptap/core"; -import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { TextSelection } from "@tiptap/pm/state"; +import type { Mark, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Selection, TextSelection } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; +import { find as findLinks } from "linkifyjs"; import { buildIssueLink, @@ -117,10 +118,118 @@ function unwrapExactBuzzLink(text: string): string | null { } function unwrapExactHttpLink(text: string): string | null { + if (!text || /\s/.test(text)) return null; const match = /^(?:<(https?:\/\/[^\s<>]+)>|(https?:\/\/\S+))$/i.exec(text); return match?.[1] ?? match?.[2] ?? null; } +/** + * Resolves a clipboard payload that is exactly a supported link into the href + * the composer should apply when linkifying selected text on paste. + */ +export function resolveExactLinkPaste( + text: string, + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +): { href: string } | null { + const buzzHref = unwrapExactBuzzLink(text); + if (buzzHref) { + const attrs = resolveComposerMessageLinkAttributes( + buzzHref, + resolveChannelName, + ); + return attrs ? { href: attrs.href } : null; + } + + const httpHref = unwrapExactHttpLink(text); + return httpHref ? { href: httpHref } : null; +} + +/** + * Resolves a clipboard payload for the *selected text* branch of paste + * handling, where this handler is the only one that runs. + * + * The exact Buzz/http matchers win first, so Buzz links keep their canonical + * form. Anything else falls back to linkify with `defaultProtocol: "http"` — + * the same matcher TipTap's `linkOnPaste` used before the composer took sole + * ownership of this branch, so `www.example.com`, `foo@example.com` and + * `ftp://…` still hyperlink the selection instead of replacing it. + * + * Deliberately scoped to the selection branch: broadening + * `resolveExactLinkPaste` would also change caret paste, where these shapes + * must keep arriving as plain text for `autolink` to pick up. + */ +export function resolveSelectionLinkPaste( + text: string, + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +): { href: string } | null { + const exactLinkPaste = resolveExactLinkPaste(text, resolveChannelName); + if (exactLinkPaste) return exactLinkPaste; + + const link = findLinks(text, { defaultProtocol: "http" }).find( + (candidate) => candidate.isLink && candidate.value === text, + ); + return link ? { href: link.href } : null; +} + +function selectionContainsComposerMessageLinkNode(view: EditorView): boolean { + const { from, to } = view.state.selection; + let containsMessageLink = false; + view.state.doc.nodesBetween(from, to, (node) => { + if (containsMessageLink) return false; + if (node.type.name === COMPOSER_MESSAGE_LINK_NODE_NAME) { + containsMessageLink = true; + return false; + } + return true; + }); + return containsMessageLink; +} + +/** + * Checks the *outcome* of an `addMark` rather than predicting it: every inline + * node in the range must have come out carrying `mark`. Predicting is what a + * parent-level `allowsMarkType` probe does, and it misses mark exclusion — + * `code`'s `excludes: "_"` makes `Mark.addToSet` silently drop a link, so a + * selection spanning plain text and an inline code span passes the prediction + * but only gets partially linked. + */ +function everyInlineNodeCarriesMark( + doc: ProseMirrorNode, + from: number, + to: number, + mark: Mark, +): boolean { + let containsInlineContent = false; + let allInlineContentCarriesMark = true; + doc.nodesBetween(from, to, (node) => { + if (!node.isInline) return true; + containsInlineContent = true; + if (!mark.isInSet(node.marks)) allInlineContentCarriesMark = false; + return true; + }); + return containsInlineContent && allInlineContentCarriesMark; +} + +function applyLinkToSelection(view: EditorView, href: string): boolean { + const { from, to } = view.state.selection; + const linkMark = view.state.schema.marks.link; + if (!linkMark) return false; + + const mark = linkMark.create({ href }); + let transaction = view.state.tr.addMark(from, to, mark); + // Bail before dispatching, so the document and selection are untouched and + // the paste falls through to normal replacement. + if (!everyInlineNodeCarriesMark(transaction.doc, from, to, mark)) + return false; + + transaction = transaction.setSelection( + Selection.near(transaction.doc.resolve(transaction.mapping.map(to)), -1), + ); + view.dispatch(transaction.setStoredMarks([]).scrollIntoView()); + view.focus(); + return true; +} + function replaceSelectionWithNode(view: EditorView, node: ProseMirrorNode) { const { from, to } = view.state.selection; let transaction = view.state.tr.replaceRangeWith(from, to, node); @@ -140,6 +249,21 @@ export function createComposerLinkPasteHandler( ) { return (view: EditorView, event: ClipboardEvent): boolean => { const text = event.clipboardData?.getData("text/plain") ?? ""; + if ( + !view.state.selection.empty && + !selectionContainsComposerMessageLinkNode(view) + ) { + const selectionLinkPaste = resolveSelectionLinkPaste( + text, + resolveChannelName, + ); + if (selectionLinkPaste) { + if (!applyLinkToSelection(view, selectionLinkPaste.href)) return false; + event.preventDefault(); + return true; + } + } + const buzzHref = unwrapExactBuzzLink(text); const buzzLinkType = view.state.schema.nodes[COMPOSER_MESSAGE_LINK_NODE_NAME]; diff --git a/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs new file mode 100644 index 0000000000..ad00eb296c --- /dev/null +++ b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getVisibleAgentAddressPubkeys } from "./getVisibleAgentAddressPubkeys.ts"; + +const DARIA = "a".repeat(64); +const RIZZ = "b".repeat(64); + +test("hides an address prefix already represented by an inline mention", () => { + assert.deepEqual( + getVisibleAgentAddressPubkeys("@Daria please review this", [DARIA], { + daria: DARIA, + }), + [], + ); +}); + +test("keeps a tag-backed prefix when its inline mention was deleted", () => { + assert.deepEqual( + getVisibleAgentAddressPubkeys("please review this", [DARIA], { + daria: DARIA, + }), + [DARIA], + ); +}); + +test("filters only addressed agents that are present inline", () => { + assert.deepEqual( + getVisibleAgentAddressPubkeys( + "@Daria please pair with someone", + [DARIA, RIZZ], + { + daria: DARIA, + rizz: RIZZ, + }, + ), + [RIZZ], + ); +}); diff --git a/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts new file mode 100644 index 0000000000..f96279628f --- /dev/null +++ b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts @@ -0,0 +1,20 @@ +import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Keep tag-backed address recipients visible without repeating ordinary inline + * mentions already present in the message body. + */ +export function getVisibleAgentAddressPubkeys( + body: string, + addressedPubkeys: readonly string[], + mentionPubkeysByName: Readonly> | undefined, +): string[] { + const inlineMentionPubkeys = new Set( + orderMentionPubkeysByText(body, mentionPubkeysByName, () => true), + ); + + return addressedPubkeys.filter( + (pubkey) => !inlineMentionPubkeys.has(normalizePubkey(pubkey)), + ); +} diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index 79c193fb19..e1af5be6bc 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -597,22 +597,31 @@ test("imetaMediaFromTags: entry without size leaves size 0", () => { assert.equal(out[0].size, 0); }); -test("buildImetaTags: omits x line when sha256 is empty", () => { +test("buildImetaTags: omits hashless external media entirely", () => { const tags = buildImetaTags([ { - url: "https://b/a.png", - type: "image/png", + url: "https://static.klipy.com/a.gif", + type: "image/gif", sha256: "", size: 1, uploaded: 0, }, ]); - assert.equal(tags.length, 1); - // No element starts with "x " or "x\t" — no empty x line emitted. - assert.ok( - !tags[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(tags[0])}`, - ); + assert.deepEqual(tags, []); +}); + +test("buildOutgoingMessage: hashless external media is content-only", () => { + const out = buildOutgoingMessage("", [ + { + url: "https://static.klipy.com/a.gif", + type: "image/gif", + sha256: "", + size: 1, + uploaded: 0, + }, + ]); + assert.equal(out.content, "\n![image](https://static.klipy.com/a.gif)"); + assert.equal(out.mediaTags, undefined); }); test("buildImetaTags: omits size line when size is 0", () => { @@ -632,31 +641,14 @@ test("buildImetaTags: omits size line when size is 0", () => { ); }); -test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", () => { - // Legacy / cross-client entry: only url + m. No x, no size. +test("round-trip: sparse legacy imeta is not re-emitted without a hash", () => { const legacyTags = [["imeta", "url https://b/legacy.png", "m image/png"]]; const projected = imetaMediaFromTags(legacyTags); assert.equal(projected.length, 1); assert.equal(projected[0].sha256, ""); assert.equal(projected[0].size, 0); - const rebuilt = buildImetaTags(projected); - assert.equal(rebuilt.length, 1); - // Neither "x " nor "size 0" leaked into the rebuilt tag. - assert.ok( - !rebuilt[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(rebuilt[0])}`, - ); - assert.ok( - !rebuilt[0].some((part) => /^size[\s\t]/.test(part)), - `expected no size line, got ${JSON.stringify(rebuilt[0])}`, - ); - // url and m survived. - assert.deepEqual(rebuilt[0], [ - "imeta", - "url https://b/legacy.png", - "m image/png", - ]); + assert.deepEqual(buildImetaTags(projected), []); }); const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"]; diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index fde8492289..f02b9906ca 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -79,29 +79,31 @@ export function imetaMediaFromTags( * Shared by the send path (initial post) and the edit path (full new tag set * on the edit event), so the two stay perfectly symmetric. * - * `url` and `m` are always emitted (NIP-92's only de-facto required fields; - * `m` carries a fallback in `imetaMediaFromTags`). All other fields are - * conditional — including `x` and `size` — because legacy and cross-client - * imeta entries can land without a sha256 or size, and our relay validator - * rejects literal `"x "` / `"size 0"` empties. NIP-92 itself treats every - * field except `url` as optional, so dropping them is spec-clean. + * `url`, `m`, and `x` are emitted for verified relay-hosted media. Entries + * without a hash represent external content (for example KLIPY GIFs); their + * markdown URL remains in the message body, but they are omitted from imeta + * because Buzz's relay validator requires a hash-backed local `/media/` path. + * Other fields remain conditional so legacy entries do not emit invalid + * literal `"size 0"` values. */ export function buildImetaTags( imetaMedia: ReadonlyArray, ): string[][] { - return imetaMedia.map((d) => [ - "imeta", - `url ${d.url}`, - `m ${d.type}`, - ...(d.sha256 ? [`x ${d.sha256}`] : []), - ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), - ...(d.dim ? [`dim ${d.dim}`] : []), - ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), - ...(d.thumb ? [`thumb ${d.thumb}`] : []), - ...(d.duration != null ? [`duration ${d.duration}`] : []), - ...(d.image ? [`image ${d.image}`] : []), - ...(d.filename ? [`filename ${d.filename}`] : []), - ]); + return imetaMedia + .filter((d) => d.sha256.length > 0) + .map((d) => [ + "imeta", + `url ${d.url}`, + `m ${d.type}`, + `x ${d.sha256}`, + ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), + ...(d.dim ? [`dim ${d.dim}`] : []), + ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), + ...(d.thumb ? [`thumb ${d.thumb}`] : []), + ...(d.duration != null ? [`duration ${d.duration}`] : []), + ...(d.image ? [`image ${d.image}`] : []), + ...(d.filename ? [`filename ${d.filename}`] : []), + ]); } const MEDIA_LINE_RE = @@ -329,9 +331,11 @@ export function buildOutgoingMessage( spoiler: spoileredMediaUrls.has(d.url), }); } - const mediaTags = - pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined; - return { content, mediaTags }; + const mediaTags = buildImetaTags(pendingImeta); + return { + content, + mediaTags: mediaTags.length > 0 ? mediaTags : undefined, + }; } /** diff --git a/desktop/src/features/messages/lib/linkPasteTrailingSpace.ts b/desktop/src/features/messages/lib/linkPasteTrailingSpace.ts new file mode 100644 index 0000000000..0b6d5c53cf --- /dev/null +++ b/desktop/src/features/messages/lib/linkPasteTrailingSpace.ts @@ -0,0 +1,64 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, TextSelection } from "@tiptap/pm/state"; + +const PASTED_LINK_AT_END_RE = + /(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i; + +function shouldAppendSpaceAfterPaste(text: string): boolean { + const trimmedEnd = text.trimEnd(); + if (!trimmedEnd || trimmedEnd.length !== text.length) return false; + return PASTED_LINK_AT_END_RE.test(trimmedEnd); +} + +/** + * Appends a trailing space after a paste that ends in a bare link, so the + * caret lands outside the autolinked mark and the next typed character is + * not swallowed into the link. + */ +export const LinkPasteTrailingSpace = Extension.create({ + name: "linkPasteTrailingSpace", + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + handlePaste(view, event) { + const pastedText = event.clipboardData?.getData("text/plain") ?? ""; + if (!shouldAppendSpaceAfterPaste(pastedText)) return false; + + window.setTimeout(() => { + if (!view.dom.isConnected) return; + const { state } = view; + if (!state.selection.empty) return; + + const from = state.selection.from; + if (from < state.doc.content.size) { + const nextText = state.doc.textBetween( + from, + Math.min(state.doc.content.size, from + 1), + "\n", + "\n", + ); + if (/^\s$/.test(nextText)) return; + } + + let transaction = state.tr.insertText(" ", from, from); + const linkMark = state.schema.marks.link; + if (linkMark) { + transaction = transaction.removeMark(from, from + 1, linkMark); + } + transaction = transaction.setSelection( + TextSelection.create(transaction.doc, from + 1), + ); + transaction.setStoredMarks([]); + view.dispatch(transaction.scrollIntoView()); + view.focus(); + }, 0); + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 3ad358a0d6..659832dead 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -46,6 +46,7 @@ export type MentionCandidate = { secondaryLabel?: string | null; ownerPubkey?: string | null; isAgent: boolean; + isActiveAgent?: boolean; isManagedAgent?: boolean; isGlobalSearchResult?: boolean; }; diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 82809eaabc..3d1d5d6ef7 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -202,6 +202,7 @@ export function settleAutocompleteMentionInsert( editor: { storage: object }, tr: Transaction, text: string, + settleCaret = true, ): void { const storage = mentionHighlightStorage(editor); const mentionInsert = /(?:^|[\s(])([@#])([^\s]+) $/.exec(text); @@ -222,7 +223,7 @@ export function settleAutocompleteMentionInsert( } } } - tr.setMeta(mentionHighlightKey, true); + if (settleCaret) tr.setMeta(mentionHighlightKey, true); } export function syncMentionHighlightFromProps( diff --git a/desktop/src/features/messages/lib/mentionRanking.test.mjs b/desktop/src/features/messages/lib/mentionRanking.test.mjs index 2e74bba52d..bdbf1cf11d 100644 --- a/desktop/src/features/messages/lib/mentionRanking.test.mjs +++ b/desktop/src/features/messages/lib/mentionRanking.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { rankMentionCandidates } from "./mentionRanking.ts"; +import { + pickDefaultAgentCandidate, + rankMentionCandidates, +} from "./mentionRanking.ts"; const CHANNEL_BRAIN_PUBKEY = "1".repeat(64); const OTHER_BRAIN_PUBKEY = "2".repeat(64); @@ -139,3 +142,108 @@ test("rankMentionCandidates: owned teams rank with runnable personas", () => { ["team", "identity"], ); }); + +test("pickDefaultAgentCandidate: active agents outrank stopped channel members", () => { + const stoppedMember = candidate({ + displayName: "Ada", + isActiveAgent: false, + isAgent: true, + isMember: true, + pubkey: CHANNEL_BRAIN_PUBKEY, + }); + const runningNonMember = candidate({ + displayName: "Bea", + isActiveAgent: true, + isAgent: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + + assert.equal( + pickDefaultAgentCandidate([stoppedMember, runningNonMember]), + runningNonMember, + ); +}); + +test("pickDefaultAgentCandidate: stable labels break ties instead of roster order", () => { + const vogue = candidate({ + displayName: "Vogue", + isActiveAgent: true, + isAgent: true, + isMember: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + const morgarita = candidate({ + displayName: "Morgarita", + isActiveAgent: true, + isAgent: true, + isMember: true, + pubkey: CHANNEL_BRAIN_PUBKEY, + }); + + assert.equal(pickDefaultAgentCandidate([vogue, morgarita]), morgarita); + assert.equal(pickDefaultAgentCandidate([morgarita, vogue]), morgarita); +}); + +test("pickDefaultAgentCandidate: runnable personas break otherwise equal ties", () => { + const plain = candidate({ + displayName: "Zulu", + isActiveAgent: true, + isAgent: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + const runnable = candidate({ + displayName: "Zulu 2", + isActiveAgent: true, + isAgent: true, + personaId: "active-persona", + pubkey: CHANNEL_BRAIN_PUBKEY, + }); + + assert.equal( + pickDefaultAgentCandidate([plain, runnable], new Set(["active-persona"])), + runnable, + ); +}); + +test("pickDefaultAgentCandidate: recent eligible mentions outrank the fallback ranking", () => { + const stoppedRecentMember = candidate({ + displayName: "Ada", + isActiveAgent: false, + isAgent: true, + isMember: true, + pubkey: CHANNEL_BRAIN_PUBKEY, + }); + const runningNonMember = candidate({ + displayName: "Bea", + isActiveAgent: true, + isAgent: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + + assert.equal( + pickDefaultAgentCandidate( + [runningNonMember, stoppedRecentMember], + new Set(), + [CHANNEL_BRAIN_PUBKEY], + ), + stoppedRecentMember, + ); +}); + +test("pickDefaultAgentCandidate: skips recent pubkeys that are not eligible candidates", () => { + const runningAgent = candidate({ + isActiveAgent: true, + isAgent: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + + assert.equal( + pickDefaultAgentCandidate([runningAgent], new Set(), ["f".repeat(64)]), + runningAgent, + ); +}); + +test("pickDefaultAgentCandidate: returns null without an addressable agent", () => { + assert.equal(pickDefaultAgentCandidate([]), null); + assert.equal(pickDefaultAgentCandidate([candidate()]), null); +}); diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 09b9e03de7..3df5bba0b0 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -3,6 +3,7 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; export type MentionCandidateForRanking = { displayName: string | null; isAgent: boolean; + isActiveAgent?: boolean; isMember: boolean; kind: "identity" | "persona" | "team"; personaId?: string | null; @@ -51,6 +52,58 @@ function scoreMentionCandidateLabel( return null; } +export function pickDefaultAgentCandidate( + candidates: readonly T[], + activePersonaIds: ReadonlySet = new Set(), + recentMentionPubkeys: readonly string[] = [], +): T | null { + const recentMentionRankByPubkey = new Map( + recentMentionPubkeys.map((pubkey, index) => [ + normalizePubkey(pubkey), + index, + ]), + ); + return ( + candidates + .filter((candidate) => candidate.isAgent && Boolean(candidate.pubkey)) + .sort((left, right) => { + const leftRecentRank = left.pubkey + ? recentMentionRankByPubkey.get(normalizePubkey(left.pubkey)) + : undefined; + const rightRecentRank = right.pubkey + ? recentMentionRankByPubkey.get(normalizePubkey(right.pubkey)) + : undefined; + const recentDiff = + (leftRecentRank ?? recentMentionPubkeys.length) - + (rightRecentRank ?? recentMentionPubkeys.length); + if (recentDiff !== 0) return recentDiff; + const activeDiff = + Number(right.isActiveAgent === true) - + Number(left.isActiveAgent === true); + if (activeDiff !== 0) return activeDiff; + const memberDiff = Number(right.isMember) - Number(left.isMember); + if (memberDiff !== 0) return memberDiff; + const runnableDiff = + Number( + Boolean(right.personaId) && + activePersonaIds.has(right.personaId ?? ""), + ) - + Number( + Boolean(left.personaId) && + activePersonaIds.has(left.personaId ?? ""), + ); + if (runnableDiff !== 0) return runnableDiff; + const labelDiff = (left.displayName ?? "").localeCompare( + right.displayName ?? "", + undefined, + { sensitivity: "base" }, + ); + if (labelDiff !== 0) return labelDiff; + return (left.pubkey ?? "").localeCompare(right.pubkey ?? ""); + })[0] ?? null + ); +} + export function rankMentionCandidates( candidates: readonly T[], query: string, diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index 9be4f2c4a5..cd84aa6e8c 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -3,7 +3,9 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { formatOwnerLabel } from "@/features/profile/lib/identity"; import type { ChannelRole, ChannelType } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import type { TeamMentionMember } from "./mentionCandidates"; +import type { MentionCandidate, TeamMentionMember } from "./mentionCandidates"; +import { mentionCandidateLabel } from "./mentionCandidates"; +import { pickDefaultAgentCandidate } from "./mentionRanking"; export type MentionSuggestionCandidate = { kind: "identity" | "persona" | "team"; @@ -74,3 +76,26 @@ export function mapMentionCandidateToSuggestion(opts: { role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, }; } + +export function pickDefaultAgentSuggestion(opts: { + activePersonaIds: ReadonlySet; + agentProvenanceReady: boolean; + candidates: readonly MentionCandidate[]; + channelType?: ChannelType | null; + currentPubkey?: string | null; + ownerProfiles?: UserProfileLookup; + profiles?: UserProfileLookup; + recentMentionPubkeys?: readonly string[]; +}): MentionSuggestion | null { + const candidate = pickDefaultAgentCandidate( + opts.candidates, + opts.activePersonaIds, + opts.recentMentionPubkeys, + ); + if (!candidate) return null; + return mapMentionCandidateToSuggestion({ + ...opts, + candidate, + label: mentionCandidateLabel(candidate), + }); +} diff --git a/desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs b/desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs new file mode 100644 index 0000000000..f8add9c84a --- /dev/null +++ b/desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getRecentMentionPubkeys } from "./recentMentionPubkeys.ts"; + +const AUTHOR = "a".repeat(64); +const OLDER = "1".repeat(64); +const LATEST_FIRST = "2".repeat(64); +const LATEST_LAST = "3".repeat(64); +const REPLY_AUTHOR = "4".repeat(64); + +function message(createdAt, tags) { + return { + id: String(createdAt), + createdAt, + pubkey: AUTHOR, + author: "Author", + time: "", + body: "", + depth: 0, + tags, + }; +} + +test("returns loaded channel mentions newest-first and excludes structural author tags", () => { + assert.deepEqual( + getRecentMentionPubkeys([ + message(1, [ + ["p", AUTHOR], + ["p", OLDER], + ]), + message(2, [ + ["p", AUTHOR], + ["p", LATEST_FIRST], + ["p", LATEST_LAST], + ]), + ]), + [LATEST_LAST, LATEST_FIRST, OLDER], + ); +}); + +test("keeps a top-level mention when the event omits its structural author tag", () => { + assert.deepEqual( + getRecentMentionPubkeys([message(1, [["p", LATEST_FIRST]])]), + [LATEST_FIRST], + ); +}); + +test("filters desktop structural self-tags by identity", () => { + const parent = message(1, []); + assert.deepEqual( + getRecentMentionPubkeys([ + parent, + { + ...message(2, [ + ["p", REPLY_AUTHOR], + ["p", LATEST_FIRST], + ]), + id: "desktop-reply", + parentId: parent.id, + pubkey: REPLY_AUTHOR, + }, + ]), + [LATEST_FIRST], + ); +}); + +test("keeps an sdk-shaped reply mention that matches the parent author", () => { + const parent = message(1, []); + assert.deepEqual( + getRecentMentionPubkeys([ + parent, + { + ...message(2, [["p", AUTHOR]]), + id: "sdk-reply", + parentId: parent.id, + pubkey: REPLY_AUTHOR, + }, + ]), + [AUTHOR], + ); +}); + +test("ignores DM participant fan-out tags", () => { + assert.deepEqual( + getRecentMentionPubkeys( + [ + message(1, [ + ["p", AUTHOR], + ["p", LATEST_FIRST], + ["p", LATEST_LAST], + ]), + ], + "dm", + ), + [], + ); +}); + +test("deduplicates repeated mentions at their newest position", () => { + assert.deepEqual( + getRecentMentionPubkeys([ + message(1, [ + ["p", AUTHOR], + ["p", LATEST_FIRST], + ]), + message(2, [ + ["p", AUTHOR], + ["p", LATEST_FIRST], + ]), + ]), + [LATEST_FIRST], + ); +}); diff --git a/desktop/src/features/messages/lib/recentMentionPubkeys.ts b/desktop/src/features/messages/lib/recentMentionPubkeys.ts new file mode 100644 index 0000000000..5150904ddb --- /dev/null +++ b/desktop/src/features/messages/lib/recentMentionPubkeys.ts @@ -0,0 +1,41 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import type { ChannelType } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Return explicitly addressed pubkeys from the loaded channel window, newest + * first. DM `p` tags fan out to every participant and cannot distinguish inline + * mentions, so DMs deliberately fall back to the non-recency ranking ladder. + * Desktop-authored stream events may include the message author as a structural + * `p` tag, while SDK-authored events omit it. Filter by author identity rather + * than tag position so an SDK mention of a reply target remains eligible. + */ +export function getRecentMentionPubkeys( + messages: readonly TimelineMessage[], + channelType?: ChannelType | null, +): string[] { + if (channelType === "dm") return []; + + const seen = new Set(); + const recent: string[] = []; + + for ( + let messageIndex = messages.length - 1; + messageIndex >= 0; + messageIndex -= 1 + ) { + const message = messages[messageIndex]; + const authorPubkey = normalizePubkey(message.pubkey ?? ""); + const tags = message.tags ?? []; + for (let tagIndex = tags.length - 1; tagIndex >= 0; tagIndex -= 1) { + const tag = tags[tagIndex]; + if (tag[0] !== "p" || !tag[1]) continue; + const pubkey = normalizePubkey(tag[1]); + if (!pubkey || pubkey === authorPubkey || seen.has(pubkey)) continue; + seen.add(pubkey); + recent.push(pubkey); + } + } + + return recent; +} diff --git a/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts b/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts new file mode 100644 index 0000000000..0ac56e72eb --- /dev/null +++ b/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts @@ -0,0 +1,24 @@ +import * as React from "react"; +import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function useActiveAgentPubkeys( + managedAgents?: readonly ManagedAgent[], + relayAgents?: readonly RelayAgent[], +): ReadonlySet { + return React.useMemo( + () => + new Set([ + ...(managedAgents ?? []) + .filter( + (agent) => + agent.status === "running" || agent.status === "deployed", + ) + .map((agent) => normalizePubkey(agent.pubkey)), + ...(relayAgents ?? []) + .filter((agent) => agent.status !== "offline") + .map((agent) => normalizePubkey(agent.pubkey)), + ]), + [managedAgents, relayAgents], + ); +} diff --git a/desktop/src/features/messages/lib/useDefaultAgentSuggestion.ts b/desktop/src/features/messages/lib/useDefaultAgentSuggestion.ts new file mode 100644 index 0000000000..5748ca2098 --- /dev/null +++ b/desktop/src/features/messages/lib/useDefaultAgentSuggestion.ts @@ -0,0 +1,50 @@ +import * as React from "react"; +import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelType } from "@/shared/api/types"; +import type { MentionCandidate } from "./mentionCandidates"; +import { pickDefaultAgentSuggestion } from "./mentionSuggestionMapping"; + +export function useDefaultAgentSuggestion({ + activePersonaIds, + agentProvenanceReady, + candidates, + channelType, + currentPubkey, + ownerProfiles, + profiles, + recentMentionPubkeys, +}: { + activePersonaIds: ReadonlySet; + agentProvenanceReady: boolean; + candidates: readonly MentionCandidate[]; + channelType?: ChannelType | null; + currentPubkey?: string | null; + ownerProfiles?: UserProfileLookup; + profiles?: UserProfileLookup; + recentMentionPubkeys?: readonly string[]; +}): () => MentionSuggestion | null { + return React.useCallback( + () => + pickDefaultAgentSuggestion({ + activePersonaIds, + agentProvenanceReady, + candidates, + channelType, + currentPubkey, + ownerProfiles, + profiles, + recentMentionPubkeys, + }), + [ + activePersonaIds, + agentProvenanceReady, + candidates, + channelType, + currentPubkey, + ownerProfiles, + profiles, + recentMentionPubkeys, + ], + ); +} diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 2a78e88132..a3e0fcf919 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -114,6 +114,10 @@ function storageKey(): string { : legacyStorageKey(); } +export function getDraftStoreScope(): string { + return storageKey(); +} + function legacyStorageKey(): string { return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 30b7a1f48a..1a6ca1be4a 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -37,6 +37,8 @@ import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; +import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys"; +import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion"; import { flushMentionDebounce } from "./flushMentionDebounce"; import { useAgentMentionRevalidation } from "./agentMentionRevalidation"; import { extractMentionPubkeys } from "./extractMentionPubkeys"; @@ -61,9 +63,12 @@ import { type MentionCandidate, mentionCandidateLabel, } from "./mentionCandidates"; -const MENTION_DEBOUNCE_MS = 120; -const MENTION_SUGGESTION_LIMIT = 50; -type UseMentionsOptions = { channelType?: ChannelType | null }; +const MENTION_DEBOUNCE_MS = 120, + MENTION_SUGGESTION_LIMIT = 50; +type UseMentionsOptions = { + channelType?: ChannelType | null; + recentMentionPubkeys?: readonly string[]; +}; export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], @@ -170,6 +175,10 @@ export function useMentions( ), [relayAgentsQuery.data], ); + const activeAgentPubkeys = useActiveAgentPubkeys( + managedAgentsQuery.data, + relayAgentsQuery.data, + ); const sharedChannelIds = React.useMemo( () => getSharedChannelIds(channelsQuery.data), [channelsQuery.data], @@ -276,6 +285,7 @@ export function useMentions( ? (candidate.displayName ?? current.displayName) : (current.displayName ?? candidate.displayName), isAgent: current.isAgent || candidate.isAgent, + isActiveAgent: current.isActiveAgent || candidate.isActiveAgent, isMember: current.isMember || candidate.isMember, personaId: current.personaId ?? candidate.personaId, personaName: current.personaName ?? candidate.personaName ?? null, @@ -321,6 +331,7 @@ export function useMentions( member.role === "bot" || managedAgentNamesByPubkey.has(pubkey) || relayAgentNamesByPubkey.has(pubkey), + isActiveAgent: activeAgentPubkeys.has(pubkey), ownerPubkey: profile?.ownerPubkey ?? null, personaName: personaNameByPubkey.get(pubkey) ?? null, role: member.role, @@ -342,6 +353,7 @@ export function useMentions( (activePersonaById.has(pubkey) ? pubkey : undefined), ownerPubkey: agent.ownerPubkey, isAgent: true, + isActiveAgent: agent.status !== "offline", }); } for (const agent of managedAgentsQuery.data ?? []) { @@ -351,6 +363,8 @@ export function useMentions( displayName: agent.name, isMember: false, isAgent: true, + isActiveAgent: + agent.status === "running" || agent.status === "deployed", isManagedAgent: true, personaId: agent.personaId ?? undefined, personaName: @@ -406,6 +420,7 @@ export function useMentions( ); }, [ activePersonaById, + activeAgentPubkeys, activePersonas, userSearchResults, canSearchGlobalUsers, @@ -534,6 +549,16 @@ export function useMentions( ownerProfilesQuery.data?.profiles, profiles, ]); + const getDefaultAgentSuggestion = useDefaultAgentSuggestion({ + activePersonaIds, + agentProvenanceReady: agentDirectoriesReady, + candidates: mentionCandidates, + channelType: options?.channelType, + currentPubkey, + ownerProfiles: ownerProfilesQuery.data?.profiles, + profiles, + recentMentionPubkeys: options?.recentMentionPubkeys, + }); const fetchMoreSuggestions = React.useCallback(() => { if (userSearchQuery.hasNextPage && !userSearchQuery.isFetchingNextPage) { void userSearchQuery.fetchNextPage(); @@ -938,6 +963,7 @@ export function useMentions( return { cancelMentionAutocomplete, clearMentions, + getDefaultAgentSuggestion, extractMentionPersonas, extractMentionPubkeys: extractMentionPubkeysForCurrentMentions, revalidateMentionPubkeys, diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 36a75f32e5..fa9644fa61 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -6,7 +6,7 @@ import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Link from "@tiptap/extension-link"; import { Extension, type KeyboardShortcutCommand } from "@tiptap/core"; -import { Plugin, Selection, TextSelection } from "@tiptap/pm/state"; +import { Selection, TextSelection } from "@tiptap/pm/state"; import type { ResolvedPos } from "@tiptap/pm/model"; import { readTextFromSystemClipboard } from "@/shared/api/tauriMedia"; @@ -34,6 +34,7 @@ import { buildPlainTextProjection } from "./plainTextProjection"; import { parseSnapshotClipboardHtml } from "./agentSnapshotClipboard"; import { buildPreviewUpdate } from "./linkPreviewContent"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; +import { LinkPasteTrailingSpace } from "./linkPasteTrailingSpace"; import { CodeBlockAfterHardBreak, handleCodeFenceEnter, @@ -71,6 +72,8 @@ export type AutocompleteEdit = { replaceFromOffset: number; replaceToOffset: number; insertText: string; + /** Keep the current selection mapped through this edit instead of moving it to the insertion. */ + preserveSelection?: boolean; /** * When set, the replaced range becomes a CustomEmojiNode for this * shortcode (followed by `insertText`, which carries the trailing space) @@ -134,63 +137,6 @@ export type RichTextEditorOptions = { onLinkShortcut?: () => boolean; }; -const PASTED_LINK_AT_END_RE = - /(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i; - -function shouldAppendSpaceAfterPaste(text: string): boolean { - const trimmedEnd = text.trimEnd(); - if (!trimmedEnd || trimmedEnd.length !== text.length) return false; - return PASTED_LINK_AT_END_RE.test(trimmedEnd); -} - -const LinkPasteTrailingSpace = Extension.create({ - name: "linkPasteTrailingSpace", - - addProseMirrorPlugins() { - return [ - new Plugin({ - props: { - handlePaste(view, event) { - const pastedText = event.clipboardData?.getData("text/plain") ?? ""; - if (!shouldAppendSpaceAfterPaste(pastedText)) return false; - - window.setTimeout(() => { - if (!view.dom.isConnected) return; - const { state } = view; - if (!state.selection.empty) return; - - const from = state.selection.from; - if (from < state.doc.content.size) { - const nextText = state.doc.textBetween( - from, - Math.min(state.doc.content.size, from + 1), - "\n", - "\n", - ); - if (/^\s$/.test(nextText)) return; - } - - let transaction = state.tr.insertText(" ", from, from); - const linkMark = state.schema.marks.link; - if (linkMark) { - transaction = transaction.removeMark(from, from + 1, linkMark); - } - transaction = transaction.setSelection( - TextSelection.create(transaction.doc, from + 1), - ); - transaction.setStoredMarks([]); - view.dispatch(transaction.scrollIntoView()); - view.focus(); - }, 0); - - return false; - }, - }, - }), - ]; - }, -}); - /** * Creates and manages a Tiptap editor configured for Markdown output. * @@ -476,7 +422,13 @@ export function useRichTextEditor({ }).configure({ openOnClick: false, autolink: true, - linkOnPaste: true, + // The composer's own paste handler owns every selected-text link + // paste (`createComposerLinkPasteHandler`). TipTap's `linkOnPaste` + // recognises the same URLs one layer down, and when our handler + // declines a selection it can't link cleanly, `linkOnPaste` still + // fires and partially links it. No ordering trick removes a second + // handler — the only fix is not to register it. + linkOnPaste: false, // Allow Buzz message links through TipTap's URL sanitiser. // http(s) and mailto are accepted by default; non-listed protocols are // stripped on paste/typed input. @@ -824,6 +776,7 @@ export function useRichTextEditor({ toOffset: number, text: string, customEmojiShortcode?: string, + preserveSelection = false, ) => { if (!editor) return; const projection = buildPlainTextProjection(editor.state.doc); @@ -856,19 +809,23 @@ export function useRichTextEditor({ } const tr = editor.state.tr.insertText(text, fromPM, toPM); - // Place cursor at the end of the inserted text. We map `toPM` (the - // right end of the replaced range) through the transaction's - // mapping — that's the post-transaction position right after the - // inserted text, valid even if mark normalisation shifted things. - // (Mapping `fromPM + text.length` directly would be a pre-image - // position that may not exist in the original doc, which throws - // "Position N out of range".) - const cursorPM = tr.mapping.map(toPM); - tr.setSelection(TextSelection.create(tr.doc, cursorPM)); - settleAutocompleteMentionInsert(editor, tr, text); + if (preserveSelection) { + tr.setSelection(editor.state.selection.map(tr.doc, tr.mapping)); + } else { + // Place cursor at the end of the inserted text. We map `toPM` (the + // right end of the replaced range) through the transaction's + // mapping — that's the post-transaction position right after the + // inserted text, valid even if mark normalisation shifted things. + // (Mapping `fromPM + text.length` directly would be a pre-image + // position that may not exist in the original doc, which throws + // "Position N out of range".) + const cursorPM = tr.mapping.map(toPM); + tr.setSelection(TextSelection.create(tr.doc, cursorPM)); + } + settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection); editor.view.dispatch(tr); editor.view.focus(); - reassertMentionCaretAfterFocus(editor.view); + if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view); }, [editor, customEmojiWiring.resolveUrl], ); diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs index 78c14aeef9..4fe40da4b3 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs +++ b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs @@ -78,9 +78,8 @@ test("mention control expands with automatically mentioned agents", async () => assert.match(manage.className, /(?:^|\s)pl-2(?:\s|$)/); assert.match(manage.parentElement?.className ?? "", /(?:^|\s)pl-2(?:\s|$)/); assert.match( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", - /(?:^|\s)pr-1(?:\s|$)/, + manage.parentElement?.className ?? "", + /(?:^|\s)pr-1\.5(?:\s|$)/, ); assert.match( view.getByRole("button", { name: "Manage automatic agent mentions" }) @@ -111,17 +110,21 @@ test("mention control expands with automatically mentioned agents", async () => /scale\(0.8\)/, ); } - const remove = view.getByRole("button", { - name: "Stop automatically mentioning Agent Ada", - }); + const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey"); assert.match( remove.querySelector("span.absolute")?.className ?? "", /group-hover\/address:opacity-100/, ); fireEvent.click(remove); assert.deepEqual(removed, ["agent-pubkey"]); - fireEvent.click( - view.getByRole("button", { name: "Manage automatic agent mentions" }), + view.rerender(renderButton([])); + const exitingLocks = view.getByTestId("composer-address-locks"); + assert.match(exitingLocks.className, /(?:^|\s)overflow-hidden(?:\s|$)/); + assert.match( + view.getByRole("button", { name: "Mention someone" }).parentElement + ?.className ?? "", + /(?:^|\s)pr-1\.5(?:\s|$)/, ); + fireEvent.click(view.getByRole("button", { name: "Mention someone" })); assert.equal(opened, 1); }); diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index 2c8c28f5de..f141a4d2fd 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -9,6 +9,7 @@ import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; export type ComposerAddressAgent = { @@ -58,7 +59,7 @@ function AddressedAgentAvatar({ return ( void; + onConfirmationTurnOff?: () => void; onCaptureSelection: () => void; onOpen: () => void; onRemove: (pubkey: string) => void; @@ -142,89 +149,161 @@ export function ComposerMentionButton({ const visibleAgents = showAgents ? agents.slice(0, VISIBLE_AGENT_LIMIT) : []; const hiddenCount = showAgents ? agents.length - visibleAgents.length : 0; const hasAgents = visibleAgents.length > 0; + const shouldReduceMotion = useReducedMotion(); + const [showActiveChrome, setShowActiveChrome] = React.useState(hasAgents); const newlyAddedAgentPubkeys = useNewlyAddedAgentPubkeys(visibleAgents); + React.useEffect(() => { + if (hasAgents) setShowActiveChrome(true); + }, [hasAgents]); + return ( -
{ + if (!open) onConfirmationDismiss?.(); + }} + open={Boolean(confirmationTitle)} > - - + +
+ + + + + + {hasAgents + ? "Manage automatic agent mentions" + : "Mention someone"} + + + { + if (!hasAgents) setShowActiveChrome(false); + }} + > + {hasAgents ? ( + + + {visibleAgents.map((agent) => ( + + + onRemove(agent.pubkey)} + transition={ + shouldReduceMotion + ? { duration: 0 } + : addressEntryTransition + } + type="button" + > + + + + + + + Stop automatically mentioning {agent.displayName} + + + ))} + + + + ) : null} + +
+
+ {confirmationTitle ? ( + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + side="right" + sideOffset={8} + style={{ width: "max-content" }} + > + {confirmationTitle} -
- - {hasAgents ? "Manage automatic agent mentions" : "Mention someone"} - -
- {hasAgents ? ( - - - {visibleAgents.map((agent) => ( - - - onRemove(agent.pubkey)} - transition={addressEntryTransition} - type="button" - > - - - - - - - Stop automatically mentioning {agent.displayName} - - - ))} - - - + ) : null} -
+ ); } diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index f46336b3e9..be0db49649 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -13,7 +13,6 @@ import { X, } from "lucide-react"; -import type { BlobDescriptor } from "@/shared/api/tauri"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { @@ -226,7 +225,7 @@ function composerMediaStyle(): React.CSSProperties { } type MediaAttachmentItemProps = { - attachment: BlobDescriptor; + attachment: ImetaMedia; isSpoilered: boolean; onEditSave?: (url: string, bytes: Uint8Array) => Promise; onRemove: (url: string) => void; @@ -266,6 +265,18 @@ const MediaAttachmentItem = React.forwardRef< const hash = shortHash(attachment.sha256); const isVideo = attachment.type.startsWith("video/"); + // One accessible name for every control/label in this item. Provider media + // (e.g. KLIPY GIFs) carries a `displayLabel` but no content hash; ordinary + // uploads keep their historical type-aware `Attachment ` / + // `Video attachment ` name; only genuinely hashless non-provider media + // falls back to a filename. + const mediaLabel = + attachment.displayLabel?.trim() || + (attachment.sha256 + ? isVideo + ? `Video attachment ${hash}` + : `Attachment ${hash}` + : attachment.filename?.trim() || `Attachment ${hash}`); const thumbUrl = attachment.thumb ? rewriteRelayUrl(attachment.thumb) : rewriteRelayUrl(attachment.url); @@ -275,7 +286,11 @@ const MediaAttachmentItem = React.forwardRef< ? rewriteRelayUrl(attachment.thumb) : undefined; - const canEdit = !isVideo && onEditSave !== undefined; + // Only Buzz-hosted uploads have a content hash. URL-only provider media + // must remain externally hosted instead of being copied into storage by the + // image editor's save path. + const canEdit = + !isVideo && onEditSave !== undefined && attachment.sha256.length === 64; const canRevert = !isVideo && onRevert !== undefined && originalUrl !== undefined; @@ -338,40 +353,41 @@ const MediaAttachmentItem = React.forwardRef< style={composerMediaStyle()} > - -
- {isVideo ? ( -
- {videoPosterUrl ? ( - {`Video - ) : ( -
- )} -
-
- -
+ + {isVideo ? ( +
+ {videoPosterUrl ? ( + + ) : ( +
+ )} +
+
+
- ) : ( - {`Attachment - )} - {isSpoilered ? ( -
- -
- ) : null} -
+
+ ) : ( + + )} + {isSpoilered ? ( +
+ +
+ ) : null} - Attachment {hash} preview + {mediaLabel} preview Full-size attachment preview. Press Escape or click outside to @@ -401,7 +417,7 @@ const MediaAttachmentItem = React.forwardRef< ) : null} {mode === "edit" && !isVideo ? ( ) : ( {`Attachment
diff --git a/desktop/src/features/messages/ui/DiffViewer.tsx b/desktop/src/features/messages/ui/DiffViewer.tsx index 1444cf9977..ed6703f66c 100644 --- a/desktop/src/features/messages/ui/DiffViewer.tsx +++ b/desktop/src/features/messages/ui/DiffViewer.tsx @@ -2,6 +2,8 @@ import { Diff, Hunk, type ViewType } from "react-diff-view"; import "react-diff-view/style/index.css"; import { useMemo } from "react"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { countDiffFileChanges, DIFF_TYPE_LABELS, @@ -18,6 +20,7 @@ type DiffViewerProps = { fallbackFilePath?: string; viewType?: ViewType; className?: string; + searchQuery?: string; }; function FileChangeBadge({ @@ -46,16 +49,20 @@ export function DiffViewer({ fallbackFilePath, viewType = "unified", className, + searchQuery, }: DiffViewerProps) { const { files, parseError } = useMemo( () => parseUnifiedDiff(content), [content], ); + const searchPreview = searchQuery + ? buildSearchResultPreview(content, searchQuery, 160) + : null; if (parseError) { return (
-        {content}
+        
       
); } @@ -70,6 +77,17 @@ export function DiffViewer({ return (
+ {searchPreview ? ( +
+          
+        
+ ) : null}
{files.map((file) => { const label = getDiffFileLabel(file, fallbackFilePath); diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 9c490edb07..02dcdcaf6d 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -18,6 +18,7 @@ import { toast } from "sonner"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; +import { useCustomEmoji } from "@/features/custom-emoji/hooks"; import { getThreadReference } from "@/features/messages/lib/threading"; import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog"; import { MessageModerationMenuItems } from "@/features/moderation/ui/MessageModerationMenuItems"; @@ -25,9 +26,15 @@ import type { TimelineMessage, TimelineReaction, } from "@/features/messages/types"; -import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis"; +import { + recordQuickReactionEmoji, + useQuickReactionEmojis, +} from "@/features/messages/ui/useQuickReactionEmojis"; +import { reactionEmojiUrl } from "@/shared/api/customEmoji"; import { cn } from "@/shared/lib/cn"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { emojiDisplayName } from "@/shared/lib/emojiName"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; import { Button } from "@/shared/ui/button"; import { HashArrowIn } from "@/shared/ui/icons"; @@ -329,6 +336,51 @@ function MoreActionsMenu({ ); } +function QuickReactionButton({ + customEmojiUrl, + emoji, + onSelect, +}: { + customEmojiUrl?: string; + emoji: string; + onSelect: (emoji: string) => void; +}) { + const displayName = emojiDisplayName(emoji); + const mediaUrl = customEmojiUrl ? rewriteRelayUrl(customEmojiUrl) : null; + + return ( + + + + + {displayName} + + ); +} + +function isCustomEmojiShortcode(emoji: string) { + return emoji.startsWith(":") && emoji.endsWith(":"); +} + export const MessageActionBar = React.memo(function MessageActionBar({ channelId, message, @@ -372,6 +424,20 @@ export const MessageActionBar = React.memo(function MessageActionBar({ }) { const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); + const customEmoji = useCustomEmoji(); + const quickReactionEmojis = useQuickReactionEmojis(3, customEmoji); + const quickReactionItems = React.useMemo( + () => + quickReactionEmojis + .map((emoji) => ({ + customEmojiUrl: reactionEmojiUrl(emoji, customEmoji), + emoji, + })) + .filter( + (item) => !isCustomEmojiShortcode(item.emoji) || item.customEmojiUrl, + ), + [customEmoji, quickReactionEmojis], + ); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); @@ -436,6 +502,19 @@ export const MessageActionBar = React.memo(function MessageActionBar({ >
+ {hasReactionAction && quickReactionItems.length > 0 ? ( +
+ {quickReactionItems.map(({ customEmojiUrl, emoji }) => ( + + ))} +
+ ) : null} + {hasReactionAction ? ( ) : null} + {hasReactionAction && quickReactionItems.length > 0 ? ( + Promise; placeholder?: string; profiles?: UserProfileLookup; + /** Explicit mention pubkeys from the loaded channel window, newest first. */ + recentMentionPubkeys?: readonly string[]; replyTarget?: { author: string; body: string; diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index 46b5d91d5b..e5f08b6ebd 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -585,6 +585,225 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => { await handle.unmount(); }); +test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async () => { + const DRAFT_KEY = "chan-clear-race"; + setupStore("pubkey-clear-race"); + persistDraftEntry(DRAFT_KEY, "draft text", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "draft text"); + + trackAuthoredContent(""); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "the authoritative update removes the stale body even while editor reads lag", + ); + + await handle.unmount(); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "async settlement must not repersist the deleted body", + ); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "the deleted body must not be restored"); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-image"; + setupStore("pubkey-clear-caption-image"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [IMG_A], [IMG_A.url]); + + let editorContent = ""; + let pendingImeta = []; + let spoileredUrls = new Set(); + let trackAuthoredContent; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: pendingImeta, + setPendingImeta: (imeta) => { + pendingImeta = imeta; + }, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: (urls) => { + spoileredUrls = urls; + }, + spoileredAttachmentUrlsRef: { + get current() { + return spoileredUrls; + }, + }, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(pendingImeta[0]?.url, IMG_A.url); + assert.deepEqual([...spoileredUrls], [IMG_A.url]); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, ""); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_queued_file_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-file"; + setupStore("pubkey-clear-caption-file"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [], []); + const FILE_A = { + file: new File(["report"], "report.pdf", { type: "application/pdf" }), + id: 9, + spoilered: true, + }; + + let editorContent = ""; + let queuedAttachments = [FILE_A]; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + getQueuedAttachments: () => queuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: () => { + queuedAttachments = []; + }, + restoreQueuedAttachments: (attachments) => { + queuedAttachments = attachments; + }, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + saveQueuedAttachmentsForDraft(DRAFT_KEY, [FILE_A]); + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(queuedAttachments[0]?.file.name, "report.pdf"); + assert.equal(queuedAttachments[0]?.spoilered, true); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_authority_is_scoped_to_relay_and_identity", async () => { + const DRAFT_KEY = "shared-key"; + installFreshLocalStorage(); + clearAllDrafts(); + initDraftStore("pubkey-a", "wss://relay-a.example"); + persistDraftEntry(DRAFT_KEY, "workspace A", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const workspaceA = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await workspaceA.unmount(); + + initDraftStore("pubkey-b", "wss://relay-b.example"); + persistDraftEntry(DRAFT_KEY, "workspace B", DRAFT_KEY, [], []); + const workspaceB = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "workspace B"); + await workspaceB.unmount(); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "workspace B"); + + initDraftStore("pubkey-a", "wss://relay-a.example"); + const workspaceARemount = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "workspace A stale text must stay cleared"); + await workspaceARemount.unmount(); +}); + test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => { setupStore("pubkey-switch-files"); const FILE_A = { diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index d4e2284b85..6af8b6368b 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/react"; import { AnimatePresence, motion } from "motion/react"; import { ALargeSmall, Paperclip, X } from "lucide-react"; +import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload"; import { Button } from "@/shared/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { @@ -26,15 +27,19 @@ const ignoreAddressRemoval = () => {}; export const MessageComposerToolbar = React.memo( function MessageComposerToolbar({ addressedAgents = NO_ADDRESSED_AGENTS, + autoPinConfirmationTitle, composerDisabled, editor, extraActions, formattingDisabled, + gifMediaController, isEmojiPickerOpen, isFormattingOpen, isSending, isUploading, onCaptureSelection, + onAutoPinConfirmationDismiss, + onAutoPinConfirmationTurnOff, onEmojiPickerOpenChange, onEmojiSelect, onFormattingToggle, @@ -47,15 +52,19 @@ export const MessageComposerToolbar = React.memo( shakeVersionByPubkey, }: { addressedAgents?: readonly ComposerAddressAgent[]; + autoPinConfirmationTitle?: string | null; composerDisabled: boolean; editor: Editor | null; extraActions?: React.ReactNode; formattingDisabled: boolean; + gifMediaController: Pick; isEmojiPickerOpen: boolean; isFormattingOpen: boolean; isSending: boolean; isUploading: boolean; onCaptureSelection: () => void; + onAutoPinConfirmationDismiss?: () => void; + onAutoPinConfirmationTurnOff?: () => void; onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; onFormattingToggle: (pressed: boolean) => void; @@ -175,7 +184,10 @@ export const MessageComposerToolbar = React.memo( > editor?.commands.focus()} onEmojiSelect={onEmojiSelect} onOpenChange={onEmojiPickerOpenChange} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 536631b02d..0f1ab3c981 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { AlertTriangle } from "lucide-react"; - import { depthGuideActionsEqual, numberArrayEqual, @@ -59,6 +58,7 @@ import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { getAgentAddressMentionPubkeys } from "@/features/messages/lib/agentAddressMention.mjs"; +import { getVisibleAgentAddressPubkeys } from "@/features/messages/lib/getVisibleAgentAddressPubkeys"; import { MessageAgentAddressPrefix } from "./MessageAgentAddressPrefix"; const DiffMessage = React.lazy(() => import("./DiffMessage")); @@ -280,10 +280,12 @@ export const MessageRow = React.memo( return Object.keys(values).length > 0 ? values : undefined; }, [isKnownAgentPubkey, mentionPubkeysByName]); const addressedAgentPubkeys = React.useMemo(() => { - return getAgentAddressMentionPubkeys(message.tags).filter( - isKnownAgentPubkey, + return getVisibleAgentAddressPubkeys( + message.body, + getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey), + mentionPubkeysByName, ); - }, [isKnownAgentPubkey, message.tags]); + }, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]); const agentAddressPrefix = addressedAgentPubkeys.length > 0 ? ( @@ -417,6 +420,7 @@ export const MessageRow = React.memo( fallbackText={waveMessage.fallbackText} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} + searchQuery={searchQuery} /> ); } diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 01fbcda7ff..16ee26bcc7 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -81,6 +81,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { onScrollTargetResolved: () => void; onScrollTargetSettled?: (messageId: string) => void; scrollTargetHighlights?: boolean; + searchMessageId?: string | null; + searchQuery?: string; onSelectReplyTarget: (message: TimelineMessage) => void; onSend: ( content: string, @@ -104,6 +106,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { remove: boolean, ) => Promise; profiles?: UserProfileLookup; + recentMentionPubkeys?: readonly string[]; replyTargetMessage: TimelineMessage | null; scrollTargetId: string | null; threadHead: TimelineMessage | null; @@ -182,9 +185,12 @@ export function MessageThreadPanel({ onToggleReaction, onUnfollowThread, profiles, + recentMentionPubkeys, replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, + searchMessageId, + searchQuery, threadHead, videoReviewPresentation, threadReplies, @@ -506,8 +512,12 @@ export function MessageThreadPanel({ tabIndex={-1} ref={threadBodyRef} > + {/* The gallery is intentionally DOM-scoped: only media currently rendered + in this open thread participates. Collapsed or unloaded descendants + join only after the thread UI renders them. */}
onUnfollowThread() : undefined } profiles={profiles} + searchQuery={ + searchMessageId === threadHead.id ? searchQuery : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( threadHead.id, @@ -725,6 +738,11 @@ export function MessageThreadPanel({ onSendToChannel={stableSendToChannel} onToggleReaction={onToggleReaction} profiles={profiles} + searchQuery={ + searchMessageId === entry.message.id + ? searchQuery + : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( entry.message.id, @@ -839,6 +857,7 @@ export function MessageThreadPanel({ : `Reply in thread to ${threadHead.author}` } profiles={profiles} + recentMentionPubkeys={recentMentionPubkeys} replyTarget={composerReplyTarget} typingParentEventId={threadHead.id} typingRootEventId={threadHead.rootId} diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx index 84934d1826..ab092b5756 100644 --- a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx @@ -4,6 +4,7 @@ import type { Editor } from "@tiptap/react"; import { cn } from "@/shared/lib/cn"; import { FormattingToolbar } from "./FormattingToolbar"; +import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom"; type SelectionFormattingTrayProps = { editor: Editor | null; @@ -129,6 +130,7 @@ export function SelectionFormattingTray({ if ( suppressRightClickUpdatesRef.current || !editor || + !getMountedEditorDom(editor) || disabled || !editor.isEditable || !editor.isFocused @@ -161,7 +163,7 @@ export function SelectionFormattingTray({ return; } - const editorDom = editor.view.dom; + let editorDom: HTMLElement | null = null; const hide = () => setPosition(null); const handleContextMenu = () => { suppressRightClickUpdatesRef.current = true; @@ -176,26 +178,44 @@ export function SelectionFormattingTray({ if (event.button === 0) clearSuppression(); }; - scheduleUpdate(); + const detachEditorDom = () => { + if (!editorDom) return; + editorDom.removeEventListener("contextmenu", handleContextMenu); + editorDom.removeEventListener("pointerdown", handlePointerDown); + editorDom.removeEventListener("keydown", clearSuppression); + editorDom = null; + }; + + const attachEditorDom = () => { + const nextEditorDom = getMountedEditorDom(editor); + if (!nextEditorDom || nextEditorDom === editorDom) return; + detachEditorDom(); + editorDom = nextEditorDom; + editorDom.addEventListener("contextmenu", handleContextMenu); + editorDom.addEventListener("pointerdown", handlePointerDown); + editorDom.addEventListener("keydown", clearSuppression); + scheduleUpdate(); + }; + + editor.on("mount", attachEditorDom); + editor.on("unmount", detachEditorDom); editor.on("selectionUpdate", scheduleUpdate); editor.on("transaction", scheduleUpdate); editor.on("focus", scheduleUpdate); editor.on("blur", hide); - editorDom.addEventListener("contextmenu", handleContextMenu); - editorDom.addEventListener("pointerdown", handlePointerDown); - editorDom.addEventListener("keydown", clearSuppression); window.addEventListener("resize", scheduleUpdate); window.addEventListener("scroll", scheduleUpdate, true); + attachEditorDom(); return () => { cancelScheduledUpdate(); + editor.off("mount", attachEditorDom); + editor.off("unmount", detachEditorDom); editor.off("selectionUpdate", scheduleUpdate); editor.off("transaction", scheduleUpdate); editor.off("focus", scheduleUpdate); editor.off("blur", hide); - editorDom.removeEventListener("contextmenu", handleContextMenu); - editorDom.removeEventListener("pointerdown", handlePointerDown); - editorDom.removeEventListener("keydown", clearSuppression); + detachEditorDom(); window.removeEventListener("resize", scheduleUpdate); window.removeEventListener("scroll", scheduleUpdate, true); }; diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx index f782326295..9c1a6feeee 100644 --- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx +++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { channelsQueryKey } from "@/features/channels/hooks"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useHuddle } from "@/features/huddle"; import { formatHuddleActionError } from "@/features/huddle/lib/huddleError"; import { @@ -20,6 +21,7 @@ type WaveMessageAttachmentProps = { fallbackText: string; huddleMemberPubkeys?: readonly string[]; huddleMemberPubkeysPending?: boolean; + searchQuery?: string; }; export function WaveMessageAttachment({ @@ -27,6 +29,7 @@ export function WaveMessageAttachment({ fallbackText, huddleMemberPubkeys = [], huddleMemberPubkeysPending = false, + searchQuery, }: WaveMessageAttachmentProps) { const queryClient = useQueryClient(); const { isStarting, startHuddle } = useHuddle(); @@ -68,7 +71,12 @@ export function WaveMessageAttachment({ 👋 - {fallbackText} + + + Start a huddle to talk to them. diff --git a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs index e98763a4a5..b95528dd93 100644 --- a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -44,13 +44,13 @@ test("agent picker preference skips people", async () => { assert.equal(view.result.current.mentionSelectedIndex, 1); }); -test("primary+Shift+Enter opens the picker or toggles in place", async () => { +test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAlwaysAddressShortcut } = await import( "./useAlwaysAddressShortcut.ts" ); const { isMacPlatform } = await import("@/shared/lib/platform"); - const opened = []; + const selected = []; const toggled = []; const suggestion = { displayName: "Agent Ada", @@ -59,8 +59,9 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { }; const createEvent = () => ({ altKey: false, + code: "KeyM", ctrlKey: !isMacPlatform(), - key: "Enter", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, @@ -71,24 +72,123 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { useAlwaysAddressShortcut({ enabled: true, mentions: { + getDefaultAgentSuggestion: () => suggestion, isMentionOpen, mentionSelectedIndex: 0, suggestions: [suggestion], }, - onOpenPicker: (insertTrigger) => opened.push(insertTrigger), + onOpenPicker: () => {}, + onSelect: (value) => selected.push(value), onToggle: (value) => toggled.push(value), }), { initialProps: { isMentionOpen: false } }, ); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(opened, [false]); - assert.deepEqual(toggled, []); + assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, []); view.rerender({ isMentionOpen: true }); act(() => assert.equal(view.result.current(createEvent()), true)); assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, [suggestion]); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion, suggestion]); + assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, [suggestion, suggestion]); +}); + +test("primary+Shift+M removes the current locked agent before choosing a new default", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + const lockedAgent = { + avatarUrl: null, + displayName: "Agent Ada", + pubkey: "agent-a", + }; + const defaultAgent = { + displayName: "Agent Bea", + isAgent: true, + pubkey: "agent-b", + }; + const toggled = []; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + lockedAgent, + mentions: { + getDefaultAgentSuggestion: () => defaultAgent, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => {}, + onSelect: () => {}, + onToggle: (value) => toggled.push(value), + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.deepEqual(toggled, [{ ...lockedAgent, isAgent: true }]); +}); + +test("primary+Shift+M opens the picker when no default agent is ready", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + let opened = 0; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + mentions: { + getDefaultAgentSuggestion: () => null, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => { + opened += 1; + }, + onSelect: () => {}, + onToggle: () => {}, + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.equal(opened, 1); }); diff --git a/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs new file mode 100644 index 0000000000..433af4664d --- /dev/null +++ b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom.ts"; + +test("returns null while the TipTap editor view is not mounted", () => { + const editor = { + get view() { + return new Proxy( + {}, + { + get(_target, key) { + throw new Error(`editor view cannot access ${String(key)}`); + }, + }, + ); + }, + }; + + assert.equal(getMountedEditorDom(editor), null); +}); + +test("returns the editor DOM after the TipTap view mounts", () => { + const dom = {}; + const editor = { view: { dom } }; + + assert.equal(getMountedEditorDom(editor), dom); +}); diff --git a/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts new file mode 100644 index 0000000000..25733239ba --- /dev/null +++ b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts @@ -0,0 +1,10 @@ +import type { Editor } from "@tiptap/react"; + +/** Returns the editor DOM only after TipTap has mounted its view. */ +export function getMountedEditorDom(editor: Editor): HTMLElement | null { + try { + return editor.view.dom; + } catch { + return null; + } +} diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 4acd89cf0a..95e0af9e06 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -23,7 +23,7 @@ afterEach(async () => { after(() => dom.window.close()); -test("always addressing an agent keeps autocomplete open, adds the lock, and pulses", async () => { +test("always addressing an agent keeps autocomplete open, inserts the chip, adds the lock, and pulses", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" @@ -32,7 +32,7 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul const addedPubkeys = []; const pulsedPubkeys = []; let cancelCount = 0; - const text = "Ask @Agent Ada later @"; + const text = "@"; const mentions = { cancelMentionAutocomplete: () => { cancelCount += 1; @@ -45,6 +45,9 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }, ], getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: true, + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -73,7 +76,14 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + preserveSelection: true, + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); @@ -83,6 +93,49 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul ); }); +test("always addressing a new agent delegates the first add for immediate confirmation", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const addressedSuggestions = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: false, + registerMentionPubkey: () => {}, + }, + onAddressAgentMention: (value) => addressedSuggestions.push(value), + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText: { + getPlainTextAndCursor: () => ({ text: "@Agent Ada ", cursor: 11 }), + }, + }), + ); + + act(() => result.current.toggleAlwaysAddressAgent(suggestion)); + + assert.deepEqual(addressedSuggestions, [suggestion]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); +}); + test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( @@ -105,6 +158,7 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }, ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -136,7 +190,13 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 4, + replaceToOffset: 15, + insertText: "", + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, []); @@ -158,10 +218,13 @@ test("selecting an already addressed agent from the explicit picker pulses its b cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("an already addressed agent must not be inserted"); - }, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), mentionStartIndex: 5, }; const audience = { @@ -190,16 +253,23 @@ test("selecting an already addressed agent from the explicit picker pulses its b }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }, + ]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); }); -test("selecting an agent from a typed query leaves the inline mention for send", async () => { +test("selecting an agent from a typed query immediately auto-addresses it", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const autoPinnedSuggestions = []; const appliedEdits = []; const addedPubkeys = []; const pulsedPubkeys = []; @@ -207,6 +277,7 @@ test("selecting an agent from a typed query leaves the inline mention for send", cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => true, insertMention: () => ({ replaceFromOffset: 5, @@ -224,6 +295,206 @@ test("selecting an agent from a typed query leaves the inline mention for send", // makes the editor text/cursor insufficient to re-detect the typed query. getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience, + audienceScope: "channel-scope", + mentions, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText, + }), + ); + + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + act(() => result.current.selectMentionSuggestion(suggestion)); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 6, + insertText: "@Agent Ada ", + }, + ]); + assert.deepEqual(autoPinnedSuggestions, [suggestion]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); + assert.equal(result.current.announcement, ""); +}); + +test("selecting a human mention never changes automatic addressing", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const autoPinnedSuggestions = []; + const appliedEdits = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { pubkeys: [], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getMentionDisplayName: () => "Alice", + insertMention: () => ({ + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }), + }, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), + onPulseAddressLock: () => {}, + richText: { + getPlainTextAndCursor: () => ({ text: "@Al", cursor: 3 }), + }, + }), + ); + + act(() => + result.current.selectMentionSuggestion({ + pubkey: "human-pubkey", + displayName: "Alice", + isAgent: false, + }), + ); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }, + ]); + assert.deepEqual(autoPinnedSuggestions, []); +}); + +test("removing the last agent chip clears its automatic address", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const mentionRefsByText = { + "@Agent Ada first @Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "@Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "": [], + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["agent-pubkey", "existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => mentionRefsByText[text] ?? [], + getMentionDisplayName: () => "Agent Ada", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => result.current.trackMentionAddressedAgent("agent-pubkey")); + act(() => + result.current.syncAddressedAgentsFromText( + "@Agent Ada first @Agent Ada second", + ), + ); + act(() => result.current.syncAddressedAgentsFromText("@Agent Ada second")); + assert.deepEqual(removedPubkeys, []); + + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["agent-pubkey"]); +}); + +test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => { + if (text === "@Alice @Existing Agent") { + return [ + { displayName: "Alice", pubkey: "human-pubkey", isAgent: false }, + { + displayName: "Existing Agent", + pubkey: "existing-lock", + isAgent: true, + }, + ]; + } + return text + ? [{ displayName: "Alice", pubkey: "human-pubkey", isAgent: false }] + : []; + }, + getMentionDisplayName: () => "Existing Agent", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => + result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"), + ); + act(() => result.current.syncAddressedAgentsFromText("@Alice")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); +}); + +test("selecting an agent from the explicit picker auto-addresses it", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const mentions = { + cancelMentionAutocomplete: () => {}, + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, + isInlineMentionSelection: () => false, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), + mentionStartIndex: 5, + }; + const audience = { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }; + const richText = { + getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), + }; const { result } = renderHook(() => useAgentAddressLockPicker({ applyAutocompleteEdit: (edit) => appliedEdits.push(edit), @@ -246,60 +517,10 @@ test("selecting an agent from a typed query leaves the inline mention for send", assert.deepEqual(appliedEdits, [ { replaceFromOffset: 5, - replaceToOffset: 6, + replaceToOffset: 5, insertText: "@Agent Ada ", }, ]); - assert.deepEqual(addedPubkeys, []); - assert.deepEqual(pulsedPubkeys, []); - assert.equal(result.current.announcement, ""); -}); - -test("selecting an agent from the explicit picker auto-addresses it", async () => { - const { act, renderHook } = await import("@testing-library/react"); - const { useAgentAddressLockPicker } = await import( - "./useAgentAddressLockPicker.ts" - ); - const appliedEdits = []; - const addedPubkeys = []; - const pulsedPubkeys = []; - const mentions = { - cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], - getMentionDisplayName: () => "Agent Ada", - isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("explicit picker selections must become addressing"); - }, - mentionStartIndex: 5, - }; - const audience = { - pubkeys: [], - addPubkey: (pubkey) => addedPubkeys.push(pubkey), - }; - const richText = { - getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), - }; - const { result } = renderHook(() => - useAgentAddressLockPicker({ - applyAutocompleteEdit: (edit) => appliedEdits.push(edit), - audience, - audienceScope: "channel-scope", - mentions, - onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), - richText, - }), - ); - - act(() => { - result.current.selectMentionSuggestion({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); - }); - - assert.deepEqual(appliedEdits, []); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); assert.equal( @@ -319,8 +540,11 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn const pulsedPubkeys = []; const mentions = { cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], + getDraftMentionRefs: () => [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, insertMention: () => ({ replaceFromOffset: 0, @@ -330,7 +554,10 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn mentionStartIndex: 0, }; const richText = { - getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + getPlainTextAndCursor: () => ({ + text: "@Agent Ada keep this authored text", + cursor: 35, + }), }; const { result, rerender } = renderHook( ({ pubkeys }) => @@ -350,6 +577,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ); act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); + assert.deepEqual(appliedEdits, []); rerender({ pubkeys: [] }); act(() => { result.current.selectMentionSuggestion({ diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 8d5a87b9b8..17883900ec 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -16,8 +16,7 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; function buildMentionRemovalEdits( text: string, displayNames: readonly string[], - queryStart: number, - cursor: number, + queryRange?: { start: number; end: number }, ): AutocompleteEdit[] { const ranges = displayNames.flatMap((displayName) => getMentionOffsets(text, displayName).map((start) => { @@ -26,10 +25,12 @@ function buildMentionRemovalEdits( return { start, end }; }), ); - ranges.push({ - start: Math.max(0, Math.min(queryStart, text.length)), - end: Math.max(0, Math.min(cursor, text.length)), - }); + if (queryRange) { + ranges.push({ + start: Math.max(0, Math.min(queryRange.start, text.length)), + end: Math.max(0, Math.min(queryRange.end, text.length)), + }); + } const merged = ranges .filter(({ start, end }) => start < end) @@ -56,6 +57,8 @@ export function useAgentAddressLockPicker({ audience, audienceScope, mentions, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, profiles, richText, @@ -64,6 +67,8 @@ export function useAgentAddressLockPicker({ audience: ReturnType; audienceScope: string | null; mentions: UseMentionsResult; + onAddressAgentMention?: (suggestion: MentionSuggestion) => void; + onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; onPulseAddressLock: (pubkey: string) => void; profiles?: UserProfileLookup; richText: UseRichTextEditorResult; @@ -79,6 +84,12 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.clear(); } const lockedAgentNamesRef = React.useRef(new Map()); + const visibleAgentMentionPubkeysRef = React.useRef(new Set()); + const mentionSyncScopeRef = React.useRef(audienceScope); + if (mentionSyncScopeRef.current !== audienceScope) { + mentionSyncScopeRef.current = audienceScope; + visibleAgentMentionPubkeysRef.current.clear(); + } const [announcement, setAnnouncement] = React.useState(""); const lockedAgents = React.useMemo( () => @@ -104,41 +115,42 @@ export function useAgentAddressLockPicker({ }), [audience.pubkeys, mentions.getMentionDisplayName, profiles], ); - const consumeAddressSuggestion = React.useCallback( - ( - suggestion: MentionSuggestion, - { removeInlineMentions }: { removeInlineMentions: boolean }, - ): string | null => { - const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (!audienceScope || !pubkey || !suggestion.isAgent) return null; - - const { text, cursor } = richText.getPlainTextAndCursor(); - const matchingDisplayNames = removeInlineMentions - ? mentions - .getDraftMentionRefs(text) - .filter((ref) => normalizePubkey(ref.pubkey) === pubkey) - .map((ref) => ref.displayName) - : []; - mentions.cancelMentionAutocomplete(); - for (const edit of buildMentionRemovalEdits( - text, - matchingDisplayNames, - mentions.mentionStartIndex, - cursor, - )) { - applyAutocompleteEdit(edit); + const trackMentionAddressedAgent = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (audienceScope && normalized) { + visibleAgentMentionPubkeysRef.current.add(normalized); } - return pubkey; + }, + [audienceScope], + ); + const syncAddressedAgentsFromText = React.useCallback( + (text: string) => { + if (!audienceScope) return; + const presentAgentPubkeys = new Set( + mentions + .getDraftMentionRefs(text) + .filter((ref) => ref.isAgent) + .map((ref) => normalizePubkey(ref.pubkey)), + ); + for (const pubkey of visibleAgentMentionPubkeysRef.current) { + if ( + !presentAgentPubkeys.has(pubkey) && + lockedAgentPubkeys.has(pubkey) + ) { + audience.removePubkey(pubkey); + } + } + visibleAgentMentionPubkeysRef.current = presentAgentPubkeys; }, [ - applyAutocompleteEdit, + audience.removePubkey, audienceScope, - mentions.cancelMentionAutocomplete, + lockedAgentPubkeys, mentions.getDraftMentionRefs, - mentions.mentionStartIndex, - richText.getPlainTextAndCursor, ], ); + const removeAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); @@ -148,24 +160,63 @@ export function useAgentAddressLockPicker({ }, [audience.removePubkey, audienceScope], ); + const removeAddressedAgentMentions = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!audienceScope || !normalized) return; + const { text } = richText.getPlainTextAndCursor(); + const matchingDisplayNames = mentions + .getDraftMentionRefs(text) + .filter((ref) => normalizePubkey(ref.pubkey) === normalized) + .map((ref) => ref.displayName); + for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) { + applyAutocompleteEdit(edit); + } + removeAddressedAgent(normalized); + }, + [ + applyAutocompleteEdit, + audienceScope, + mentions.getDraftMentionRefs, + removeAddressedAgent, + richText.getPlainTextAndCursor, + ], + ); const toggleAlwaysAddressAgent = React.useCallback( (suggestion: MentionSuggestion) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); if (!audienceScope || !pubkey || !suggestion.isAgent) return; if (lockedAgentPubkeys.has(pubkey)) { - removeAddressedAgent(pubkey); + removeAddressedAgentMentions(pubkey); setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); } else { unpinnedAgentPubkeysRef.current.delete(pubkey); - audience.addPubkey(pubkey); - onPulseAddressLock(pubkey); + mentions.registerMentionPubkey(suggestion.displayName, pubkey, { + isAgent: true, + }); + const { text } = richText.getPlainTextAndCursor(); + if (getMentionOffsets(text, suggestion.displayName).length === 0) { + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: `@${suggestion.displayName} `, + preserveSelection: true, + }); + } + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); } - if (mentions.isMentionOpen) { + if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) { const { text, cursor } = richText.getPlainTextAndCursor(); const activeMention = detectPrefixQuery("@", text, cursor, [ suggestion.displayName.toLowerCase(), @@ -190,12 +241,16 @@ export function useAgentAddressLockPicker({ audience.addPubkey, audienceScope, lockedAgentPubkeys, + mentions.isInlineMentionSelection, mentions.isMentionOpen, mentions.mentionStartIndex, mentions.openMentionPicker, + mentions.registerMentionPubkey, + onAddressAgentMention, onPulseAddressLock, - removeAddressedAgent, + removeAddressedAgentMentions, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, ], ); @@ -209,15 +264,25 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.has(pubkey); if (mentions.isInlineMentionSelection() || wasUnpinned) { applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey); + trackMentionAddressedAgent(pubkey); + onAutoPinAgentMention?.(suggestion); return; } - consumeAddressSuggestion(suggestion, { removeInlineMentions: false }); + applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); if (!lockedAgentPubkeys.has(pubkey)) { - audience.addPubkey(pubkey); + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); + } else { + onPulseAddressLock(pubkey); } - onPulseAddressLock(pubkey); return; } @@ -228,12 +293,84 @@ export function useAgentAddressLockPicker({ applyAutocompleteEdit, audience.addPubkey, audienceScope, - consumeAddressSuggestion, lockedAgentPubkeys, mentions.isInlineMentionSelection, mentions.insertMention, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, + ], + ); + + const restoreAddressedAgentMentions = React.useCallback( + ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys: readonly string[] = [], + ) => { + const restorePubkeys = pubkeys + ? new Set(pubkeys.map(normalizePubkey)) + : null; + const allowedUnpinned = new Set( + allowedUnpinnedPubkeys.map(normalizePubkey), + ); + const currentAudiencePubkeys = new Set( + audience.pubkeys.map(normalizePubkey), + ); + const targetAgents = [...(restorePubkeys ?? currentAudiencePubkeys)] + .filter( + (pubkey) => + currentAudiencePubkeys.has(pubkey) || allowedUnpinned.has(pubkey), + ) + .map((pubkey) => { + const profile = profiles?.[pubkey]; + const displayName = + profile?.displayName?.trim() || + profile?.name?.trim() || + profile?.nip05Handle?.trim() || + mentions.getMentionDisplayName(pubkey)?.trim() || + lockedAgentNamesRef.current.get(pubkey) || + truncatePubkey(pubkey); + return { pubkey, displayName }; + }); + const { text } = richText.getPlainTextAndCursor(); + for (const agent of targetAgents) { + if (getMentionOffsets(text, agent.displayName).length > 0) { + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + } + const missingAgents = targetAgents.filter( + (agent) => + (!unpinnedAgentPubkeysRef.current.has(agent.pubkey) || + allowedUnpinned.has(agent.pubkey)) && + getMentionOffsets(text, agent.displayName).length === 0, + ); + if (missingAgents.length === 0) return text; + for (const agent of missingAgents) { + mentions.registerMentionPubkey(agent.displayName, agent.pubkey, { + isAgent: true, + }); + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + const insertedText = `${missingAgents + .map((agent) => `@${agent.displayName}`) + .join(" ")} `; + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: insertedText, + preserveSelection: true, + }); + return `${insertedText}${text}`; + }, + [ + applyAutocompleteEdit, + audience.pubkeys, + mentions.getMentionDisplayName, + mentions.registerMentionPubkey, + profiles, + richText.getPlainTextAndCursor, ], ); @@ -242,7 +379,10 @@ export function useAgentAddressLockPicker({ lockedAgents, lockedAgentPubkeys, removeAddressedAgent, + restoreAddressedAgentMentions, selectMentionSuggestion, + syncAddressedAgentsFromText, toggleAlwaysAddressAgent, + trackMentionAddressedAgent, }; } diff --git a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts index 8fd253df22..ed92e7b3f0 100644 --- a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts +++ b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts @@ -6,21 +6,30 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; export function useAlwaysAddressShortcut({ enabled, + lockedAgent, mentions, onOpenPicker, + onSelect, onToggle, }: { enabled: boolean; + lockedAgent?: Pick; mentions: UseMentionsResult; onOpenPicker: (insertTrigger?: boolean) => void; + onSelect: (suggestion: MentionSuggestion) => void; onToggle: (suggestion: MentionSuggestion) => void; }) { - const { isMentionOpen, mentionSelectedIndex, suggestions } = mentions; + const { + getDefaultAgentSuggestion, + isMentionOpen, + mentionSelectedIndex, + suggestions, + } = mentions; return React.useCallback( (event: React.KeyboardEvent): boolean => { if ( !enabled || - event.key !== "Enter" || + event.code !== "KeyM" || !hasPrimaryShortcutModifier(event) || event.altKey || !event.shiftKey @@ -30,21 +39,30 @@ export function useAlwaysAddressShortcut({ event.preventDefault(); if (event.repeat) return true; - if (!isMentionOpen) { - onOpenPicker(false); + const suggestion = isMentionOpen + ? suggestions[mentionSelectedIndex] + : lockedAgent + ? { ...lockedAgent, isAgent: true } + : getDefaultAgentSuggestion(); + if (!suggestion?.isAgent || !suggestion.pubkey) { + if (!isMentionOpen) onOpenPicker(false); return true; } - - const suggestion = suggestions[mentionSelectedIndex]; - if (!suggestion?.isAgent || !suggestion.pubkey) return true; - onToggle(suggestion); + if (isMentionOpen) { + onSelect(suggestion); + } else { + onToggle(suggestion); + } return true; }, [ enabled, + getDefaultAgentSuggestion, isMentionOpen, + lockedAgent, mentionSelectedIndex, onOpenPicker, + onSelect, onToggle, suggestions, ], diff --git a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts index 44b87c9f1d..d0557567fd 100644 --- a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts +++ b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts @@ -1,36 +1,62 @@ import * as React from "react"; -import { toast } from "sonner"; import { + getPersistentAgentAudienceRevision, promotePersistentAgentAudienceIfUnchanged, removePersistentAgentAudienceMembersIfUnchanged, } from "@/features/messages/lib/persistentAgentAudience"; import { normalizePubkey } from "@/shared/lib/pubkey"; +const CONFIRMATION_DURATION_MS = 4_000; + +type Confirmation = { + expectedRevision: number; + pubkeys: readonly string[]; + scope: string; + title: string; +}; + type Options = { audienceScope: string | null; enabled: boolean; getDisplayName: (pubkey: string) => string | null | undefined; - onOpenOptions: () => void; onPulse: (pubkey: string) => void; + onTurnOff: () => void; }; export function useAutoPinMentionedAgents({ audienceScope, enabled, getDisplayName, - onOpenOptions, onPulse, + onTurnOff, }: Options) { - return React.useCallback( + const [confirmation, setConfirmation] = React.useState( + null, + ); + + React.useEffect(() => { + if (!confirmation) return; + const timeout = window.setTimeout( + () => setConfirmation(null), + CONFIRMATION_DURATION_MS, + ); + return () => window.clearTimeout(timeout); + }, [confirmation]); + + const promoteAgents = React.useCallback( ({ - expectedRevision, + expectedRevision = audienceScope + ? getPersistentAgentAudienceRevision(audienceScope) + : 0, pubkeys, + requirePreference, }: { - expectedRevision: number; + expectedRevision?: number; pubkeys: readonly string[]; + requirePreference: boolean; }) => { - if (!audienceScope || !enabled) return; + if (!audienceScope || (requirePreference && !enabled)) return; const normalizedPubkeys = [ ...new Set(pubkeys.map(normalizePubkey)), ].filter(Boolean); @@ -52,23 +78,47 @@ export function useAutoPinMentionedAgents({ : promotedPubkeys.length === 1 ? "Agent will be mentioned automatically" : `${promotedPubkeys.length} agents will be mentioned automatically`; - toast.success(title, { - action: { - label: "Undo", - onClick: () => { - if ( - removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: revision, - pubkeys: promotedPubkeys, - scope: audienceScope, - }) - ) { - onOpenOptions(); - } - }, - }, + setConfirmation({ + expectedRevision: revision, + pubkeys: promotedPubkeys, + scope: audienceScope, + title, }); }, - [audienceScope, enabled, getDisplayName, onOpenOptions, onPulse], + [audienceScope, enabled, getDisplayName, onPulse], ); + const promoteMentionedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: true }), + [promoteAgents], + ); + const promoteExplicitlyAddressedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: false }), + [promoteAgents], + ); + + const dismissConfirmation = React.useCallback( + () => setConfirmation(null), + [], + ); + const turnOffConfirmation = React.useCallback(() => { + if (!confirmation) return; + setConfirmation(null); + removePersistentAgentAudienceMembersIfUnchanged({ + expectedRevision: confirmation.expectedRevision, + pubkeys: confirmation.pubkeys, + scope: confirmation.scope, + }); + onTurnOff(); + }, [confirmation, onTurnOff]); + + return { + confirmationTitle: + confirmation?.scope === audienceScope ? confirmation.title : null, + dismissConfirmation, + promoteExplicitlyAddressedAgents, + promoteMentionedAgents, + turnOffConfirmation, + }; } diff --git a/desktop/src/features/messages/ui/useComposerPasteHandler.ts b/desktop/src/features/messages/ui/useComposerPasteHandler.ts new file mode 100644 index 0000000000..8e56da071a --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerPasteHandler.ts @@ -0,0 +1,73 @@ +import * as React from "react"; +import type { Editor } from "@tiptap/react"; +import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { + hasMentionClipboardHtml, + normalizeMentionClipboardHtml, +} from "@/features/messages/lib/normalizeMentionClipboard"; +import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; + +export function useComposerPasteHandler(options: { + editor: Editor | null; + scrollToBottom: () => void; + setPendingImeta: ( + update: (current: BlobDescriptor[]) => BlobDescriptor[], + ) => void; + uploadFile: (file: File) => Promise; +}) { + const uploadFileRef = React.useRef(options.uploadFile); + uploadFileRef.current = options.uploadFile; + React.useEffect(() => { + const editor = options.editor; + if (!editor) return; + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + handlePaste: (view, event) => { + const mediaItem = Array.from(event.clipboardData?.items ?? []).find( + (item) => item.kind === "file", + ); + if (mediaItem) { + const file = mediaItem.getAsFile(); + if (file) void uploadFileRef.current(file); + return true; + } + const codeBlockText = getBuzzCodeBlockClipboardText( + event.clipboardData, + ); + if (codeBlockText !== null) { + event.preventDefault(); + editor + .chain() + .focus() + .insertContent([ + { + type: "codeBlock", + content: + codeBlockText.length > 0 + ? [{ type: "text", text: codeBlockText }] + : [], + }, + { type: "paragraph" }, + ]) + .run(); + options.scrollToBottom(); + return true; + } + if (handleAgentSnapshotPaste(event, options.setPendingImeta)) + return true; + const html = event.clipboardData?.getData("text/html"); + if (html && hasMentionClipboardHtml(html)) { + event.preventDefault(); + view.pasteHTML(normalizeMentionClipboardHtml(html)); + return true; + } + if ((event.clipboardData?.getData("text/plain") ?? "").includes("\n")) + options.scrollToBottom(); + return false; + }, + }, + }); + }, [options.editor, options.scrollToBottom, options.setPendingImeta]); +} diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 14dae33adb..694bf6a2a5 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -2,9 +2,10 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { - DraftMentionRef, - DraftState, +import { + getDraftStoreScope, + type DraftMentionRef, + type DraftState, } from "@/features/messages/lib/useDrafts"; type UseDraftPersistLifecycleParams = { @@ -60,6 +61,21 @@ type UseDraftPersistLifecycleParams = { syncComposerContentFromEditor: () => string; }; +type UseDraftPersistLifecycleResult = { + /** + * Record the latest authored editor content. Empty content is persisted + * immediately and remains authoritative across composer remounts until a + * later non-empty editor update supersedes it. + */ + trackAuthoredContent: (content: string) => void; +}; + +const authoritativelyClearedDraftKeys = new Set(); + +function scopedDraftKey(draftKey: string): string { + return `${getDraftStoreScope()}:${draftKey}`; +} + /** * Owns the draft-persist lifecycle for `MessageComposer`. * @@ -104,8 +120,10 @@ export function useDraftPersistLifecycle({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, -}: UseDraftPersistLifecycleParams): void { +}: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult { const pendingImetaForPersistRef = React.useRef([]); + const emptyContentIsAuthoritativeRef = React.useRef(false); + const isRestoringContentRef = React.useRef(false); const restoredQueuedAttachmentsRef = React.useRef( [], ); @@ -117,7 +135,7 @@ export function useDraftPersistLifecycle({ pendingImetaForPersistRef.current = livePendingImeta; // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger - React.useEffect(() => { + React.useLayoutEffect(() => { // The outgoing draft is persisted by the cleanup below, which runs before // this body on key changes and has the correct outgoing channelId in its // closure. Do NOT re-persist prevKey here: channelId in this render @@ -134,10 +152,21 @@ export function useDraftPersistLifecycle({ : []; } restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current); + const authoritativeDraftKey = effectiveDraftKey + ? scopedDraftKey(effectiveDraftKey) + : null; + const wasAuthoritativelyCleared = authoritativeDraftKey + ? authoritativelyClearedDraftKeys.has(authoritativeDraftKey) + : false; const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; + emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared; + isRestoringContentRef.current = true; if (saved) { - setContent(saved.content); - restoreMentionRefs(saved.mentionRefs ?? []); + const restoredContent = wasAuthoritativelyCleared ? "" : saved.content; + setContent(restoredContent); + restoreMentionRefs( + wasAuthoritativelyCleared ? [] : (saved.mentionRefs ?? []), + ); // Set the persist-snapshot ref SYNCHRONOUSLY before calling the async // state setter, so the cleanup closure (which may fire before the state // update commits in React StrictMode's simulate-unmount pass) reads the @@ -153,6 +182,7 @@ export function useDraftPersistLifecycle({ setPendingImeta([]); setSpoileredAttachmentUrls(new Set()); } + isRestoringContentRef.current = false; return () => { if (effectiveDraftKey) { @@ -160,7 +190,9 @@ export function useDraftPersistLifecycle({ if (queuedAttachments.length > 0) { saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); } - const content = syncComposerContentFromEditor(); + const content = emptyContentIsAuthoritativeRef.current + ? "" + : syncComposerContentFromEditor(); persistDraft( effectiveDraftKey, content, @@ -172,4 +204,29 @@ export function useDraftPersistLifecycle({ } }; }, [effectiveDraftKey]); + + const trackAuthoredContent = React.useCallback( + (content: string) => { + if (!effectiveDraftKey || isRestoringContentRef.current) return; + const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey); + if (content.length > 0) { + authoritativelyClearedDraftKeys.delete(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = false; + return; + } + authoritativelyClearedDraftKeys.add(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = true; + persistDraft( + effectiveDraftKey, + content, + channelId ?? effectiveDraftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + [], + ); + }, + [channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef], + ); + + return { trackAuthoredContent }; } diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index 8176bb13bb..ab7d8c2f4d 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -13,7 +13,6 @@ export { MENTION_REFERENCE_TAG }; export type PendingNonMemberMentionSend = { addressedAgentPubkeys: string[]; - audienceRevision: number; inlineAgentMentionPubkeys: string[]; capturedChannelId: string | null; capturedThreadContext: { @@ -38,7 +37,6 @@ export type PendingNonMemberMentionSend = { export type SendMessageWithMentionFlowInput = { addressedAgentPubkeys?: readonly string[]; - audienceRevision?: number; capturedChannelId: string | null; capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; pendingImeta: ImetaMedia[]; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 601c3b4113..0a4d89c049 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -17,21 +17,14 @@ import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgent import { prepareBackgroundMediaUpload, saveQueuedAttachmentsForDraft, - type QueuedMediaAttachment, } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; -import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { buildOutgoingMessage, type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; -import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; -import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; +import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { @@ -47,46 +40,8 @@ import { uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; -type UseMentionSendFlowOptions = { - channelId: string | null; - channelLinks: Pick; - channelType: ChannelType | null; - contentRef: React.MutableRefObject; - customEmoji: CustomEmoji[]; - drafts: Pick; - emojiAutocomplete: Pick; - mentions: UseMentionsResult; - onPrepareSendChannel?: (pubkeys?: string[]) => Promise; - onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; - onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; - onInlineAgentMentionsSent?: (promotion: { - expectedRevision: number; - pubkeys: readonly string[]; - }) => void; - onSendRef: React.MutableRefObject< - ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - channelId?: string | null, - threadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null, - forceRest?: boolean, - ) => Promise - >; - richText: Pick; - setContent: (content: string) => void; - setIsEmojiPickerOpen: React.Dispatch>; - setPendingImeta: (pendingImeta: ImetaMedia[]) => void; - hasUnsavedMedia: () => boolean; - clearQueuedAttachments: () => void; - restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; - setSpoileredAttachmentUrls?: React.Dispatch< - React.SetStateAction> - >; -}; +import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; + export function useMentionSendFlow({ channelId, channelLinks, @@ -97,9 +52,9 @@ export function useMentionSendFlow({ emojiAutocomplete, mentions, onPrepareSendChannel, - onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onSendRef, richText, setContent, @@ -406,6 +361,7 @@ export function useMentionSendFlow({ ); }; let composerCleared = false; + let optimisticComposerContent = ""; const restoreComposerAfterFailure = () => { if (!composerCleared) return; composerCleared = false; @@ -422,7 +378,7 @@ export function useMentionSendFlow({ } const canRestoreCurrentComposer = canAnimateCurrentComposer && - contentRef.current.trim().length === 0 && + contentRef.current.trim() === optimisticComposerContent.trim() && !hasUnsavedMedia(); if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { saveQueuedAttachmentsForDraft( @@ -447,10 +403,13 @@ export function useMentionSendFlow({ draft.capturedChannelId === channelIdRef.current || channelIdRef.current === null ) { - if (draft.addressedAgentPubkeys.length > 0) { - onAddressedAgentsSendStarted?.(draft.addressedAgentPubkeys); - } clearComposer(); + if (draft.addressedAgentPubkeys.length > 0) { + optimisticComposerContent = + onAddressedAgentsComposerCleared?.(draft.addressedAgentPubkeys) ?? + ""; + contentRef.current = optimisticComposerContent; + } composerCleared = true; } let uploadStarted = false; @@ -587,12 +546,23 @@ export function useMentionSendFlow({ const sentMentionPubkeys = new Set( revalidatedMentionPubkeys.map(normalizePubkey), ); - onInlineAgentMentionsSent?.({ - expectedRevision: draft.audienceRevision, - pubkeys: draft.inlineAgentMentionPubkeys.filter((pubkey) => - sentMentionPubkeys.has(normalizePubkey(pubkey)), - ), - }); + const newlyPinnedPubkeys = draft.inlineAgentMentionPubkeys.filter( + (pubkey) => sentMentionPubkeys.has(normalizePubkey(pubkey)), + ); + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + onAddressedAgentsSendSucceeded?.( + [ + ...new Set([ + ...draft.addressedAgentPubkeys, + ...newlyPinnedPubkeys, + ]), + ], + newlyPinnedPubkeys, + ); + } if (draft.sentDraftKey) { drafts.markDraftSent( draft.sentDraftKey, @@ -604,12 +574,18 @@ export function useMentionSendFlow({ } }; if (preparedUpload) { + let settleUpload!: () => void; + const uploadSettled = new Promise((resolve) => { + settleUpload = resolve; + }); uploadStarted = preparedUpload.start({ onComplete: async (uploaded, signal) => { try { await finishSend(uploaded, signal); } catch { restoreComposerAfterFailure(); + } finally { + settleUpload(); } }, onError: (error) => { @@ -617,14 +593,18 @@ export function useMentionSendFlow({ toast.error( `Upload failed: ${getErrorMessage(error, "Unknown error")}`, ); + settleUpload(); }, onCancel: () => { restoreComposerAfterFailure(); + settleUpload(); }, }); if (!uploadStarted) { + settleUpload(); return restoreComposerAfterFailure(); } + await uploadSettled; } if (!preparedUpload) { try { @@ -656,9 +636,9 @@ export function useMentionSendFlow({ getManagedAgentsByPubkey, mentions.isAgentPubkey, mentions.revalidateMentionPubkeys, - onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, richText.setContent, @@ -674,7 +654,6 @@ export function useMentionSendFlow({ const sendMessageWithMentionFlow = React.useCallback( async ({ addressedAgentPubkeys = [], - audienceRevision = 0, capturedChannelId, capturedThreadContext = null, pendingImeta, @@ -783,7 +762,6 @@ export function useMentionSendFlow({ const savedMentionRefs = mentions.getDraftMentionRefs(trimmed); const pendingDraft: PendingNonMemberMentionSend = { addressedAgentPubkeys: uniqueNormalizedPubkeys(addressedAgentPubkeys), - audienceRevision, inlineAgentMentionPubkeys: uniqueNormalizedPubkeys( savedMentionRefs .filter((ref) => ref.isAgent) diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts new file mode 100644 index 0000000000..fe16ba2f63 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -0,0 +1,51 @@ +import type * as React from "react"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import type { ChannelType } from "@/shared/api/types"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; +import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; +import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; + +export type UseMentionSendFlowOptions = { + channelId: string | null; + channelLinks: Pick; + channelType: ChannelType | null; + contentRef: React.MutableRefObject; + customEmoji: CustomEmoji[]; + drafts: Pick; + emojiAutocomplete: Pick; + mentions: UseMentionsResult; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onAddressedAgentsComposerCleared?: (pubkeys: readonly string[]) => string; + onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; + onAddressedAgentsSendSucceeded?: ( + pubkeys: readonly string[], + newlyPinnedPubkeys: readonly string[], + ) => void; + onSendRef: React.MutableRefObject< + ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, + ) => Promise + >; + richText: Pick; + setContent: (content: string) => void; + setIsEmojiPickerOpen: React.Dispatch>; + setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; + setSpoileredAttachmentUrls?: React.Dispatch< + React.SetStateAction> + >; +}; diff --git a/desktop/src/features/search/lib/searchMatch.test.mjs b/desktop/src/features/search/lib/searchMatch.test.mjs new file mode 100644 index 0000000000..4f9651a75d --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildSearchResultPreview, splitSearchMatches } from "./searchMatch.ts"; + +test("splitSearchMatches highlights every case-insensitive lexeme match", () => { + assert.deepEqual(splitSearchMatches("Mentions and mentions", "mentions"), [ + { isMatch: true, key: "0-8", text: "Mentions" }, + { isMatch: false, key: "8-5", text: " and " }, + { isMatch: true, key: "13-8", text: "mentions" }, + ]); +}); + +test("splitSearchMatches normalizes punctuation into search lexemes", () => { + assert.deepEqual(splitSearchMatches("foo bar release", "foo-bar"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "bar" }, + { isMatch: false, key: "7-8", text: " release" }, + ]); +}); + +test("splitSearchMatches keeps completed tokens on lexeme boundaries", () => { + assert.deepEqual( + splitSearchMatches("projectile notes about project planning", "project pl"), + [ + { + isMatch: false, + key: "0-23", + text: "projectile notes about ", + }, + { isMatch: true, key: "23-7", text: "project" }, + { isMatch: false, key: "30-1", text: " " }, + { isMatch: true, key: "31-2", text: "pl" }, + { isMatch: false, key: "33-6", text: "anning" }, + ], + ); +}); + +test("splitSearchMatches preserves exact and prefix modes for a repeated term", () => { + assert.deepEqual(splitSearchMatches("foo foobar", "foo foo"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "foo" }, + { isMatch: false, key: "7-3", text: "bar" }, + ]); +}); + +test("splitSearchMatches highlights non-adjacent prefix-search terms", () => { + assert.deepEqual(splitSearchMatches("agent status mentions", "agent ment"), [ + { isMatch: true, key: "0-5", text: "agent" }, + { isMatch: false, key: "5-8", text: " status " }, + { isMatch: true, key: "13-4", text: "ment" }, + { isMatch: false, key: "17-4", text: "ions" }, + ]); +}); + +test("splitSearchMatches keeps one-character prefixes on lexeme boundaries", () => { + assert.deepEqual(splitSearchMatches("A plan", "a"), [ + { isMatch: true, key: "0-1", text: "A" }, + { isMatch: false, key: "1-5", text: " plan" }, + ]); +}); + +test("splitSearchMatches maps expanding lowercase prefixes to original spans", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "İs"), [ + { isMatch: true, key: "0-2", text: "İs" }, + { isMatch: false, key: "2-14", text: "tanbul release" }, + ]); + assert.deepEqual(splitSearchMatches("İstanbul release", "İst"), [ + { isMatch: true, key: "0-3", text: "İst" }, + { isMatch: false, key: "3-13", text: "anbul release" }, + ]); +}); + +test("splitSearchMatches does not split a character whose lowercase form expands", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "i"), [ + { isMatch: true, key: "0-1", text: "İ" }, + { isMatch: false, key: "1-15", text: "stanbul release" }, + ]); +}); + +test("splitSearchMatches preserves UTF-16 boundaries for supplementary letters", () => { + assert.deepEqual(splitSearchMatches("𐐀İstanbul release", "𐐨İs"), [ + { isMatch: true, key: "0-4", text: "𐐀İs" }, + { isMatch: false, key: "4-14", text: "tanbul release" }, + ]); +}); + +test("buildSearchResultPreview keeps a late match visible", () => { + const content = `${"prefix ".repeat(30)}mentions appear here ${"suffix ".repeat(20)}`; + const preview = buildSearchResultPreview(content, "mentions", 96); + + assert.equal(preview.length <= 96, true); + assert.match(preview, /mentions/i); + assert.match(preview, /^\.\.\./); + assert.match(preview, /\.\.\.$/); +}); + +test("buildSearchResultPreview ignores an invalid completed-token substring", () => { + const content = `${"projectile filler ".repeat(20)}project planning release notes`; + const preview = buildSearchResultPreview(content, "project pl", 80); + + assert.match(preview, /project planning/); + assert.match(preview, /^\.\.\./); +}); + +test("buildSearchResultPreview keeps the existing leading excerpt without a match", () => { + assert.equal( + buildSearchResultPreview("abcdefghijklmnopqrstuvwxyz", "missing", 10), + "abcdefg...", + ); +}); diff --git a/desktop/src/features/search/lib/searchMatch.ts b/desktop/src/features/search/lib/searchMatch.ts new file mode 100644 index 0000000000..f74d1a2678 --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.ts @@ -0,0 +1,201 @@ +export type SearchMatchPart = { + isMatch: boolean; + key: string; + text: string; +}; + +type SearchHighlightTerm = { + isPrefix: boolean; + value: string; +}; + +type TextLexeme = { + end: number; + normalized: string; + start: number; +}; + +// PostgreSQL's `simple` text-search configuration breaks ordinary punctuation +// into lexemes (for example, `foo-bar` contributes `foo` and `bar`). Keep the +// desktop highlighter on those lexical boundaries rather than treating raw +// whitespace tokens as unrestricted substrings. +const LEXEME_PATTERN = /[\p{L}\p{N}]+/gu; + +function extractLexemes(value: string): string[] { + return Array.from(value.matchAll(LEXEME_PATTERN), (match) => + match[0].toLowerCase(), + ); +} + +function getSearchHighlightMatchers(query: string): SearchHighlightTerm[] { + const rawTokens = query.trim().split(/\s+/).filter(Boolean); + const matchers: SearchHighlightTerm[] = []; + + rawTokens.forEach((rawToken, tokenIndex) => { + const isPrefix = tokenIndex === rawTokens.length - 1; + for (const value of extractLexemes(rawToken)) { + matchers.push({ isPrefix, value }); + } + }); + + // Deduplicate repeated constraints without collapsing exact and prefix modes: + // `foo foo` asks Postgres for both an exact `foo` and a `foo:*` lexeme. + const deduped = new Map(); + for (const matcher of matchers) { + deduped.set( + `${matcher.isPrefix ? "prefix" : "exact"}:${matcher.value}`, + matcher, + ); + } + return [...deduped.values()].sort( + (left, right) => right.value.length - left.value.length, + ); +} + +/** + * Lexemes used by desktop prefix search after punctuation normalization. + * Completed whitespace-delimited tokens match exactly; only lexemes from the + * trailing token match prefixes. + */ +export function getSearchHighlightTerms(query: string): string[] { + return getSearchHighlightMatchers(query).map((matcher) => matcher.value); +} + +function getTextLexemes(text: string): TextLexeme[] { + return Array.from(text.matchAll(LEXEME_PATTERN), (match) => ({ + end: (match.index ?? 0) + match[0].length, + normalized: match[0].toLowerCase(), + start: match.index ?? 0, + })); +} + +function getOriginalPrefixLength( + original: string, + normalizedLength: number, +): number { + let normalizedOffset = 0; + let originalOffset = 0; + + for (const character of original) { + normalizedOffset += character.toLowerCase().length; + originalOffset += character.length; + if (normalizedOffset >= normalizedLength) { + return originalOffset; + } + } + + return original.length; +} + +function getMatchSpans( + text: string, + query: string, +): Array<{ end: number; start: number }> { + const matchers = getSearchHighlightMatchers(query); + if (matchers.length === 0) { + return []; + } + + const spans: Array<{ end: number; start: number }> = []; + for (const lexeme of getTextLexemes(text)) { + const exactMatch = matchers.find( + (matcher) => !matcher.isPrefix && matcher.value === lexeme.normalized, + ); + if (exactMatch) { + spans.push({ start: lexeme.start, end: lexeme.end }); + continue; + } + + const prefixMatch = matchers.find( + (matcher) => + matcher.isPrefix && lexeme.normalized.startsWith(matcher.value), + ); + if (prefixMatch) { + const originalLexeme = text.slice(lexeme.start, lexeme.end); + spans.push({ + start: lexeme.start, + end: + lexeme.start + + getOriginalPrefixLength(originalLexeme, prefixMatch.value.length), + }); + } + } + + return spans; +} + +/** Split text around case-insensitive lexeme/prefix matches of the query. */ +export function splitSearchMatches( + text: string, + query: string, +): SearchMatchPart[] { + const spans = getMatchSpans(text, query); + if (spans.length === 0) { + return [{ isMatch: false, key: "0", text }]; + } + + const parts: SearchMatchPart[] = []; + let offset = 0; + for (const span of spans) { + if (span.start > offset) { + parts.push({ + isMatch: false, + key: `${offset}-${span.start - offset}`, + text: text.slice(offset, span.start), + }); + } + parts.push({ + isMatch: true, + key: `${span.start}-${span.end - span.start}`, + text: text.slice(span.start, span.end), + }); + offset = span.end; + } + if (offset < text.length) { + parts.push({ + isMatch: false, + key: `${offset}-${text.length - offset}`, + text: text.slice(offset), + }); + } + return parts; +} + +/** + * Build a compact result excerpt that keeps the first matching search term + * visible. Context is biased before the match so the excerpt still reads like + * a sentence while avoiding a match that is clipped offscreen. + */ +export function buildSearchResultPreview( + content: string, + query: string, + maxLength = 96, +): string { + const text = content.trim(); + if (!text) { + return "No message body."; + } + if (text.length <= maxLength) { + return text; + } + + const matchIndex = getMatchSpans(text, query)[0]?.start ?? -1; + if (matchIndex < 0) { + return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; + } + + const contextBefore = Math.min(32, Math.floor(maxLength / 3)); + let start = Math.max(0, matchIndex - contextBefore); + const end = Math.min(text.length, start + maxLength); + + if (end === text.length) { + start = Math.max(0, end - maxLength); + } + + const prefix = start > 0 ? "..." : ""; + const suffix = end < text.length ? "..." : ""; + const available = Math.max(0, maxLength - prefix.length - suffix.length); + const excerpt = text.slice(start, start + available).trim(); + + return `${prefix}${excerpt}${suffix}`; +} diff --git a/desktop/src/features/search/ui/HighlightedSearchText.tsx b/desktop/src/features/search/ui/HighlightedSearchText.tsx new file mode 100644 index 0000000000..51745f8873 --- /dev/null +++ b/desktop/src/features/search/ui/HighlightedSearchText.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import { splitSearchMatches } from "@/features/search/lib/searchMatch"; +import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; + +export function HighlightedSearchText({ + query, + text, +}: { + query: string; + text: string; +}) { + return splitSearchMatches(text, query).map((part) => + part.isMatch ? ( + + {part.text} + + ) : ( + {part.text} + ), + ); +} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 963ae6cf42..70365497bc 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -3,6 +3,8 @@ import * as React from "react"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { getMinimumSearchQueryLength } from "@/features/search/hooks"; +import { parseSearchOperators } from "@/features/search/lib/parseSearchOperators"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; import { useSearchResults } from "@/features/search/useSearchResults"; import { resultIcon, @@ -15,6 +17,7 @@ import { getChannelScopeLabel, SearchDialogInputRow, } from "@/features/search/ui/SearchScopeControls"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -36,7 +39,7 @@ type TopbarSearchProps = { currentChannelId?: string | null; focusRequest?: number; onOpenChannel: (channelId: string) => void; - onOpenResult: (hit: SearchHit) => void; + onOpenResult: (hit: SearchHit, query: string) => void; onOpenUser?: (user: UserSearchResult) => void | Promise; onBrowseChannels?: () => void | Promise; onCreateAgent?: () => void | Promise; @@ -72,19 +75,6 @@ type SearchHitContextLabel = { text: string; }; -function truncateResultText(content: string, maxLength = 96) { - const trimmed = content.trim(); - if (trimmed.length === 0) { - return "No message body."; - } - - if (trimmed.length <= maxLength) { - return trimmed; - } - - return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`; -} - function formatRelativeTime(unixSeconds: number) { const diff = Math.floor(Date.now() / 1_000) - unixSeconds; @@ -436,6 +426,10 @@ export function TopbarSearch({ scopeChannelId, }); const trimmedQuery = query.trim(); + // Bind highlights to the debounced result source so stale results can never + // pair with newly typed text during the debounce window. + const resultQuery = parseSearchOperators(debouncedQuery).text; + const resultsAreCurrent = debouncedQuery === trimmedQuery; const isIconVariant = variant === "icon"; const currentChannel = currentChannelId ? (channelLookup.get(currentChannelId) ?? null) @@ -504,9 +498,10 @@ export function TopbarSearch({ ), [currentPubkeyNormalized, results], ); + const visibleSearchableResults = resultsAreCurrent ? searchableResults : []; const searchResultSections = React.useMemo( - () => groupSearchResults(searchableResults), - [searchableResults], + () => groupSearchResults(visibleSearchableResults), + [visibleSearchableResults], ); const groupedSearchResults = React.useMemo( () => searchResultSections.flatMap((section) => section.results), @@ -516,8 +511,11 @@ export function TopbarSearch({ ? scopeChannel ? [] : suggestionResults - : groupedSearchResults; + : resultsAreCurrent + ? groupedSearchResults + : []; const isSearchLoading = + (!isShowingSuggestions && !resultsAreCurrent) || isWaitingOnFromResolution || searchQuery.isLoading || fuzzyUserCandidatesQuery.isLoading || @@ -581,7 +579,7 @@ export function TopbarSearch({ return; } - onOpenResult(result.hit); + onOpenResult(result.hit, resultQuery); }, [ onBrowseChannels, @@ -592,6 +590,7 @@ export function TopbarSearch({ onOpenUser, openAfterExit, setQuery, + resultQuery, ], ); @@ -697,7 +696,7 @@ export function TopbarSearch({ ? result.action.description : result.kind === "user" ? getUserSecondaryLabel(result.user) - : truncateResultText(result.hit.content); + : buildSearchResultPreview(result.hit.content, resultQuery); const trailingLabel = result.kind === "channel" ? getChannelSuggestionMeta(result.channel) @@ -771,7 +770,7 @@ export function TopbarSearch({ ) : null} {preview ? ( - {preview} + ) : null} @@ -873,12 +872,13 @@ export function TopbarSearch({
) - ) : isSearchLoading && searchableResults.length === 0 ? ( + ) : isSearchLoading && visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}
- ) : searchQuery.error instanceof Error && searchableResults.length === 0 ? ( + ) : searchQuery.error instanceof Error && + visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

- ) : searchableResults.length === 0 ? ( + ) : visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

{ diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 8d626c4593..ab35b8b598 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -88,7 +88,7 @@ export type AppSidebarProps = { onSelectWorkflows: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; /** Full channel set for global search, including channels outside the joined sidebar list. */ searchChannels: Channel[]; searchFocusRequests: readonly [global: number, channel: number]; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 1e0db29cac..a9bf7058eb 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -30,7 +30,7 @@ type AppSidebarPinnedHeaderProps = { onCreateAgent: () => void; onCreateChannel: () => void; onOpenDm: (input: { pubkeys: string[] }) => Promise; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; onSelectChannel: (channelId: string) => void; searchChannels: Channel[]; searchFocusRequest: number; diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 0c99eaf16d..c4c459aeff 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -294,6 +294,14 @@ export function ChannelMenuButton({ (hasSidebarUnreadProjections ? unreadThreadChannelIds.has(channel.id) : hasUnread); + const showsUnreadCount = + !isActive && channel.channelType !== "dm" && unreadCount > 0; + const showsEphemeralBadge = + Boolean(ephemeralDisplay) && + !activeWorking && + !isMuted && + !showsUnreadCount && + !hasThreadUnread; const inactiveContentOpacity = cn( !isActive && !hasTopLevelUnread && !isMuted && "opacity-80", !isActive && @@ -334,7 +342,7 @@ export function ChannelMenuButton({ > {resolvedLabel} - {ephemeralDisplay ? ( + {showsEphemeralBadge && ephemeralDisplay ? ( ) : null} - {!isActive && channel.channelType !== "dm" && unreadCount > 0 ? ( + {showsUnreadCount ? ( { - const trimmed = query.trim(); - if (trimmed.length < 2) return; - - const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "i"); - function walk(nodes: HastNode[]): HastNode[] { const result: HastNode[] = []; for (const node of nodes) { if (isText(node)) { - const parts = node.value.split(pattern); - if (parts.length === 1) { + const parts = splitSearchMatches(node.value, query); + if (!parts.some((part) => part.isMatch)) { result.push(node); continue; } - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (!part) continue; - - if (i % 2 === 1) { - // Odd indices from split-with-capture are always the match. + for (const part of parts) { + if (part.isMatch) { result.push({ type: "element", tagName: "mark", properties: { - className: - "rounded-xs bg-primary/20 text-foreground dark:bg-primary/30", + className: SEARCH_MATCH_HIGHLIGHT_CLASS, + "data-search-match": "true", }, - children: [{ type: "text", value: part }], + children: [{ type: "text", value: part.text }], }); } else { - result.push({ type: "text", value: part }); + result.push({ type: "text", value: part.text }); } } } else if (isElement(node)) { diff --git a/desktop/src/shared/lib/searchHighlightStyle.ts b/desktop/src/shared/lib/searchHighlightStyle.ts new file mode 100644 index 0000000000..0ede60986c --- /dev/null +++ b/desktop/src/shared/lib/searchHighlightStyle.ts @@ -0,0 +1,3 @@ +/** Shared visual treatment for literal search matches. */ +export const SEARCH_MATCH_HIGHLIGHT_CLASS = + "rounded-xs bg-yellow-300/80 text-yellow-950 dark:bg-yellow-300/70 dark:text-yellow-950"; diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css index 781ac8a196..5ebe4b5775 100644 --- a/desktop/src/shared/styles/globals/composer.css +++ b/desktop/src/shared/styles/globals/composer.css @@ -96,6 +96,7 @@ @apply text-message font-normal tracking-normal; outline: none; min-height: 1lh; + padding-block: 0.0625rem; } .rich-text-composer .tiptap p { @@ -260,8 +261,14 @@ caret into their decorated range and swallows the trailing space after an @mention. Composer Buzz links are atom nodes, but their labels may still fragment between characters; only the icon and leading label fragment stay - together. */ + together. The line-height keeps each inline decoration box tall enough to + paint both block-padding edges without clipping. */ .rich-text-composer .tiptap .mention-chip { display: inline; min-height: 0; + line-height: calc( + 1em + + var(--inline-chip-padding-block-start) + + var(--inline-chip-padding-block-end) + ); } diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 9d0c382ac3..251fdb525c 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -1,5 +1,5 @@ .message-markdown { - --inline-chip-padding-block-start: 0.1875rem; + --inline-chip-padding-block-start: 0.125rem; --inline-chip-padding-block-end: 0.125rem; --inline-chip-padding-inline: 0.25rem; --inline-chip-min-height: calc( @@ -101,15 +101,11 @@ } .message-markdown .mention-chip.buzz-link-unavailable, -.message-markdown .mention-chip.buzz-link-unavailable:hover { - background: hsl(var(--secondary)); - color: hsl(var(--secondary-foreground) / 0.7); -} - +.message-markdown .mention-chip.buzz-link-unavailable:hover, .message-markdown .mention-chip.buzz-link-deleted, .message-markdown .mention-chip.buzz-link-deleted:hover { - background: hsl(var(--destructive) / 0.12); - color: hsl(var(--destructive)); + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground) / 0.7); } .message-markdown .mention-chip.inbox-channel-chip { @@ -307,21 +303,6 @@ color: hsl(var(--primary) / 0.9); } -/* @mentions use an opaque highlighter-yellow treatment so they scan - differently from channel links while retaining AA contrast on every theme. - Shared by human and agent chips in both timeline and composer. */ -.message-markdown .mention-chip.inline-chip-icon-human, -.message-markdown .mention-chip.inline-chip-icon-agent { - background: hsl(var(--mention-highlight)); - color: hsl(var(--buzz-content-dark)); -} - -.message-markdown .mention-chip-hover.inline-chip-icon-human:hover, -.message-markdown .mention-chip-hover.inline-chip-icon-agent:hover { - background: hsl(var(--mention-highlight-hover)); - color: hsl(var(--buzz-content-dark)); -} - .message-markdown .mention-prefix-hidden { display: inline-block; width: 0; @@ -336,6 +317,7 @@ } .message-markdown .inline-chip-icon-human::before { + transform: translateY(calc(-50% + 0.0625rem)); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8'/%3E%3C/svg%3E") center / contain no-repeat; diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index 875a17aedf..9b55da2703 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -32,10 +32,6 @@ destructive, these must stay recognizably red with AA label contrast. */ --notification: 348 65% 48%; --notification-foreground: 350 100% 98%; - /* Opaque highlighter colors keep near-black mention text readable on every - message surface instead of depending on the active theme underneath. */ - --mention-highlight: 48 96% 70%; - --mention-highlight-hover: 48 96% 62%; --border: 225 13.56% 76.86%; --input: 225 13.56% 76.86%; --ring: 234 16.02% 35.49%; diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index af3997f815..e9d4b1e8ec 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1,13 +1,7 @@ import * as React from "react"; import { createPortal } from "react-dom"; import type { Components } from "react-markdown"; -import { - ChevronLeft, - ChevronRight, - Download, - ZoomIn, - ZoomOut, -} from "lucide-react"; +import { ChevronLeft, ChevronRight, Download } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; @@ -49,6 +43,8 @@ import { markdownPropsAreEqual, } from "./markdownUtils"; import { ImageMosaic } from "./markdown/ImageMosaic"; +import { ImageGalleryStatus } from "./markdown/ImageGalleryStatus"; +import { ImageLightboxZoomControls } from "./markdown/ImageLightboxZoomControls"; import { CODE_BLOCK_CLASS, extractLanguage, @@ -74,7 +70,6 @@ import { } from "./markdown/MediaContextMenu"; import { isVideoMedia } from "./markdown/mediaEntry"; import { - clampImageLightboxZoom, type ImageGalleryDirection, type ImageGalleryItem, type ImageLightboxBox, @@ -90,13 +85,11 @@ import { IMAGE_LIGHTBOX_GALLERY_EASE, IMAGE_LIGHTBOX_GALLERY_SLIDE_DISTANCE_PX, IMAGE_LIGHTBOX_GALLERY_SLIDE_MS, - IMAGE_LIGHTBOX_MAX_ZOOM, IMAGE_LIGHTBOX_MIN_ZOOM, IMAGE_LIGHTBOX_REDUCED_MOTION_MS, IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS, IMAGE_LIGHTBOX_WHEEL_ZOOM_MAX_DELTA, IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED, - IMAGE_LIGHTBOX_ZOOM_STEP, IMAGE_LIGHTBOX_ZOOM_TRANSITION_MS, imageLightboxBasisBoxForItem, imageLightboxBoxFromRect, @@ -110,6 +103,8 @@ import { imageLightboxTargetBox, imageLightboxTransform, imageLightboxZoomBox, + imageLightboxZoomStateAtPoint, + imageLightboxZoomStateAtZoom, normalizedWheelDeltaY, visibleImageGalleryForTrigger, } from "./markdown/imageLightbox"; @@ -151,12 +146,30 @@ type WebKitGestureLikeEvent = Event & { scale?: number; }; +function copyImageToClipboard(src: string | undefined) { + if (!src) return; + invokeTauri("copy_image_to_clipboard", { url: src }) + .then(() => { + toast.success("Copied to clipboard"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Copy failed"; + toast.error(msg); + }); +} + +function downloadImage(src: string | undefined) { + if (!src) return; + invokeTauri("download_image", { url: src }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Download failed"; + toast.error(msg); + }); +} + function ImageZoomOverlay({ alt, galleryIndex = 0, galleryItems, - onCopy, - onDownload, onClose, resolvedSrc, sourceBox, @@ -167,8 +180,6 @@ function ImageZoomOverlay({ alt: string | undefined; galleryIndex?: number; galleryItems?: ImageGalleryItem[]; - onCopy: (src: string | undefined) => void; - onDownload: (src: string | undefined) => void; onClose: () => void; resolvedSrc: string; sourceBox: ImageLightboxBox; @@ -218,7 +229,10 @@ function ImageZoomOverlay({ const [returnBox, setReturnBox] = React.useState(sourceBox); const [returnCornerRadii, setReturnCornerRadii] = React.useState(sourceCornerRadii); - const [zoom, setZoom] = React.useState(IMAGE_LIGHTBOX_MIN_ZOOM); + const [{ zoom, zoomOffset }, setZoomState] = React.useState(() => ({ + zoom: IMAGE_LIGHTBOX_MIN_ZOOM, + zoomOffset: { x: 0, y: 0 }, + })); const controlPointerDownRef = React.useRef(false); const fadeTimerRef = React.useRef(null); const galleryTransitionTimerRef = React.useRef(null); @@ -269,7 +283,6 @@ function ImageZoomOverlay({ Date.now() + IMAGE_LIGHTBOX_CONTROL_SUPPRESS_CLOSE_MS; }, []); const closeMenu = React.useCallback(() => setMenu(null), []); - const finishZoomGestureSoon = React.useCallback(() => { if (zoomIdleTimerRef.current != null) { window.clearTimeout(zoomIdleTimerRef.current); @@ -280,13 +293,21 @@ function ImageZoomOverlay({ }, IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS); }, []); - const setClampedZoom = React.useCallback((nextZoom: number) => { - setZoom(clampImageLightboxZoom(nextZoom)); - }, []); + const setClampedZoom = React.useCallback( + (nextZoom: number) => + setZoomState((current) => + imageLightboxZoomStateAtZoom(current, nextZoom), + ), + [], + ); - const updateZoom = React.useCallback((updater: (zoom: number) => number) => { - setZoom((currentZoom) => clampImageLightboxZoom(updater(currentZoom))); - }, []); + const updateZoom = React.useCallback( + (updater: (zoom: number) => number) => + setZoomState((current) => + imageLightboxZoomStateAtZoom(current, updater(current.zoom)), + ), + [], + ); const close = React.useCallback(() => { if (closeTimerRef.current != null) return; @@ -351,7 +372,10 @@ function ImageZoomOverlay({ galleryTransitionTimerRef.current = null; }, IMAGE_LIGHTBOX_GALLERY_SLIDE_MS); setIsAdjustingZoom(false); - setZoom(IMAGE_LIGHTBOX_MIN_ZOOM); + setZoomState({ + zoom: IMAGE_LIGHTBOX_MIN_ZOOM, + zoomOffset: { x: 0, y: 0 }, + }); setCurrentIndex(nextIndex); }, [currentIndex, items.length, markControlGesture, prefersReducedMotion], @@ -638,7 +662,7 @@ function ImageZoomOverlay({ const isClosing = phase === "closing"; const isOpen = phase === "open"; const isFading = phase === "fading"; - const displayBox = imageLightboxZoomBox(targetBox, zoom); + const displayBox = imageLightboxZoomBox(targetBox, zoom, zoomOffset); const frameBox = isReturning ? returnBox : targetBox; const frameCornerRadii = isReturning ? returnCornerRadii @@ -677,11 +701,25 @@ function ImageZoomOverlay({ : isFading ? IMAGE_LIGHTBOX_FADE_EXIT_MS : IMAGE_LIGHTBOX_FADE_ENTER_MS; - const zoomFillPercent = - ((zoom - IMAGE_LIGHTBOX_MIN_ZOOM) / - (IMAGE_LIGHTBOX_MAX_ZOOM - IMAGE_LIGHTBOX_MIN_ZOOM)) * - 100; const label = currentItem.alt?.trim() || "Image preview"; + const handleImageClick = React.useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!isOpen || isReturning) { + return; + } + + setIsAdjustingZoom(false); + setZoomState((current) => + imageLightboxZoomStateAtPoint(targetBox, current, { + x: event.clientX, + y: event.clientY, + }), + ); + }, + [isOpen, isReturning, targetBox], + ); const handleImageContextMenu = React.useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -697,13 +735,13 @@ function ImageZoomOverlay({ const handleMenuCopy = React.useCallback(() => { setMenu(null); markControlGesture(); - onCopy(currentItem.src); - }, [currentItem.src, markControlGesture, onCopy]); + copyImageToClipboard(currentItem.src); + }, [currentItem.src, markControlGesture]); const handleMenuDownload = React.useCallback(() => { setMenu(null); markControlGesture(); - onDownload(currentItem.src); - }, [currentItem.src, markControlGesture, onDownload]); + downloadImage(currentItem.src); + }, [currentItem.src, markControlGesture]); return createPortal(

{ if ( - event.target instanceof HTMLElement && + event.target instanceof Element && event.target.closest("[data-image-lightbox-controls]") ) { controlPointerDownRef.current = true; @@ -756,7 +794,8 @@ function ImageZoomOverlay({ tabIndex={-1} >

- Full-size image preview. Press Escape or click to close. + Full-size image preview. Press Escape or click outside the image to + close. Click the image to zoom.

IMAGE_LIGHTBOX_MIN_ZOOM + ? "cursor-zoom-out" + : "cursor-zoom-in", )} custom={galleryDirection} exit="exit" initial="enter" - key={currentItem.resolvedSrc} + key={`${currentIndex}:${currentItem.resolvedSrc}`} src={currentItem.resolvedSrc} transition={{ duration: prefersReducedMotion @@ -843,6 +885,7 @@ function ImageZoomOverlay({ ease: IMAGE_LIGHTBOX_GALLERY_EASE, }} variants={galleryImageVariants} + onClick={handleImageClick} onContextMenuCapture={handleImageContextMenu} /> @@ -910,7 +953,7 @@ function ImageZoomOverlay({ type="button" onClick={(event) => { event.stopPropagation(); - onDownload(currentItem.src); + downloadImage(currentItem.src); }} > @@ -919,39 +962,14 @@ function ImageZoomOverlay({ aria-hidden="true" className="h-5 w-px shrink-0 bg-muted-foreground/15" /> -
{menu && canActOnCurrentImage ? ( @@ -1000,7 +1018,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const triggerRef = React.useRef(null); useSmoothCorners(inlineImageRef); useSmoothCorners(thumbnailImageRef); - const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{ height: number; src: string; @@ -1078,7 +1095,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { return () => observer.disconnect(); }, []); - const closeMenu = React.useCallback(() => setMenu(null), []); useDismissMediaContextMenu(Boolean(menu), closeMenu); @@ -1089,7 +1105,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { e.nativeEvent.stopImmediatePropagation(); setMenu({ x: e.clientX, y: e.clientY }); }; - const openLightbox = React.useCallback( (image: HTMLImageElement) => { if (!resolvedSrc || isInsideHiddenSpoiler(image)) { @@ -1113,6 +1128,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { { alt, dim, + trigger: triggerRef.current, resolvedSrc, src, thumbnailBox: sourceBox, @@ -1140,27 +1156,13 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const handleCopyImage = React.useCallback((copySrc: string | undefined) => { setMenu(null); - if (!copySrc) return; - invokeTauri("copy_image_to_clipboard", { url: copySrc }) - .then(() => { - toast.success("Copied to clipboard"); - }) - .catch((err: unknown) => { - const msg = err instanceof Error ? err.message : "Copy failed"; - toast.error(msg); - }); + copyImageToClipboard(copySrc); }, []); const handleDownload = React.useCallback( (downloadSrc: string | undefined) => { setMenu(null); - if (!downloadSrc) return; - invokeTauri("download_image", { url: downloadSrc }).catch( - (err: unknown) => { - const msg = err instanceof Error ? err.message : "Download failed"; - toast.error(msg); - }, - ); + downloadImage(downloadSrc); }, [], ); @@ -1215,8 +1217,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { alt={alt} galleryIndex={lightboxState.galleryIndex} galleryItems={lightboxState.galleryItems} - onCopy={handleCopyImage} - onDownload={handleDownload} onClose={() => setLightboxState(null)} resolvedSrc={resolvedSrc} sourceBox={lightboxState.sourceBox} diff --git a/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx new file mode 100644 index 0000000000..66e7b68b8b --- /dev/null +++ b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx @@ -0,0 +1,31 @@ +type ImageGalleryStatusProps = { + currentIndex: number; + itemCount: number; +}; + +export function ImageGalleryStatus({ + currentIndex, + itemCount, +}: ImageGalleryStatusProps) { + if (itemCount <= 1) { + return null; + } + + const position = currentIndex + 1; + return ( + <> +