diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 3a090b3ebd7..3db5c6baaaf 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -91,7 +91,7 @@ jobs: echo "enabled=true" echo "tag=${TAG_PREFIX}${VERSION}" if [[ "$TAG_PREFIX" == desktop-v ]]; then - echo "target_sha=${{ github.event.pull_request.merge_commit_sha }}" + echo "target_sha=${{ github.event.pull_request.head.sha }}" echo "desktop=true" else echo "target_sha=$GITHUB_SHA" @@ -112,6 +112,7 @@ jobs: PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + MERGED_AT: ${{ github.event.pull_request.merged_at }} run: | VERSION="${VERSION#desktop-v}" export VERSION @@ -146,7 +147,17 @@ jobs: exit 1 fi fi - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ -f sha="$TARGET_SHA" \ - --silent + --silent; then + # Ref creation is atomic. A concurrent retry may have won the race; + # accept that only when it created the exact immutable ref. + EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG was concurrently created at $TARGET_SHA" + exit 0 + fi + echo "::error::Tag creation failed and $TAG resolves to $EXISTING_SHA (expected $TARGET_SHA)" + exit 1 + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60507182d5c..f894c0e12fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: token: '' @@ -78,6 +78,10 @@ jobs: run: scripts/test-release-ref-contract.sh - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh + - name: OSS desktop promotion contract + run: | + scripts/test-oss-desktop-promotion.sh + scripts/test-oss-desktop-promotion-behavior.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh @@ -317,6 +321,9 @@ jobs: if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' permissions: contents: read + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -341,6 +348,13 @@ jobs: . desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} + # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; + # the bounded PR 5224 trial writes only to its isolated merge-ref scope. + - name: Set up sccache + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. + with: + version: v0.16.0 - name: Install cargo-nextest if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 @@ -348,6 +362,8 @@ jobs: tool: cargo-nextest@0.9.136 - name: Build relay artifacts if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + env: + RUSTC_WRAPPER: sccache run: | cargo build --profile ci -p buzz-relay -p git-credential-nostr cargo nextest archive \ @@ -359,7 +375,9 @@ jobs: --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Save relay artifacts cache - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + # PR-scoped exact-source entries cannot warm main or other PRs and churn + # the shared cache pool. sccache provides read-only PR reuse instead. + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | @@ -768,6 +786,19 @@ jobs: env: RELAY_URL: ws://localhost:3000 GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr + - name: Media read-auth e2e + # Reads require kind:24242 `t=get` auth, so these binaries are the only + # coverage that a real relay rejects bare reads and honours host- and + # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so + # the lane never ran; select it here, where MinIO and the seeded + # 'localhost:3000' community already exist. + # --no-fail-fast: without it cargo stops after the first failing binary, + # so one broken case hides every later binary's result. + run: | + cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 - name: Upload relay logs if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -1023,7 +1054,7 @@ jobs: git log -1 --format=%s | grep -qx smoke echo "Host bash resolved and functional; git commit round-trip passed" - name: Check (Tauri crate) - run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target $env:TARGET env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Test (Tauri crate) diff --git a/.github/workflows/desktop-release-cache-proof.yml b/.github/workflows/desktop-release-cache-proof.yml new file mode 100644 index 00000000000..cf9c8e78275 --- /dev/null +++ b/.github/workflows/desktop-release-cache-proof.yml @@ -0,0 +1,164 @@ +name: Desktop release cache tag-scope proof + +# Dispatch from a cache-proof-* tag at the same trusted-main SHA warmed by all +# four canaries. Every job restores only and requires an exact cache hit. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + macos: + name: Prove macOS ${{ matrix.target }} cache visibility + if: github.repository == 'block/buzz' + runs-on: macos-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + features: mesh-llm + - target: x86_64-apple-darwin + features: default + steps: + - name: Require cache proof tag + run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Patch proof dependency graph + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + env: + CACHE_TARGET: ${{ matrix.target }} + CACHE_FEATURES: ${{ matrix.features }} + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target "$CACHE_TARGET" --features "$CACHE_FEATURES" --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' + + linux: + name: Prove Linux cache visibility + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + timeout-minutes: 15 + defaults: + run: + shell: bash + steps: + - name: Require cache proof tag and install release native tools + run: | + [[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; } + apt-get update + apt-get install -y --no-install-recommends build-essential ca-certificates curl git libasound2-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf pkg-config + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Patch proof dependency graph + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' + + windows: + name: Prove Windows cache visibility + if: github.repository == 'block/buzz' + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Require cache proof tag + shell: bash + run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Patch proof dependency graph + shell: bash + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + shell: bash + run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + shell: bash + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-pc-windows-msvc --features default --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + shell: bash + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml index eddebea6852..61ccc800af3 100644 --- a/.github/workflows/desktop-release-candidate.yml +++ b/.github/workflows/desktop-release-candidate.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: read jobs: validate: @@ -20,6 +21,7 @@ jobs: - name: Validate immutable desktop candidate if: startsWith(github.event.pull_request.head.ref, 'version-bump/') env: + GH_TOKEN: ${{ github.token }} VERSION: ${{ github.event.pull_request.head.ref }} run: | VERSION="${VERSION#version-bump/}" diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index e1625f4ec83..d8b10032b2a 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -7,8 +7,8 @@ name: Linux Canary # Design notes vs. signed-macos-canary.yml: # - fix-appimage.sh is run without signing env vars; the script detects # their absence and skips re-signing, repacking only (documented inline). -# - mold linker added (rui314/setup-mold) to reduce link time, matching -# the Linux Rust CI jobs in ci.yml. +# - Build tools match release.yml; cache keys derive the concrete linker and +# native library identity rather than assuming the moving runner image. # - pnpm store restore/save pattern mirrors ci.yml:149-196. on: workflow_dispatch: @@ -83,18 +83,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to linux-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: linux-canary-release - - name: Install appimagetool run: | case "$(uname -m)" in @@ -154,6 +142,38 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target x86_64-unknown-linux-gnu \ + --features mesh-llm \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config run: | cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' @@ -190,6 +210,24 @@ jobs: fi bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}" + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.github/workflows/macos-intel-canary.yml b/.github/workflows/macos-intel-canary.yml new file mode 100644 index 00000000000..35b05313c9f --- /dev/null +++ b/.github/workflows/macos-intel-canary.yml @@ -0,0 +1,126 @@ +name: macOS Intel Canary + +# Produces an unsigned Intel DMG from trusted main. Its release-equivalent +# Cargo state warms the distinct x86_64 release target without signing or +# publishing anything. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build macOS Intel canary + if: github.repository == 'block/buzz' + runs-on: macos-latest + timeout-minutes: 60 + env: + TARGET: x86_64-apple-darwin + steps: + - name: Require main + env: + SOURCE_REF: ${{ github.ref }} + run: | + if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then + echo "::error::Canary builds must run from main; got $SOURCE_REF" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Add Rust target + run: rustup target add "$TARGET" + + - name: Install desktop dependencies + run: just desktop-install-ci + + - name: Derive and patch canary version + run: | + BASE_VERSION=$(node -p "require('./desktop/package.json').version") + VERSION="${BASE_VERSION%%-*}-intel-test.${GITHUB_RUN_NUMBER}" + cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" + cd src-tauri && cargo update --workspace + + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target "$TARGET" \ + --features default \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + + - name: Generate non-updating bundle config + run: | + cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' + {"bundle":{"createUpdaterArtifacts":false,"macOS":{"minimumSystemVersion":"10.15"}}} + JSON + + - name: Build Intel sidecars + run: | + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" + + - name: Build unsigned Intel DMG + run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --bundles dmg --config src-tauri/tauri.canary.conf.json + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + MACOSX_DEPLOYMENT_TARGET: "10.15" + CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" + TAURI_BUNDLER_DMG_IGNORE_CI: "true" + + - name: Locate fresh Intel DMG + id: artifact + run: | + DMG=$(find "desktop/src-tauri/target/${TARGET}/release/bundle/dmg" -name '*.dmg' -type f | head -1) + [[ -n "$DMG" ]] || { echo "::error::No Intel DMG found"; exit 1; } + echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + + - name: Upload Intel canary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: buzz-macos-intel-canary-${{ github.sha }} + path: ${{ steps.artifact.outputs.dmg }} + if-no-files-found: error + retention-days: 7 + + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} diff --git a/.github/workflows/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml new file mode 100644 index 00000000000..4780083ba49 --- /dev/null +++ b/.github/workflows/mesh-lifecycle.yml @@ -0,0 +1,111 @@ +name: Mesh Lifecycle +# Relay-driven mesh lifecycle smoke: membership → signed discovery notes → +# relay-derived allowlist → join → CPU inference over QUIC → stranger denied +# (relay membership rejection + no routed inference, with a differential +# trusted-inference health proof so a dead serve node can't fake a denial). +# Runs the full Buzz "shared compute" join story with three real mesh-llm +# node processes on one runner, using the Buzz relay as the control plane +# (no hand-carried invite tokens). Mirrors the shape mesh-llm's own CI uses +# for its two-node smokes (tiny CPU model, one runner, real QUIC mesh). + +on: + push: + branches: [main] + paths: + - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-relay/Cargo.toml' + - 'crates/buzz-admin/**' + - 'crates/buzz-test-client/**' + - 'crates/buzz-ws-client/**' + - 'Cargo.lock' + - 'desktop/src-tauri/src/mesh_llm/**' + - 'scripts/ci-mesh-lifecycle-smoke.sh' + - 'scripts/start-relay-for-tests.sh' + - '.github/workflows/mesh-lifecycle.yml' + pull_request: + paths: + - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-relay/Cargo.toml' + - 'crates/buzz-admin/**' + - 'crates/buzz-test-client/**' + - 'crates/buzz-ws-client/**' + - 'Cargo.lock' + - 'desktop/src-tauri/src/mesh_llm/**' + - 'scripts/ci-mesh-lifecycle-smoke.sh' + - 'scripts/start-relay-for-tests.sh' + - '.github/workflows/mesh-lifecycle.yml' + workflow_dispatch: + +concurrency: + group: mesh-lifecycle-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + lifecycle-smoke: + name: Relay-Driven Mesh Lifecycle Smoke + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + save-if: ${{ github.event_name != 'pull_request' }} + + # The mesh-llm SDK downloads a signed native runtime (llama.cpp CPU + # build) on first init, and the serve node downloads the smoke model + # from HuggingFace on first run. Key on the lockfile so a mesh pin bump + # rolls the runtime cache; the model ref is stable. + - name: Restore mesh runtime + model caches + id: mesh-caches + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cache/mesh-llm/native-runtimes + ~/.cache/huggingface/hub + key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }} + restore-keys: | + mesh-lifecycle-${{ runner.os }}-smollm2-135m- + + - name: Start integration services + run: | + for attempt in 1 2 3; do + if docker compose up -d postgres redis minio minio-init; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "docker compose up failed after 3 attempts" >&2 + exit 1 + fi + echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 + sleep $((attempt * 5)) + done + + - name: Run relay-driven mesh lifecycle smoke + run: ./scripts/ci-mesh-lifecycle-smoke.sh 2>&1 | tee /tmp/mesh-lifecycle-harness.log + + - name: Save mesh runtime + model caches + if: github.ref == 'refs/heads/main' && steps.mesh-caches.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cache/mesh-llm/native-runtimes + ~/.cache/huggingface/hub + key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }} + + - name: Upload relay + harness logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mesh-lifecycle-logs + path: | + /tmp/buzz-relay.log + /tmp/mesh-lifecycle-harness.log + if-no-files-found: ignore diff --git a/.github/workflows/promote-oss-desktop-release.yml b/.github/workflows/promote-oss-desktop-release.yml new file mode 100644 index 00000000000..f73bbd032b1 --- /dev/null +++ b/.github/workflows/promote-oss-desktop-release.yml @@ -0,0 +1,45 @@ +name: Promote OSS Desktop Auto-Update +run-name: Promote desktop-v${{ inputs.version }} to auto-update + +on: + workflow_dispatch: + inputs: + version: + description: Stable desktop version to promote (X.Y.Z) + required: true + type: string + +concurrency: + group: oss-desktop-auto-update-promotion + cancel-in-progress: false + +permissions: + contents: read + +jobs: + promote: + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Require the reviewed workflow from main + env: + DISPATCH_REF: ${{ github.ref }} + run: | + if [ "$DISPATCH_REF" != "refs/heads/main" ]; then + echo "::error::OSS desktop promotion must be dispatched from main, not $DISPATCH_REF" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Validate and promote exact release manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + run: scripts/promote-oss-desktop-release.sh "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9da067b74ee..2b0eb25c688 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -907,6 +907,7 @@ jobs: [ "${#TRIPLES[@]}" -ge 3 ] || { echo "::error::too few platforms (${#TRIPLES[@]})"; exit 1; } bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json + cp latest.json staged/updater-manifest.json - name: Create or verify versioned draft run: | @@ -946,7 +947,3 @@ jobs: - name: Publish complete versioned release if: env.already_published != 'true' run: gh release edit "desktop-v${VERSION}" --draft=false - - - name: Upload latest.json to rolling release last - if: ${{ !contains(needs.setup.outputs.version, '-') }} - run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index 0a3a513eef0..5957f4785dd 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -34,16 +34,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to macos-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: macos-canary-release - - name: Get pnpm store directory id: pnpm-cache run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" @@ -78,6 +68,38 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target aarch64-apple-darwin \ + --features mesh-llm \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config run: | cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' @@ -210,6 +232,24 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers or signed artifacts from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 29f74fa0f64..7093efd2dc6 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -46,24 +46,9 @@ jobs: shell: bash run: rustup target add "$TARGET" - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to windows-canary-release so canary - # runs warm each other without colliding with CI's debug-profile key - # (CI windows job does clippy/check, not --release). - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: windows-canary-release - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.14.1 - # Disable setup-node's built-in cache: we manage the pnpm store cache - # explicitly below (restore before install, save after) to mirror the - # pattern used by ci.yml and to keep caching logic consistent across - # all three canary workflows. package-manager-cache: false - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 @@ -108,6 +93,40 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + shell: bash + run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + shell: bash + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target x86_64-pc-windows-msvc \ + --features default \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config shell: bash run: | @@ -152,6 +171,25 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Measure release Cargo cache inputs + if: always() + shell: bash + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 843ac0dd7d7..b8516ee3463 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.8", - "base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f", - "previous_tag": "desktop-v0.5.7", - "previous_base_sha": "74b913cff8512c015dc6f1a7473b253fa803f954", - "previous_merge_sha": "13c9e900c84cac1e2c8eeb7551bd1510ecb544d3", - "tag": "desktop-v0.5.8", - "commit_count": 4 + "version": "0.5.11", + "base_sha": "4749bc7be3cdb78c2db4ce4864775ba7ab60b4cc", + "previous_tag": "desktop-v0.5.10", + "previous_base_sha": "f35930104bcbdb1332ff13735214ecb9fce1fc7b", + "previous_merge_sha": "4b3570671eb2786594267758af18784ac6e82972", + "tag": "desktop-v0.5.11", + "commit_count": 16 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca95250749..06a08ad5a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,98 @@ # Changelog +## v0.5.11 + +### Desktop and shared changes + +- perf(desktop): persist channel snapshot hash ([#5684](https://github.com/block/buzz/pull/5684)) ([`c86443c5997c96c42829ce200e73e6e6efe52d96`](https://github.com/block/buzz/commit/c86443c5997c96c42829ce200e73e6e6efe52d96)) +- fix(agent): raise output limit and allow 3 recoveries ([#5475](https://github.com/block/buzz/pull/5475)) ([`72d56e7bd3a94fa3ee20b5a50bd1b868a9329d9c`](https://github.com/block/buzz/commit/72d56e7bd3a94fa3ee20b5a50bd1b868a9329d9c)) +- fix(desktop): defer foreground resume work ([#5696](https://github.com/block/buzz/pull/5696)) ([`59f613c404958d8ac99525b4aaaf26843257de31`](https://github.com/block/buzz/commit/59f613c404958d8ac99525b4aaaf26843257de31)) +- perf(desktop): coalesce thread-activity localStorage writes ([#5693](https://github.com/block/buzz/pull/5693)) ([`c6c6e7eca70d6b526c43af925e596e8616b19fb8`](https://github.com/block/buzz/commit/c6c6e7eca70d6b526c43af925e596e8616b19fb8)) +- Batch observer-store publications per relay envelope ([#5680](https://github.com/block/buzz/pull/5680)) ([`c3b0ccf383fe4ee936abbe6b9c9134b5728cc2b5`](https://github.com/block/buzz/commit/c3b0ccf383fe4ee936abbe6b9c9134b5728cc2b5)) +- feat(buzz-acp): idle re-sleep for woken lazy pools ([#5682](https://github.com/block/buzz/pull/5682)) ([`dc2dbfe0f570abb818d3f3da8a71ea235555ed27`](https://github.com/block/buzz/commit/dc2dbfe0f570abb818d3f3da8a71ea235555ed27)) +- fix(desktop): preserve agent mention separator after send ([#5623](https://github.com/block/buzz/pull/5623)) ([`a8e5c89e23b85ee93306f2c3c11d8fe6300cd360`](https://github.com/block/buzz/commit/a8e5c89e23b85ee93306f2c3c11d8fe6300cd360)) +- fix(link-previews): proxy sent preview media ([#5627](https://github.com/block/buzz/pull/5627)) ([`884ed8a5d35dfba3892fc40437f39e08856dec7d`](https://github.com/block/buzz/commit/884ed8a5d35dfba3892fc40437f39e08856dec7d)) +- feat(deletion): add durable whole-community deletion ([#4425](https://github.com/block/buzz/pull/4425)) ([`8a2c9af2dbe0cf315e77f43a4560d3572da5e554`](https://github.com/block/buzz/commit/8a2c9af2dbe0cf315e77f43a4560d3572da5e554)) +- fix(desktop): preserve live channel timelines ([#5662](https://github.com/block/buzz/pull/5662)) ([`63d14a0e95c8d5ae19f3f80123027729ec209bb2`](https://github.com/block/buzz/commit/63d14a0e95c8d5ae19f3f80123027729ec209bb2)) +- Refine channel settings and profile panels ([#5574](https://github.com/block/buzz/pull/5574)) ([`63f961c7e4818a1d29f1185002c123e486bd4a19`](https://github.com/block/buzz/commit/63f961c7e4818a1d29f1185002c123e486bd4a19)) +- fix(deps): bump webbrowser to 1.2.4 for RUSTSEC-2026-0257 ([#5659](https://github.com/block/buzz/pull/5659)) ([`c966b862fe8b9018c68c384b1680ca0173d0128c`](https://github.com/block/buzz/commit/c966b862fe8b9018c68c384b1680ca0173d0128c)) +- fix(desktop): launch Databricks OAuth from passive model discovery ([#5607](https://github.com/block/buzz/pull/5607)) ([`1ff98fa685fdb7133dbc18437d23dcdeeb42ce6e`](https://github.com/block/buzz/commit/1ff98fa685fdb7133dbc18437d23dcdeeb42ce6e)) + +### Other repository changes + +- feat(acp): report standard adapter usage ([#4950](https://github.com/block/buzz/pull/4950)) ([`4749bc7be3cdb78c2db4ce4864775ba7ab60b4cc`](https://github.com/block/buzz/commit/4749bc7be3cdb78c2db4ce4864775ba7ab60b4cc)) +- fix(mobile): settle hydrated threads on latest reply ([#4702](https://github.com/block/buzz/pull/4702)) ([`7634fe74563ea7f3c86fb6017a0ad647a9934477`](https://github.com/block/buzz/commit/7634fe74563ea7f3c86fb6017a0ad647a9934477)) +- feat(acp): deliver channel description in prompt [Context] ([#4552](https://github.com/block/buzz/pull/4552)) ([`6e0631f6b5d2139e4e080bf94e27ecee8a3d4d74`](https://github.com/block/buzz/commit/6e0631f6b5d2139e4e080bf94e27ecee8a3d4d74)) + +[Compare desktop-v0.5.10...desktop-v0.5.11](https://github.com/block/buzz/compare/desktop-v0.5.10...desktop-v0.5.11) + +## v0.5.10 + +### Desktop and shared changes + +- fix(desktop): remove 0.5.9+ perf regressions, speed up get_channels ([#5599](https://github.com/block/buzz/pull/5599)) ([`f35930104bcbdb1332ff13735214ecb9fce1fc7b`](https://github.com/block/buzz/commit/f35930104bcbdb1332ff13735214ecb9fce1fc7b)) +- perf(desktop): coalesce read state localStorage persistence ([#5591](https://github.com/block/buzz/pull/5591)) ([`9203bf60eea44875cafb36410252f8705ce54e2d`](https://github.com/block/buzz/commit/9203bf60eea44875cafb36410252f8705ce54e2d)) +- fix(desktop): bound initial timeline retention ([#5603](https://github.com/block/buzz/pull/5603)) ([`d9dc76c0aa7ab8a96b2ecf89325eef6b1536039d`](https://github.com/block/buzz/commit/d9dc76c0aa7ab8a96b2ecf89325eef6b1536039d)) +- Improve desktop search scoping ([#5306](https://github.com/block/buzz/pull/5306)) ([`cf03bd7c37cb3918afd4fe2a561360d01e11b68e`](https://github.com/block/buzz/commit/cf03bd7c37cb3918afd4fe2a561360d01e11b68e)) +- Add glass appearance and cohesive settings ([#5478](https://github.com/block/buzz/pull/5478)) ([`cd2aa5c12d1c802ea9d93c30809f3625c49e9bd4`](https://github.com/block/buzz/commit/cd2aa5c12d1c802ea9d93c30809f3625c49e9bd4)) +- Add Send to channel for thread messages ([#5305](https://github.com/block/buzz/pull/5305)) ([`b0795a10ea0f63f2382f4028a1adc2bc3e039d79`](https://github.com/block/buzz/commit/b0795a10ea0f63f2382f4028a1adc2bc3e039d79)) +- Fix macOS attachment picker lifecycle and allow inert HTML downloads ([#5569](https://github.com/block/buzz/pull/5569)) ([`bba3e06386b8a0ca22e9867dc81aac1ca2b1b737`](https://github.com/block/buzz/commit/bba3e06386b8a0ca22e9867dc81aac1ca2b1b737)) +- fix(desktop): preserve fresh channel timelines ([#5577](https://github.com/block/buzz/pull/5577)) ([`d3ec831e0cecbff347d55a236e34b27d79961503`](https://github.com/block/buzz/commit/d3ec831e0cecbff347d55a236e34b27d79961503)) +- fix(desktop): suppress fresh focus-return refetches for channels and home-feed ([#5535](https://github.com/block/buzz/pull/5535)) ([`49357244945c2f4b8432eb8b5cebbba5b1c30a08`](https://github.com/block/buzz/commit/49357244945c2f4b8432eb8b5cebbba5b1c30a08)) +- chore: mesh upgrade, clean up legacy special case code, simplify model selection for mesh ([#5289](https://github.com/block/buzz/pull/5289)) ([`240cdd3ea17a8f4d521c8398a929294210bd1e1a`](https://github.com/block/buzz/commit/240cdd3ea17a8f4d521c8398a929294210bd1e1a)) +- fix(desktop): preserve theme when opening communities ([#5266](https://github.com/block/buzz/pull/5266)) ([`83ca595adadae32238197d9c34a5895a34950968`](https://github.com/block/buzz/commit/83ca595adadae32238197d9c34a5895a34950968)) +- fix(link-preview): resolve YouTube videos through oEmbed ([#5520](https://github.com/block/buzz/pull/5520)) ([`7eb8cc5a5f03c454a84f2b5c4369819ba6d4d11b`](https://github.com/block/buzz/commit/7eb8cc5a5f03c454a84f2b5c4369819ba6d4d11b)) +- fix(buzz-agent): harden Databricks OAuth token cache and callback ([#5534](https://github.com/block/buzz/pull/5534)) ([`5e4d0fe92508fc5e0c812ff3edbe8877d86b8ec6`](https://github.com/block/buzz/commit/5e4d0fe92508fc5e0c812ff3edbe8877d86b8ec6)) +- fix(link-preview): reliably render previews sent right after they resolve ([#5245](https://github.com/block/buzz/pull/5245)) ([`be48ce98bd163899197b79a82ad5b2bcf0bc9b54`](https://github.com/block/buzz/commit/be48ce98bd163899197b79a82ad5b2bcf0bc9b54)) +- fix(link-preview): restore Buzz entity link cards ([#5494](https://github.com/block/buzz/pull/5494)) ([`7e6e9c547fa97abff6929cf2702b956586eec9bc`](https://github.com/block/buzz/commit/7e6e9c547fa97abff6929cf2702b956586eec9bc)) + +### Other repository changes + +- fix(relay): stop panicking the ingest worker on reactions to project events ([#5294](https://github.com/block/buzz/pull/5294)) ([`16b7ae7ce623a57be1461adee3b8fce4115b3c3a`](https://github.com/block/buzz/commit/16b7ae7ce623a57be1461adee3b8fce4115b3c3a)) +- fix(relay): log event kind on the HTTP bridge /events line ([#5291](https://github.com/block/buzz/pull/5291)) ([`e8153f8f27f5a35f56b2a578ab749190787d9e91`](https://github.com/block/buzz/commit/e8153f8f27f5a35f56b2a578ab749190787d9e91)) +- feat(tracing): add PostgreSQL tracing spans ([#3678](https://github.com/block/buzz/pull/3678)) ([`397796c5f343db4251198f44505b1afebe88223f`](https://github.com/block/buzz/commit/397796c5f343db4251198f44505b1afebe88223f)) + +[Compare desktop-v0.5.9...desktop-v0.5.10](https://github.com/block/buzz/compare/desktop-v0.5.9...desktop-v0.5.10) + +## v0.5.9 + +### Desktop and shared changes + +- Polish desktop onboarding flow ([#5310](https://github.com/block/buzz/pull/5310)) ([`3f2f32641f4093d087fd9506bfac1fa0329e8b2e`](https://github.com/block/buzz/commit/3f2f32641f4093d087fd9506bfac1fa0329e8b2e)) +- fix(desktop): quiesce renderer polling while hidden (#3677) ([#5490](https://github.com/block/buzz/pull/5490)) ([`07a3c768d619db31fee3f0590f9433cdd1213e8f`](https://github.com/block/buzz/commit/07a3c768d619db31fee3f0590f9433cdd1213e8f)) +- fix(channels): restore member invitations to private channels ([#5493](https://github.com/block/buzz/pull/5493)) ([`2777189d960fa5b1d863166f36d6e37ff8ce0819`](https://github.com/block/buzz/commit/2777189d960fa5b1d863166f36d6e37ff8ce0819)) +- fix(desktop): bound nine unbounded localStorage stores ([#5454](https://github.com/block/buzz/pull/5454)) ([`9c074bb89b290721f839bbc84fdf4701269e43a0`](https://github.com/block/buzz/commit/9c074bb89b290721f839bbc84fdf4701269e43a0)) +- feat(desktop): time-based sweep for stale localStorage caches ([#5453](https://github.com/block/buzz/pull/5453)) ([`bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead`](https://github.com/block/buzz/commit/bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead)) +- feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 ([#4000](https://github.com/block/buzz/pull/4000)) ([`5e4c05f90b062898e1827ba45cb826c6ff913741`](https://github.com/block/buzz/commit/5e4c05f90b062898e1827ba45cb826c6ff913741)) +- fix(desktop): resolve overlapping member mentions ([#5225](https://github.com/block/buzz/pull/5225)) ([`44456e200e3ca6a5d2882b58b447b80474041347`](https://github.com/block/buzz/commit/44456e200e3ca6a5d2882b58b447b80474041347)) +- chore(deps): update rust crate anyhow to v1.0.104 ([#4447](https://github.com/block/buzz/pull/4447)) ([`e1ff91ecc1269682a50c17da2c0708d1448b336f`](https://github.com/block/buzz/commit/e1ff91ecc1269682a50c17da2c0708d1448b336f)) +- fix(desktop): preserve Welcome banner dismissal ([#5406](https://github.com/block/buzz/pull/5406)) ([`97aa9e31856edb9d8abcdcb33c472027f5588890`](https://github.com/block/buzz/commit/97aa9e31856edb9d8abcdcb33c472027f5588890)) +- fix(agent): retry LLM completion on malformed 2xx JSON body ([#5351](https://github.com/block/buzz/pull/5351)) ([`5bf78671f45178f8de02ba18d3d321cbbf19cd1f`](https://github.com/block/buzz/commit/5bf78671f45178f8de02ba18d3d321cbbf19cd1f)) +- fix(desktop): welcome banner overlap and missing dismiss control ([#5330](https://github.com/block/buzz/pull/5330)) ([`f029deafae6ad3b63e13c29104f3be76122cb1df`](https://github.com/block/buzz/commit/f029deafae6ad3b63e13c29104f3be76122cb1df)) +- fix(desktop): prevent horizontal clipping in Prompt Context modal ([#5324](https://github.com/block/buzz/pull/5324)) ([`fbf89e3bed9adebc033a26b7c43362c004e816a2`](https://github.com/block/buzz/commit/fbf89e3bed9adebc033a26b7c43362c004e816a2)) +- fix(buzz-agent): recover from 400-shaped image rejections; unbound benchmark agent rounds ([#5318](https://github.com/block/buzz/pull/5318)) ([`261c46076166c6de5bb9a71fb4a0fd0b70aa1efa`](https://github.com/block/buzz/commit/261c46076166c6de5bb9a71fb4a0fd0b70aa1efa)) + +### Other repository changes + +- feat(cli): add --visibility flag to channels update ([#5119](https://github.com/block/buzz/pull/5119)) ([`f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b`](https://github.com/block/buzz/commit/f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b)) +- perf(ci): experiment with sccache for relay builds ([#5224](https://github.com/block/buzz/pull/5224)) ([`5a3b3d23226474f835a1cf41d2ecc5f53cacb070`](https://github.com/block/buzz/commit/5a3b3d23226474f835a1cf41d2ecc5f53cacb070)) +- ci(release): gate OSS desktop auto-update promotion ([#5398](https://github.com/block/buzz/pull/5398)) ([`43573d114b5bfaf7cefa75eee7e219dc05cf1cd1`](https://github.com/block/buzz/commit/43573d114b5bfaf7cefa75eee7e219dc05cf1cd1)) +- fix(release): pin desktop PR operations to block/buzz ([#5212](https://github.com/block/buzz/pull/5212)) ([`c1e20a814bf694db2af959adacb375ced27af023`](https://github.com/block/buzz/commit/c1e20a814bf694db2af959adacb375ced27af023)) +- fix(search): surface exact short profile names ([#5480](https://github.com/block/buzz/pull/5480)) ([`3c76f682c3c2dfe2cd296c277c5e63799d3424f9`](https://github.com/block/buzz/commit/3c76f682c3c2dfe2cd296c277c5e63799d3424f9)) +- Reduce repeated ACP session context ([#5423](https://github.com/block/buzz/pull/5423)) ([`563e4346da37d0fb2e9ec1c95e7f1eba79f83040`](https://github.com/block/buzz/commit/563e4346da37d0fb2e9ec1c95e7f1eba79f83040)) +- chore(deps): update react monorepo ([#4441](https://github.com/block/buzz/pull/4441)) ([`119a84897f225c1e3213a09cd149abb37dcb3abc`](https://github.com/block/buzz/commit/119a84897f225c1e3213a09cd149abb37dcb3abc)) +- ci(security): allow retired relay pool advisory ([#5404](https://github.com/block/buzz/pull/5404)) ([`d2ebaa95a7d2565fb217fdfae56bafb9509be444`](https://github.com/block/buzz/commit/d2ebaa95a7d2565fb217fdfae56bafb9509be444)) +- chore(deps): update dependency @tanstack/react-virtual to v3.14.9 ([#4439](https://github.com/block/buzz/pull/4439)) ([`c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888`](https://github.com/block/buzz/commit/c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888)) +- chore(deps): update all non-major dependencies ([#3049](https://github.com/block/buzz/pull/3049)) ([`856cdb848b0a849e33620887b145b7e598dfd95c`](https://github.com/block/buzz/commit/856cdb848b0a849e33620887b145b7e598dfd95c)) +- chore(deps): update rust crate arc-swap to v1.9.2 ([#4448](https://github.com/block/buzz/pull/4448)) ([`08de85c592106ea2ffe22ba16e3a0fc10687db54`](https://github.com/block/buzz/commit/08de85c592106ea2ffe22ba16e3a0fc10687db54)) +- chore(deps): update rust crate async-trait to v0.1.91 ([#4458](https://github.com/block/buzz/pull/4458)) ([`12b1f566480d4feddc171739097f9359d3f255c1`](https://github.com/block/buzz/commit/12b1f566480d4feddc171739097f9359d3f255c1)) +- chore(deps): update rust crate diffy to v0.5.1 ([#4466](https://github.com/block/buzz/pull/4466)) ([`d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4`](https://github.com/block/buzz/commit/d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4)) +- chore(deps): update rust crate async-compression to v0.4.43 ([#4456](https://github.com/block/buzz/pull/4456)) ([`7dd8791d0765e9f15fed3299b6948e2babbfd763`](https://github.com/block/buzz/commit/7dd8791d0765e9f15fed3299b6948e2babbfd763)) +- chore(deps): update rust crate clap to v4.6.6 ([#4465](https://github.com/block/buzz/pull/4465)) ([`e668c6bb4913e36e58d7f947dbaf982e704e9132`](https://github.com/block/buzz/commit/e668c6bb4913e36e58d7f947dbaf982e704e9132)) +- chore(release): release Buzz Relay version 0.2.1 ([#2856](https://github.com/block/buzz/pull/2856)) ([`6e5c462ac524de60d7edb46c66130fd779cc9006`](https://github.com/block/buzz/commit/6e5c462ac524de60d7edb46c66130fd779cc9006)) + +[Compare desktop-v0.5.8...desktop-v0.5.9](https://github.com/block/buzz/compare/desktop-v0.5.8...desktop-v0.5.9) + ## v0.5.8 ### Desktop and shared changes diff --git a/Cargo.lock b/Cargo.lock index da86c89b851..eaea5b35a8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -128,7 +128,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -352,20 +352,21 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +checksum = "2c713e1f14c7b82e32ea159af1c6e2f070cfadbdf23fb2512acce9af0a26f1a2" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", "tokio", + "tokio-happy-eyeballs", "tokio-rustls", "tokio-socks", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.28.0", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -398,12 +399,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -597,6 +592,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "beef" version = "0.5.2" @@ -609,7 +610,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "serde", "unicode-normalization", ] @@ -644,6 +645,21 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + [[package]] name = "bitcoin-io" version = "0.1.4" @@ -657,7 +673,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals", + "hex-conservative 1.2.0", "serde", ] @@ -812,7 +840,7 @@ dependencies = [ "hex", "httparse", "nix 0.31.3", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "rustls", "serde", @@ -838,6 +866,7 @@ dependencies = [ "buzz-auth", "buzz-core", "buzz-db", + "buzz-deletion", "buzz-media", "buzz-pubsub", "buzz-search", @@ -845,7 +874,7 @@ dependencies = [ "clap", "deadpool-redis", "hex", - "nostr", + "nostr 0.44.7", "rustls", "serde_json", "sqlx", @@ -886,6 +915,7 @@ name = "buzz-audit" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-datastore-tracing", "chrono", "futures-util", "hex", @@ -905,7 +935,7 @@ version = "0.1.0" dependencies = [ "buzz-core", "hex", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "serde", "serde_json", @@ -927,7 +957,7 @@ dependencies = [ "http-body-util", "k8s-openapi", "kube", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "rustls", "serde", @@ -954,7 +984,7 @@ dependencies = [ "dirs", "hex", "infer", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", "rustls", @@ -987,7 +1017,7 @@ dependencies = [ "chrono", "hex", "hmac 0.13.0", - "nostr", + "nostr 0.44.7", "percent-encoding", "rand 0.10.1", "serde", @@ -1000,16 +1030,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "buzz-datastore-tracing" +version = "0.1.0" +dependencies = [ + "opentelemetry 0.32.0", + "opentelemetry_sdk 0.32.1", + "proc-macro2", + "quote", + "syn 2.0.117", + "tokio", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + [[package]] name = "buzz-db" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-datastore-tracing", "chrono", "hex", "metrics", "metrics-util", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "serde", "serde_json", @@ -1021,6 +1067,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-deletion" +version = "0.1.0" +dependencies = [ + "anyhow", + "buzz-core", + "buzz-db", + "buzz-media", + "chrono", + "clap", + "deadpool-redis", + "hex", + "redis", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "uuid", +] + [[package]] name = "buzz-dev-mcp" version = "0.1.0" @@ -1033,7 +1101,7 @@ dependencies = [ "ignore", "image", "nix 0.31.3", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "rmcp", "rustls", @@ -1066,7 +1134,7 @@ dependencies = [ "imagesize", "infer", "mp4", - "nostr", + "nostr 0.44.7", "rust-s3", "serde", "serde_json", @@ -1105,7 +1173,7 @@ dependencies = [ "clap", "futures-util", "hex", - "nostr", + "nostr 0.44.7", "serde_json", "thiserror 2.0.18", "tokio", @@ -1134,7 +1202,7 @@ dependencies = [ "chrono", "deadpool-redis", "futures-util", - "nostr", + "nostr 0.44.7", "redis", "serde", "serde_json", @@ -1160,7 +1228,7 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "minicbor", - "nostr", + "nostr 0.44.7", "p256", "proptest", "rand 0.10.1", @@ -1192,7 +1260,9 @@ dependencies = [ "buzz-auth", "buzz-conformance", "buzz-core", + "buzz-datastore-tracing", "buzz-db", + "buzz-deletion", "buzz-media", "buzz-pubsub", "buzz-relay-mesh", @@ -1217,7 +1287,7 @@ dependencies = [ "metrics-exporter-prometheus", "metrics-util", "moka", - "nostr", + "nostr 0.44.7", "opentelemetry 0.32.0", "opentelemetry-otlp 0.32.0", "opentelemetry_sdk 0.32.1", @@ -1258,7 +1328,7 @@ dependencies = [ "hex", "hmac 0.13.0", "iroh", - "nostr", + "nostr 0.44.7", "postcard", "proptest", "redis", @@ -1276,7 +1346,7 @@ name = "buzz-sdk" version = "0.1.0" dependencies = [ "buzz-core", - "nostr", + "nostr 0.44.7", "serde", "serde_json", "thiserror 2.0.18", @@ -1288,9 +1358,11 @@ name = "buzz-search" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-datastore-tracing", "sqlx", "thiserror 2.0.18", "tokio", + "tracing", "uuid", ] @@ -1307,7 +1379,7 @@ dependencies = [ "chrono", "futures-util", "hex", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", "rust-s3", @@ -1350,13 +1422,14 @@ version = "0.1.0" dependencies = [ "buzz-core", "buzz-db", + "buzz-deletion", "chrono", "cron", "dashmap", "evalexpr", "hex", "moka", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "serde", "serde_json", @@ -1372,7 +1445,7 @@ name = "buzz-ws-client" version = "0.1.0" dependencies = [ "futures-util", - "nostr", + "nostr 0.44.7", "serde_json", "thiserror 2.0.18", "tokio", @@ -1638,7 +1711,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1820,7 +1893,7 @@ dependencies = [ "anyhow", "buzz-sdk", "futures-util", - "nostr", + "nostr 0.44.7", "serde_json", "tokio", "tokio-tungstenite 0.29.0", @@ -2071,43 +2144,16 @@ dependencies = [ "phf", ] -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - [[package]] name = "ctor" -version = "0.6.3" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "ctr" version = "0.9.2" @@ -2558,21 +2604,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -2735,7 +2766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2834,6 +2865,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3220,7 +3261,7 @@ name = "git-credential-nostr" version = "0.1.0" dependencies = [ "base64 0.22.1", - "nostr", + "nostr 0.44.7", "serde_json", "zeroize", ] @@ -3233,7 +3274,7 @@ dependencies = [ "chrono", "hex", "libc", - "nostr", + "nostr 0.44.7", "serde_json", "zeroize", ] @@ -3313,6 +3354,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbag" version = "0.1.13" @@ -3371,6 +3421,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3399,45 +3459,31 @@ dependencies = [ ] [[package]] -name = "hf-hub" -version = "1.0.0-rc.1" +name = "hex-conservative" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", + "arrayvec", ] [[package]] name = "hf-xet" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" +checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" dependencies = [ + "anyhow", "async-trait", "bytes", "http", "more-asserts", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "uuid", "xet-client", @@ -4175,7 +4221,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.0", + "lru", "n0-error", "n0-future", "noq", @@ -4545,6 +4591,18 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "link-section" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4630,12 +4688,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - [[package]] name = "lru" version = "0.18.0" @@ -4782,8 +4834,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "mesh-llm-client", @@ -4792,8 +4844,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4803,13 +4855,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-llm-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -4840,8 +4892,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -4856,8 +4908,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4866,8 +4918,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "clap", @@ -4878,12 +4930,9 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ - "anyhow", - "cc", - "libc", "serde", "serde_json", "tracing", @@ -4891,8 +4940,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -4900,16 +4949,45 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "mesh-llm-native-runtime", ] +[[package]] +name = "mesh-llm-hf-hub" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43088a838cf0c6715c65f65a5ac99045fd6d6e90949a8a4183b8104ab791e96b" +dependencies = [ + "base64 0.22.1", + "bon", + "bytes", + "futures", + "getrandom 0.2.17", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + [[package]] name = "mesh-llm-host-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "argon2", @@ -4927,7 +5005,6 @@ dependencies = [ "flate2", "futures-util", "hex", - "hf-hub", "http", "http-body-util", "httparse", @@ -4942,6 +5019,7 @@ dependencies = [ "mesh-llm-config", "mesh-llm-events", "mesh-llm-guardrails", + "mesh-llm-hf-hub", "mesh-llm-identity", "mesh-llm-native-runtime", "mesh-llm-node", @@ -4954,6 +5032,7 @@ dependencies = [ "mesh-llm-types", "mesh-llm-ui", "mesh-mixture-of-agents", + "mesh-native-serving-plugin-host", "model-artifact", "model-hf", "model-package", @@ -5001,8 +5080,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "argon2", "base64 0.22.1", @@ -5023,8 +5102,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "serde", @@ -5034,8 +5113,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-types", @@ -5048,8 +5127,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5065,8 +5144,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5084,8 +5163,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "hex", @@ -5095,18 +5174,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "mesh-llm-release-footer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "hex", + "sha2 0.10.9", +] + [[package]] name = "mesh-llm-routing" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5128,8 +5216,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5143,8 +5231,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5154,8 +5242,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "chrono", @@ -5163,8 +5251,12 @@ dependencies = [ "dirs", "hex", "libc", + "libloading", "mesh-llm-build-info", "mesh-llm-gpu-bench", + "mesh-llm-native-runtime", + "mesh-llm-release-footer", + "mesh-llm-runtime-install", "reqwest 0.12.28", "semver", "serde", @@ -5177,8 +5269,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "serde", @@ -5188,13 +5280,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-mixture-of-agents" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5205,6 +5297,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "mesh-native-serving-plugin-api" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" + +[[package]] +name = "mesh-native-serving-plugin-host" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "anyhow", + "libloading", + "mesh-native-serving-plugin-api", + "skippy-server", +] + [[package]] name = "metrics" version = "0.24.6" @@ -5342,8 +5450,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5353,14 +5461,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", - "hf-hub", + "mesh-llm-hf-hub", "model-artifact", "model-ref", "serde", @@ -5371,14 +5479,14 @@ dependencies = [ [[package]] name = "model-package" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "bytes", "chrono", "futures", - "hf-hub", + "mesh-llm-hf-hub", "model-hf", "model-ref", "reqwest 0.12.28", @@ -5391,16 +5499,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "model-artifact", @@ -5778,6 +5886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", + "aws-lc-rs", "bytes", "derive_more", "enum-assoc", @@ -5818,9 +5927,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.11.1", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -5836,56 +5945,71 @@ dependencies = [ ] [[package]] -name = "nostr-database" -version = "0.44.0" +name = "nostr" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +checksum = "5dde8c76076d334409d86c2e1db3e97abe5deb8cb92744f939cbc1fa45bd69e7" dependencies = [ - "lru 0.16.4", - "nostr", - "tokio", + "base64 0.22.1", + "bech32 0.12.0", + "bip39", + "bitcoin_hashes 1.2.0", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "faster-hex", + "opaquerr", + "rand 0.10.1", + "secp256k1 0.30.0", + "serde", + "serde_json", + "unicode-normalization", + "universal-time", + "url", + "zeroize", ] [[package]] -name = "nostr-gossip" -version = "0.44.0" +name = "nostr-database" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf" dependencies = [ - "nostr", + "nostr 0.45.1", + "opaquerr", ] [[package]] -name = "nostr-relay-pool" -version = "0.44.3" +name = "nostr-gossip" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336" dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru 0.16.4", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", + "nostr 0.45.1", + "opaquerr", ] [[package]] name = "nostr-sdk" -version = "0.44.1" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +checksum = "26c86342f367bd9b173ec4a697e936e3a82d6dad5b4aa06c0d35d9b4f88a8e72" dependencies = [ "async-utility", - "nostr", + "async-wsocket", + "faster-hex", + "futures", + "lru", + "negentropy", + "nostr 0.45.1", "nostr-database", "nostr-gossip", - "nostr-relay-pool", + "opaquerr", + "rand 0.10.1", "tokio", + "tokio-stream", "tracing", + "universal-time", ] [[package]] @@ -6047,6 +6171,17 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -6166,10 +6301,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaquerr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" + [[package]] name = "openai-frontend" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "axum", @@ -7359,7 +7500,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7533,7 +7674,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools", "kasuari", - "lru 0.18.0", + "lru", "palette", "serde", "strum", @@ -8043,7 +8184,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8102,7 +8243,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8274,13 +8415,24 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.8.6", + "secp256k1-sys 0.10.1", +] + [[package]] name = "secp256k1" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "rand 0.9.4", "secp256k1-sys 0.11.0", ] @@ -8374,7 +8526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8578,7 +8730,6 @@ dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", - "sha2-asm", ] [[package]] @@ -8592,15 +8743,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "sha2-asm" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" -dependencies = [ - "cc", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -8744,8 +8886,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "blake3", @@ -8754,40 +8896,41 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "skippy-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "prost 0.14.3", "prost-build 0.14.3", "protoc-bin-vendored", "serde", + "skippy-tokenizer", ] [[package]] name = "skippy-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "libc", @@ -8800,8 +8943,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "ahash", "anyhow", @@ -8812,6 +8955,8 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-native-serving-plugin-api", + "model-artifact", "openai-frontend", "opentelemetry-proto 0.31.0", "serde", @@ -8821,16 +8966,25 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-tokenizer", "socket2", "tokio", "tokio-stream", "tonic", ] +[[package]] +name = "skippy-tokenizer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "serde", +] + [[package]] name = "skippy-topology" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -8869,7 +9023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9497,7 +9651,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -9510,7 +9664,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9743,6 +9897,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-happy-eyeballs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8564c32dfb6f4257f8bc6edfc178a34af97520e0b7b9815500c55eb3d092f29f" +dependencies = [ + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -9811,9 +9974,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -9821,7 +9984,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.26.2", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -9861,6 +10024,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ + "aws-lc-rs", "base64 0.22.1", "bytes", "futures-core", @@ -9878,6 +10042,30 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -10179,9 +10367,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -10246,7 +10434,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10358,6 +10546,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -10664,15 +10858,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "core-foundation 0.10.1", "jni 0.22.4", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys", @@ -10824,7 +11018,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -11371,20 +11565,18 @@ dependencies = [ [[package]] name = "xet-client" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" +checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bytes", - "clap", "crc32fast", "futures", "http", "hyper", - "lazy_static", "more-asserts", "rand 0.10.1", "redb", @@ -11398,8 +11590,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-retry", + "tokio_with_wasm", "tracing", - "tracing-subscriber", "url", "urlencoding", "web-time", @@ -11409,24 +11601,21 @@ dependencies = [ [[package]] name = "xet-core-structures" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" +checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" dependencies = [ "async-trait", "base64 0.22.1", "blake3", "bytemuck", "bytes", - "clap", "countio", - "csv", "futures", "futures-util", "getrandom 0.4.3", "heapify", "itertools", - "lazy_static", "lz4_flex", "more-asserts", "rand 0.10.1", @@ -11434,7 +11623,6 @@ dependencies = [ "safe-transmute", "serde", "static_assertions", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", @@ -11446,32 +11634,31 @@ dependencies = [ [[package]] name = "xet-data" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" +checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" dependencies = [ "anyhow", "async-trait", "bytes", "chrono", - "clap", "gearhash", "http", "itertools", - "lazy_static", "more-asserts", "rand 0.10.1", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "url", "uuid", - "walkdir", + "web-time", "xet-client", "xet-core-structures", "xet-runtime", @@ -11479,9 +11666,9 @@ dependencies = [ [[package]] name = "xet-runtime" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" +checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" dependencies = [ "anyhow", "async-trait", @@ -11495,7 +11682,6 @@ dependencies = [ "git-version", "humantime", "konst", - "lazy_static", "libc", "more-asserts", "oneshot", @@ -11509,9 +11695,11 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "tracing-appender", "tracing-subscriber", + "web-time", "whoami", "winapi", ] diff --git a/Cargo.toml b/Cargo.toml index cc1dd0f9dff..78816ff4827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/buzz-test-client", "crates/buzz-ws-client", "crates/buzz-admin", + "crates/buzz-deletion", "crates/buzz-workflow", "crates/buzz-media", "crates/buzz-cli", @@ -28,6 +29,7 @@ members = [ "crates/buzz-dev-mcp", "crates/buzz-voice", "crates/buzz-backend-kubernetes", + "crates/buzz-datastore-tracing", "examples/countdown-bot", ] exclude = ["desktop/src-tauri"] @@ -134,6 +136,7 @@ schemars = { version = "1", default-features = false } buzz-core = { path = "crates/buzz-core" } buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } +buzz-deletion = { path = "crates/buzz-deletion" } buzz-auth = { path = "crates/buzz-auth" } buzz-pubsub = { path = "crates/buzz-pubsub" } buzz-search = { path = "crates/buzz-search" } @@ -143,13 +146,14 @@ buzz-media = { path = "crates/buzz-media" } buzz-sdk = { path = "crates/buzz-sdk" } buzz-ws-client = { path = "crates/buzz-ws-client" } buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } +buzz-datastore-tracing = { path = "crates/buzz-datastore-tracing" } # CI profile — builds the relay for desktop e2e. Dependencies keep full # release optimization (warm from main's cache; they carry the runtime hot # path: tokio/sqlx/axum). Workspace crates build at opt-level 1 — enough for # stable e2e timing (PR #307 flakiness was opt-0 + debug-assertions) at -# roughly half the codegen cost. `incremental` is irrelevant in CI: -# rust-cache exports CARGO_INCREMENTAL=0 and never caches member artifacts. +# roughly half the codegen cost. Incremental stays disabled: rust-cache exports +# CARGO_INCREMENTAL=0, and the CI compiler cache requires non-incremental units. [profile.ci] inherits = "release" lto = false diff --git a/Justfile b/Justfile index 0a43249d5fc..2e62599dacf 100644 --- a/Justfile +++ b/Justfile @@ -355,7 +355,6 @@ mesh-dev-fresh: mesh-e2e-hardware: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo run -p buzz-relay --example mesh_serve_client_smoke # Three isolated node processes: trusted member joins and infers; stranger is rejected. @@ -363,14 +362,12 @@ mesh-e2e-hardware: mesh-e2e-admission: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo run -p buzz-relay --example mesh_admission_smoke # Full hardware confidence suite: routing, owner admission, and real agent inference. mesh-e2e-confidence: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo build --release -p buzz-agent -p buzz-dev-mcp cargo run -p buzz-relay --example mesh_serve_client_smoke cargo run -p buzz-relay --example mesh_admission_smoke @@ -457,9 +454,6 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations done fi cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay - if [[ -n "{{mesh}}" ]]; then - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" - fi # Docker Desktop's forwarded MinIO port can stall under the deployment # probe's 32 concurrent writers. Keep the gate enabled in local dev, using # the bounded profile already used by the relay test launcher. @@ -536,7 +530,6 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the @@ -572,7 +565,6 @@ production *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the diff --git a/RELEASING.md b/RELEASING.md index 53d58055619..8d1fad74807 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -173,10 +173,10 @@ for distributable builds or builds from an immutable release tag. `release.yml` has no manual dispatch and cannot build from `main` or another caller-selected ref. If a run for an existing immutable `desktop-v` tag fails, rerun that failed workflow from GitHub Actions -(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also -repairs `buzz-desktop-latest/latest.json` if the original run published the -versioned release but failed during that final rolling-manifest upload. Do not -recreate, move, or push the immutable tag again. +(or use `gh run rerun --failed --repo block/buzz`). A rerun +repairs the versioned draft if publication did not complete. It does not +promote that version to the auto-updater; promotion is a separate manual +action. Do not recreate, move, or push the immutable tag again. Mobile intentionally has no branch or arbitrary-ref fallback. The private Buildkite pipeline accepts only an exact candidate tag. @@ -200,8 +200,25 @@ for the rest of the private pipeline contract. Desktop publishes two GitHub releases: -1. **`desktop-v`**: the user-facing release with installers. -2. **`buzz-desktop-latest`**: the rolling auto-updater release. +1. **`desktop-v`**: the user-facing release with installers and the + exact `updater-manifest.json` promotion candidate. Publishing this release + does not expose it through in-app auto-update. +2. **`buzz-desktop-latest`**: the rolling auto-updater release. Its + `latest.json` changes only through the manual promotion workflow. + +### Promote an OSS desktop release to auto-update + +After installing and testing the published `desktop-v` artifacts, run +**Promote OSS Desktop Auto-Update** from the `main` branch and enter the exact +stable `X.Y.Z` version. The workflow validates the immutable tag and release, +the retained manifest and every referenced updater asset, and requires the +version to be newer than the currently promoted version before replacing +`buzz-desktop-latest/latest.json`. Same-version retries succeed only when the +manifest is identical; downgrades are rejected. + +Withholding promotion leaves existing clients on the previous version. If a +promoted release is bad, ship and promote a higher patch version; changing the +manifest to an older version does not downgrade clients that already updated. Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts and rollout records retain the exact tag they used. Mobile does not publish a diff --git a/VISION.md b/VISION.md index 66a106bdebc..900e5a9475b 100644 --- a/VISION.md +++ b/VISION.md @@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate. | Type | Visibility | Join | Create | |------|-----------|------|--------| | **Open channels** | Searchable by all members | Self-join | Any member | -| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member | +| **Private channels** | Hidden, invite-only | Invited by member | Any member | | **DMs** | Participants only | N/A (up to 9) | Any member | | **Guests** | Scoped to specific channels | Invited | N/A | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 9e99877fb41..f04b8eeec0d 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -14,18 +14,14 @@ use tokio::process::{Child, ChildStdin, ChildStdout}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; use crate::observer::{ObserverContext, ObserverHandle}; -use crate::usage::{TurnUsage, UsageTracker}; +use crate::usage::{ + PromptResponseUsage, StandardAdapterKind, StandardUsageTracker, TurnUsage, UsageTracker, +}; /// Maximum allowed size of a single NDJSON line from the agent's stdout. /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB -/// Maximum final-answer material observed during one prompt. This is deliberately -/// below the transport line limit and applies across all chunks, IDs, and tool boundaries. -const MAX_FINAL_ANSWER_BYTES: usize = 1_000_000; -/// Bound per-prompt bookkeeping even when an agent streams tiny chunks. -const MAX_FINAL_ANSWER_CHUNKS: usize = 4_096; - /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -48,7 +44,7 @@ pub struct EnvVar { /// Stop reason returned by `session/prompt` when the agent finishes a turn. /// /// Maps to the `stopReason` field in the `SessionPromptResponse`. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum StopReason { /// Agent completed the turn normally (`"end_turn"`). EndTurn, @@ -80,181 +76,6 @@ impl StopReason { } } -/// A terminal assistant message that is safe to hand to host-managed delivery. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FinalAnswer { - /// Exact ACP message identifier shared by all accumulated chunks. - pub message_id: String, - /// Reconstructed final-answer text in wire order. - pub text: String, -} - -/// Typed result of one terminal `session/prompt` response. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PromptTurnResult { - /// Terminal reason reported by the ACP prompt response. - pub stop_reason: StopReason, - /// Fail-closed typed answer, present only for an exact canonical end turn. - pub final_answer: Option, -} - -/// Per-turn collector for adapter-typed final answer chunks. -#[derive(Debug, Default)] -struct FinalAnswerCollector { - groups: Vec, - observed_bytes: usize, - observed_chunks: usize, - candidate_invalid: bool, - limit_exceeded: bool, - wire_invalid: bool, -} - -impl FinalAnswerCollector { - fn observe(&mut self, update: &serde_json::Value) { - let update_type = update.get("sessionUpdate").and_then(|value| value.as_str()); - let is_final_answer = update - .pointer("/_meta/codex/phase") - .and_then(|value| value.as_str()) - == Some("final_answer"); - - if is_final_answer { - if self.limit_exceeded || self.wire_invalid { - return; - } - if !self.account_relevant_chunk(update) { - self.exceed_limit(); - return; - } - if update_type != Some("agent_message_chunk") { - self.invalidate_candidate(); - return; - } - if self.candidate_invalid { - return; - } - - let Some(message_id) = update - .get("messageId") - .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - else { - self.invalidate_candidate(); - return; - }; - if update - .pointer("/content/type") - .and_then(|value| value.as_str()) - != Some("text") - { - self.invalidate_candidate(); - return; - } - let Some(text) = update - .pointer("/content/text") - .and_then(|value| value.as_str()) - else { - self.invalidate_candidate(); - return; - }; - - match self - .groups - .iter_mut() - .find(|group| group.message_id == message_id) - { - Some(group) => group.text.push_str(text), - None => self.groups.push(FinalAnswer { - message_id: message_id.to_owned(), - text: text.to_owned(), - }), - } - return; - } - - if update_type == Some("tool_call") { - if update - .get("toolCallId") - .and_then(|value| value.as_str()) - .is_some_and(|value| !value.trim().is_empty()) - { - self.groups.clear(); - self.candidate_invalid = false; - } else { - self.invalidate_candidate(); - } - } - } - - fn account_relevant_chunk(&mut self, update: &serde_json::Value) -> bool { - let Some(observed_chunks) = self.observed_chunks.checked_add(1) else { - return false; - }; - let material_bytes = update - .get("messageId") - .into_iter() - .chain(update.get("content")) - .try_fold(0usize, |total, value| { - total.checked_add(serde_json::to_vec(value).ok()?.len()) - }); - let Some(observed_bytes) = - material_bytes.and_then(|bytes| self.observed_bytes.checked_add(bytes)) - else { - return false; - }; - if observed_bytes > MAX_FINAL_ANSWER_BYTES || observed_chunks > MAX_FINAL_ANSWER_CHUNKS { - return false; - } - self.observed_bytes = observed_bytes; - self.observed_chunks = observed_chunks; - true - } - - fn invalidate_candidate(&mut self) { - self.groups.clear(); - self.candidate_invalid = true; - } - - fn exceed_limit(&mut self) { - self.invalidate_candidate(); - self.limit_exceeded = true; - } - - fn observe_unparseable_line(&mut self, bytes: usize) { - let counts = self - .observed_chunks - .checked_add(1) - .zip(self.observed_bytes.checked_add(bytes)); - match counts { - Some((chunks, total_bytes)) - if chunks <= MAX_FINAL_ANSWER_CHUNKS && total_bytes <= MAX_FINAL_ANSWER_BYTES => - { - self.observed_chunks = chunks; - self.observed_bytes = total_bytes; - } - _ => self.limit_exceeded = true, - } - self.invalidate_candidate(); - self.wire_invalid = true; - } - - fn finish(&mut self, exact_end_turn: bool) -> Option { - let answer = if !self.candidate_invalid - && !self.limit_exceeded - && !self.wire_invalid - && exact_end_turn - && self.groups.len() == 1 - { - self.groups - .pop() - .filter(|answer| !answer.text.trim().is_empty()) - } else { - None - }; - *self = Self::default(); - answer - } -} - /// Errors that can occur in the ACP client. #[derive(Debug, thiserror::Error)] pub enum AcpError { @@ -387,19 +208,12 @@ pub struct AcpClient { /// outside of a goose-native turn — the read loop's steer arm is /// disabled in that case. steer_rx: Option>, - /// Usage tracker — accumulates cumulative token counts from - /// `_goose/unstable/session/update` notifications and computes per-turn - /// deltas. Both goose and buzz-agent emit this notification; goose gates - /// on client capability advertisement, buzz-agent emits unconditionally. + /// Usage tracker for goose/buzz-agent's cumulative notification format. goose_usage: UsageTracker, - /// Reset before each prompt; populated only by typed final-answer updates. - final_answer_collector: FinalAnswerCollector, - /// Session whose updates may contribute to the current typed result. - active_prompt_session_id: Option, - /// Completed typed result retained until the pool consumes it. This closes - /// the prompt/control race where `select!` can drop an already-completed - /// prompt future after `last_prompt_id` was cleared. - last_completed_prompt_result: Option, + /// Per-turn prompt-response usage and Claude's optional cumulative cost. + standard_usage: StandardUsageTracker, + /// Known adapter identity for prompt-response usage mapping. + standard_adapter: Option, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -637,31 +451,11 @@ impl AcpClient { /// `build_codex_config_env`. Pass `false` for test spawns and non-Codex agents. /// /// After spawning, call [`initialize`](Self::initialize) before any other method. - #[cfg(test)] pub async fn spawn( command: &str, args: &[String], extra_env: &[(String, String)], has_generated_codex_config: bool, - ) -> Result { - Self::spawn_with_identity( - command, - command, - args, - extra_env, - has_generated_codex_config, - ) - .await - } - - /// Spawn an agent executable while deriving runtime-specific defaults from - /// a separately supplied logical identity. Callers must authenticate it. - pub async fn spawn_with_identity( - command: &str, - agent_identity: &str, - args: &[String], - extra_env: &[(String, String)], - has_generated_codex_config: bool, ) -> Result { use std::process::Stdio; @@ -703,7 +497,7 @@ impl AcpClient { // Applied first so both persona `extra_env` (below, via `Command::env` // key replacement) and inherited parent env (via the parent-presence // check) override them. - for &(key, value) in crate::config::default_agent_env(agent_identity) { + for &(key, value) in crate::config::default_agent_env(command) { if std::env::var_os(key).is_none() { cmd.env(key, value); } @@ -732,6 +526,14 @@ impl AcpClient { // console-subsystem child process spawned from a GUI/non-console parent. configure_no_window(&mut cmd); + let standard_adapter = + match crate::config::normalize_agent_command_identity(command).as_str() { + "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" => { + Some(StandardAdapterKind::Claude) + } + "codex" | "codex-acp" => Some(StandardAdapterKind::Codex), + _ => None, + }; let mut child = cmd.spawn()?; let stdin = child @@ -759,9 +561,8 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), - final_answer_collector: FinalAnswerCollector::default(), - active_prompt_session_id: None, - last_completed_prompt_result: None, + standard_usage: StandardUsageTracker::default(), + standard_adapter, }) } @@ -958,25 +759,7 @@ impl AcpClient { idle_timeout: std::time::Duration, max_duration: std::time::Duration, ) -> Result { - self.session_prompt_turn_with_idle_timeout( - session_id, - prompt_text, - idle_timeout, - max_duration, - ) - .await - .map(|result| result.stop_reason) - } - - /// Send one prompt and preserve an adapter-typed terminal final answer. - pub async fn session_prompt_turn_with_idle_timeout( - &mut self, - session_id: &str, - prompt_text: &str, - idle_timeout: std::time::Duration, - max_duration: std::time::Duration, - ) -> Result { - self.session_prompt_turn_blocks_with_idle_timeout( + self.session_prompt_blocks_with_idle_timeout( session_id, std::slice::from_ref(&prompt_text), idle_timeout, @@ -998,28 +781,6 @@ impl AcpClient { idle_timeout: std::time::Duration, max_duration: std::time::Duration, ) -> Result { - self.session_prompt_turn_blocks_with_idle_timeout( - session_id, - prompt_blocks, - idle_timeout, - max_duration, - ) - .await - .map(|result| result.stop_reason) - } - - /// Multi-block prompt variant that preserves the typed terminal answer. - pub async fn session_prompt_turn_blocks_with_idle_timeout( - &mut self, - session_id: &str, - prompt_blocks: &[&str], - idle_timeout: std::time::Duration, - max_duration: std::time::Duration, - ) -> Result { - self.active_prompt_session_id = None; - self.final_answer_collector = FinalAnswerCollector::default(); - self.last_completed_prompt_result = None; - self.active_prompt_session_id = Some(session_id.to_owned()); let params = build_prompt_params(session_id, prompt_blocks); let hard_deadline = tokio::time::Instant::now() + max_duration; self.current_hard_deadline = Some(hard_deadline); @@ -1028,6 +789,7 @@ impl AcpClient { // prompt so that any setup notifications recorded earlier are not // misattributed to this turn. self.goose_usage.begin_turn(session_id); + self.standard_usage.begin_turn(session_id); self.last_prompt_id = Some(self.next_id); let id = self.next_id; @@ -1044,8 +806,6 @@ impl AcpClient { if let Err(e) = self.write_ndjson(&msg).await { self.last_prompt_id = None; self.current_hard_deadline = None; - self.active_prompt_session_id = None; - self.final_answer_collector = FinalAnswerCollector::default(); return Err(e); } @@ -1075,38 +835,7 @@ impl AcpClient { self.current_hard_deadline = None; } } - self.active_prompt_session_id = None; - let result = match result { - Ok(result) => result, - Err(error) => { - self.final_answer_collector = FinalAnswerCollector::default(); - return Err(error); - } - }; - // Codex ACP 1.1.14 calls waitForSessionNotifications(sessionId) before - // returning session/prompt, so this response is the verified adapter's - // terminal turn boundary. ACP updates expose no separate turn ID. - let exact_end_turn = - result.get("stopReason").and_then(|value| value.as_str()) == Some("end_turn"); - let stop_reason = match self.parse_stop_reason(&result) { - Ok(stop_reason) => stop_reason, - Err(error) => { - self.final_answer_collector = FinalAnswerCollector::default(); - return Err(error); - } - }; - let final_answer = self.final_answer_collector.finish(exact_end_turn); - let turn_result = PromptTurnResult { - stop_reason, - final_answer, - }; - self.last_completed_prompt_result = Some(turn_result.clone()); - Ok(turn_result) - } - - /// Consume the most recently completed typed prompt result, if any. - pub fn take_completed_prompt_result(&mut self) -> Option { - self.last_completed_prompt_result.take() + self.parse_prompt_response(session_id, &result?) } /// Send a `session/cancel` **notification** (no `id` field, no response expected). @@ -1152,18 +881,13 @@ impl AcpClient { self.steering_supported } - /// Consume and return the per-turn usage record computed from the most - /// recent `_goose/unstable/session/update` notification. - /// - /// Returns `None` if no usage update arrived since the last call (i.e. - /// the harness did not emit one for this turn, or this is not a goose - /// agent). Must be called at most once per turn; subsequent calls return - /// `None` until the next `usage_update` notification is recorded. - /// - /// Intended for consumption by `publish_agent_turn_metric` in `pool.rs` to - /// publish a kind 44200 NIP-AM event. + /// Consume per-turn usage for NIP-AM publishing. Goose/buzz-agent is an + /// exclusive cumulative path; standard ACP prompt usage is used only when + /// goose emitted nothing for this turn. pub fn take_turn_usage(&mut self) -> Option { - self.goose_usage.take() + let goose_usage = self.goose_usage.take(); + let standard_usage = self.standard_usage.take(); + goose_usage.or(standard_usage) } /// Notify the usage tracker that buzz-acp just spawned a new session. @@ -1174,6 +898,7 @@ impl AcpClient { /// never when attaching to a pre-existing session. pub(crate) fn notify_session_spawned(&mut self, session_id: &str) { self.goose_usage.seed_zero_baseline(session_id); + self.standard_usage.seed_zero_baseline(session_id); } /// Install a per-turn steer request channel for goose-native @@ -1297,8 +1022,6 @@ impl AcpClient { let prompt_id = self.last_prompt_id.take().ok_or_else(|| { AcpError::Protocol("cancel_with_cleanup called with no in-flight prompt".into()) })?; - self.active_prompt_session_id = None; - self.final_answer_collector = FinalAnswerCollector::default(); // Step 1: respond to any pending permission request with "cancelled", // but only if we haven't already responded (guards against double-response race). @@ -1335,7 +1058,7 @@ impl AcpClient { remaining, ) .await?; - self.parse_stop_reason(&result) + self.parse_prompt_response(session_id, &result) } /// Serialize `value` as a single NDJSON line and flush to the agent's stdin. @@ -1820,10 +1543,6 @@ impl AcpClient { let msg: serde_json::Value = match serde_json::from_str(trimmed) { Ok(v) => v, Err(e) => { - if self.active_prompt_session_id.is_some() { - self.final_answer_collector - .observe_unparseable_line(trimmed.len()); - } self.observe( "acp_parse_error", serde_json::json!({ @@ -2028,14 +1747,6 @@ impl AcpClient { /// needs no run id. fn handle_session_update(&mut self, msg: &serde_json::Value) -> bool { let update = &msg["params"]["update"]; - let update_session_id = msg["params"]["sessionId"].as_str(); - if self - .active_prompt_session_id - .as_deref() - .is_some_and(|expected| update_session_id == Some(expected)) - { - self.final_answer_collector.observe(update); - } let update_type = update .get("sessionUpdate") .and_then(|v| v.as_str()) @@ -2130,6 +1841,10 @@ impl AcpClient { } false } + "usage_update" => { + self.handle_standard_usage_update(msg); + false + } "keepalive" => false, other => { tracing::debug!(target: "acp::update", "session/update: {other}"); @@ -2138,6 +1853,30 @@ impl AcpClient { } } + /// Record the standard ACP cumulative cost notification when emitted by + /// Claude. Unlike Goose's payload, `used`/`size` are context occupancy and + /// are intentionally not mapped to token accounting. + fn handle_standard_usage_update(&mut self, msg: &serde_json::Value) { + if self.standard_adapter != Some(StandardAdapterKind::Claude) { + return; + } + let session_id = match msg + .pointer("/params/sessionId") + .and_then(serde_json::Value::as_str) + { + Some(session_id) => session_id, + None => return, + }; + let cost = match msg + .pointer("/params/update/cost/amount") + .and_then(serde_json::Value::as_f64) + { + Some(cost) => cost, + None => return, + }; + self.standard_usage.record_cost(session_id, cost); + } + /// Parse a `_goose/unstable/session/update` notification and record the /// usage snapshot in the per-session tracker. /// @@ -2269,6 +2008,28 @@ impl AcpClient { Ok(()) } + /// Parse a completed prompt response and retain its optional per-turn usage. + fn parse_prompt_response( + &mut self, + session_id: &str, + result: &serde_json::Value, + ) -> Result { + let stop_reason = self.parse_stop_reason(result)?; + if let Some(adapter) = self.standard_adapter { + match serde_json::from_value::(result["usage"].clone()) { + Ok(usage) => self + .standard_usage + .record_prompt_usage(session_id, usage, adapter), + Err(_) if result.get("usage").is_some() => tracing::debug!( + target: "acp::usage", + "session/prompt response contained malformed standard usage" + ), + Err(_) => {} + } + } + Ok(stop_reason) + } + /// Parse `stopReason` from a `session/prompt` result value. fn parse_stop_reason(&self, result: &serde_json::Value) -> Result { let raw = result["stopReason"].as_str().ok_or_else(|| { @@ -3198,13 +2959,36 @@ mod tests { .expect("failed to spawn test script") } + #[cfg(unix)] + async fn spawn_named_script(name: &str, script: &str) -> (AcpClient, std::path::PathBuf) { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!( + "buzz-acp-{name}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).expect("create temp adapter dir"); + let path = dir.join(name); + std::fs::write(&path, format!("#!/usr/bin/env bash\n{script}\n")) + .expect("write fake adapter"); + let mut permissions = std::fs::metadata(&path) + .expect("adapter metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("chmod fake adapter"); + let client = AcpClient::spawn(path.to_str().expect("utf8 path"), &[], &[], false) + .await + .expect("spawn named fake adapter"); + (client, dir) + } + /// Spawn a probe script whose file name carries a runtime identity (e.g. /// `hermes-acp`) and return the value of `var` as the child observed it. /// `` means the child did not receive the var. #[cfg(unix)] - async fn spawn_named_with_identity_and_read_child_env( + async fn spawn_named_and_read_child_env( file_name: &str, - runtime_identity: &str, var: &str, extra_env: &[(String, String)], ) -> String { @@ -3213,9 +2997,6 @@ mod tests { let dir = std::env::temp_dir().join(format!("buzz-acp-env-probe-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create env probe dir"); let path = dir.join(file_name); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create nested env probe dir"); - } std::fs::write( &path, format!("#!/bin/sh\nprintf '%s\\n' \"${{{var}:-}}\"\n"), @@ -3225,9 +3006,8 @@ mod tests { permissions.set_mode(0o700); std::fs::set_permissions(&path, permissions).expect("chmod probe"); - let mut client = AcpClient::spawn_with_identity( + let mut client = AcpClient::spawn( path.to_str().expect("probe path is UTF-8"), - runtime_identity, &[], extra_env, false, @@ -3245,67 +3025,6 @@ mod tests { observed } - #[cfg(unix)] - async fn spawn_named_and_read_child_env( - file_name: &str, - var: &str, - extra_env: &[(String, String)], - ) -> String { - spawn_named_with_identity_and_read_child_env(file_name, file_name, var, extra_env).await - } - - #[cfg(unix)] - #[tokio::test] - async fn spawn_uses_production_codex_identity_for_generic_verified_executable() { - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir().join(format!( - "buzz-acp-codex-identity-probe-{}", - uuid::Uuid::new_v4() - )); - let dist = dir.join("codex-acp/dist"); - std::fs::create_dir_all(&dist).expect("create Codex probe dist directory"); - let executable = dist.join("index.js"); - std::fs::write( - &executable, - "#!/bin/sh\ncase \"${CODEX_CONFIG:-}\" in *'\"network_access\":true'*) printf 'true\\n' ;; *) printf 'missing\\n' ;; esac\n", - ) - .expect("write Codex identity probe"); - let mut permissions = std::fs::metadata(&executable) - .expect("stat Codex identity probe") - .permissions(); - permissions.set_mode(0o700); - std::fs::set_permissions(&executable, permissions).expect("chmod Codex identity probe"); - - let runtime_identity = "codex"; - let network_env = - crate::config::codex_network_env(runtime_identity, "wss://relay.example.com") - .into_iter() - .collect::>(); - let mut client = AcpClient::spawn_with_identity( - executable.to_str().expect("probe path is UTF-8"), - runtime_identity, - &[], - &network_env, - true, - ) - .await - .expect("spawn Codex identity probe"); - let observed = client - .reader - .next() - .await - .expect("Codex identity probe produced output") - .expect("Codex identity probe stdout was readable"); - client.shutdown().await; - std::fs::remove_dir_all(&dir).expect("remove Codex identity probe directory"); - - assert_eq!( - observed, "true", - "logical Codex identity must enable network access even when the verified executable basename is index.js" - ); - } - /// Buzz-owned Hermes processes get the configured-MCP isolation default, /// and an explicit persona entry still overrides it (defaults are applied /// before `extra_env`, so the later `Command::env` write wins). @@ -3324,17 +3043,6 @@ mod tests { "1", "Hermes spawns must default {VAR}=1" ); - assert_eq!( - spawn_named_with_identity_and_read_child_env( - "hermes-acp/dist/index.js", - "hermes-acp", - VAR, - &[], - ) - .await, - "1", - "logical Hermes identity must survive executable canonicalization" - ); assert_eq!( spawn_named_and_read_child_env("hermes-acp", VAR, &[(VAR.into(), "0".into())]).await, "0", @@ -4651,6 +4359,254 @@ mod tests { } } + // ── Standard ACP prompt-response usage ───────────────────────────────── + + fn prompt_response_usage( + input: u64, + output: u64, + total: u64, + cached_read: Option, + cached_write: Option, + ) -> serde_json::Value { + let mut usage = serde_json::json!({ + "inputTokens": input, + "outputTokens": output, + "totalTokens": total, + }); + if let Some(cached_read) = cached_read { + usage["cachedReadTokens"] = serde_json::json!(cached_read); + } + if let Some(cached_write) = cached_write { + usage["cachedWriteTokens"] = serde_json::json!(cached_write); + } + serde_json::json!({"stopReason": "end_turn", "usage": usage}) + } + + fn standard_cost_update(session_id: &str, cost: f64) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": { + "sessionUpdate": "usage_update", + "cost": {"amount": cost, "currency": "USD"} + } + } + }) + } + + #[tokio::test] + async fn claude_prompt_response_usage_merges_with_cumulative_cost() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.notify_session_spawned("claude-session"); + client.standard_usage.begin_turn("claude-session"); + client.handle_session_update(&standard_cost_update("claude-session", 0.042)); + assert_eq!( + client + .parse_prompt_response( + "claude-session", + &prompt_response_usage(100, 20, 175, Some(30), Some(25)), + ) + .unwrap(), + StopReason::EndTurn + ); + + let usage = client.take_turn_usage().expect("prompt usage"); + assert!(usage.delta_reliable, "response tokens need no baseline"); + assert_eq!(usage.turn_input_tokens, Some(155)); + assert_eq!(usage.turn_output_tokens, Some(20)); + assert_eq!( + usage.turn_total_tokens, None, + "Claude total is adapter-derived" + ); + assert_eq!(usage.turn_cache_read_tokens, Some(30)); + assert_eq!(usage.turn_cache_write_tokens, Some(25)); + assert_eq!(usage.turn_cost_usd, Some(0.042)); + assert_eq!(usage.cumulative_cost_usd, Some(0.042)); + assert_eq!(usage.cumulative_input_tokens, None); + assert_eq!(usage.cumulative_output_tokens, None); + } + + #[tokio::test] + async fn codex_prompt_response_usage_preserves_provider_total_without_cost() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Codex); + client.standard_usage.begin_turn("codex-session"); + client.handle_session_update(&standard_cost_update("codex-session", 0.042)); + client + .parse_prompt_response( + "codex-session", + &prompt_response_usage(90, 10, 140, Some(40), None), + ) + .unwrap(); + + let usage = client.take_turn_usage().expect("prompt usage"); + assert!(usage.delta_reliable); + assert_eq!(usage.turn_input_tokens, Some(130)); + assert_eq!(usage.turn_output_tokens, Some(10)); + assert_eq!(usage.turn_total_tokens, Some(140)); + assert_eq!(usage.turn_cache_read_tokens, Some(40)); + assert_eq!(usage.turn_cache_write_tokens, None); + assert_eq!( + usage.cumulative_cost_usd, None, + "Codex cost update is ignored" + ); + assert_eq!(usage.cumulative_input_tokens, None); + assert_eq!(usage.cumulative_output_tokens, None); + } + + #[tokio::test] + async fn standard_prompt_input_overflow_fails_closed() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.standard_usage.begin_turn("overflow-session"); + client + .parse_prompt_response( + "overflow-session", + &prompt_response_usage(u64::MAX, 10, u64::MAX, Some(1), None), + ) + .unwrap(); + + assert!( + client.take_turn_usage().is_none(), + "overflow without another valid signal must not emit all-null usage" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn claude_named_adapter_wire_lifecycle_records_prompt_and_cost() { + let script = r#" + read -r REQ + ID=$(printf '%s' "$REQ" | sed -E 's/.*"id":([0-9]+).*/\1/') + echo '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"wire-session","update":{"sessionUpdate":"usage_update","cost":{"amount":0.5,"currency":"USD"}}}}' + echo '{"jsonrpc":"2.0","id":'"$ID"',"result":{"stopReason":"end_turn","usage":{"inputTokens":7,"outputTokens":3,"totalTokens":10,"cachedReadTokens":2}}}' + sleep 1 + "#; + let (mut client, dir) = spawn_named_script("claude-code", script).await; + assert_eq!(client.standard_adapter, Some(StandardAdapterKind::Claude)); + client.notify_session_spawned("wire-session"); + + let stop = client + .session_prompt_with_idle_timeout( + "wire-session", + "hello", + std::time::Duration::from_secs(2), + std::time::Duration::from_secs(5), + ) + .await + .expect("wire prompt"); + assert_eq!(stop, StopReason::EndTurn); + + let usage = client.take_turn_usage().expect("wire usage"); + assert_eq!(usage.turn_seq, 1); + assert_eq!(usage.turn_input_tokens, Some(9)); + assert_eq!(usage.turn_output_tokens, Some(3)); + assert_eq!(usage.turn_cost_usd, Some(0.5)); + assert_eq!(usage.cumulative_cost_usd, Some(0.5)); + drop(client); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn claude_cost_only_record_survives_missing_prompt_usage() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.notify_session_spawned("cost-only-session"); + client.standard_usage.begin_turn("cost-only-session"); + client.handle_session_update(&standard_cost_update("cost-only-session", 0.125)); + + let usage = client.take_turn_usage().expect("cost-only usage"); + assert_eq!(usage.turn_seq, 1); + assert!(usage.delta_reliable); + assert_eq!(usage.turn_input_tokens, None); + assert_eq!(usage.turn_cost_usd, Some(0.125)); + assert_eq!(usage.cumulative_cost_usd, Some(0.125)); + } + + #[tokio::test] + async fn attached_claude_session_does_not_invent_first_cost_delta() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.standard_usage.begin_turn("attached-session"); + client.handle_session_update(&standard_cost_update("attached-session", 1.25)); + client + .parse_prompt_response( + "attached-session", + &prompt_response_usage(10, 2, 12, None, None), + ) + .unwrap(); + + let usage = client.take_turn_usage().expect("attached usage"); + assert_eq!(usage.turn_cost_usd, None); + assert_eq!(usage.cumulative_cost_usd, Some(1.25)); + } + + #[tokio::test] + async fn standard_usage_two_prompts_preserve_both_monotonic_sequences() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.notify_session_spawned("two-prompt-session"); + + client.standard_usage.begin_turn("two-prompt-session"); + client.handle_session_update(&standard_cost_update("two-prompt-session", 0.1)); + client + .parse_prompt_response( + "two-prompt-session", + &prompt_response_usage(10, 2, 12, None, None), + ) + .unwrap(); + let initial = client.take_turn_usage().expect("initial prompt usage"); + + client.standard_usage.begin_turn("two-prompt-session"); + client.handle_session_update(&standard_cost_update("two-prompt-session", 0.25)); + client + .parse_prompt_response( + "two-prompt-session", + &prompt_response_usage(20, 3, 23, None, None), + ) + .unwrap(); + let user = client.take_turn_usage().expect("user prompt usage"); + + assert_eq!((initial.turn_seq, user.turn_seq), (1, 2)); + assert_eq!( + (initial.turn_input_tokens, user.turn_input_tokens), + (Some(10), Some(20)) + ); + assert_eq!( + (initial.turn_cost_usd, user.turn_cost_usd), + (Some(0.1), Some(0.15)) + ); + } + + #[tokio::test] + async fn goose_usage_stays_exclusive_and_drains_standard_usage() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.goose_usage.begin_turn("goose-session"); + client.standard_usage.begin_turn("goose-session"); + client.handle_goose_usage_update(&goose_usage_update_msg("goose-session", 1000, 200, None)); + client + .parse_prompt_response( + "goose-session", + &prompt_response_usage(100, 20, 120, None, None), + ) + .unwrap(); + + let usage = client.take_turn_usage().expect("goose usage"); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert_eq!( + usage.turn_input_tokens, None, + "goose first delta remains exclusive" + ); + assert!( + client.take_turn_usage().is_none(), + "standard usage was drained" + ); + } + // ── Goose usage notification integration ────────────────────────────── /// Build a `_goose/unstable/session/update` JSON-RPC notification. @@ -5001,389 +4957,4 @@ mod tests { "error must mention sandbox_workspace_write" ); } - - #[test] - fn typed_final_answer_is_deliverable_only_after_end_turn() { - let mut collector = FinalAnswerCollector::default(); - collector.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text", "text": "hello"}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - - assert_eq!(collector.finish(false), None); - assert!(collector.groups.is_empty()); - - collector.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text", "text": "hello"}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - assert_eq!( - collector.finish(true), - Some(FinalAnswer { - message_id: "message-1".into(), - text: "hello".into(), - }) - ); - } - - #[test] - fn final_answer_before_last_tool_boundary_is_not_deliverable() { - let mut collector = FinalAnswerCollector::default(); - collector.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text", "text": "premature"}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - collector.observe(&serde_json::json!({ - "sessionUpdate": "tool_call", - "toolCallId": "tool-1" - })); - - assert_eq!(collector.finish(true), None); - } - - #[test] - fn ambiguous_or_nonterminal_wire_shapes_fail_closed() { - let cases = [ - ( - serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": " ", - "content": {"type": "text", "text": "blank id"}, - "_meta": {"codex": {"phase": "final_answer"}} - }), - StopReason::EndTurn, - ), - ( - serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text", "text": "commentary"}, - "_meta": {"codex": {"phase": "commentary"}} - }), - StopReason::EndTurn, - ), - ( - serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text", "text": "cancelled"}, - "_meta": {"codex": {"phase": "final_answer"}} - }), - StopReason::Cancelled, - ), - ( - serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text", "text": "refusal"}, - "_meta": {"codex": {"phase": "final_answer"}} - }), - StopReason::Refusal, - ), - ( - serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text", "text": " \n"}, - "_meta": {"codex": {"phase": "final_answer"}} - }), - StopReason::EndTurn, - ), - ]; - - for (update, stop_reason) in cases { - let mut collector = FinalAnswerCollector::default(); - collector.observe(&update); - assert_eq!(collector.finish(stop_reason == StopReason::EndTurn), None); - } - - let mut multiple = FinalAnswerCollector::default(); - for message_id in ["message-1", "message-2"] { - multiple.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": message_id, - "content": {"type": "text", "text": "ambiguous"}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - } - assert_eq!(multiple.finish(true), None); - } - - #[test] - fn malformed_final_chunk_poisons_existing_candidate_until_tool_boundary() { - let malformed_updates = [ - serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "missing id"}, - "_meta": {"codex": {"phase": "final_answer"}} - }), - serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "image", "text": "not text content"}, - "_meta": {"codex": {"phase": "final_answer"}} - }), - serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text"}, - "_meta": {"codex": {"phase": "final_answer"}} - }), - ]; - - for malformed in malformed_updates { - let mut collector = FinalAnswerCollector::default(); - collector.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text", "text": "partial"}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - collector.observe(&malformed); - assert_eq!(collector.finish(true), None); - } - - let mut collector = FinalAnswerCollector::default(); - collector.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "missing id"}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - assert!(collector.candidate_invalid); - - collector.observe(&serde_json::json!({ - "sessionUpdate": "tool_call", - "toolCallId": "tool-1" - })); - collector.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-2", - "content": {"type": "text", "text": "complete"}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - assert_eq!( - collector.finish(true), - Some(FinalAnswer { - message_id: "message-2".into(), - text: "complete".into(), - }) - ); - } - - #[test] - fn final_answer_aggregate_limits_fail_closed_across_tool_boundaries() { - let mut bytes = FinalAnswerCollector::default(); - let half = "x".repeat(MAX_FINAL_ANSWER_BYTES / 2); - for (message_id, text) in [("message-1", half.as_str()), ("message-2", half.as_str())] { - bytes.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": message_id, - "content": {"type": "text", "text": text}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - if message_id == "message-1" { - bytes.observe(&serde_json::json!({ - "sessionUpdate": "tool_call", - "toolCallId": "tool-1" - })); - } - } - assert!(bytes.limit_exceeded); - assert_eq!(bytes.finish(true), None); - - let mut chunks = FinalAnswerCollector::default(); - let update = serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "m", - "content": {"type": "text", "text": ""}, - "_meta": {"codex": {"phase": "final_answer"}} - }); - for index in 0..=MAX_FINAL_ANSWER_CHUNKS { - chunks.observe(&update); - if index == MAX_FINAL_ANSWER_CHUNKS / 2 { - chunks.observe(&serde_json::json!({ - "sessionUpdate": "tool_call", - "toolCallId": "tool-1" - })); - } - } - assert!(chunks.limit_exceeded); - assert_eq!(chunks.finish(true), None); - } - - #[test] - fn malformed_final_chunks_consume_nonresettable_aggregate_budgets() { - let malformed = serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "malformed", - "content": {"type": "image", "text": "not text content"}, - "_meta": {"codex": {"phase": "final_answer"}} - }); - let valid_tool = serde_json::json!({ - "sessionUpdate": "tool_call", - "toolCallId": "tool-1" - }); - let valid_answer = serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "valid", - "content": {"type": "text", "text": "answer"}, - "_meta": {"codex": {"phase": "final_answer"}} - }); - - let mut chunks = FinalAnswerCollector::default(); - for _ in 0..=MAX_FINAL_ANSWER_CHUNKS { - chunks.observe(&malformed); - } - chunks.observe(&valid_tool); - chunks.observe(&valid_answer); - assert!(chunks.limit_exceeded); - assert_eq!(chunks.finish(true), None); - - let mut bytes = FinalAnswerCollector::default(); - bytes.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "malformed", - "content": { - "type": "image", - "text": "x".repeat(MAX_FINAL_ANSWER_BYTES) - }, - "_meta": {"codex": {"phase": "final_answer"}} - })); - bytes.observe(&valid_tool); - bytes.observe(&valid_answer); - assert!(bytes.limit_exceeded); - assert_eq!(bytes.finish(true), None); - } - - #[test] - fn malformed_outer_final_chunks_poison_and_consume_aggregate_budgets() { - let valid_answer = serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "valid", - "content": {"type": "text", "text": "answer"}, - "_meta": {"codex": {"phase": "final_answer"}} - }); - let malformed_outer = serde_json::json!({ - "messageId": "malformed", - "content": {"type": "text", "text": "hidden"}, - "_meta": {"codex": {"phase": "final_answer"}} - }); - - let mut poisoned = FinalAnswerCollector::default(); - poisoned.observe(&valid_answer); - poisoned.observe(&malformed_outer); - assert_eq!(poisoned.finish(true), None); - - let mut limited = FinalAnswerCollector::default(); - for _ in 0..=MAX_FINAL_ANSWER_CHUNKS { - limited.observe(&malformed_outer); - } - limited.observe(&serde_json::json!({ - "sessionUpdate": "tool_call", - "toolCallId": "tool-1" - })); - limited.observe(&valid_answer); - assert!(limited.limit_exceeded); - assert_eq!(limited.finish(true), None); - } - - #[test] - fn malformed_tool_boundary_cannot_reenable_collection() { - let mut collector = FinalAnswerCollector::default(); - collector.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-1", - "content": {"type": "text", "text": "before"}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - collector.observe(&serde_json::json!({"sessionUpdate": "tool_call"})); - collector.observe(&serde_json::json!({ - "sessionUpdate": "agent_message_chunk", - "messageId": "message-2", - "content": {"type": "text", "text": "after malformed boundary"}, - "_meta": {"codex": {"phase": "final_answer"}} - })); - assert_eq!(collector.finish(true), None); - } - - #[tokio::test] - async fn noncanonical_end_turn_does_not_classify_final_answer() { - for raw_stop_reason in ["END_TURN", "End_Turn"] { - let script = format!( - "read _; printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{{\"sessionId\":\"session-1\",\"update\":{{\"sessionUpdate\":\"agent_message_chunk\",\"messageId\":\"message-1\",\"content\":{{\"type\":\"text\",\"text\":\"hello\"}},\"_meta\":{{\"codex\":{{\"phase\":\"final_answer\"}}}}}}}}}}' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{{\"stopReason\":\"{raw_stop_reason}\"}}}}'; sleep 1" - ); - let mut client = spawn_script(&script).await; - let result = client - .session_prompt_turn_with_idle_timeout( - "session-1", - "prompt", - std::time::Duration::from_secs(1), - std::time::Duration::from_secs(2), - ) - .await - .expect("case-insensitive legacy stop parsing remains supported"); - assert_eq!(result.stop_reason, StopReason::EndTurn); - assert_eq!(result.final_answer, None); - } - } - - #[tokio::test] - async fn session_prompt_returns_typed_final_answer_from_wire() { - let mut client = spawn_script( - r#"read _; printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-other","update":{"sessionUpdate":"agent_message_chunk","messageId":"wrong-session","content":{"type":"text","text":"wrong"},"_meta":{"codex":{"phase":"final_answer"}}}}}' '{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"agent_message_chunk","messageId":"missing-session","content":{"type":"text","text":"wrong"},"_meta":{"codex":{"phase":"final_answer"}}}}}' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"message-1","content":{"type":"text","text":"hello"},"_meta":{"codex":{"phase":"final_answer"}}}}}' '{"jsonrpc":"2.0","id":0,"result":{"stopReason":"end_turn"}}'; sleep 1"#, - ) - .await; - - let result = client - .session_prompt_turn_with_idle_timeout( - "session-1", - "prompt", - std::time::Duration::from_secs(1), - std::time::Duration::from_secs(2), - ) - .await - .expect("prompt result"); - - assert_eq!(result.stop_reason, StopReason::EndTurn); - assert_eq!( - client.take_completed_prompt_result(), - Some(result.clone()), - "pool/control races must still be able to consume the completed turn" - ); - assert_eq!(client.take_completed_prompt_result(), None); - assert_eq!( - result.final_answer, - Some(FinalAnswer { - message_id: "message-1".into(), - text: "hello".into(), - }) - ); - } - - #[tokio::test] - async fn invalid_ndjson_during_prompt_poisons_typed_final_answer() { - let mut client = spawn_script( - r#"read _; printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"message-1","content":{"type":"text","text":"partial"},"_meta":{"codex":{"phase":"final_answer"}}}}}' '{"jsonrpc":"2.0","method":"session/update"' '{"jsonrpc":"2.0","id":0,"result":{"stopReason":"end_turn"}}'; sleep 1"#, - ) - .await; - - let result = client - .session_prompt_turn_with_idle_timeout( - "session-1", - "prompt", - std::time::Duration::from_secs(1), - std::time::Duration::from_secs(2), - ) - .await - .expect("prompt result"); - - assert_eq!(result.stop_reason, StopReason::EndTurn); - assert_eq!(result.final_answer, None); - } } diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index e47f125f84d..f9e7bf1ed8a 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -191,10 +191,6 @@ pub struct AuthAgentArgs { #[arg(long, env = "BUZZ_ACP_AGENT_COMMAND", default_value = "goose")] pub agent_command: String, - /// Trusted logical runtime identity when the executable has a generic name. - #[arg(long, env = "BUZZ_ACP_AGENT_IDENTITY", hide = true)] - pub agent_identity: Option, - /// Arguments passed to the agent binary. #[arg( long, @@ -254,10 +250,6 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_AGENT_COMMAND", default_value = "goose")] pub agent_command: String, - /// Trusted logical runtime identity when the executable has a generic name. - #[arg(long, env = "BUZZ_ACP_AGENT_IDENTITY", hide = true)] - pub agent_identity: Option, - #[arg( long, env = "BUZZ_ACP_AGENT_ARGS", @@ -490,6 +482,13 @@ pub struct CliArgs { /// Connect and subscribe before starting the ACP/LLM subprocess pool. #[arg(long, env = "BUZZ_ACP_LAZY_POOL", default_value_t = false)] pub lazy_pool: bool, + + /// Tear the woken pool back down to the lazy empty-slot state after this + /// many seconds with no dispatched turn in flight and an empty queue, + /// releasing worker subprocesses until the next accepted event re-wakes. + /// Requires `--lazy-pool`; ignored otherwise. 0 disables idle re-sleep. + #[arg(long, env = "BUZZ_ACP_IDLE_POOL_SLEEP", default_value_t = 0)] + pub idle_pool_sleep: u64, } /// Merged NIP-01 subscription filter for a single channel. @@ -506,7 +505,6 @@ pub struct Config { pub keys: Keys, pub relay_url: String, pub agent_command: String, - pub agent_identity: String, pub agent_args: Vec, pub mcp_command: String, pub idle_timeout_secs: u64, @@ -568,6 +566,10 @@ pub struct Config { pub exit_after_inactivity_secs: u64, /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives. pub lazy_pool: bool, + /// Seconds with no dispatched turn in flight and an empty queue before a + /// woken lazy pool is torn back down to the empty-slot state. 0 = disabled. + /// Only meaningful when `lazy_pool` is true. + pub idle_pool_sleep_secs: u64, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, @@ -921,14 +923,7 @@ impl Config { )); } - let agent_identity = args.agent_identity.unwrap_or_else(|| agent_command.clone()); - if agent_identity.trim().is_empty() { - return Err(ConfigError::ConfigFile( - "agent_identity must not be empty".into(), - )); - } - - let agent_args = normalize_agent_args(&agent_identity, args.agent_args); + let agent_args = normalize_agent_args(&agent_command, args.agent_args); if let Some(ref channels) = args.channels { for ch in channels { @@ -1067,7 +1062,7 @@ impl Config { // opens the Seatbelt network sandbox for buzz-cli (an MCP subprocess). No-op // for non-Codex agents or unparseable relay URLs. let has_generated_codex_config = - if let Some(network_env) = codex_network_env(&agent_identity, &args.relay_url) { + if let Some(network_env) = codex_network_env(&agent_command, &args.relay_url) { persona_env_vars.push(network_env); true } else { @@ -1080,7 +1075,6 @@ impl Config { keys, relay_url: args.relay_url, agent_command, - agent_identity, agent_args, mcp_command: args.mcp_command, idle_timeout_secs, @@ -1124,6 +1118,7 @@ impl Config { relay_observer: args.relay_observer, exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, + idle_pool_sleep_secs: args.idle_pool_sleep, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, base_prompt_content, @@ -1460,7 +1455,6 @@ mod tests { keys: nostr::Keys::generate(), relay_url: "ws://localhost:3000".into(), agent_command: "goose".into(), - agent_identity: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "".into(), idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS, @@ -1496,6 +1490,7 @@ mod tests { relay_observer: false, exit_after_inactivity_secs: 0, lazy_pool: false, + idle_pool_sleep_secs: 0, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -2216,6 +2211,22 @@ channels = "ALL" assert!(!CliArgs::parse_from(["buzz-acp", "--private-key", &key]).lazy_pool); } + #[test] + fn idle_pool_sleep_defaults_disabled_and_accepts_cli_value() { + let key = "0".repeat(64); + let default = CliArgs::parse_from(["buzz-acp", "--private-key", &key]); + assert_eq!(default.idle_pool_sleep, 0); + + let configured = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &key, + "--idle-pool-sleep", + "300", + ]); + assert_eq!(configured.idle_pool_sleep, 300); + } + #[test] fn lazy_pool_cli_flag_enables_deferred_startup() { let key = "0".repeat(64); @@ -2760,37 +2771,6 @@ channels = "ALL" const TEST_PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; - #[test] - fn production_codex_identity_controls_config_for_generic_executable() { - let args = CliArgs::try_parse_from([ - "buzz-acp", - "--private-key", - TEST_PRIVATE_KEY, - "--relay-url", - "wss://relay.example.com", - "--agent-command", - "/opt/codex-acp/dist/index.js", - "--agent-identity", - "codex", - ]) - .expect("clap should parse separate command and identity"); - let config = Config::from_args(args).expect("logical Codex identity should configure"); - - assert_eq!(config.agent_identity, "codex"); - assert!(config.has_generated_codex_config); - let network_access = config - .persona_env_vars - .iter() - .find(|(key, _)| key == "CODEX_CONFIG") - .and_then(|(_, value)| serde_json::from_str::(value).ok()) - .and_then(|value| { - value - .pointer("/sandbox_workspace_write/network_access") - .and_then(serde_json::Value::as_bool) - }); - assert_eq!(network_access, Some(true)); - } - #[test] fn allowed_respond_to_full_path_rejects_disallowed_mode() { // --allowed-respond-to=owner-only,allowlist + --respond-to=anyone → ConfigError diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 8921fce6a80..27b9000b7bb 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1468,6 +1468,33 @@ fn inactivity_expired( !bound.is_zero() && !turn_in_flight && now.duration_since(last_activity) >= bound } +/// Whether a woken lazy pool may be torn back down to the empty-slot state. +/// +/// True only when the pool is ready, the idle bound has elapsed with no +/// dispatched turn or heartbeat in flight and no in-flight prompt tasks, no +/// work is queued, and no wake/respawn task is running. The queue and task +/// gates make teardown race-safe with enqueue/wake: an event that landed in +/// the queue (or a wake/respawn already in flight) blocks this decision, so a +/// queued batch is never stranded — the caller's next loop iteration will +/// dispatch or wake it instead. +#[allow(clippy::too_many_arguments)] +fn idle_pool_sleep_due( + pool_ready: bool, + last_activity: tokio::time::Instant, + now: tokio::time::Instant, + bound: Duration, + turn_in_flight: bool, + prompt_tasks_in_flight: bool, + work_queued: bool, + wake_or_respawn_in_flight: bool, +) -> bool { + pool_ready + && !work_queued + && !prompt_tasks_in_flight + && !wake_or_respawn_in_flight + && inactivity_expired(last_activity, now, bound, turn_in_flight) +} + #[cfg(test)] mod inactivity_tests { use super::*; @@ -1512,6 +1539,179 @@ mod inactivity_tests { } } +#[cfg(test)] +mod idle_pool_sleep_tests { + use super::*; + + // The all-clear baseline: pool ready, bound elapsed, nothing busy or + // queued. Every negative case below flips exactly one gate off this. + fn ready_after_bound() -> (tokio::time::Instant, tokio::time::Instant, Duration) { + let started = tokio::time::Instant::now(); + ( + started, + started + Duration::from_secs(61), + Duration::from_secs(60), + ) + } + + #[test] + fn sleeps_when_ready_idle_and_quiet() { + let (last, now, bound) = ready_after_bound(); + assert!(idle_pool_sleep_due( + true, last, now, bound, false, false, false, false + )); + } + + #[test] + fn zero_bound_never_sleeps() { + let (last, now, _) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, + last, + now, + Duration::ZERO, + false, + false, + false, + false + )); + } + + #[test] + fn not_ready_never_sleeps() { + // A still-sleeping (or waking) pool must not "re-sleep". + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + false, last, now, bound, false, false, false, false + )); + } + + #[test] + fn active_turn_defers_sleep() { + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, last, now, bound, true, false, false, false + )); + } + + #[test] + fn in_flight_prompt_task_defers_sleep() { + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, last, now, bound, false, true, false, false + )); + } + + #[test] + fn queued_work_at_boundary_defers_sleep() { + // Enqueue-at-teardown protection: a batch sitting in the queue blocks + // teardown so it is never stranded — the loop dispatches it instead. + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, last, now, bound, false, false, true, false + )); + } + + #[test] + fn wake_or_respawn_in_flight_defers_sleep() { + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, last, now, bound, false, false, false, true + )); + } + + #[test] + fn recent_activity_defers_sleep() { + // Activity 50s ago under a 60s bound: not yet idle. + let started = tokio::time::Instant::now(); + let recent = started + Duration::from_secs(50); + let now = started + Duration::from_secs(59); + assert!(!idle_pool_sleep_due( + true, + recent, + now, + Duration::from_secs(60), + false, + false, + false, + false + )); + } + + fn slot(respawn_in_flight: bool) -> SlotCircuit { + SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight, + } + } + + // The call-site signal for the `wake_or_respawn_in_flight` gate is + // `any_respawn_in_flight(&crash_history)`, NOT `!respawn_tasks.is_empty()`. + // Regression for the PR #5682 review blocker: completed respawn tasks are + // never joined from the `respawn_tasks` JoinSet (their payloads arrive + // out-of-band via `respawn_rx`), so `!is_empty()` stays true forever after + // the first refill/crash recovery and the pool could never re-sleep. The + // authoritative signal clears per-slot when the payload is received. + #[test] + fn respawn_in_flight_signal_gates_then_clears_for_sleep() { + let (last, now, bound) = ready_after_bound(); + + // A respawn in flight for any slot defers sleep. + let busy = [slot(false), slot(true), slot(false)]; + assert!(any_respawn_in_flight(&busy)); + assert!(!idle_pool_sleep_due( + true, + last, + now, + bound, + false, + false, + false, + any_respawn_in_flight(&busy), + )); + + // Once the respawn completes (payload received → flag cleared), the + // signal goes false and the otherwise-quiet pool becomes sleep-eligible + // — even though a naive `!JoinSet.is_empty()` would still be stuck true. + let quiet = [slot(false), slot(false), slot(false)]; + assert!(!any_respawn_in_flight(&quiet)); + assert!(idle_pool_sleep_due( + true, + last, + now, + bound, + false, + false, + false, + any_respawn_in_flight(&quiet), + )); + } + + // The reaper (`respawn_tasks.join_next().now_or_never()` loop) must drain + // completed handles so the JoinSet does not grow without bound and so + // `!respawn_tasks.is_empty()` cannot become a permanent busy bit if anyone + // ever reintroduces it as the gate signal. + #[tokio::test] + async fn completed_respawn_tasks_are_reaped_from_the_joinset() { + let mut respawn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); + respawn_tasks.spawn(async {}); + respawn_tasks.spawn(async {}); + // Let both tasks run to completion. + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(10)).await; + + // The reaper drains finished handles non-blockingly. + while respawn_tasks.join_next().now_or_never().flatten().is_some() {} + + assert!( + respawn_tasks.is_empty(), + "completed respawn tasks must be reaped so the set does not wedge \ + the idle-sleep gate or grow unbounded" + ); + } +} + pub fn run() -> Result<()> { config::propagate_legacy_env_vars(); tokio_main() @@ -1841,7 +2041,7 @@ async fn tokio_main() -> Result<()> { .as_deref() .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, - harness_name: crate::config::normalize_agent_command_identity(&config.agent_identity), + harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), }); @@ -1900,6 +2100,27 @@ async fn tokio_main() -> Result<()> { )) }; + // Idle pool re-sleep: tear a woken lazy pool back down to the empty-slot + // state after `idle_pool_sleep_bound` of quiet, releasing worker + // subprocesses. The next accepted event re-wakes it through the same lazy + // path. Only meaningful under `lazy_pool`; the tick arm additionally gates + // on `pool_ready`, so a still-sleeping pool never re-sleeps. Reuses the + // `last_activity` clock the dispatch path already maintains. + let idle_pool_sleep_bound = if config.lazy_pool { + Duration::from_secs(config.idle_pool_sleep_secs) + } else { + Duration::ZERO + }; + let mut idle_pool_sleep_reaper = if idle_pool_sleep_bound.is_zero() { + None + } else { + let interval = idle_pool_sleep_bound.min(Duration::from_secs(30)); + Some(tokio::time::interval_at( + tokio::time::Instant::now() + interval, + interval, + )) + }; + // Runs at the TOP of every loop iteration via Instant check — cannot be // starved by the biased select. Slot refill spawns background tasks so // spawn_and_init never blocks the main loop. @@ -2056,16 +2277,13 @@ async fn tokio_main() -> Result<()> { slot.respawn_in_flight = true; tracing::info!(agent = idx, "slot refill: spawning background respawn"); let cmd = config.agent_command.clone(); - let identity = config.agent_identity.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); let has_codex = config.has_generated_codex_config; let observer = observer.clone(); let guard = RespawnGuard::new(idx, respawn_tx.clone()); respawn_tasks.spawn(async move { - let result = - spawn_and_init(&cmd, &identity, &args, &env, has_codex, idx, observer) - .await; + let result = spawn_and_init(&cmd, &args, &env, has_codex, idx, observer).await; guard.send(result); }); } @@ -2110,6 +2328,17 @@ async fn tokio_main() -> Result<()> { } } } + // Reap completed respawn handles from the JoinSet. Payloads are + // delivered out-of-band through `respawn_rx` (drained above), so the + // JoinSet is never joined by the normal flow — Tokio retains finished + // tasks until `join_next`, so without this the set grows on every + // refill/crash recovery and `!respawn_tasks.is_empty()` would stay true + // forever. Non-blocking (`now_or_never`), same pattern as + // `drain_ready_join_results` for `pool.join_set`. The authoritative + // in-flight signal is `any_respawn_in_flight(&crash_history)` (each + // slot's `respawn_in_flight` is cleared when its payload is received), + // not JoinSet occupancy. + while respawn_tasks.join_next().now_or_never().flatten().is_some() {} // Flush requeued events that were waiting for a live agent. Without // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. @@ -2602,6 +2831,56 @@ async fn tokio_main() -> Result<()> { } None } + _ = async { + match idle_pool_sleep_reaper.as_mut() { + Some(timer) => timer.tick().await, + None => std::future::pending().await, + } + } => { + let _ = result_rx; // end split borrow before touching pool + // A wake in flight (pool not yet ready) is covered by the + // pool_ready gate; respawn tasks and in-flight prompt tasks + // are the remaining "busy" signals. Never sleep mid-work: + // `has_undispatched_work()` (not `has_flushable_work()`) + // keeps `work_queued` true for a retry-throttled batch too, + // so a failed turn awaiting backoff is never stranded — the + // next iteration dispatches or re-wakes it. + if idle_pool_sleep_due( + pool_ready, + last_activity, + tokio::time::Instant::now(), + idle_pool_sleep_bound, + queue.has_in_flight() || heartbeat_in_flight, + !pool.join_set.is_empty(), + queue.has_undispatched_work(), + !wake_tasks.is_empty() + || any_respawn_in_flight(&crash_history), + ) { + tracing::info!( + idle_pool_sleep_seconds = config.idle_pool_sleep_secs, + "idle pool sleep bound reached — tearing pool back to lazy state" + ); + shutdown_agent_pool(&mut pool).await; + // Return to the exact pre-wake lazy state: empty slots, + // Listening lifecycle. The top-of-loop wake path re-wakes + // on the next accepted event. No second lifecycle. + pool = AgentPool::from_slots( + (0..config.agents).map(|_| None).collect(), + ); + pool_ready = false; + pool_lifecycle = PoolLifecycle::listening(); + last_activity = tokio::time::Instant::now(); + emit_runtime_lifecycle( + observer.as_ref(), + &runtime_start_nonce, + &pubkey_hex, + &config.relay_url, + "listening", + None, + ); + } + None + } _ = async { match heartbeat.as_mut() { Some(hb) => hb.tick().await, @@ -3423,14 +3702,6 @@ fn handle_prompt_result( pool.task_map_mut() .retain(|_, meta| meta.agent_index != agent_index); debug_assert_eq!(before, pool.task_map().len() + 1); - if let Some(final_answer) = result.final_answer.as_ref() { - tracing::debug!( - target: "pool::final-answer", - message_id = %final_answer.message_id, - bytes = final_answer.text.len(), - "captured fail-closed terminal ACP answer; delivery is intentionally out of scope", - ); - } if let PromptSource::Channel(channel_id) = &result.source { // The task may have invalidated this session before returning. Never // resurrect delivery state for a dead session; its replacement must @@ -3881,7 +4152,6 @@ fn recover_panicked_agent( // Spawn respawn work off the main loop. slot.respawn_in_flight = true; let cmd = config.agent_command.clone(); - let identity = config.agent_identity.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); let has_codex = config.has_generated_codex_config; @@ -3890,7 +4160,7 @@ fn recover_panicked_agent( if !delay.is_zero() { tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &identity, &args, &env, has_codex, i, observer).await; + let result = spawn_and_init(&cmd, &args, &env, has_codex, i, observer).await; guard.send(result); }); } @@ -4077,7 +4347,6 @@ fn spawn_respawn_task( // Spawn the actual work (shutdown + sleep + spawn + init) off the main loop. let cmd = config.agent_command.clone(); - let identity = config.agent_identity.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); let has_codex = config.has_generated_codex_config; @@ -4092,7 +4361,7 @@ fn spawn_respawn_task( tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &identity, &args, &env, has_codex, index, observer).await; + let result = spawn_and_init(&cmd, &args, &env, has_codex, index, observer).await; guard.send(result); }); @@ -4133,7 +4402,6 @@ async fn shutdown_agent_pool(pool: &mut AgentPool) { struct PoolStartup { agents: u32, command: String, - identity: String, args: Vec, extra_env: Vec<(String, String)>, has_generated_codex_config: bool, @@ -4146,7 +4414,6 @@ impl PoolStartup { Self { agents: config.agents, command: config.agent_command.clone(), - identity: config.agent_identity.clone(), args: config.agent_args.clone(), extra_env: config.persona_env_vars.clone(), has_generated_codex_config: config.has_generated_codex_config, @@ -4164,9 +4431,8 @@ async fn initialize_agent_pool( // Attempt each spawn under a 60-second timeout; a partial pool is valid. let mut agent_slots: Vec> = Vec::with_capacity(startup.agents as usize); for i in 0..startup.agents as usize { - let spawn_result = AcpClient::spawn_with_identity( + let spawn_result = AcpClient::spawn( &startup.command, - &startup.identity, &startup.args, &startup.extra_env, startup.has_generated_codex_config, @@ -4267,22 +4533,15 @@ async fn initialize_agent_pool( /// borrowing `Config`. All respawn/refill paths use this. async fn spawn_and_init( command: &str, - identity: &str, args: &[String], extra_env: &[(String, String)], has_generated_codex_config: bool, agent_index: usize, observer: Option, ) -> Result<(AcpClient, u32, String)> { - let mut acp = AcpClient::spawn_with_identity( - command, - identity, - args, - extra_env, - has_generated_codex_config, - ) - .await - .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; + let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config) + .await + .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; acp.set_observer(observer, agent_index); match acp.initialize().await { @@ -4310,12 +4569,8 @@ async fn spawn_and_init( } async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result { - let identity = agent - .agent_identity - .as_deref() - .unwrap_or(&agent.agent_command); - let agent_args = config::normalize_agent_args(identity, agent.agent_args.clone()); - AcpClient::spawn_with_identity(&agent.agent_command, identity, &agent_args, &[], false).await + let agent_args = config::normalize_agent_args(&agent.agent_command, agent.agent_args.clone()); + AcpClient::spawn(&agent.agent_command, &agent_args, &[], false).await } fn extract_auth_methods(init_result: &serde_json::Value) -> Vec { @@ -4436,12 +4691,7 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { async fn run_models(args: ModelsArgs) -> Result<()> { use acp::{extract_model_config_options, extract_model_state}; - let identity = args - .agent - .agent_identity - .as_deref() - .unwrap_or(&args.agent.agent_command); - let agent_args = config::normalize_agent_args(identity, args.agent.agent_args); + let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args); let cwd = std::env::current_dir() .unwrap_or_else(|_| std::path::PathBuf::from("/")) .to_string_lossy() @@ -4449,21 +4699,14 @@ async fn run_models(args: ModelsArgs) -> Result<()> { // Spawn outside the timeout so we always own the child for cleanup. // `models` subcommand doesn't use persona packs — no extra env, no codex config. - let mut client = match AcpClient::spawn_with_identity( - &args.agent.agent_command, - identity, - &agent_args, - &[], - false, - ) - .await - { - Ok(c) => c, - Err(e) => { - eprintln!("error: failed to spawn agent: {e}"); - std::process::exit(1); - } - }; + let mut client = + match AcpClient::spawn(&args.agent.agent_command, &agent_args, &[], false).await { + Ok(c) => c, + Err(e) => { + eprintln!("error: failed to spawn agent: {e}"); + std::process::exit(1); + } + }; // Initialize + session/new under a timeout. Client is owned above, // so shutdown() runs on all paths (success, error, timeout). @@ -5070,6 +5313,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "dm".into(), channel_type: "dm".into(), + description: None, }, ), ( @@ -5077,6 +5321,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "stream".into(), channel_type: "stream".into(), + description: None, }, ), ]); @@ -5093,6 +5338,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "unknown".into(), channel_type: "unknown".into(), + description: None, }, )]); assert!( @@ -6253,7 +6499,6 @@ mod build_mcp_servers_tests { keys: nostr::Keys::generate(), relay_url: "ws://localhost:3000".into(), agent_command: "goose".into(), - agent_identity: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "test-mcp-server".into(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, @@ -6289,6 +6534,7 @@ mod build_mcp_servers_tests { relay_observer: false, exit_after_inactivity_secs: 0, lazy_pool: false, + idle_pool_sleep_secs: 0, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -6476,7 +6722,6 @@ mod error_outcome_emission_tests { // harmlessly off the JoinSet — irrelevant to the synchronous // feed emission under test. agent_command: "true".into(), - agent_identity: "true".into(), agent_args: vec![], mcp_command: "test-mcp-server".into(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, @@ -6512,6 +6757,7 @@ mod error_outcome_emission_tests { relay_observer: false, exit_after_inactivity_secs: 0, lazy_pool: false, + idle_pool_sleep_secs: 0, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -6605,7 +6851,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(channel_id), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), - final_answer: None, batch: None, }; @@ -6678,7 +6923,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(channel_id), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), - final_answer: None, batch: None, }; @@ -6793,7 +7037,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(channel_id), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), - final_answer: None, batch: None, }; @@ -6857,7 +7100,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(Uuid::new_v4()), turn_id: "test-turn-id".to_string(), outcome, - final_answer: None, batch: None, }; @@ -7026,7 +7268,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(Uuid::new_v4()), turn_id: "test-turn-id".to_string(), outcome, - final_answer: None, batch: None, }; handle_prompt_result( @@ -7118,7 +7359,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(channel_id), turn_id: "test-turn-id".to_string(), outcome, - final_answer: None, batch: Some(batch), }; handle_prompt_result( @@ -7225,7 +7465,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(channel_id), turn_id: "test-turn-id".to_string(), outcome, - final_answer: None, batch: Some(batch), }; handle_prompt_result( @@ -7318,7 +7557,6 @@ mod error_outcome_emission_tests { outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, }), - final_answer: None, batch: Some(batch), }; handle_prompt_result( @@ -7413,7 +7651,6 @@ mod error_outcome_emission_tests { outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, }), - final_answer: None, batch: Some(batch), }; handle_prompt_result( @@ -7529,7 +7766,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(channel_id), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), - final_answer: None, batch: Some(batch), }; @@ -7663,7 +7899,6 @@ mod error_outcome_emission_tests { // Explicit Stop already dropped the batch upstream in // `classify_control_cancel_failure` — `handle_prompt_result` // never sees one to requeue. - final_answer: None, batch: None, }; @@ -7848,7 +8083,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(channel_id), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(auth_error), - final_answer: None, batch: Some(batch), }; handle_prompt_result( @@ -7935,7 +8169,6 @@ mod error_outcome_emission_tests { source: PromptSource::Channel(channel_id), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(usage_error), - final_answer: None, batch: Some(batch), }; handle_prompt_result( diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2a7e6851595..e38fa9b83e4 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -31,8 +31,8 @@ use uuid::Uuid; use crate::acp::{ extract_model_config_options, extract_model_state, model_in_catalog, - resolve_model_switch_method, AcpClient, AcpError, EnvVar, FinalAnswer, McpServer, - ModelSwitchMethod, StopReason, SystemPromptTransport, + resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, + StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -279,9 +279,6 @@ pub struct PromptResult { /// Identifies the completed turn for observer terminal events. pub turn_id: String, pub outcome: PromptOutcome, - /// Adapter-typed terminal answer. Present only for an unambiguous - /// `final_answer` group followed by `end_turn`. - pub final_answer: Option, /// Present on failure in Queue mode, for requeue. pub batch: Option, } @@ -527,6 +524,7 @@ impl ChannelInfoResolver { PromptChannelInfo { name: info.name, channel_type: info.channel_type, + description: info.description, }, )) }) @@ -1432,19 +1430,11 @@ fn send_prompt_result( batch: Option, ) { agent.acp.clear_steer_rx(); - let final_answer = agent.acp.take_completed_prompt_result().and_then(|result| { - if matches!(outcome, PromptOutcome::Ok(StopReason::EndTurn)) { - result.final_answer - } else { - None - } - }); let _ = result_tx.send(PromptResult { agent, source, turn_id: turn_id.to_owned(), outcome, - final_answer, batch, }); } @@ -1860,6 +1850,16 @@ pub async fn run_prompt_task( if !agent.has_system_prompt_support() { agent.state.mark_channel_delivery_success(*cid, true, []); } + let usage = agent.acp.take_turn_usage(); + publish_agent_turn_metric( + &ctx, + usage, + Some(*cid), + &session_id, + &format!("{turn_id}:initial"), + Some(acp_stop_to_core(&stop_reason)), + ) + .await; } Err(AcpError::AgentExited) => { agent.state.invalidate_all(); @@ -1884,7 +1884,17 @@ pub async fn run_prompt_task( .cancel_with_cleanup(&session_id, ctx.idle_timeout) .await { - Ok(_) => { + Ok(stop_reason) => { + let usage = agent.acp.take_turn_usage(); + publish_agent_turn_metric( + &ctx, + usage, + Some(*cid), + &session_id, + &format!("{turn_id}:initial"), + Some(acp_stop_to_core(&stop_reason)), + ) + .await; agent.state.invalidate(&source); } Err(AcpError::AgentExited) => { @@ -2296,7 +2306,7 @@ pub async fn run_prompt_task( agent, source, PromptOutcome::Ok(StopReason::EndTurn), - None, + None, // turn succeeded — batch was processed, no requeue ); return; } @@ -2588,17 +2598,25 @@ pub(crate) async fn fetch_channel_info( let ev = events.first()?; let tags = ev.get("tags")?.as_array()?; let mut name = None; + let mut description = None; for tag in tags { if let Some(arr) = tag.as_array() { - if arr.first().and_then(|v| v.as_str()) == Some("name") { - name = arr.get(1).and_then(|v| v.as_str()); + match arr.first().and_then(|v| v.as_str()) { + Some("name") => name = arr.get(1).and_then(|v| v.as_str()), + Some("about") => description = arr.get(1).and_then(|v| v.as_str()), + _ => {} } } } let channel_type = crate::relay::channel_type_from_tags(tags); + let description = description + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string); Some(PromptChannelInfo { name: name.unwrap_or(UNKNOWN_CHANNEL_NAME).to_string(), channel_type, + description, }) } Ok(Err(e)) => { @@ -5814,6 +5832,7 @@ done"# crate::relay::ChannelInfo { name: "test-dm".into(), channel_type: "dm".into(), + description: None, }, )]), RestClient { @@ -5975,6 +5994,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" crate::relay::ChannelInfo { name: "test-dm".into(), channel_type: "dm".into(), + description: None, }, )]), RestClient { @@ -7866,6 +7886,38 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" server.abort(); } + /// A channel's `about` tag is parsed through the lazy-fetch path and + /// delivered as the resolved description. + #[tokio::test] + async fn test_channel_resolver_delivers_description() { + let id = Uuid::new_v4(); + let response = channel_metadata_response( + id, + &[ + ["name", "team-chat"], + ["t", "stream"], + ["about", "Engineering discussions"], + ], + ); + let (resolver, _requests, server) = counting_resolver(response).await; + + let info = resolver.resolve(id).await.expect("should resolve"); + assert_eq!(info.description.as_deref(), Some("Engineering discussions")); + server.abort(); + } + + /// A metadata event with no `about` tag yields no description. + #[tokio::test] + async fn test_channel_resolver_absent_description_when_no_about_tag() { + let id = Uuid::new_v4(); + let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]); + let (resolver, _requests, server) = counting_resolver(response).await; + + let info = resolver.resolve(id).await.expect("should resolve"); + assert_eq!(info.description, None); + server.abort(); + } + /// A DM carries no useful name, so it gets the bare agent title (and no /// canvas section). #[tokio::test] diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 3bf19622429..dabee13afd5 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -590,6 +590,39 @@ impl EventQueue { .any(|id| !self.in_flight_channels.contains(id)) } + /// Returns `true` if any undispatched work remains for a channel that is + /// NOT currently in-flight — *including* work held back only by a + /// `retry_after` backoff throttle. + /// + /// This is deliberately broader than [`has_flushable_work`](Self::has_flushable_work): + /// that method excludes `retry_after`-throttled channels because they are + /// not flushable *right now*, but the events are still queued and MUST be + /// delivered once the backoff deadline passes. Idle-pool-sleep teardown + /// must gate on this, not on flushability — a failed turn requeued with a + /// future backoff deadline is real queued work, and sleeping on it (while + /// the maintenance timer is disabled and lazy re-wake is itself gated by + /// flushability) would strand the batch until unrelated traffic arrives. + /// + /// Covers the three tables where undispatched, non-in-flight work can + /// live: non-empty `queues` (throttled or not), pending `cancelled_batches`, + /// and `withheld_native_steer` events. Read-only (no in-flight expiry) — + /// in-flight liveness is gated separately by [`has_in_flight`](Self::has_in_flight). + pub fn has_undispatched_work(&self) -> bool { + let has_queued = self + .queues + .iter() + .any(|(id, q)| !q.is_empty() && !self.in_flight_channels.contains(id)); + let has_cancelled = self + .cancelled_batches + .keys() + .any(|id| !self.in_flight_channels.contains(id)); + let has_withheld = self + .withheld_native_steer + .iter() + .any(|(id, v)| !v.is_empty() && !self.in_flight_channels.contains(id)); + has_queued || has_cancelled || has_withheld + } + /// Number of channels with pending events. pub fn pending_channels(&self) -> usize { self.queues.len() @@ -1003,6 +1036,8 @@ pub struct ContextMessage { pub struct PromptChannelInfo { pub name: String, pub channel_type: String, + /// Channel description from the kind-39000 `about` tag, if present. + pub description: Option, } /// Minimal profile fields needed to label users in ACP prompts. @@ -1231,6 +1266,48 @@ fn resolve_reply_anchor( ) } +/// Maximum length (in characters) of a channel description rendered into `[Context]`. +/// +/// Limits prompt bloat from unusually long descriptions; a raw embedded newline +/// in a description must not be able to spoof another `[Context]` 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. +/// +/// 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. +/// 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()) { + Some(d) if !d.is_empty() => d, + _ => return, + }; + // Collapse newlines to spaces so the description can never spoof another field. + let collapsed: String = desc + .split(['\n', '\r']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + if collapsed.is_empty() { + return; + } + // Truncate at a character boundary (not byte boundary) to avoid splitting + // multi-byte sequences. + let truncated = if collapsed.chars().count() > MAX_DESCRIPTION_LEN { + let end = collapsed + .char_indices() + .nth(MAX_DESCRIPTION_LEN) + .map(|(i, _)| i) + .unwrap_or(collapsed.len()); + format!("{}…", &collapsed[..end]) + } else { + collapsed + }; + s.push_str(&format!("\nDescription: {truncated}")); +} + /// Format a `[Context]` hints section based on event scope. /// /// `reply_anchor` is the pre-resolved `--reply-to` target for this turn (see @@ -1301,9 +1378,10 @@ fn format_context_hints( let mut s = format!( "[Context]\n\ Scope: thread\n\ - Channel: {channel_display}\n\ - Thread root: {root}" + Channel: {channel_display}" ); + append_channel_description(&mut s, channel_info); + s.push_str(&format!("\nThread root: {root}")); if let Some(ref parent) = thread_tags.parent_event_id { if parent != root { s.push_str(&format!("\nParent: {parent}")); @@ -1318,8 +1396,11 @@ fn format_context_hints( let mut s = format!( "[Context]\n\ Scope: channel\n\ - Channel: {channel_display}\n\ - Hint: Use `buzz messages get --channel ` for recent messages if needed." + Channel: {channel_display}" + ); + append_channel_description(&mut s, channel_info); + s.push_str( + "\nHint: Use `buzz messages get --channel ` for recent messages if needed.", ); if let Some(event_id) = reply_anchor { append_new_thread_reply_instruction(&mut s, event_id); @@ -2202,6 +2283,85 @@ mod tests { assert_eq!(batch2.events[1].event.content, "msg2"); } + // ── Retry-throttled work must block idle-pool-sleep teardown ──────────── + // + // Regression for the PR #5682 review blocker: a failed turn requeued with a + // future backoff deadline is real queued work. `has_flushable_work()` + // returns false for it (throttled → not flushable *now*), so gating + // idle-pool-sleep on flushability would tear down the pool while the batch + // sits waiting — and because lazy re-wake is itself gated on flushability + // and the maintenance timer is disabled while sleeping, the batch would be + // stranded until unrelated traffic arrived. `has_undispatched_work()` must + // see the throttled batch so the sleep gate keeps the pool alive. + #[test] + fn test_retry_throttled_batch_is_undispatched_but_not_flushable() { + let mut queue = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + queue.push(make_queued(ch, "msg1")); + queue.push(make_queued(ch, "msg2")); + + // Drive a real failure → requeue-with-backoff → mark_complete cycle. + let batch = queue.flush_next().unwrap(); + assert_eq!(batch.events.len(), 2); + assert!( + queue.requeue(batch).is_none(), + "batch requeued, not dead-lettered" + ); + queue.mark_complete(ch); + + // The batch is back in the queue, no longer in-flight, and throttled by + // a future `retry_after`. BASE_RETRY_DELAY guarantees the deadline is in + // the future, so this is not timing-fragile. + assert!( + queue + .retry_after + .get(&ch) + .is_some_and(|&t| t > Instant::now()), + "requeue must have set a future backoff deadline" + ); + assert!(!queue.has_in_flight(), "turn completed, nothing in-flight"); + + // The bug: throttled work is invisible to flushability... + assert!( + !queue.has_flushable_work(), + "throttled batch must NOT be flushable yet" + ); + // ...but it IS undispatched work the sleep gate must protect. + assert!( + queue.has_undispatched_work(), + "retry-throttled batch MUST count as undispatched work" + ); + } + + #[test] + fn test_has_undispatched_work_false_when_truly_empty_or_in_flight() { + let mut queue = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + + // Empty queue: no undispatched work. + assert!(!queue.has_undispatched_work()); + + // Dispatched batch (in-flight): the events left the queue, and an + // in-flight turn is gated separately (has_in_flight), so this must be + // false — otherwise the pool could never sleep after any turn. + queue.push(make_queued(ch, "msg1")); + assert!( + queue.has_undispatched_work(), + "queued-but-not-flushed is undispatched" + ); + let batch = queue.flush_next().unwrap(); + assert!(queue.has_in_flight()); + assert!( + !queue.has_undispatched_work(), + "in-flight work is not undispatched — it is gated by has_in_flight" + ); + + // Completed cleanly (no requeue): fully drained, nothing left. + queue.mark_complete(batch.channel_id); + assert!(!queue.has_undispatched_work()); + assert!(!queue.has_in_flight()); + } + #[test] fn test_requeue_interleaves_with_other_channels() { let mut queue = EventQueue::new(DedupMode::Queue); @@ -3087,6 +3247,7 @@ mod tests { let ci = PromptChannelInfo { name: "engineering".into(), channel_type: "stream".into(), + description: None, }; let prompt = format_prompt( @@ -3118,6 +3279,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let prompt = format_prompt( @@ -3230,6 +3392,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let ctx = ConversationContext::Dm { messages: vec![ContextMessage { @@ -3488,6 +3651,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; // Thread context fetched (as the fetch path does for DM replies). let ctx = ConversationContext::Thread { @@ -3587,6 +3751,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let trigger_only_prompt = format_prompt( @@ -3635,6 +3800,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; // No context fetched — hints only. @@ -4130,6 +4296,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let prompt = format_prompt( @@ -4193,6 +4360,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let prompt = format_prompt( @@ -4961,4 +5129,254 @@ mod tests { "second extend must not move deadline backward (monotonic)" ); } + + // ── channel description delivery ───────────────────────────────────────── + + #[test] + fn test_append_channel_description_adds_description_line() { + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some("Engineering discussions".into()), + }; + let mut s = "[Context]\nScope: channel\nChannel: team (#abc)".to_string(); + append_channel_description(&mut s, Some(&ci)); + assert!( + s.contains("\nDescription: Engineering discussions"), + "description must be appended; got: {s}" + ); + } + + #[test] + fn test_append_channel_description_absent_when_none() { + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: None, + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + assert!( + !s.contains("Description:"), + "no description must be appended when None; got: {s}" + ); + } + + #[test] + fn test_append_channel_description_absent_when_channel_info_none() { + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, None); + assert!( + !s.contains("Description:"), + "no description must be appended when channel_info is None; got: {s}" + ); + } + + #[test] + fn test_append_channel_description_collapses_newlines_spoof_prevention() { + // A multiline description must not be able to inject a fake [Context] 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(); + 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(); + assert_eq!( + desc_line, "Description: Line one Scope: injected Line two", + "multiline description must collapse to one line, never a fake field" + ); + assert_eq!( + s.lines().filter(|l| l.starts_with("Description:")).count(), + 1, + "exactly one Description line is rendered" + ); + } + + #[test] + fn test_append_channel_description_truncates_at_cap() { + let long_desc = "x".repeat(600); + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some(long_desc), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); + assert!( + desc_line.ends_with('…'), + "truncated description must end with '…'; got: {desc_line}" + ); + // Value = first MAX_DESCRIPTION_LEN chars + the "…" marker. + let value = desc_line.strip_prefix("Description: ").unwrap(); + assert_eq!( + value.chars().count(), + MAX_DESCRIPTION_LEN + 1, + "truncated value is exactly the cap plus the ellipsis marker" + ); + } + + #[test] + fn test_append_channel_description_multibyte_truncation_is_char_safe() { + // Truncation must land on a char boundary, never split a multi-byte code point. + let long_desc = "é".repeat(600); + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some(long_desc), + }; + let mut s = "[Context]\nScope: 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(); + assert_eq!(value.chars().count(), MAX_DESCRIPTION_LEN + 1); + } + + #[test] + fn test_append_channel_description_whitespace_only_is_absent() { + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some("\n \r\n \n".into()), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + assert!( + !s.contains("Description:"), + "a whitespace-only description collapses to empty and is not rendered; got: {s}" + ); + } + + fn description_batch(ch: Uuid, event: Event) -> FlushBatch { + FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + #[test] + fn test_format_prompt_includes_description_in_context_for_channel_turn() { + let ch = Uuid::new_v4(); + let batch = description_batch(ch, make_event("what should we build?")); + let ci = PromptChannelInfo { + name: "engineering".into(), + channel_type: "stream".into(), + description: Some("Engineering discussions and planning.".into()), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: channel"), + "channel-scope turn expected; got: {prompt}" + ); + assert!( + prompt.contains("Description: Engineering discussions and planning."), + "description must appear in [Context] for channel turns; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_includes_description_in_context_for_thread_turn() { + let ch = Uuid::new_v4(); + let event = make_event_with_tags( + "reply in thread", + vec![vec![ + "e".into(), + "root123".into(), + "".into(), + "reply".into(), + ]], + ); + let batch = description_batch(ch, event); + let ci = PromptChannelInfo { + name: "engineering".into(), + channel_type: "stream".into(), + description: Some("Engineering discussions and planning.".into()), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: thread"), + "thread-scope turn expected; got: {prompt}" + ); + assert!( + prompt.contains("Description: Engineering discussions and planning."), + "description must appear in [Context] for thread turns; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_excludes_description_for_dm_turn() { + let ch = Uuid::new_v4(); + let batch = description_batch(ch, make_event("hey")); + let ci = PromptChannelInfo { + name: "DM".into(), + channel_type: "dm".into(), + description: Some("This should not appear.".into()), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: dm"), + "dm-scope turn expected; got: {prompt}" + ); + assert!( + !prompt.contains("Description:"), + "DM turn must not include a Description field; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_no_description_when_channel_metadata_unresolved() { + let ch = Uuid::new_v4(); + let batch = description_batch(ch, make_event("what should we build?")); + // channel_info None models unresolved metadata: no name, no description. + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: None, + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: channel"), + "channel-scope turn expected; got: {prompt}" + ); + assert!( + !prompt.contains("Description:"), + "unresolved metadata must not render a Description field; got: {prompt}" + ); + } } diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 2cbb82411fd..17a818867dd 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -136,6 +136,8 @@ use crate::config::ChannelFilter; pub struct ChannelInfo { pub name: String, pub channel_type: String, + /// Channel description from the kind-39000 `about` tag, if present. + pub description: Option, } pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String { @@ -175,7 +177,7 @@ pub(crate) fn merge_discovered_channels( channel_uuids: Vec, meta_events: &serde_json::Value, ) -> HashMap { - let mut meta_map: HashMap = HashMap::new(); + let mut meta_map: HashMap)> = HashMap::new(); let mut archived: std::collections::HashSet = std::collections::HashSet::new(); if let Some(arr) = meta_events.as_array() { for ev in arr { @@ -186,11 +188,13 @@ pub(crate) fn merge_discovered_channels( let mut d_val = None; let mut name = None; let mut is_archived = false; + let mut description = None; for tag in tags { if let Some(arr) = tag.as_array() { match arr.first().and_then(|v| v.as_str()) { Some("d") => d_val = arr.get(1).and_then(|v| v.as_str()), Some("name") => name = arr.get(1).and_then(|v| v.as_str()), + Some("about") => description = arr.get(1).and_then(|v| v.as_str()), Some("archived") => { is_archived = arr.get(1).and_then(|v| v.as_str()) == Some("true") } @@ -206,7 +210,11 @@ pub(crate) fn merge_discovered_channels( } let ch_name = name.unwrap_or("unknown").to_string(); let ch_type = channel_type_from_tags(tags); - meta_map.insert(uuid, (ch_name, ch_type)); + let ch_desc = description + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + meta_map.insert(uuid, (ch_name, ch_type, ch_desc)); } } } @@ -217,10 +225,17 @@ pub(crate) fn merge_discovered_channels( if archived.contains(&uuid) { continue; } - let (name, channel_type) = meta_map + let (name, channel_type, description) = meta_map .remove(&uuid) - .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string())); - map.insert(uuid, ChannelInfo { name, channel_type }); + .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string(), None)); + map.insert( + uuid, + ChannelInfo { + name, + channel_type, + description, + }, + ); } map } @@ -4163,6 +4178,46 @@ mod tests { assert!(map.contains_key(&ch), "archived=false is treated as live"); } + #[test] + fn merge_discovered_channels_parses_about_as_description() { + let ch = Uuid::new_v4(); + let meta = serde_json::json!([meta_event( + ch, + "team", + &["t", "stream", "about", "Engineering discussions"] + )]); + + let map = merge_discovered_channels(vec![ch], &meta); + + assert_eq!( + map[&ch].description.as_deref(), + Some("Engineering discussions") + ); + } + + #[test] + fn merge_discovered_channels_blank_about_is_none() { + let ch = Uuid::new_v4(); + let meta = serde_json::json!([meta_event(ch, "team", &["about", " "])]); + + let map = merge_discovered_channels(vec![ch], &meta); + + assert_eq!( + map[&ch].description, None, + "a whitespace-only about tag is trimmed away to None" + ); + } + + #[test] + fn merge_discovered_channels_missing_about_is_none() { + let ch = Uuid::new_v4(); + let meta = serde_json::json!([meta_event(ch, "team", &["t", "stream"])]); + + let map = merge_discovered_channels(vec![ch], &meta); + + assert_eq!(map[&ch].description, None); + } + #[test] fn parse_ok_accepted() { let text = r#"["OK","abc123",true,""]"#; diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index 1eb3eba3b17..2197b99ef5f 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -244,6 +244,158 @@ pub struct TurnUsage { pub pricing_identity: Option, } +/// Per-turn usage carried by a standard ACP `session/prompt` response. +/// Adapter input excludes cache reads and writes, so NIP-AM input must add +/// those subsets with checked arithmetic. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PromptResponseUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub cached_read_tokens: Option, + pub cached_write_tokens: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StandardAdapterKind { + Claude, + Codex, +} + +#[derive(Debug, Default)] +struct StandardSessionState { + published_seq: u64, + last_cost: Option, + cost_poisoned: bool, +} + +#[derive(Debug, Default)] +pub(crate) struct StandardUsageTracker { + sessions: HashMap, + in_flight_session: Option, + pending_cost: Option<(String, f64)>, + pending_prompt: Option<(String, PromptResponseUsage, StandardAdapterKind)>, +} + +impl StandardUsageTracker { + pub(crate) fn seed_zero_baseline(&mut self, session_id: &str) { + self.sessions + .entry(session_id.to_string()) + .or_insert_with(|| StandardSessionState { + published_seq: 0, + last_cost: Some(0.0), + cost_poisoned: false, + }); + } + + pub(crate) fn begin_turn(&mut self, session_id: &str) { + self.in_flight_session = Some(session_id.to_string()); + self.pending_cost = None; + self.pending_prompt = None; + } + + /// Claude's `usage_update.cost.amount` is a raw session-cumulative total. + pub(crate) fn record_cost(&mut self, session_id: &str, cost: f64) { + if cost.is_finite() && cost >= 0.0 && self.in_flight_session.as_deref() == Some(session_id) + { + self.pending_cost = Some((session_id.to_string(), cost)); + } + } + + pub(crate) fn record_prompt_usage( + &mut self, + session_id: &str, + usage: PromptResponseUsage, + adapter: StandardAdapterKind, + ) { + if self.in_flight_session.as_deref() == Some(session_id) { + self.pending_prompt = Some((session_id.to_string(), usage, adapter)); + } + } + + pub(crate) fn take(&mut self) -> Option { + self.in_flight_session = None; + let prompt = self.pending_prompt.take(); + let cost = self.pending_cost.take(); + let session_id = prompt + .as_ref() + .map(|(session_id, _, _)| session_id.clone()) + .or_else(|| cost.as_ref().map(|(session_id, _)| session_id.clone()))?; + + let (inclusive_input, output_tokens, total_tokens, cache_read, cache_write) = match prompt { + Some((_, usage, adapter)) => { + let inclusive_input = usage + .input_tokens + .checked_add(usage.cached_read_tokens.unwrap_or(0)) + .and_then(|input| input.checked_add(usage.cached_write_tokens.unwrap_or(0))); + let total_tokens = + (adapter == StandardAdapterKind::Codex).then_some(usage.total_tokens); + ( + inclusive_input, + inclusive_input.map(|_| usage.output_tokens), + inclusive_input.and(total_tokens), + inclusive_input.and(usage.cached_read_tokens), + inclusive_input.and(usage.cached_write_tokens), + ) + } + None => (None, None, None, None, None), + }; + + let state = self.sessions.entry(session_id.clone()).or_default(); + let cumulative_cost = cost.map(|(_, cost)| cost); + let turn_cost = match (state.cost_poisoned, state.last_cost, cumulative_cost) { + (false, Some(previous), Some(current)) if current >= previous => { + let delta = current - previous; + delta.is_finite().then_some(delta) + } + _ => None, + }; + if let Some(current) = cumulative_cost { + // A decrease means the cumulative series restarted or is corrupt. + // Poison the baseline rather than deriving a later delta across the + // discontinuity. The raw cumulative value still remains observable. + if state.last_cost.is_some_and(|previous| current < previous) { + state.cost_poisoned = true; + state.last_cost = None; + } else if !state.cost_poisoned { + state.last_cost = Some(current); + } + } + + // Input overflow invalidates the standard prompt counters. Emit only if + // another valid signal (normally Claude cost) remains; NIP-AM forbids an + // otherwise all-null usage record. + if inclusive_input.is_none() && cumulative_cost.is_none() { + return None; + } + + state.published_seq += 1; + Some(TurnUsage { + session_id, + turn_seq: state.published_seq, + // Standard prompt counters are per-turn already. A cost-only record + // is reliable only when a seeded/previous cumulative baseline made + // the cost delta provable. + delta_reliable: inclusive_input.is_some() || turn_cost.is_some(), + turn_input_tokens: inclusive_input, + turn_output_tokens: output_tokens, + turn_total_tokens: total_tokens, + turn_cost_usd: turn_cost, + turn_cache_read_tokens: cache_read, + turn_cache_write_tokens: cache_write, + cumulative_input_tokens: None, + cumulative_output_tokens: None, + cumulative_total_tokens: None, + cumulative_cost_usd: cumulative_cost, + cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, + model: None, + pricing_identity: None, + }) + } +} + /// Tracks per-session cumulative usage state across turns. /// /// Cheap to construct. Usage lifecycle per turn: diff --git a/crates/buzz-admin/Cargo.toml b/crates/buzz-admin/Cargo.toml index 7a69e146bb9..00c2804cbcf 100644 --- a/crates/buzz-admin/Cargo.toml +++ b/crates/buzz-admin/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] buzz-db = { workspace = true } +buzz-deletion = { workspace = true } buzz-core = { workspace = true } buzz-auth = { workspace = true } buzz-pubsub = { workspace = true } diff --git a/crates/buzz-admin/src/deletions.rs b/crates/buzz-admin/src/deletions.rs new file mode 100644 index 00000000000..64cb8bd732a --- /dev/null +++ b/crates/buzz-admin/src/deletions.rs @@ -0,0 +1,19 @@ +//! Thin `buzz-admin deletions` adapter. + +pub use buzz_deletion::Command as DeletionsCommand; + +/// Delegate to the shared durable deletion engine. +pub async fn run(command: DeletionsCommand) -> anyhow::Result { + buzz_deletion::run(command).await +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + #[test] + fn continuous_worker_command_is_not_exposed() { + let command = crate::Cli::try_parse_from(["buzz-admin", "deletions", "worker"]); + assert!(command.is_err()); + } +} diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index bb30ddfae4f..580d5865913 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -20,6 +20,8 @@ //! newest timestamp and collide on the bumped second. run.sh serialization is //! the guard against parallel adds (e.g. `xargs -P`). +mod deletions; + use std::sync::Arc; use anyhow::Result; @@ -81,6 +83,11 @@ enum Command { #[command(subcommand)] command: ProductFeedbackCommand, }, + /// Durable CLI-only whole-community deletion control plane. + Deletions { + #[command(subcommand)] + command: deletions::DeletionsCommand, + }, /// Emit kind:39000/39002 events for channels missing them. /// /// Channels created via direct SQL (seed scripts, pre-migration data) won't @@ -148,6 +155,7 @@ async fn run(cli: Cli) -> Result { Command::ProductFeedback { command: ProductFeedbackCommand::List { limit }, } => cmd_list_product_feedback(limit).await, + Command::Deletions { command } => deletions::run(command).await, Command::ReconcileChannels { relay_key } => { reconcile_channels(relay_key).await?; Ok(0) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 5d942777d5e..0bc03db7813 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -153,7 +153,8 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. | | `BUZZ_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. | | `BUZZ_AGENT_MAX_ROUNDS` | `0` | Tool-loop iteration cap. 0 = unlimited. | -| `BUZZ_AGENT_MAX_OUTPUT_TOKENS` | `32768` | Per LLM call. Headroom for large tool-call inputs (e.g. file writes via heredoc); Sonnet 4 / Opus 4 cap at 64K. | +| `BUZZ_AGENT_MAX_OUTPUT_TOKENS` | `65536` | Desired per-call ceiling. Set this at or below the served model's output limit for each agent deployment. Proactive handoff is independently based on 90% of `BUZZ_AGENT_MAX_CONTEXT_TOKENS`. | +| `BUZZ_AGENT_MAX_TOKEN_RECOVERIES` | `3` | Retries after a successful response is truncated at the output-token limit. `0` disables recovery; the finite value and `BUZZ_AGENT_MAX_ROUNDS` prevent infinite retries. | | `BUZZ_AGENT_MAX_CONTEXT_TOKENS` | `200000` | Provider context window used by the handoff gate. | | `BUZZ_AGENT_MAX_HANDOFFS` | `10` | Max context handoffs per session before falling back to truncation. | | `BUZZ_AGENT_LLM_TIMEOUT_SECS` | `240` | Max seconds with no response bytes before abandoning an LLM call (per-read inactivity, not wall-clock). | diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 0805fddb12d..9258ce449f3 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -31,12 +31,7 @@ const UNSUPPORTED_IMAGE_TOOL_MESSAGE: &str = "The current model does not support /// its output-token limit. This is a user message rather than a synthetic tool /// result because truncation can happen without a tool call (and an unpaired /// tool result is invalid on every provider wire format). -const MAX_TOKENS_RECOVERY_MESSAGE: &str = "Your previous response exceeded the model's output token limit and was truncated. Any incomplete tool call was not run. Continue the task, breaking the work or tool call into smaller steps and keeping the response concise."; - -/// A provider can repeatedly spend its entire output allowance without making -/// progress, while `max_rounds` is unbounded by default. Keep the in-turn rescue -/// finite so a persistently truncating model eventually surfaces `max_tokens`. -const MAX_TOKENS_RECOVERIES_PER_RUN: u32 = 2; +const MAX_TOKENS_RECOVERY_MESSAGE: &str = "Your previous response reached the model's output token limit and was truncated. Any incomplete tool calls were discarded and were not run. Stop prolonged internal reasoning now. Use the available tools immediately: write a script or artifact to a file and run it in small, verifiable steps instead of emitting the entire solution inline. Continue the task concisely from the preserved text."; /// Remove image blocks that the provider has explicitly rejected while keeping /// their surrounding tool result (and therefore the tool-call/result pairing) @@ -667,9 +662,10 @@ impl RunCtx<'_> { tool_calls: Vec::new(), reasoning_details: response.reasoning_details, }); - if max_tokens_recoveries >= MAX_TOKENS_RECOVERIES_PER_RUN { + if max_tokens_recoveries >= self.cfg.max_token_recoveries { tracing::warn!( recoveries = max_tokens_recoveries, + max_recoveries = self.cfg.max_token_recoveries, "provider repeatedly hit output token limit; recovery budget exhausted" ); return Ok(StopReason::MaxTokens); @@ -677,8 +673,7 @@ impl RunCtx<'_> { max_tokens_recoveries = max_tokens_recoveries.saturating_add(1); tracing::warn!( recovery = max_tokens_recoveries, - max_recoveries = MAX_TOKENS_RECOVERIES_PER_RUN, - discarded_tool_calls = response.tool_calls.len(), + max_recoveries = self.cfg.max_token_recoveries, "provider hit output token limit; asking model to continue in smaller steps" ); self.history diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index 3f43925de36..a78a499bdd1 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -16,7 +16,8 @@ //! calls hit the cache and silently refresh when expired. use std::fs; -use std::path::PathBuf; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -188,15 +189,16 @@ impl PkceOAuthTokenSource { } /// Persist a token to disk and the in-memory cell. + /// + /// The cache holds both the access and refresh tokens, so the on-disk + /// file is written owner-only (`0o600` on Unix) via an atomic + /// inode-swapping rename — see [`write_private_cache`]. fn save(&self, state: &mut Option, token: CachedToken) -> Result<(), AgentError> { let body = serde_json::to_vec_pretty(&token) .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; - // Atomic rename so a concurrent reader never sees a partial write. - let tmp = self.cache_path.with_extension("json.tmp"); - fs::write(&tmp, &body) - .map_err(|e| AgentError::Llm(format!("oauth cache write {tmp:?}: {e}")))?; - fs::rename(&tmp, &self.cache_path) - .map_err(|e| AgentError::Llm(format!("oauth cache rename: {e}")))?; + write_private_cache(&self.cache_path, &body).map_err(|e| { + AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) + })?; *state = Some(token); Ok(()) } @@ -463,11 +465,162 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { Ok(dir.join(format!("{hash}.json"))) } -fn read_cache(path: &PathBuf) -> Option { - let body = fs::read(path).ok()?; +/// Load a cached token, enforcing the owner-only invariant on load. +/// +/// Owner-only permissions are a cache *lifecycle* invariant, not just a +/// write-path property: a world-readable cache left by an older buzz-agent +/// (or any tampering) must be tightened the moment we touch it, before the +/// tokens are used — otherwise a file that never expires stays exposed until +/// some future refresh happens to rewrite it. Every load path (initial and +/// cross-process re-reads) funnels through here, so the repair covers them +/// all. Returns `None` when the cache is absent, unreadable, unparseable, or +/// cannot be secured; the caller then falls through to refresh/browser. +fn read_cache(path: &Path) -> Option { + let body = read_private_cache(path).ok()?; serde_json::from_slice(&body).ok() } +/// Open the cache, reject symlinks, tighten loose permissions to `0o600`, and +/// return its bytes. +/// +/// On Unix `O_NOFOLLOW` rejects a symlinked cache path at the kernel level +/// (no stat/open TOCTOU), and `fchmod` on the already-open handle repairs a +/// loose mode against the pinned inode rather than re-resolving the path. +/// A cache that exists but cannot be secured is an error, so the caller fails +/// closed instead of using an exposed file. +#[cfg(unix)] +fn read_private_cache(path: &Path) -> io::Result> { + use std::io::Read; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(path)?; + + let meta = file.metadata()?; + if !meta.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "oauth cache is not a regular file", + )); + } + // Tighten in place on the open fd if any group/other bit is set. fchmod + // targets the inode we already hold, so no attacker can swap the path + // between the check and the repair. + if meta.permissions().mode() & 0o077 != 0 { + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + + let mut body = Vec::new(); + file.read_to_end(&mut body)?; + Ok(body) +} + +/// Non-Unix fallback: read the cache as-is. Owner-only enforcement is the +/// Windows DACL work deferred behind the [`create_private_temp_file`] seam. +#[cfg(not(unix))] +fn read_private_cache(path: &Path) -> io::Result> { + fs::read(path) +} + +/// Removes a temp file on drop unless it was already renamed away. Keeps a +/// failed/partial write from leaving a stray token file behind. +struct TmpFileGuard<'a>(&'a Path); + +impl Drop for TmpFileGuard<'_> { + fn drop(&mut self) { + let _ = fs::remove_file(self.0); + } +} + +/// A per-write-unique temp suffix so concurrent savers — sibling threads or +/// separate processes sharing `$HOME` — never collide on one temp path. +/// Falls back to a timestamp if the RNG is unavailable rather than panicking +/// mid-auth. +fn unique_suffix() -> String { + let mut bytes = [0u8; 8]; + if getrandom::fill(&mut bytes).is_ok() { + return hex::encode(bytes); + } + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +/// Write `body` to `path` as an owner-only file via an atomic rename. +/// +/// The cache holds both the refresh and access tokens, so it must never be +/// readable by other users. We create a uniquely-named temp file in the same +/// directory with owner-only protection at creation time — mode `0o600` on +/// Unix (see [`create_private_temp_file`]) — so it is never briefly +/// world/other readable, write and fsync it, then rename over the +/// destination. The rename swaps the inode/entry wholesale, so a pre-existing +/// cache file with loose permissions is *replaced* by the new private one; +/// its old mode never survives. `fs::rename` maps to +/// `MOVEFILE_REPLACE_EXISTING` on Windows, so the atomic replace holds on +/// both platforms; the Windows owner-only DACL is pending the unsafe-FFI +/// decision noted at the seam. +fn write_private_cache(path: &Path, body: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "oauth cache path has no parent directory", + ) + })?; + fs::create_dir_all(parent)?; + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("oauth-cache"); + let tmp = parent.join(format!(".{file_name}.{}.tmp", unique_suffix())); + let guard = TmpFileGuard(&tmp); + + let mut f = create_private_temp_file(&tmp)?; + f.write_all(body)?; + f.sync_all()?; + drop(f); + + fs::rename(&tmp, path)?; + // The rename consumed the temp path; nothing left to clean up. + std::mem::forget(guard); + Ok(()) +} + +/// Create `tmp` for writing with owner-only permissions from the moment it +/// exists. Fails if the file already exists (`create_new`), which the +/// per-write-unique suffix makes effectively impossible. +#[cfg(unix)] +fn create_private_temp_file(tmp: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(tmp) +} + +/// Non-Unix fallback: create the temp file if it does not already exist. +/// +/// On Windows the owner-only equivalent is an explicit DACL set at creation +/// (`CreateFileW` with SDDL `D:P(A;;FA;;;OW)`, matching goose's +/// `private_file.rs`), but that FFI needs `unsafe`, which this crate forbids. +/// Reconciling the two — an isolated helper crate, a vetted safe dependency, +/// or descoping Windows — is an open decision escalated to the maintainer, so +/// this interim relies on the default per-user ACLs and drops the owner-only +/// implementation in behind this seam once the decision lands. `create_new` +/// fails if the file already exists. +#[cfg(not(unix))] +fn create_private_temp_file(tmp: &Path) -> io::Result { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(tmp) +} + /// Parse a token-endpoint JSON response. Fails loudly when `access_token` /// is missing or empty — without this, a malformed server response would /// be cached and `bearer()` would silently return `""` until the entry @@ -518,6 +671,47 @@ fn random_state() -> Result { Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) } +/// Decide the OAuth callback result and the HTML page to serve. +/// +/// Returns `(result, page)`: `result` carries the auth code (or a detail +/// string on failure) to the waiting flow via the oneshot channel; `page` is +/// the *static* HTML shown in the browser. The page never embeds any request +/// parameter — the `error` query value is attacker-influenceable, so +/// reflecting it would be an XSS sink on the localhost callback. Failure +/// detail travels only through `result`, which surfaces in the process error +/// and logs, never in the served markup. +fn callback_outcome( + params: &std::collections::HashMap, + expected_state: &str, +) -> (Result, String) { + let result = match (params.get("code"), params.get("state")) { + (Some(code), Some(st)) if st == expected_state => Ok(code.clone()), + (Some(_), Some(_)) => Err("state mismatch".to_string()), + _ => Err(params + .get("error") + .map(|e| sanitize_callback_detail(e)) + .unwrap_or_else(|| "missing code".into())), + }; + let page = match result { + Ok(_) => "

Buzz: signed in

You can close this window.

", + Err(_) => "

Buzz auth failed

You can close this window and try again.

", + } + .to_string(); + (result, page) +} + +/// Neutralize an attacker-controllable OAuth `error` value before it enters +/// an error string that later reaches the logs. Control characters (CR/LF in +/// particular) enable log-line injection, and an unbounded value could flood +/// the logs — replace control chars with spaces and cap the length. +fn sanitize_callback_detail(raw: &str) -> String { + const MAX: usize = 200; + raw.chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(MAX) + .collect() +} + /// Spin up a localhost callback server, open the authorize URL in a /// browser, wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then /// exchange the code for a token. @@ -544,23 +738,11 @@ async fn browser_pkce_flow( let tx = Arc::clone(&tx); let expected = expected_state.clone(); async move { - let result = match (params.get("code"), params.get("state")) { - (Some(code), Some(st)) if st == &expected => Ok(code.clone()), - (Some(_), Some(_)) => Err("state mismatch".to_string()), - _ => Err(params - .get("error") - .cloned() - .unwrap_or_else(|| "missing code".into())), - }; + let (result, page) = callback_outcome(¶ms, &expected); if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(result.clone()); - } - match result { - Ok(_) => Html( - "

Buzz: signed in

You can close this window.

".to_string(), - ), - Err(e) => Html(format!("

Buzz auth failed

{e}
")), + let _ = sender.send(result); } + Html(page) } }), ); @@ -844,4 +1026,320 @@ mod tests { ), } } + + // ---- callback HTML must never reflect input -------------------------- + + #[test] + fn test_callback_failure_page_omits_reflected_error_param() { + // A hostile `error` query value carrying markup must not appear in + // the served HTML — otherwise the localhost callback is an XSS sink. + let payload = ""; + let mut params = std::collections::HashMap::new(); + params.insert("error".to_string(), payload.to_string()); + + let (result, page) = callback_outcome(¶ms, "expected-state"); + + // The failure detail still reaches the waiting flow via `result`... + assert_eq!(result.as_ref().err().map(String::as_str), Some(payload)); + // ...but the browser page is static and inert. + assert!( + !page.contains(payload), + "callback page reflected the raw error param: {page}" + ); + assert!( + !page.contains(""; - let result = validate_file_content(html, &config); + // Sanity: this fixture is exactly the shape `infer` classifies as HTML. + assert_eq!(infer::get(html).map(|k| k.mime_type()), Some("text/html")); + let (mime, ext) = validate_file_content(html, &config).unwrap(); + assert_eq!(mime, "text/html"); + assert_eq!(ext, "html"); + assert!( + !serve_inline(&mime), + "text/html must never be served inline — it must force download" + ); + } + + #[test] + fn test_validate_file_executable_still_rejected() { + // Removing HTML from the deny-list must not weaken the executable + // block. `infer` classifies an ELF header as `application/x-executable`, + // which the generic path must still reject via the deny-list. + let config = test_config(); + // `infer`'s ELF matcher requires the magic plus >52 bytes of header. + let mut elf = b"\x7fELF".to_vec(); + elf.extend_from_slice(&[0u8; 60]); + assert_eq!( + infer::get(&elf).map(|k| k.mime_type()), + Some("application/x-executable") + ); assert!( - matches!(result, Err(MediaError::DisallowedContentType(ref m)) if m == "text/html"), - "expected DisallowedContentType(text/html), got {result:?}" + matches!(validate_file_content(&elf, &config), Err(MediaError::DisallowedContentType(ref m)) if m == "application/x-executable"), + "ELF executable must still be rejected by the generic file path" ); } + #[test] + fn test_generic_deny_list_keeps_active_content_and_executables() { + // Static guard on the deny-list itself: HTML is intentionally gone, but + // SVG, JavaScript, XHTML, and the native-executable types remain. These + // are the entries that keep the inert-download boundary honest even if a + // future `infer` upgrade starts classifying more of them by content. + assert!(!BLOCKED_FILE_MIME_TYPES.contains(&"text/html")); + for kept in [ + "image/svg+xml", + "application/xhtml+xml", + "application/javascript", + "text/javascript", + "application/x-msdownload", + "application/x-executable", + "application/vnd.microsoft.portable-executable", + "application/x-mach-binary", + "application/x-msi", + "application/x-apple-diskimage", + ] { + assert!( + BLOCKED_FILE_MIME_TYPES.contains(&kept), + "{kept} must remain in the generic-file deny-list" + ); + } + } + #[test] fn test_validate_file_too_large_rejected() { let mut config = test_config(); diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 65fe32b6b31..deb2e7e16a5 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -19,6 +19,8 @@ path = "src/main.rs" buzz-core = { workspace = true } buzz-conformance = { workspace = true } buzz-db = { workspace = true } +buzz-datastore-tracing = { workspace = true } +buzz-deletion = { workspace = true } buzz-auth = { workspace = true } buzz-pubsub = { workspace = true } buzz-audit = { workspace = true } @@ -84,8 +86,8 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } # Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): # the relay client for discovery notes and the exact ed25519 the mesh owner # keys use for binding verification. diff --git a/crates/buzz-relay/examples/mesh_admission_smoke.rs b/crates/buzz-relay/examples/mesh_admission_smoke.rs index a541f35bbcd..94c86c2a68e 100644 --- a/crates/buzz-relay/examples/mesh_admission_smoke.rs +++ b/crates/buzz-relay/examples/mesh_admission_smoke.rs @@ -54,16 +54,11 @@ fn env(name: &str) -> anyhow::Result { std::env::var(name).map_err(|_| anyhow::anyhow!("{name} is required for this role")) } +/// Installs the signed release runtime when none is cached — the same path the +/// desktop takes. Deliberately no "is it installed?" precondition: this runs in +/// three separate processes, and a guard here would refuse before reaching the +/// call that does the installing. async fn init_native_runtime() -> anyhow::Result<()> { - let cache = mesh_llm_sdk::native_runtime::native_runtime_cache(None)?; - let current = mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION; - if !cache - .installed()? - .iter() - .any(|runtime| runtime.mesh_version == current) - { - anyhow::bail!("MeshLLM native runtime for MeshLLM {current} is not installed; run `just mesh-e2e-hardware` once to prepare it"); - } mesh_llm_host_runtime::initialize_host_runtime() .await .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error}")) diff --git a/crates/buzz-relay/examples/mesh_agent_e2e.rs b/crates/buzz-relay/examples/mesh_agent_e2e.rs index 345ca4c746c..db0fc6090be 100644 --- a/crates/buzz-relay/examples/mesh_agent_e2e.rs +++ b/crates/buzz-relay/examples/mesh_agent_e2e.rs @@ -112,10 +112,12 @@ async fn run() -> anyhow::Result<()> { Err(e) => record("P1 explicit-model chat", false, e.to_string()), } - // P2: auto — router picks the model. + // P2: the virtual `mesh` model — exactly what apply_relay_mesh_env now puts + // on the wire for shared compute. With one served model there is no + // committee, so this proves MeshLLM's degrade_to_single_model path. let r = agent_chat( &base, - "auto", + "mesh", None, "Reply with exactly one word: PONG", &[], @@ -123,11 +125,15 @@ async fn run() -> anyhow::Result<()> { .await; match r { Ok(text) => record( - "P2 auto-model chat", + "P2 virtual-mesh chat (degrades to single model)", text.to_uppercase().contains("PONG"), text, ), - Err(e) => record("P2 auto-model chat", false, e.to_string()), + Err(e) => record( + "P2 virtual-mesh chat (degrades to single model)", + false, + e.to_string(), + ), } // P3: regression — an output budget no served model's context can hold @@ -139,7 +145,7 @@ async fn run() -> anyhow::Result<()> { // GLM-4.7-Flash. let r = agent_chat( &base, - &served_id, + "mesh", Some("150000"), "Reply with exactly one word: PONG", &[], @@ -169,7 +175,7 @@ async fn run() -> anyhow::Result<()> { ); let mcp = vec![("dev".to_string(), repo_bin("buzz-dev-mcp")?)]; let (r, marker) = - agent_chat_with_marker(&base, &served_id, None, &prompt, &mcp, &marker_name).await; + agent_chat_with_marker(&base, "mesh", None, &prompt, &mcp, &marker_name).await; let file_ok = std::fs::read_to_string(&marker) .map(|c| c.contains("BUZZ_OK")) .unwrap_or(false); @@ -270,7 +276,9 @@ async fn agent_chat_in_isolated_home( .env_clear() .env("PATH", std::env::var("PATH").unwrap_or_default()) .env("HOME", &home) - // Exactly the environment apply_relay_mesh_env() supplies. + // The transport subset of apply_relay_mesh_env(): provider, base URL, + // model, key, and chat API. Not BUZZ_AGENT_REQUIRE_REPLY, which needs + // Buzz's publish tools to mean anything. .env("BUZZ_AGENT_PROVIDER", "openai") .env("BUZZ_AGENT_MODEL", model) .env("OPENAI_COMPAT_BASE_URL", base) diff --git a/crates/buzz-relay/examples/mesh_serve_client_smoke.rs b/crates/buzz-relay/examples/mesh_serve_client_smoke.rs index 903d1a73c0e..19e6c775d74 100644 --- a/crates/buzz-relay/examples/mesh_serve_client_smoke.rs +++ b/crates/buzz-relay/examples/mesh_serve_client_smoke.rs @@ -41,15 +41,11 @@ const CLIENT_CONSOLE_PORT: u16 = 13132; async fn main() -> anyhow::Result<()> { let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); eprintln!("[smoke] model: {model}"); - let cache = mesh_llm_sdk::native_runtime::native_runtime_cache(None)?; - let current = mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION; - if !cache - .installed()? - .iter() - .any(|runtime| runtime.mesh_version == current) - { - anyhow::bail!("MeshLLM native runtime for MeshLLM {current} is not installed; run `just mesh-e2e-hardware` to prepare it"); - } + // No cache precondition: `initialize_host_runtime` installs the signed + // release runtime itself when none is present, which is how the desktop + // gets one too. The guard that used to stand here refused before reaching + // this line and told the reader to run the very recipe that runs this + // example. mesh_llm_host_runtime::initialize_host_runtime() .await .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error}"))?; diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453fd..dfce484494a 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -654,12 +654,13 @@ pub async fn submit_event( submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; match &outcome { - SubmitOutcome::Ok { accepted, .. } => { + SubmitOutcome::Ok { accepted, kind, .. } => { tracing::info!( pubkey = %pubkey_hex, route = "/events", status = 200u16, accepted, + kind, "HTTP bridge request" ); } @@ -713,6 +714,7 @@ enum SubmitOutcome { /// Ingest pipeline ran and returned a result (accepted or not). Ok { accepted: bool, + kind: u32, response: Json, }, /// JSON parse failure before ingest — log category/line/column, not msg. @@ -843,6 +845,7 @@ async fn submit_event_authed( })); SubmitOutcome::Ok { accepted: result.accepted, + kind: kind_u32, response, } } diff --git a/crates/buzz-relay/src/api/git/manifest.rs b/crates/buzz-relay/src/api/git/manifest.rs index baf109c1ade..0dfbdb35a4d 100644 --- a/crates/buzz-relay/src/api/git/manifest.rs +++ b/crates/buzz-relay/src/api/git/manifest.rs @@ -474,6 +474,17 @@ mod tests { m.validate().expect("no parent is fine (first push)"); } + #[test] + fn pointer_writer_is_covered_by_deletion_taxonomy() { + let community = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); + let owner = "a".repeat(64); + let key = pointer_key(community, &owner, "repo"); + let prefixes = buzz_media::tenant_prefixes(*community.as_uuid()); + + assert!(prefixes.iter().any(|prefix| key.starts_with(prefix))); + assert!(buzz_media::is_tenant_owned_key(*community.as_uuid(), &key)); + } + #[test] fn pointer_key_strips_dot_git() { let c = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 53e3f59463c..3b2241046a3 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -1783,6 +1783,21 @@ pub(crate) struct PushContext { pub repo_handle: HydratedRepo, } +#[derive(Default)] +struct FinalizePushHooks { + #[cfg(test)] + post_cas_gate: Option>, + #[cfg(test)] + fail_ref_state_insert: bool, +} + +#[cfg(test)] +#[derive(Default)] +struct PostCasGate { + reached: tokio::sync::Notify, + resume: tokio::sync::Notify, +} + /// Finalize a push request: CAS-commit the new state into the object /// store, derive kind:30618 from the committed manifest, and only then /// build the success response. @@ -1793,6 +1808,17 @@ pub(crate) struct PushContext { /// constructor of a push 2xx, so the seam is structural (not by /// convention). async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { + finalize_push_inner(state, ctx, &FinalizePushHooks::default()).await +} + +async fn finalize_push_inner( + state: &Arc, + ctx: PushContext, + hooks: &FinalizePushHooks, +) -> Response { + #[cfg(not(test))] + let _ = hooks; + // The push fence, part 0 — **a rejected push publishes nothing.** // // `ctx.pack.ok` is false when git aborted the ref updates: either the @@ -1823,10 +1849,41 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { return response; } + // An already-running receive-pack may cross the durable fence after + // request admission. Revalidate immediately before object-store CAS; DB + // trigger fencing alone cannot roll back an S3 pointer mutation. + let serving_write = match buzz_deletion::acquire_serving_write( + &state.db, + ctx.tenant.community(), + "git_publish", + ) + .await + { + Ok(guard) => guard, + Err(error) => { + warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push rejected by community deletion fence"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + "community writes are fenced", + ) + .into_response(); + } + }; + + if let Err(error) = serving_write.verify().await { + warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push lost community serving lease"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + "community write lease lost", + ) + .into_response(); + } + // Step 7 (CAS). The PushContext binds `parent_state` (observed at // hydrate) to the CAS predicate here — no re-reading of the pointer - // between hydrate and CAS. - let success = match cas_publish( + // between hydrate and CAS. Observe serving-lease loss throughout the + // potentially long upload/CAS operation, not only at its boundaries. + let publish = cas_publish( &state.git_store, &ctx.tenant, ctx.repo_handle.path(), @@ -1838,72 +1895,87 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { max_pack_bytes: state.config.git_max_pack_bytes, max_repo_bytes: state.config.git_max_repo_bytes, }, - ) - .await - { - Ok(s) => s, - Err(CasError::Conflict { - winner_manifest_key, - .. - }) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - winner = %winner_manifest_key, - "push lost CAS race; tempdir dropped, returning 409" - ); - return ( - StatusCode::CONFLICT, - "push superseded by a concurrent writer; pull and retry", - ) - .into_response(); - } - Err(CasError::ManifestInvalid(e)) => { - // 4xx-class: the workspace produced refs/HEAD/oids the - // manifest validator rejects (unsafe refname, malformed oid, - // empty head, malformed parent). Pre-CAS — no pointer was - // written. - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - error = %e, - "push rejected: manifest validation failed" - ); - return ( - StatusCode::BAD_REQUEST, - "push produced invalid manifest state", - ) - .into_response(); - } - Err(CasError::ResourceLimit(e)) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - error = %e, - "push rejected: repo exceeds relay resource limits" - ); + ); + let success = match serving_write.protect(publish).await { + Ok(result) => match result { + Ok(s) => s, + Err(CasError::Conflict { + winner_manifest_key, + .. + }) => { + warn!( + owner = %ctx.owner, + repo = %ctx.repo, + winner = %winner_manifest_key, + "push lost CAS race; tempdir dropped, returning 409" + ); + return ( + StatusCode::CONFLICT, + "push superseded by a concurrent writer; pull and retry", + ) + .into_response(); + } + Err(CasError::ManifestInvalid(e)) => { + // 4xx-class: the workspace produced refs/HEAD/oids the + // manifest validator rejects (unsafe refname, malformed oid, + // empty head, malformed parent). Pre-CAS — no pointer was + // written. + warn!( + owner = %ctx.owner, + repo = %ctx.repo, + error = %e, + "push rejected: manifest validation failed" + ); + return ( + StatusCode::BAD_REQUEST, + "push produced invalid manifest state", + ) + .into_response(); + } + Err(CasError::ResourceLimit(e)) => { + warn!( + owner = %ctx.owner, + repo = %ctx.repo, + error = %e, + "push rejected: repo exceeds relay resource limits" + ); + return ( + StatusCode::PAYLOAD_TOO_LARGE, + "repository exceeds relay resource limits", + ) + .into_response(); + } + Err(e) => { + // 5xx-class: ManifestReadFailed (parent corruption), + // Backend, PackCapture. The tempdir drops on scope exit; no + // pointer was written (or, on rare ManifestReadFailed during + // winner-fetch, the winner is already installed and the + // loser's data is unrelated). + error!( + owner = %ctx.owner, + repo = %ctx.repo, + error = %e, + "push failed pre-response" + ); + return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error").into_response(); + } + }, + Err(error) => { + warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push lost community serving lease during CAS publish"); return ( - StatusCode::PAYLOAD_TOO_LARGE, - "repository exceeds relay resource limits", + StatusCode::SERVICE_UNAVAILABLE, + "community write lease lost", ) .into_response(); } - Err(e) => { - // 5xx-class: ManifestReadFailed (parent corruption), - // Backend, PackCapture. The tempdir drops on scope exit; no - // pointer was written (or, on rare ManifestReadFailed during - // winner-fetch, the winner is already installed and the - // loser's data is unrelated). - error!( - owner = %ctx.owner, - repo = %ctx.repo, - error = %e, - "push failed pre-response" - ); - return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error").into_response(); - } }; + #[cfg(test)] + if let Some(gate) = &hooks.post_cas_gate { + gate.reached.notify_one(); + gate.resume.notified().await; + } + // Derived after CAS: kind:30618 ref-state event over the *committed* // manifest's refs/head. Spec §Implementation Correspondence: // "kind:30618 is derived after CAS, never the commit." We emit only @@ -1927,7 +1999,7 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { (Some(before), Some(after)) => before != after, _ => true, // first push (parent None) or impossible-shape after key → publish }; - if manifest_changed { + let publication_result: Result<(), String> = if manifest_changed { let inputs = RefStateInputs { repo_id: &ctx.repo_id, head: &success.manifest.head, @@ -1938,11 +2010,23 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { Ok(event) => { // Relay-signed kind:30618 belongs to the same server-resolved // tenant as the git request that committed the pointer. - match state + #[cfg(test)] + let insert_result = if hooks.fail_ref_state_insert { + Err(buzz_db::DbError::InvalidData( + "injected kind:30618 insert failure".to_string(), + )) + } else { + state + .db + .insert_event_with_serving_write_guard(serving_write.lease(), &event, None) + .await + }; + #[cfg(not(test))] + let insert_result = state .db - .insert_event(ctx.tenant.community(), &event, None) - .await - { + .insert_event_with_serving_write_guard(serving_write.lease(), &event, None) + .await; + match insert_result { Ok((stored, true)) => { // Routed through the guarded send path for uniformity; // the access gate no-ops for this globally-scoped @@ -1959,6 +2043,7 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { manifest = %success.manifest_key, "kind:30618 published (derived after CAS)" ); + Ok(()) } Ok((_, false)) => { info!( @@ -1966,26 +2051,41 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { repo = %ctx.repo_id, "kind:30618 deduplicated by relay db" ); + Ok(()) } - Err(e) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, - error = %e, - "kind:30618 insert failed; push remains durable in object store" - ); - } + Err(error) => Err(format!("kind:30618 insert failed: {error}")), } } - Err(e) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, - error = %e, - "kind:30618 build failed; push remains durable in object store" - ); - } + Err(error) => Err(format!("kind:30618 build failed: {error}")), } + } else { + Ok(()) + }; + + // The admitted serving write spans the complete publication attempt. Fence + // acquisition cannot overtake the pointer CAS, durable 30618 insert, or + // local fan-out attempt; only now may the lease be released. + if let Err(error) = serving_write.finish().await { + warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "failed to release community serving lease after push publication"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + "community write lease lost during publication", + ) + .into_response(); + } + if let Err(error) = publication_result { + error!( + owner = %ctx.owner, + repo = %ctx.repo_id, + manifest = %success.manifest_key, + %error, + "push pointer committed but kind:30618 publication failed" + ); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "push committed but ref-state publication failed; retry", + ) + .into_response(); } // Only now — after CAS commit and (optional) 30618 emission — build @@ -2014,12 +2114,14 @@ pub fn git_router(state: Arc) -> Router { #[cfg(test)] mod track_c_tests { use super::*; + use crate::api::git::hydrate::{hydrate_for_write, HydrationOptions}; use crate::api::git::manifest::Manifest; use buzz_core::CommunityId; use nostr::{EventBuilder, Keys, Kind, Tag}; use std::collections::BTreeMap; use std::io::Write; use std::process::Output; + use tempfile::TempDir; fn oid_sha1() -> String { "cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string() @@ -2160,6 +2262,303 @@ mod track_c_tests { assert!(remote.join("refs/heads/master").exists()); } + async fn run_finalize_git(repo: &Path, args: &[&str]) -> std::process::Output { + let mut command = Command::new("git"); + command.current_dir(repo).args(args); + harden_git_env(&mut command); + let output = command.output().await.expect("spawn git"); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + output + } + + async fn finalize_test_state() -> (Arc, sqlx::PgPool) { + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&config.database_url) + .await + .expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test DB"); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + 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("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + (Arc::new(state), pool) + } + + async fn approved_deletion( + state: &AppState, + host: &str, + ) -> ( + buzz_db::deletion::DeletionRequest, + buzz_db::deletion::ClaimedDeletion, + ) { + use buzz_db::deletion::{ + FrozenInventory, KeyStreamDigest, PrefixManifest, StorageManifest, + DEFAULT_LEASE_DURATION, + }; + + let store = state.db.deletion_store(); + let request = store + .submit(host, "git-finalize-test", Some("post-CAS lease regression")) + .await + .expect("submit deletion"); + let inventory = FrozenInventory { + schema: store + .inventory_schema(request.community_id) + .await + .expect("schema inventory"), + storage: StorageManifest { + version: 4, + prefixes: buzz_media::tenant_prefixes(*request.community_id.as_uuid()) + .into_iter() + .map(|prefix| PrefixManifest { + prefix, + object_count: 0, + total_bytes: 0, + keys_digest: KeyStreamDigest::new().finish().0, + }) + .collect(), + }, + }; + store + .freeze_inventory(request.id, &inventory) + .await + .expect("freeze inventory"); + store + .approve(request.id, "git-finalize-test", None) + .await + .expect("approve deletion"); + let claim = store + .claim_specific(request.id, "git-finalize-test", DEFAULT_LEASE_DURATION) + .await + .expect("claim deletion") + .expect("won deletion claim"); + (request, claim) + } + + async fn pushed_context( + state: &AppState, + community: CommunityId, + host: &str, + owner: String, + repo: String, + pusher: nostr::PublicKey, + scratch: &Path, + ) -> PushContext { + let tenant = TenantContext::resolved(community, host); + let (hydrated, parent_state) = hydrate_for_write( + &state.git_store, + &tenant, + &owner, + &repo, + HydrationOptions { + pack_cache: &state.git_pack_cache, + scratch_dir: scratch, + max_pack_bytes: 1024 * 1024, + max_repo_bytes: 2 * 1024 * 1024, + }, + ) + .await + .expect("hydrate empty test repo"); + let source = scratch.join("source"); + tokio::fs::create_dir(&source) + .await + .expect("source directory"); + run_finalize_git(&source, &["init", "--quiet", "--initial-branch=main"]).await; + run_finalize_git(&source, &["config", "user.email", "finalize@test"]).await; + run_finalize_git(&source, &["config", "user.name", "finalize"]).await; + tokio::fs::write(source.join("file.txt"), b"committed\n") + .await + .expect("write source file"); + run_finalize_git(&source, &["add", "file.txt"]).await; + run_finalize_git(&source, &["commit", "--quiet", "-m", "committed"]).await; + let remote = hydrated.path().to_str().expect("hydrated path utf8"); + run_finalize_git(&source, &["push", "--quiet", remote, "main"]).await; + + PushContext { + pack: PackOutput { + stdout: b"push-ok".to_vec(), + ok: true, + }, + parent_state, + owner, + repo: repo.clone(), + repo_id: repo, + pusher, + tenant, + repo_handle: hydrated, + } + } + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn finalize_push_holds_serving_lease_through_post_cas_publication() { + let (state, pool) = finalize_test_state().await; + let host = format!("git-finalize-{}.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 scratch = TempDir::new().expect("scratch"); + let owner = format!("owner-{}", uuid::Uuid::new_v4().simple()); + let repo = format!("repo-{}", uuid::Uuid::new_v4().simple()); + let ctx = pushed_context( + &state, + community, + &host, + owner, + repo.clone(), + Keys::generate().public_key(), + scratch.path(), + ) + .await; + let gate = Arc::new(PostCasGate::default()); + let hooks = FinalizePushHooks { + post_cas_gate: Some(Arc::clone(&gate)), + fail_ref_state_insert: false, + }; + let finalize_state = Arc::clone(&state); + let finalize = + tokio::spawn(async move { finalize_push_inner(&finalize_state, ctx, &hooks).await }); + + gate.reached.notified().await; + state + .db + .deletion_store() + .begin_quiescing(&claim.lease) + .await + .expect("quiesce after CAS"); + let error = state + .db + .deletion_store() + .fence(&claim.lease) + .await + .expect_err("post-CAS serving lease must block fence"); + assert!(matches!( + error, + buzz_db::DbError::ServingWritesNotDrained { .. } + )); + assert!(!state + .db + .deletion_store() + .is_serving_active(community) + .await + .expect("quiescing rejects new serving work")); + + gate.resume.notify_one(); + let response = finalize.await.expect("finalize task"); + assert_eq!(response.status(), StatusCode::OK); + let mut query = buzz_db::event::EventQuery::for_community(community); + query.kinds = Some(vec![30_618]); + query.d_tag = Some(repo); + let events = state.db.query_events(&query).await.expect("query 30618"); + assert_eq!(events.len(), 1, "kind:30618 must be durable before 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 publication"); + 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_db_failure_after_cas_is_not_success_and_releases_lease() { + let (state, pool) = finalize_test_state().await; + let host = format!( + "git-finalize-fail-{}.example", + uuid::Uuid::new_v4().simple() + ); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let scratch = TempDir::new().expect("scratch"); + let ctx = pushed_context( + &state, + community, + &host, + format!("owner-{}", uuid::Uuid::new_v4().simple()), + format!("repo-{}", uuid::Uuid::new_v4().simple()), + Keys::generate().public_key(), + scratch.path(), + ) + .await; + let hooks = FinalizePushHooks { + post_cas_gate: None, + fail_ref_state_insert: true, + }; + + let response = finalize_push_inner(&state, ctx, &hooks).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(state + .db + .deletion_store() + .serving_writes_drained(community) + .await + .expect("serving lease released on failure")); + drop(state); + pool.close().await; + } + /// A gzip-encoded request body is transparently inflated before it /// reaches the git subprocess. Git's smart-HTTP client gzips the /// upload-pack/receive-pack request body past a size threshold (fires diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171ccad..d09c7fc6119 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -260,6 +260,19 @@ async fn authenticate( Ok((tenant, pubkey)) } +fn map_mint_error(error: buzz_db::DbError) -> (StatusCode, Json) { + match error { + buzz_db::DbError::InvalidData(message) | buzz_db::DbError::DeletionSafety(message) => { + api_error(StatusCode::BAD_REQUEST, &message) + } + buzz_db::DbError::AccessDenied(_) => api_error( + StatusCode::SERVICE_UNAVAILABLE, + "community writes are temporarily unavailable", + ), + error => internal_error(&format!("invite mint: {error}")), + } +} + /// Mint an invite code — `POST /api/invites`, NIP-98 signed by an owner/admin. /// /// Returns the code, its expiry, and a shareable landing-page URL on the @@ -304,10 +317,7 @@ pub async fn mint_invite( .db .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) .await - .map_err(|error| match error { - buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), - error => internal_error(&format!("invite mint: {error}")), - })?; + .map_err(map_mint_error)?; // Same TLS-posture logic as nip98_expected_url: wss deployments get an // https landing page URL, ws dev/test deployments get http. @@ -895,6 +905,19 @@ mod tests { } } + #[test] + fn mint_fence_errors_map_to_temporary_unavailability() { + let (status, body) = super::map_mint_error(buzz_db::DbError::AccessDenied( + "community is write-fenced".to_string(), + )); + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + body.0.get("error").and_then(Value::as_str), + Some("community writes are temporarily unavailable") + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn malformed_and_unknown_v2_codes_are_forbidden_without_v1_fallback() { diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index a2f3640bde5..3b6e07bad66 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -283,6 +283,19 @@ async fn upload_attribution( }) } +fn serving_write_error(error: anyhow::Error) -> MediaError { + if buzz_deletion::ServingWriteGuard::acquisition_is_fenced(&error) { + MediaError::CommunityWriteFenced + } else { + MediaError::ServiceUnavailable + } +} + +fn serving_lease_lost(error: anyhow::Error) -> MediaError { + tracing::warn!(%error, "media serving-write lease lost"); + MediaError::ServiceUnavailable +} + /// PUT `/upload` or the temporary media-only `/media/upload` alias. /// /// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body @@ -310,6 +323,11 @@ pub async fn upload_blob( ) -> Result, MediaError> { let attribution = upload_attribution(&state, &auth, &headers).await; + let serving_write = + buzz_deletion::acquire_serving_write(&state.db, auth.tenant.community(), "media_upload") + .await + .map_err(serving_write_error)?; + if auth.route_mode == UploadRouteMode::LegacyMedia { metrics::counter!("buzz_media_legacy_upload_route_total").increment(1); } @@ -335,69 +353,86 @@ pub async fn upload_blob( } let replay = futures_util::stream::iter(replay_chunks.into_iter().map(Ok)).chain(source); - let mut descriptor = if should_stream_as_video(&sniff) { - // Video path: stream body directly to disk — never fully buffered in RAM. - let content_length = headers - .get("content-length") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()); - buzz_media::process_video_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - replay, - content_length, - attribution, - ) - .await? - } else { - // Non-video path: buffer the body (bounded by the larger of the image - // and generic-file caps), then decide image-vs-generic by sniffed MIME. - // Images go through the thumbnailing pipeline; non-media attachments - // (docs, archives, text, data) take the generic file path and are - // served as downloads. Recognized audio/video cannot fall through it. - let max = state - .config - .media - .max_image_bytes - .max(state.config.media.max_file_bytes); - let bytes = axum::body::to_bytes(axum::body::Body::from_stream(replay), max as usize) - .await - .map_err(|_| MediaError::FileTooLarge { size: 0, max })?; - - let is_image = matches!( - infer::get(&bytes).map(|t| t.mime_type()), - Some("image/jpeg" | "image/png" | "image/gif" | "image/webp") - ); - - if is_image { - buzz_media::process_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - bytes, - attribution, - ) - .await? - } else if auth.route_mode == UploadRouteMode::LegacyMedia { - let mime = infer::get(&bytes) - .map(|kind| kind.mime_type().to_string()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - return Err(MediaError::DisallowedContentType(mime)); - } else { - buzz_media::process_file_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - bytes, - attribution, - ) - .await? - } - }; + serving_write.verify().await.map_err(serving_lease_lost)?; + + let mut descriptor = serving_write + .protect(async { + Ok(if should_stream_as_video(&sniff) { + // Video path: stream body directly to disk — never fully buffered in RAM. + let content_length = headers + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + buzz_media::process_video_upload( + &state.media_storage, + &state.config.media, + &auth.tenant, + &auth.auth_event, + replay, + content_length, + attribution, + ) + .await? + } else { + // Non-video path: buffer the body (bounded by the larger of the image + // and generic-file caps), then decide image-vs-generic by sniffed MIME. + // Images go through the thumbnailing pipeline; non-media attachments + // (docs, archives, text, data) take the generic file path and are + // served as downloads. Recognized audio/video cannot fall through it. + let max = state + .config + .media + .max_image_bytes + .max(state.config.media.max_file_bytes); + let bytes = + axum::body::to_bytes(axum::body::Body::from_stream(replay), max as usize) + .await + .map_err(|_| MediaError::FileTooLarge { size: 0, max })?; + + let is_image = matches!( + infer::get(&bytes).map(|t| t.mime_type()), + Some("image/jpeg" | "image/png" | "image/gif" | "image/webp") + ); + + if is_image { + buzz_media::process_upload( + &state.media_storage, + &state.config.media, + &auth.tenant, + &auth.auth_event, + bytes, + attribution, + ) + .await? + } else if auth.route_mode == UploadRouteMode::LegacyMedia { + let mime = infer::get(&bytes) + .map(|kind| kind.mime_type().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + return Err(MediaError::DisallowedContentType(mime)); + } else { + buzz_media::process_file_upload( + &state.media_storage, + &state.config.media, + &auth.tenant, + &auth.auth_event, + bytes, + attribution, + ) + .await? + } + }) + }) + .await + .map_err(|error| { + if buzz_deletion::ServingWriteGuard::is_lease_lost(&error) { + serving_lease_lost(error) + } else { + match error.downcast::() { + Ok(error) => error, + Err(_) => MediaError::Internal, + } + } + })??; rewrite_descriptor_urls_for_tenant( &mut descriptor, @@ -441,6 +476,7 @@ pub async fn upload_blob( } } + serving_write.finish().await.map_err(serving_lease_lost)?; Ok(Json(descriptor)) } @@ -913,6 +949,20 @@ mod tests { const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + #[test] + fn serving_write_error_taxonomy_separates_fence_from_backend_failure() { + let fenced = anyhow::Error::from(buzz_db::DbError::AccessDenied("fenced".to_string())); + assert!(matches!( + serving_write_error(fenced), + MediaError::CommunityWriteFenced + )); + let backend = anyhow::Error::from(buzz_db::DbError::Sqlx(sqlx::Error::PoolTimedOut)); + assert!(matches!( + serving_write_error(backend), + MediaError::ServiceUnavailable + )); + } + #[test] fn upload_routes_distinguish_standard_and_legacy_modes() { assert_eq!( diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 16cd56209c7..4c158eab0c4 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -25,7 +25,7 @@ use bytes::Bytes; use futures_util::{SinkExt, StreamExt}; use nostr::{EventBuilder, Kind, Tag}; use serde::Deserialize; -use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore}; +use tokio::sync::{mpsc, watch, OwnedSemaphorePermit, Semaphore}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use uuid::Uuid; @@ -38,7 +38,7 @@ use buzz_core::StoredEvent; use buzz_pubsub::EventTopic; use crate::audio::room::PeerCtrl; -use crate::state::{run_registered_community_connection, AppState}; +use crate::state::{run_registered_community_connection, AppState, CommunityConnectionControl}; /// Maximum binary frame size: 4 KB is generous for a single Opus packet. const MAX_AUDIO_FRAME_BYTES: usize = 4096; @@ -149,6 +149,7 @@ async fn handle_audio_connection( _permit: OwnedSemaphorePermit, ) { let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel); let community_id = tenant.community(); let registry = Arc::clone(&state.community_connections); let check_state = Arc::clone(&state); @@ -157,9 +158,11 @@ async fn handle_audio_connection( ®istry, Uuid::new_v4(), community_id, - cancel.clone(), + control, move || async move { check_state.db.is_community_active(community_id).await }, - move || handle_active_audio_connection(socket, run_state, tenant, channel_id, cancel), + move |control| { + handle_active_audio_connection(socket, run_state, tenant, channel_id, control) + }, ) .await; } @@ -169,8 +172,10 @@ async fn handle_active_audio_connection( state: Arc, tenant: TenantContext, channel_id: Uuid, - cancel: CancellationToken, + control: CommunityConnectionControl, ) { + let cancel = control.cancellation_token(); + let disconnect_reason = control.disconnect_reason(); let (mut ws_send, mut ws_recv) = socket.split(); let challenge = generate_challenge(); @@ -660,7 +665,13 @@ async fn handle_active_audio_connection( let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); let send_cancel = cancel.child_token(); - let send_task = tokio::spawn(send_loop(ws_send, data_rx, ctrl_rx, send_cancel)); + let send_task = tokio::spawn(send_loop( + ws_send, + data_rx, + ctrl_rx, + send_cancel, + disconnect_reason, + )); let hb_cancel = cancel.clone(); let hb_missed = Arc::clone(&missed_pongs); @@ -1056,12 +1067,15 @@ async fn recv_loop( /// /// Control frames (Ping, Pong, Close, control JSON) are drained first on every /// iteration, so heartbeat pings are never starved by audio backpressure. -async fn send_loop( - mut ws_send: futures_util::stream::SplitSink, +async fn send_loop( + mut ws_send: S, mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, cancel: CancellationToken, -) { + disconnect_reason: watch::Receiver>, +) where + S: futures_util::Sink + Unpin, +{ loop { // Priority: drain all pending control frames before data. while let Ok(ctrl_msg) = ctrl_rx.try_recv() { @@ -1073,7 +1087,10 @@ async fn send_loop( tokio::select! { biased; _ = cancel.cancelled() => { - let _ = ws_send.send(WsMessage::Close(None)).await; + let close = disconnect_reason + .borrow() + .map_or(WsMessage::Close(None), |reason| reason.close_message()); + let _ = ws_send.send(close).await; break; } Some(ctrl_msg) = ctrl_rx.recv() => { @@ -1416,6 +1433,74 @@ mod tests { received } + #[tokio::test] + async fn audio_send_loop_sends_policy_close_when_community_is_deleted() { + use futures_util::Sink; + + struct MockSink { + messages: Arc>>, + } + + impl Sink for MockSink { + type Error = std::io::Error; + + fn poll_ready( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn start_send( + self: std::pin::Pin<&mut Self>, + item: WsMessage, + ) -> Result<(), Self::Error> { + self.messages.lock().expect("mock sink poisoned").push(item); + Ok(()) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.poll_flush(cx) + } + } + + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let disconnect_reason = control.disconnect_reason(); + let registry = crate::state::CommunityConnectionRegistry::new(); + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let _guard = registry.register(Uuid::new_v4(), community, control); + assert_eq!(registry.disconnect_community(community), 1); + let messages = Arc::new(Mutex::new(Vec::new())); + let sink = MockSink { + messages: Arc::clone(&messages), + }; + + send_loop(sink, data_rx, ctrl_rx, cancel, disconnect_reason).await; + + let messages = messages.lock().expect("mock sink poisoned"); + assert_eq!(messages.len(), 1); + match &messages[0] { + WsMessage::Close(Some(close)) => { + assert_eq!(close.code, axum::extract::ws::close_code::POLICY); + assert_eq!(close.reason.as_str(), "community deleted"); + } + other => panic!("expected one 1008 deletion close, got {other:?}"), + } + } + #[tokio::test] async fn audio_websocket_parser_rejects_oversized_messages_before_handler_reads_them() { assert!( diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 72a7eb91269..c37421e7e80 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -8,7 +8,7 @@ use std::time::Duration; use axum::extract::ws::{Message as WsMessage, WebSocket}; use futures_util::{Sink, SinkExt, StreamExt}; -use tokio::sync::{mpsc, Mutex, RwLock}; +use tokio::sync::{mpsc, watch, Mutex, RwLock}; use tokio_util::sync::CancellationToken; use tracing::Instrument as _; use tracing::{debug, info, trace, warn}; @@ -20,7 +20,10 @@ use nostr::Filter; use crate::handlers; use crate::protocol::{ClientMessage, RelayMessage}; -use crate::state::{run_registered_community_connection, AppState}; +use crate::state::{ + run_registered_community_connection, AppState, CommunityConnectionControl, + CommunityDisconnectReason, +}; use buzz_pubsub::EventTopic; /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. @@ -128,6 +131,7 @@ pub async fn handle_connection( ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel); let community_id = tenant.community(); let registry = Arc::clone(&state.community_connections); let check_state = Arc::clone(&state); @@ -136,9 +140,9 @@ pub async fn handle_connection( ®istry, conn_id, community_id, - cancel.clone(), + control, move || async move { check_state.db.is_community_active(community_id).await }, - move || handle_active_connection(socket, run_state, addr, tenant, conn_id, cancel), + move |control| handle_active_connection(socket, run_state, addr, tenant, conn_id, control), ) .await; } @@ -149,8 +153,10 @@ async fn handle_active_connection( addr: SocketAddr, tenant: TenantContext, conn_id: Uuid, - cancel: CancellationToken, + control: CommunityConnectionControl, ) { + let cancel = control.cancellation_token(); + let disconnect_reason = control.disconnect_reason(); let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { @@ -226,7 +232,14 @@ async fn handle_active_connection( let (ws_send, ws_recv) = socket.split(); let send_cancel = cancel.child_token(); - let send_task = tokio::spawn(send_loop(ws_send, rx, ctrl_rx, restart_rx, send_cancel)); + let send_task = tokio::spawn(send_loop( + ws_send, + rx, + ctrl_rx, + restart_rx, + send_cancel, + disconnect_reason, + )); let missed_pongs = Arc::new(AtomicU8::new(0)); let heartbeat_cancel = cancel.clone(); @@ -310,8 +323,17 @@ async fn send_loop( ctrl_rx: mpsc::Receiver, restart_rx: mpsc::Receiver, cancel: CancellationToken, + disconnect_reason: watch::Receiver>, ) { - send_loop_inner(ws_send, data_rx, ctrl_rx, restart_rx, cancel).await; + send_loop_inner( + ws_send, + data_rx, + ctrl_rx, + restart_rx, + cancel, + disconnect_reason, + ) + .await; } async fn send_loop_inner( @@ -320,6 +342,7 @@ async fn send_loop_inner( mut ctrl_rx: mpsc::Receiver, mut restart_rx: mpsc::Receiver, cancel: CancellationToken, + disconnect_reason: watch::Receiver>, ) where S: Sink + Unpin, { @@ -359,7 +382,10 @@ async fn send_loop_inner( break; } } - let _ = ws_send.send(WsMessage::Close(None)).await; + let close = disconnect_reason + .borrow() + .map_or(WsMessage::Close(None), |reason| reason.close_message()); + let _ = ws_send.send(close).await; break; } Some(ctrl_msg) = ctrl_rx.recv() => { @@ -787,6 +813,17 @@ mod tests { } } + fn ordinary_disconnect_reason() -> watch::Receiver> { + let (_tx, rx) = watch::channel(None); + rx + } + + fn deleted_community_disconnect_reason() -> watch::Receiver> { + let (tx, rx) = watch::channel(None); + tx.send_replace(Some(CommunityDisconnectReason::CommunityDeleted)); + rx + } + fn text_payloads(messages: &[WsMessage]) -> Vec { messages .iter() @@ -823,7 +860,15 @@ mod tests { let (sink, state) = MockSink::new(Some(1)); let (_restart_tx, restart_rx) = mpsc::channel(1); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 1); @@ -844,7 +889,15 @@ mod tests { let (sink, state) = MockSink::new(Some(1)); let (_restart_tx, restart_rx) = mpsc::channel(1); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 1); @@ -870,7 +923,15 @@ mod tests { let (sink, state) = MockSink::new(Some(2)); let (_restart_tx, restart_rx) = mpsc::channel(1); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 2); @@ -894,7 +955,15 @@ mod tests { .expect("queue restart close"); let (sink, state) = MockSink::new(None); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; assert_eq!(flushed_rx.await, Ok(true)); let state = state.lock().expect("mock sink poisoned"); @@ -923,7 +992,15 @@ mod tests { .expect("queue restart close"); let (sink, state) = MockSink::new(Some(1)); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; assert_eq!(flushed_rx.await, Ok(false)); let state = state.lock().expect("mock sink poisoned"); @@ -931,6 +1008,59 @@ mod tests { assert_eq!(state.messages.len(), 1, "no fallback close is appended"); } + #[tokio::test] + async fn send_loop_sends_policy_close_when_community_is_deleted() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + cancel.cancel(); + + let (sink, state) = MockSink::new(None); + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + cancel, + deleted_community_disconnect_reason(), + ) + .await; + + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.messages.len(), 1); + match &state.messages[0] { + WsMessage::Close(Some(close)) => { + assert_eq!(close.code, axum::extract::ws::close_code::POLICY); + assert_eq!(close.reason.as_str(), "community deleted"); + } + other => panic!("expected one 1008 deletion close, got {other:?}"), + } + } + + #[tokio::test] + async fn send_loop_sends_bare_close_for_ordinary_cancellation() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + cancel.cancel(); + + let (sink, state) = MockSink::new(None); + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + cancel, + ordinary_disconnect_reason(), + ) + .await; + + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.messages.as_slice(), [WsMessage::Close(None)]); + } + #[tokio::test] async fn send_loop_flushes_queued_control_before_close_on_cancel() { // A ban disconnect queues its `OK false "blocked: …"` reason frame on @@ -951,7 +1081,15 @@ mod tests { let (sink, state) = MockSink::new(None); let (_restart_tx, restart_rx) = mpsc::channel(1); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, cancel).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + cancel, + ordinary_disconnect_reason(), + ) + .await; let state = state.lock().expect("mock sink poisoned"); assert_eq!( @@ -966,8 +1104,8 @@ mod tests { other => panic!("expected the ban reason frame first, got {other:?}"), } assert!( - matches!(state.messages[1], WsMessage::Close(_)), - "Close is sent only after the reason frame is flushed" + matches!(state.messages[1], WsMessage::Close(None)), + "ordinary cancellation retains the bare Close after the reason frame" ); } } diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2d82736807a..abb9bb20665 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -19,6 +19,7 @@ use uuid::Uuid; use buzz_core::kind::*; use buzz_core::tenant::{CommunityId, TenantContext}; +use buzz_datastore_tracing::datastore_span; use buzz_db::workflow::{ApprovalStatus, RunStatus}; use buzz_db::DbError; use buzz_workflow::executor::TriggerContext; @@ -97,6 +98,7 @@ enum PersistResult { /// persists without the event record. On retry, the event INSERT succeeds /// (no conflict), and the mutation re-executes — which is safe for idempotent /// operations (open_dm, hide_dm, update_approval, upsert_workflow). +#[datastore_span(name = "persist_command_event", system = "postgresql")] async fn persist_command_event( state: &Arc, tenant: &TenantContext, @@ -110,6 +112,12 @@ async fn persist_command_event( .begin_transaction() .await .map_err(|e| IngestError::Internal(format!("error: begin transaction: {e}")))?; + buzz_deletion::store(&state.db) + .guard_transaction(&mut tx, tenant.community()) + .await + .map_err(|error| { + IngestError::Rejected(format!("restricted: community writes are fenced: {error}")) + })?; // INSERT with ON CONFLICT DO NOTHING — idempotency guard. let id_bytes = event.id.as_bytes(); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a67797385b8..ccba40f3282 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -705,16 +705,49 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc {} + Ok(false) => { + reject("restricted"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "restricted: community writes are fenced", + )); + return; + } + Err(error) => { + reject("error"); + tracing::warn!(%error, event_id = %event_id_hex, "failed to check ephemeral-event community lifecycle"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "error: internal server error", + )); + return; + } + } + match handle_ephemeral_event( event, conn_id, - &event_id_hex, pubkey_bytes, auth_pubkey, - conn, + Arc::clone(&conn), state, ) - .await; + .await + { + Ok(()) => { + conn.send(RelayMessage::ok(&event_id_hex, true, "")); + } + Err(message) => { + reject("invalid"); + conn.send(RelayMessage::ok(&event_id_hex, false, &message)); + } + } return; } @@ -762,33 +795,19 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, auth_pubkey: nostr::PublicKey, conn: Arc, state: Arc, -) { +) -> Result<(), String> { let event_clone = event.clone(); + let event_id = event.id.to_hex(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; match verify_result { Ok(Ok(())) => {} - Ok(Err(e)) => { - conn.send(RelayMessage::ok( - event_id_hex, - false, - &format!("invalid: {e}"), - )); - return; - } - Err(_) => { - conn.send(RelayMessage::ok( - event_id_hex, - false, - "error: internal error", - )); - return; - } + Ok(Err(e)) => return Err(format!("invalid: {e}")), + Err(_) => return Err("error: internal error".to_string()), } // Special handling for presence events (kind:20001). @@ -829,18 +848,8 @@ async fn handle_ephemeral_event( // Check channel membership before publishing other ephemeral events. if let Some(ch_id) = super::ingest::extract_channel_id(&event) { - if let Err(msg) = super::ingest::check_channel_membership( - &conn.tenant, - &state, - ch_id, - &pubkey_bytes, - None, - ) - .await - { - conn.send(RelayMessage::ok(event_id_hex, false, &msg)); - return; - } + super::ingest::check_channel_membership(&conn.tenant, &state, ch_id, &pubkey_bytes, None) + .await?; // Mark as local before Redis publish to prevent double-delivery when // the event comes back through the Redis subscriber loop. @@ -854,7 +863,7 @@ async fn handle_ephemeral_event( state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); - warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral publish failed: {e}"); + warn!(conn_id = %conn_id, event_id = %event_id, "Ephemeral publish failed: {e}"); } // Direct fan-out to local WS subscribers, through the guarded send path @@ -882,7 +891,7 @@ async fn handle_ephemeral_event( state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); - warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral global publish failed: {e}"); + warn!(conn_id = %conn_id, event_id = %event_id, "Ephemeral global publish failed: {e}"); } // Direct fan-out to local WS subscribers through the guarded send path. @@ -893,7 +902,7 @@ async fn handle_ephemeral_event( fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; } - conn.send(RelayMessage::ok(event_id_hex, true, "")); + Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 7aea2ed02e4..5ba9650e91e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -299,6 +299,24 @@ pub enum IngestError { Internal(String), } +/// Map the durable community write-fence lookup onto the ingest error taxonomy. +/// +/// An inactive community is an authorization decision and keeps the exact +/// `restricted:` wire text the ephemeral path uses. A lookup outage is a +/// server fault and fails closed as `error:`/500 — a Postgres blip can +/// neither admit a write past the fence nor read as a client mistake. +fn map_serving_fence_state(active: Result) -> Result<(), IngestError> { + match active { + Ok(true) => Ok(()), + Ok(false) => Err(IngestError::Rejected( + "restricted: community writes are fenced".into(), + )), + Err(error) => Err(IngestError::Internal(format!( + "error: checking community write fence: {error}" + ))), + } +} + fn map_relay_admin_error(error: super::relay_admin::RelayAdminError) -> IngestError { use super::relay_admin::RelayAdminError; match error { @@ -1931,6 +1949,17 @@ async fn ingest_event_inner( let kind_u32 = event_kind_u32(&event); debug!(event_id = %event_id_hex, kind = kind_u32, "ingest_event"); + // Durable community write fence: persistent ingest is a DB write the + // deletion engine cannot exclude via serving-write leases (those cover + // external side effects only), so the shared WS/HTTP seam must refuse + // writes once the community leaves the active lifecycle state. Row churn + // inside the remaining race window is swept by the destructive DB stage. + map_serving_fence_state( + buzz_deletion::store(&state.db) + .is_serving_active(tenant.community()) + .await, + )?; + if kind_u32 == KIND_AUTH { return Err(IngestError::Rejected( "invalid: AUTH events cannot be submitted".into(), @@ -2837,24 +2866,29 @@ async fn ingest_event_inner( }; let pubkey_hex = auth.pubkey().to_hex(); - // Spec WriteInsert (line 514) / WriteDuplicate (line 606): emit - // the abstract write action. The persist API returns - // `was_inserted` (true → Insert, false → Duplicate). This branch - // is the reaction path; channel_id is always Some here, so - // WriteInsertGlobal does not apply. + // Spec WriteInsert (line 514) / WriteDuplicate (line 606) / + // WriteInsertGlobal (line 559): emit the abstract write action. The + // persist API returns `was_inserted` (true → Insert/Global, false → + // Duplicate). Reactions on project events (issue/PR roots and their + // comments) carry no `h` tag, so `channel_id` can be `None` here — + // mirror the message write's three-way split instead of asserting a + // channel, which panicked the ingest worker on those events. let claimed = claimed_community_from_event(&event); - let action = if was_inserted { - TraceAction::WriteInsert { + let action = match (channel_id, was_inserted) { + (Some(ch), true) => TraceAction::WriteInsert { msg_id: msg_id_label(event.id.as_bytes()), - channel: channel_label(channel_id.expect("reaction path has channel")), + channel: channel_label(ch), claimed_community: claimed, - } - } else { - TraceAction::WriteDuplicate { + }, + (Some(ch), false) => TraceAction::WriteDuplicate { msg_id: msg_id_label(event.id.as_bytes()), - channel: channel_label(channel_id.expect("reaction path has channel")), + channel: channel_label(ch), claimed_community: claimed, - } + }, + (None, _) => TraceAction::WriteInsertGlobal { + msg_id: msg_id_label(event.id.as_bytes()), + claimed_community: claimed, + }, }; emit(tracer, action, state_for_request(tenant, auth.pubkey())); dispatch_persistent_event( @@ -3166,6 +3200,123 @@ mod tests { } } + /// An active community passes the durable write fence untouched. + #[test] + fn serving_fence_active_community_admits_write() { + assert!(map_serving_fence_state(Ok(true)).is_ok()); + } + + /// A fenced/tombstoned/archived community is an authorization decision: + /// `restricted:` and (via `bridge.rs`) HTTP 400 — with the exact wire text + /// the ephemeral WS path uses, so clients see one refusal vocabulary. + #[test] + fn serving_fence_inactive_community_maps_to_restricted() { + match map_serving_fence_state(Ok(false)) { + Err(IngestError::Rejected(msg)) => { + assert_eq!(msg, "restricted: community writes are fenced"); + } + other => panic!("fenced community must map to Rejected, got {other:?}"), + } + } + + /// A fence-lookup outage is a server fault and must fail closed as + /// `error:`/500 — a Postgres blip can neither admit a write past the + /// fence nor be reported to an innocent client as a bad request. + #[test] + fn serving_fence_lookup_outage_fails_closed_as_internal() { + let outage = buzz_db::DbError::Sqlx(sqlx::Error::PoolTimedOut); + match map_serving_fence_state(Err(outage)) { + Err(IngestError::Internal(msg)) => { + assert!( + msg.starts_with("error: "), + "fence outages need the `error:` NIP-01 prefix, got {msg:?}" + ); + } + other => panic!("fence lookup failure must map to Internal, got {other:?}"), + } + } + + /// Production-path regression: the exact predicate `ingest_event_inner` + /// consults must admit writes while a community is active and refuse them + /// once the community deletion lifecycle fences it. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ingest_write_fence_follows_community_deletion_lifecycle() { + use buzz_db::deletion::{ + FrozenInventory, KeyStreamDigest, PrefixManifest, StorageManifest, + DEFAULT_LEASE_DURATION, + }; + + let 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()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool); + db.migrate().await.expect("migrate test DB"); + let store = buzz_deletion::store(&db); + + let host = format!("lane3-fence-{}.example", Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + + assert!( + map_serving_fence_state(store.is_serving_active(community).await).is_ok(), + "active community must admit persistent ingest" + ); + + let submitted = store + .submit( + &host, + "test-operator", + Some("lane3 ingest fence regression"), + ) + .await + .expect("submit"); + let inventory = FrozenInventory { + schema: store + .inventory_schema(community) + .await + .expect("schema inventory"), + storage: StorageManifest { + version: 4, + prefixes: buzz_media::tenant_prefixes(*community.as_uuid()) + .into_iter() + .map(|prefix| PrefixManifest { + prefix, + object_count: 0, + total_bytes: 0, + keys_digest: KeyStreamDigest::new().finish().0, + }) + .collect(), + }, + }; + let request = store + .freeze_inventory(submitted.id, &inventory) + .await + .expect("freeze inventory"); + store + .approve(request.id, "approver", None) + .await + .expect("approve"); + let claim = store + .claim_specific(request.id, "executor", DEFAULT_LEASE_DURATION) + .await + .expect("claim") + .expect("won claim"); + store.begin_quiescing(&claim.lease).await.expect("quiesce"); + store.fence(&claim.lease).await.expect("fence"); + + match map_serving_fence_state(store.is_serving_active(community).await) { + Err(IngestError::Rejected(msg)) => { + assert_eq!(msg, "restricted: community writes are fenced"); + } + other => panic!("fenced community must refuse persistent ingest, got {other:?}"), + } + } + #[derive(Debug, Default)] struct VecTracer { steps: Mutex>, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 98f8a9aa847..88a9f0c731c 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -358,23 +358,22 @@ pub async fn validate_admin_event( let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; - // PUT_USER: open channels allow any authenticated user. Private - // channels only let owners/admins add another identity; otherwise - // any compromised member could extend access to channel history. - // - // A self-targeted add skips this check so an idempotent re-add - // still works. That is not a way into a private channel: ingest's - // `check_channel_membership` rejects a non-member (and a - // soft-removed member) before this validator runs, and `add_member` - // independently requires the self-inviter to hold an active role. - // Self-promotion is caught by the role-change guard below. - if channel.visibility == "private" - && target_pubkey != actor_bytes - && !actor_role.is_some_and(|r| r.is_elevated()) - { - return Err(anyhow::anyhow!( - "only owners/admins may add private-channel members" - )); + // PUT_USER: open channels allow any authenticated user; private channels + // require the actor to be an existing active member. Any active member may + // add an ordinary member, guest, or bot, but only owners/admins may grant + // an elevated role. + if channel.visibility == "private" { + if actor_role.is_none() { + return Err(anyhow::anyhow!("actor not authorized")); + } + + if requested_role.is_some_and(|role| role.is_elevated()) + && !actor_role.is_some_and(|role| role.is_elevated()) + { + return Err(anyhow::anyhow!( + "only owners/admins may grant elevated roles" + )); + } } // Changing an ACTIVE existing member's role is privileged in both diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 34dc2dfcf80..3584e1849d1 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -201,6 +201,12 @@ async fn main() -> anyhow::Result<()> { error!("Failed to ensure partitions: {e}"); } + db.validate_deletion_serving_catalog().await.map_err(|e| { + error!("Community deletion serving-fence validation failed: {e}"); + anyhow::anyhow!("Community deletion serving fence is unsafe: {e}") + })?; + info!("Community deletion serving fences verified"); + // Freshness fence probe: cursor pages route to the replica only for // history the probe has verified as fully replayed. Deliberately AFTER // the migration decision: spawn_fence_probe first verifies the @@ -469,6 +475,7 @@ async fn main() -> anyhow::Result<()> { if let Some(handle) = buzz_relay::mesh_boot::boot_mesh( &state.config, state.redis_pool.clone(), + state.db.clone(), &state.relay_keypair, Arc::clone(&state.shutting_down), ) @@ -1018,6 +1025,24 @@ async fn main() -> anyhow::Result<()> { metrics::gauge!("buzz_redis_pool_size").set(rs.size as f64); metrics::gauge!("buzz_redis_pool_max").set(rs.max_size as f64); metrics::gauge!("buzz_redis_pool_waiting").set(rs.waiting as f64); + + let deletion_store = pool_state.db.deletion_store(); + match deletion_store.reap_expired_serving_write_leases(1000).await { + Ok(reaped) => metrics::counter!("buzz_deletion_serving_leases_reaped_total") + .increment(reaped), + Err(error) => tracing::warn!(%error, "serving-lease reaper failed"), + } + match deletion_store.serving_lease_stats().await { + Ok(stats) => { + metrics::gauge!("buzz_deletion_serving_leases_active") + .set(stats.active as f64); + metrics::gauge!("buzz_deletion_serving_leases_expired") + .set(stats.expired as f64); + metrics::gauge!("buzz_deletion_serving_leases_dead_tuples") + .set(stats.dead_tuples as f64); + } + Err(error) => tracing::warn!(%error, "serving-lease metrics failed"), + } } }); } diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index 20e550aa08a..cd7c427c72e 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -411,6 +411,7 @@ fn advertise_addrs(endpoint: &MeshEndpoint) -> Vec { pub async fn boot_mesh( config: &Config, redis_pool: deadpool_redis::Pool, + db: buzz_db::Db, relay_keypair: &nostr::Keys, shutting_down: Arc, ) -> anyhow::Result> { @@ -508,7 +509,7 @@ pub async fn boot_mesh( transport.set_inbound(Box::new(dispatcher.clone())); Ok(Some(MeshHandle { - directory: SessionDirectory::new(redis_pool), + directory: SessionDirectory::with_db(redis_pool, db), transport, membership: membership_arc, local_runtime_id: runtime_id, @@ -535,7 +536,13 @@ mod tests { .create_pool(Some(deadpool_redis::Runtime::Tokio1)) .unwrap(); let keys = nostr::Keys::generate(); - let handle = boot_mesh(&config, pool, &keys, Arc::new(AtomicBool::new(false))) + let db = buzz_db::Db::from_pool( + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://unused:unused@127.0.0.1:1/unused") + .expect("lazy database pool"), + ); + let handle = boot_mesh(&config, pool, db, &keys, Arc::new(AtomicBool::new(false))) .await .expect("off path is never an error"); assert!(handle.is_none()); diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 49845067eac..4946b248c65 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -418,6 +418,23 @@ async fn deliver_one( return; } }; + let serving_write = match buzz_deletion::acquire_serving_write( + &state.db, + outcome.community, + "push_delivery", + ) + .await + { + Ok(guard) => guard, + Err(error) => { + warn!(wake=%outcome.id, %error, "push delivery suppressed by community deletion fence"); + let _ = state + .db + .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await; + return; + } + }; let Some(url) = state.config.push_gateway_delivery_url.as_ref() else { return; }; @@ -429,7 +446,20 @@ async fn deliver_one( return; } }; - let response = send_gateway_request(http, url, body, auth).await; + if let Err(error) = serving_write.verify().await { + warn!(wake=%outcome.id, %error, "push serving lease lost before delivery"); + return; + } + let response = match serving_write + .protect(send_gateway_request(http, url, body, auth)) + .await + { + Ok(response) => response, + Err(error) => { + warn!(wake=%outcome.id, %error, "push serving lease lost during delivery"); + return; + } + }; match response { Ok(r) if r.status().is_success() => match r.json::().await { Ok(DeliveryResponse::Accepted) => { @@ -502,6 +532,9 @@ async fn deliver_one( .await; } } + if let Err(error) = serving_write.finish().await { + warn!(wake=%outcome.id, %error, "failed to release community serving lease after push delivery"); + } } fn delivery_body(endpoint_grant: &str, request_id: uuid::Uuid, expires_at: i64) -> Vec { diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe34..82ad9938a2f 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -376,22 +376,30 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo } let check = async { - let (pg_ok, redis_ok) = tokio::join!(state.db.ping(), async { - state.redis_pool.get().await.is_ok() - },); - (pg_ok, redis_ok) + let (pg_ok, redis_ok, deletion_catalog_ok) = tokio::join!( + state.db.ping(), + async { state.redis_pool.get().await.is_ok() }, + async { state.db.validate_deletion_serving_catalog().await.is_ok() }, + ); + (pg_ok, redis_ok, deletion_catalog_ok) }; - let (pg_ok, redis_ok) = tokio::time::timeout(Duration::from_secs(2), check) - .await - .unwrap_or((false, false)); + let (pg_ok, redis_ok, deletion_catalog_ok) = + tokio::time::timeout(Duration::from_secs(2), check) + .await + .unwrap_or((false, false, false)); - if pg_ok && redis_ok { + if pg_ok && redis_ok && deletion_catalog_ok { (StatusCode::OK, Json(json!({"status": "ready"}))).into_response() } else { ( StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"status": "not_ready", "postgres": pg_ok, "redis": redis_ok})), + Json(json!({ + "status": "not_ready", + "postgres": pg_ok, + "redis": redis_ok, + "deletion_catalog": deletion_catalog_ok + })), ) .into_response() } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 14a50df7b77..2f544e188c0 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -10,8 +10,7 @@ use axum::body::Bytes; use axum::extract::ws::{Message as WsMessage, Utf8Bytes as WsUtf8Bytes}; use dashmap::DashMap; use futures_util::future::join_all; -use tokio::sync::mpsc; -use tokio::sync::Semaphore; +use tokio::sync::{mpsc, watch, Semaphore}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -37,6 +36,55 @@ use crate::subscription::SubscriptionRegistry; pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); +/// Why a community-bound socket is being asked to stop. +/// +/// Only deletion is externally attributed today. Ordinary lifecycle exits keep +/// using cancellation alone and therefore retain the existing bare-close +/// behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommunityDisconnectReason { + CommunityDeleted, +} + +impl CommunityDisconnectReason { + pub(crate) fn close_message(self) -> WsMessage { + match self { + Self::CommunityDeleted => WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: WsUtf8Bytes::from_static("community deleted"), + })), + } + } +} + +/// Per-socket lifecycle controls shared by the registry and the writer. +#[derive(Clone)] +pub(crate) struct CommunityConnectionControl { + cancel: CancellationToken, + reason_tx: watch::Sender>, +} + +impl CommunityConnectionControl { + pub(crate) fn new(cancel: CancellationToken) -> Self { + let (reason_tx, _reason_rx) = watch::channel(None); + Self { cancel, reason_tx } + } + + pub(crate) fn cancellation_token(&self) -> CancellationToken { + self.cancel.clone() + } + + pub(crate) fn disconnect_reason(&self) -> watch::Receiver> { + self.reason_tx.subscribe() + } + + fn disconnect_community(&self) { + self.reason_tx + .send_replace(Some(CommunityDisconnectReason::CommunityDeleted)); + self.cancel.cancel(); + } +} + /// Leaves headroom under the process-wide drain deadline for a stalled writer. const RESTART_CLOSE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); type SlidingWindowCounter = (u32, Instant); @@ -68,7 +116,7 @@ struct ConnEntry { /// registration cancels the token; archival before registration is observed by /// the revalidation. The returned guard removes the entry on every exit path. pub struct CommunityConnectionRegistry { - connections: Arc>, + connections: Arc>, } impl Default for CommunityConnectionRegistry { @@ -86,26 +134,27 @@ impl CommunityConnectionRegistry { } /// Registers one socket and returns a guard that deregisters it on drop. - pub fn register( + pub(crate) fn register( &self, connection_id: Uuid, community_id: CommunityId, - cancel: CancellationToken, + control: CommunityConnectionControl, ) -> CommunityConnectionGuard { self.connections - .insert(connection_id, (community_id, cancel)); + .insert(connection_id, (community_id, control)); CommunityConnectionGuard { connection_id, connections: Arc::clone(&self.connections), } } - /// Cancels every socket type currently bound to `community_id`. + /// Disconnects every socket type currently bound to `community_id` and + /// attributes the close to community deletion. pub fn disconnect_community(&self, community_id: CommunityId) -> usize { let mut closed = 0; for entry in self.connections.iter() { if entry.value().0 == community_id { - entry.value().1.cancel(); + entry.value().1.disconnect_community(); closed += 1; } } @@ -124,7 +173,7 @@ impl CommunityConnectionRegistry { /// Removes a socket lifecycle registration on every handler exit path. pub struct CommunityConnectionGuard { connection_id: Uuid, - connections: Arc>, + connections: Arc>, } impl Drop for CommunityConnectionGuard { @@ -137,20 +186,21 @@ impl Drop for CommunityConnectionGuard { /// /// The ordering is the archival admission invariant: archive-before-query is /// observed by the query, while archive-after-registration sees the token. -pub async fn run_registered_community_connection( +pub(crate) async fn run_registered_community_connection( registry: &CommunityConnectionRegistry, connection_id: Uuid, community_id: CommunityId, - cancel: CancellationToken, + control: CommunityConnectionControl, check_active: Check, run: Run, ) where Check: FnOnce() -> CheckFuture, CheckFuture: Future>, - Run: FnOnce() -> RunFuture, + Run: FnOnce(CommunityConnectionControl) -> RunFuture, RunFuture: Future, { - let _guard = registry.register(connection_id, community_id, cancel.clone()); + let cancel = control.cancel.clone(); + let _guard = registry.register(connection_id, community_id, control.clone()); if !matches!(check_active().await, Ok(true)) { cancel.cancel(); return; @@ -158,7 +208,7 @@ pub async fn run_registered_community_connection, } /// Active session ownership lease read from Redis. @@ -179,6 +180,9 @@ pub enum DirectoryError { /// Lease TTL cannot be represented in Redis milliseconds. #[error("lease ttl must be at least 1ms and fit in i64 milliseconds")] InvalidLeaseTtl, + /// Durable community deletion fence rejected a Redis mutation. + #[error("community write fenced: {0}")] + CommunityWriteFenced(String), } impl SessionDirectory { @@ -187,9 +191,36 @@ impl SessionDirectory { Self::with_lease_ttl(pool, DEFAULT_LEASE_TTL) } + /// Create a serving directory whose Redis mutations use durable, + /// heartbeat-backed community write leases. + pub fn with_db(pool: deadpool_redis::Pool, db: buzz_db::Db) -> Self { + Self { + pool, + lease_ttl: DEFAULT_LEASE_TTL, + db: Some(db), + } + } + /// Create a directory backed by `pool` with an explicit lease TTL. pub fn with_lease_ttl(pool: deadpool_redis::Pool, lease_ttl: Duration) -> Self { - Self { pool, lease_ttl } + Self { + pool, + lease_ttl, + db: None, + } + } + + async fn begin_serving_write( + &self, + community_id: CommunityId, + ) -> Result, DirectoryError> { + match &self.db { + Some(db) => buzz_deletion::acquire_serving_write(db, community_id, "session_directory") + .await + .map(Some) + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string())), + None => Ok(None), + } } /// Attempt to create/take over the session lease. @@ -204,10 +235,11 @@ impl SessionDirectory { owner_runtime_id: RuntimeId, profile: Profile, ) -> Result { + let serving_write = self.begin_serving_write(community_id).await?; let keys = SessionKeys::new(community_id, session_id); let ttl_ms = ttl_ms(self.lease_ttl)?; let mut conn = self.pool.get().await?; - let (status, value, _known_generation): (String, String, String) = + let mutation = async { Script::new(ACQUIRE_SCRIPT) .key(&keys.lease) .key(&keys.generation) @@ -215,8 +247,22 @@ impl SessionDirectory { .arg(profile.as_wire_str()) .arg(ttl_ms) .invoke_async(&mut *conn) - .await?; + .await + }; + let (status, value, _known_generation): (String, String, String) = match &serving_write { + Some(guard) => guard + .protect(mutation) + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))??, + None => mutation.await?, + }; let lease = parse_lease(community_id, session_id, &value)?; + if let Some(guard) = serving_write { + guard + .finish() + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))?; + } match status.as_str() { "acquired" => Ok(AcquireResult::Acquired(lease)), "exists" => Ok(AcquireResult::Exists(lease)), @@ -244,18 +290,34 @@ impl SessionDirectory { /// Renew a lease only if the current Redis value exactly matches the /// caller's owner runtime and generation. pub async fn renew(&self, lease: &SessionLease) -> Result { + let serving_write = self.begin_serving_write(lease.community_id).await?; let keys = SessionKeys::new(lease.community_id, lease.session_id); let ttl_ms = ttl_ms(self.lease_ttl)?; let mut conn = self.pool.get().await?; - let (status, value, known_generation): (String, String, String) = Script::new(RENEW_SCRIPT) - .key(&keys.lease) - .key(&keys.generation) - .arg(lease.owner_runtime_id.to_hex()) - .arg(lease.generation) - .arg(ttl_ms) - .invoke_async(&mut *conn) - .await?; + let mutation = async { + Script::new(RENEW_SCRIPT) + .key(&keys.lease) + .key(&keys.generation) + .arg(lease.owner_runtime_id.to_hex()) + .arg(lease.generation) + .arg(ttl_ms) + .invoke_async(&mut *conn) + .await + }; + let (status, value, known_generation): (String, String, String) = match &serving_write { + Some(guard) => guard + .protect(mutation) + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))??, + None => mutation.await?, + }; let current = parse_optional_lease(lease.community_id, lease.session_id, &value)?; + if let Some(guard) = serving_write { + guard + .finish() + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))?; + } match status.as_str() { "renewed" => Ok(RenewResult::Renewed( current.expect("renewed returns lease"), @@ -275,17 +337,32 @@ impl SessionDirectory { /// Release a lease only if the current Redis value exactly matches the /// caller's owner runtime and generation. pub async fn release(&self, lease: &SessionLease) -> Result { + let serving_write = self.begin_serving_write(lease.community_id).await?; let keys = SessionKeys::new(lease.community_id, lease.session_id); let mut conn = self.pool.get().await?; - let (status, value, known_generation): (String, String, String) = + let mutation = async { Script::new(RELEASE_SCRIPT) .key(&keys.lease) .key(&keys.generation) .arg(lease.owner_runtime_id.to_hex()) .arg(lease.generation) .invoke_async(&mut *conn) - .await?; + .await + }; + let (status, value, known_generation): (String, String, String) = match &serving_write { + Some(guard) => guard + .protect(mutation) + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))??, + None => mutation.await?, + }; let current = parse_optional_lease(lease.community_id, lease.session_id, &value)?; + if let Some(guard) = serving_write { + guard + .finish() + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))?; + } match status.as_str() { "released" => Ok(ReleaseResult::Released( current.expect("released returns lease"), diff --git a/crates/buzz-search/Cargo.toml b/crates/buzz-search/Cargo.toml index e42bcc8041b..e28c5b68409 100644 --- a/crates/buzz-search/Cargo.toml +++ b/crates/buzz-search/Cargo.toml @@ -9,9 +9,11 @@ description = "Postgres full-text search for Buzz, scoped by community" [dependencies] buzz-core = { workspace = true } +buzz-datastore-tracing = { workspace = true } sqlx = { workspace = true } uuid = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index 7f33b660c43..bd95e8cdbbc 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -8,10 +8,12 @@ //! //! See conformance row 50. -use buzz_core::CommunityId; use sqlx::{PgPool, QueryBuilder, Row}; use uuid::Uuid; +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; + use crate::error::SearchError; /// Channel-scope filter for a community-scoped FTS query. @@ -213,6 +215,7 @@ fn normalized_search_text(q: &str) -> Option { /// /// `community_id = $ctx` is the first predicate and is non-negotiable. There /// is no code path through this function that omits it. +#[datastore_span(name = "search", system = "postgresql")] pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result { let Some(search_text) = normalized_search_text(&query.q) else { return Ok(SearchResult { @@ -229,6 +232,14 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result = QueryBuilder::new( "SELECT id, kind, pubkey, channel_id, \ @@ -292,7 +303,13 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result"; + let resp = upload(&client, &keys, html).await; + let status = resp.status().as_u16(); + assert_eq!( + status, 200, + "HTML should upload via file path, got {status}" + ); + let desc: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(desc["type"].as_str().unwrap(), "text/html"); + let url = desc["url"].as_str().unwrap(); + assert!( + url.ends_with(".html"), + "served URL must carry the .html extension, got {url}" + ); + let sha256 = desc["sha256"].as_str().unwrap(); + + let get_resp = client + .get(url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) + .send() + .await + .expect("GET request"); + assert_eq!(get_resp.status(), 200, "HTML GET roundtrip should succeed"); + + let header = |name: &str| { + get_resp + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + assert_eq!(header("content-type"), "text/html"); + assert_eq!( + header("content-disposition"), + "attachment", + "HTML must be forced to download, never rendered inline" + ); + assert_eq!( + header("x-content-type-options"), + "nosniff", + "nosniff must prevent MIME re-sniffing to an executable type" + ); + assert_eq!( + header("content-security-policy"), + "default-src 'none'", + "restrictive CSP must neutralise any active content" + ); + println!("✅ HTML → 200, served as inert attachment (disposition+nosniff+CSP)"); +} + #[tokio::test] #[ignore] async fn test_upload_pdf_accepted() { diff --git a/crates/buzz-test-client/tests/e2e_mesh_llm.rs b/crates/buzz-test-client/tests/e2e_mesh_llm.rs index 4b1bfb3d213..21a8ec29d79 100644 --- a/crates/buzz-test-client/tests/e2e_mesh_llm.rs +++ b/crates/buzz-test-client/tests/e2e_mesh_llm.rs @@ -11,7 +11,9 @@ //! # Running (manual / runbook) //! //! ```text -//! # 1. prepare the matching native runtime with `scripts/ensure-mesh-native-runtime.sh` +//! # 1. the mesh-enabled desktop installs the signed native runtime itself on +//! # first init; stale pre-0.75 cache entries are skipped rather than +//! # fatal (MeshLLM >= 0.75.1), so no manual cleanup is needed //! # 2. start the normal membership-gated relay and a mesh-enabled desktop //! # 3. have that desktop publish status, then run the trust assertions: //! RELAY_URL=ws://localhost:3000 \ diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 5b9a50b5b17..5d5ad8916c3 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -120,7 +120,12 @@ async fn seed_relay_member(host: &str, keys: &Keys, role: &str) { } async fn seed_relay_owner(keys: &Keys) { - seed_relay_member("localhost:3000", keys, "owner").await; + seed_relay_member(&relay_authority(), keys, "owner").await; +} + +fn relay_authority() -> String { + let url = url::Url::parse(&relay_http_url()).expect("relay HTTP URL"); + url[url::Position::BeforeHost..url::Position::AfterPort].to_string() } fn http_origin_for_host(host: &str) -> String { @@ -315,7 +320,7 @@ async fn test_invite_claim_rejects_invalid_code() { #[ignore] async fn test_invite_mint_requires_owner_or_admin() { let member = Keys::generate(); - seed_relay_member("localhost:3000", &member, "member").await; + seed_relay_member(&relay_authority(), &member, "member").await; let response = invite_post(&member, "/api/invites", "{}").await; assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); @@ -791,10 +796,10 @@ async fn test_auth_event_kind_rejected() { /// NIP-11 max_subscriptions must be enforced; (limit+1)th REQ gets CLOSED. /// -/// The relay's MAX_SUBSCRIPTIONS is 1024. Opening 1024 subs in a test is slow, -/// so we open a smaller batch and verify the NIP-11 advertised limit matches -/// the actual enforcement constant. The full-limit test is covered by the -/// NIP-11 assertion below (which verifies the advertised value is 1024). +/// This is a protocol-cap test, not an admission-throughput test. Open one REQ +/// at a time and wait out any shared fixed-window quota before retrying a REQ +/// rejected specifically as `rate-limited`, so production admission remains +/// enabled while the test deterministically reaches the independent 1024 cap. #[tokio::test] #[ignore] async fn test_subscription_limit_enforced() { @@ -802,60 +807,75 @@ async fn test_subscription_limit_enforced() { let keys = Keys::generate(); let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); - // Open 1024 subscriptions (the relay's MAX_SUBSCRIPTIONS). for i in 0..1024 { let sid = format!("limit-sub-{i}"); - let filter = Filter::new().kind(Kind::Custom(9)); - client - .subscribe(&sid, vec![filter]) - .await - .expect("subscribe"); - // Drain EOSE to avoid buffer buildup. - client - .collect_until_eose(&sid, Duration::from_secs(5)) - .await - .expect("EOSE"); + let filter = Filter::new().kind(Kind::Custom(49_999)); + subscribe_until_eose(&mut client, &sid, filter).await; } let overflow_sid = sub_id("overflow"); - // Use a kind that no other test writes, so we don't receive stale events. - let filter = Filter::new().kind(Kind::Custom(49999)); - client - .subscribe(&overflow_sid, vec![filter]) - .await - .expect("send REQ"); - - // Drain EOSE and stale events from the 100 earlier subscriptions - // until we receive the CLOSED for the overflow subscription. - let msg = loop { - let m = client - .recv_event(Duration::from_secs(5)) + let filter = Filter::new().kind(Kind::Custom(49_999)); + loop { + client + .subscribe(&overflow_sid, vec![filter.clone()]) .await - .expect("recv CLOSED (or timeout)"); - match &m { - RelayMessage::Eose { .. } => continue, - RelayMessage::Event { .. } => continue, // stale event from earlier subs - _ => break m, - } - }; + .expect("send overflow REQ"); - match msg { - RelayMessage::Closed { - subscription_id, - message, - } => { - assert_eq!(subscription_id, overflow_sid); - assert!( - message.to_lowercase().contains("too many"), - "Expected 'too many' in CLOSED message, got: {message}" - ); + match client + .recv_event(Duration::from_secs(6)) + .await + .expect("recv overflow CLOSED") + { + RelayMessage::Closed { + subscription_id, + message, + } if subscription_id == overflow_sid && message.starts_with("rate-limited:") => { + tokio::time::sleep(Duration::from_secs(5)).await; + } + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, overflow_sid); + assert!( + message.to_lowercase().contains("too many"), + "Expected 'too many' in CLOSED message, got: {message}" + ); + break; + } + other => panic!("Expected CLOSED for overflow subscription, got {other:?}"), } - other => panic!("Expected CLOSED for overflow subscription, got {other:?}"), } client.disconnect().await.expect("disconnect"); } +async fn subscribe_until_eose(client: &mut BuzzTestClient, sid: &str, filter: Filter) { + loop { + client + .subscribe(sid, vec![filter.clone()]) + .await + .expect("subscribe"); + match client + .recv_event(Duration::from_secs(6)) + .await + .expect("EOSE or rate-limit CLOSED") + { + RelayMessage::Eose { subscription_id } => { + assert_eq!(subscription_id, sid); + return; + } + RelayMessage::Closed { + subscription_id, + message, + } if subscription_id == sid && message.starts_with("rate-limited:") => { + tokio::time::sleep(Duration::from_secs(5)).await; + } + other => panic!("unexpected response while opening {sid}: {other:?}"), + } + } +} + #[tokio::test] #[ignore] async fn test_nip11_relay_info() { @@ -2249,14 +2269,17 @@ async fn add_member_with_role_ws( (ok.accepted, ok.message) } -/// Only owners/admins can add another identity to a private channel. +/// Any active member can add any ordinary role to a private channel. #[tokio::test] #[ignore] -async fn test_private_channel_member_cannot_invite() { +async fn test_private_channel_any_member_can_invite() { let url = relay_url(); let owner_keys = Keys::generate(); - let member_keys = Keys::generate(); - let invitee_keys = Keys::generate(); + let actors = [ + ("member", Keys::generate()), + ("guest", Keys::generate()), + ("bot", Keys::generate()), + ]; // Connect as owner and create a private channel. let mut owner_client = BuzzTestClient::connect(&url, &owner_keys) @@ -2264,54 +2287,70 @@ async fn test_private_channel_member_cannot_invite() { .expect("connect as owner"); let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await; - // Owner adds member_keys as a regular member. - let (accepted, msg) = add_member_ws( - &mut owner_client, - &channel_id, - &member_keys.public_key().to_hex(), - &owner_keys, - ) - .await; - assert!(accepted, "owner should add member, got: {msg}"); + // Seed one actor for each ordinary active role. + for (role, keys) in &actors { + let (accepted, msg) = add_member_with_role_ws( + &mut owner_client, + &channel_id, + &keys.public_key().to_hex(), + role, + &owner_keys, + ) + .await; + assert!(accepted, "owner should add {role} actor, got: {msg}"); + } - // Connect as the regular member. - let mut member_client = BuzzTestClient::connect(&url, &member_keys) - .await - .expect("connect as member"); + // Exercise the full ordinary-role target matrix. Relay and DB authorization + // both run here, unlike the Desktop/mobile policy-unit-test mirrors. + for (actor_role, actor_keys) in &actors { + let mut actor_client = BuzzTestClient::connect(&url, actor_keys) + .await + .unwrap_or_else(|err| panic!("connect as {actor_role}: {err}")); + + for target_role in ["member", "guest", "bot"] { + let target_keys = Keys::generate(); + let target_pubkey_hex = target_keys.public_key().to_hex(); + let (accepted, msg) = add_member_with_role_ws( + &mut actor_client, + &channel_id, + &target_pubkey_hex, + target_role, + actor_keys, + ) + .await; + assert!( + accepted, + "private-channel {actor_role} should add {target_role}, got: {msg}" + ); + assert_eq!( + member_role(&url, &owner_keys, &channel_id, &target_pubkey_hex).await, + Some(target_role.to_string()), + "private-channel {actor_role} add must persist the {target_role} role" + ); + } - // Regular member tries to invite a third user. - let (accepted, msg) = add_member_ws( - &mut member_client, - &channel_id, - &invitee_keys.public_key().to_hex(), - &member_keys, - ) - .await; - assert!( - !accepted, - "regular member must not add another private-channel identity: {msg}" - ); - assert!( - msg.contains("owners/admins"), - "rejection should name the owner/admin requirement, got: {msg}" - ); + // Re-adding oneself stays idempotent — the huddle bot-add and kind:9021 + // paths depend on a self-targeted PUT_USER working. + let (accepted, msg) = add_member_with_role_ws( + &mut actor_client, + &channel_id, + &actor_keys.public_key().to_hex(), + actor_role, + actor_keys, + ) + .await; + assert!( + accepted, + "self-targeted {actor_role} re-add must stay idempotent, got: {msg}" + ); - // The same member re-adding *themselves* stays idempotent — the huddle - // bot-add and kind:9021 paths depend on a self-targeted PUT_USER working. - let (accepted, msg) = add_member_ws( - &mut member_client, - &channel_id, - &member_keys.public_key().to_hex(), - &member_keys, - ) - .await; - assert!( - accepted, - "self-targeted re-add must stay idempotent, got: {msg}" - ); + actor_client + .disconnect() + .await + .unwrap_or_else(|err| panic!("disconnect {actor_role}: {err}")); + } owner_client.disconnect().await.expect("disconnect owner"); - member_client.disconnect().await.expect("disconnect member"); } /// An admin — not just the owner — can still add to a private channel. diff --git a/crates/buzz-workflow/Cargo.toml b/crates/buzz-workflow/Cargo.toml index d4813e56d42..7d361b1477a 100644 --- a/crates/buzz-workflow/Cargo.toml +++ b/crates/buzz-workflow/Cargo.toml @@ -10,6 +10,7 @@ description = "YAML-as-code workflow engine for Buzz" [dependencies] buzz-core = { workspace = true } buzz-db = { workspace = true } +buzz-deletion = { workspace = true } hex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index e30541377e4..dffa4927168 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -526,165 +526,202 @@ pub async fn dispatch_action( ) -> Result { use ActionDef::*; - match action { - SendMessage { text, channel } => { - // Look up workflow metadata for destination validation and - // attribution, scoped to the run's community — the same run/workflow - // UUID may exist in another community, so a bare-id lookup could - // load the wrong row and drive a side effect under it. - let wf_run = engine - .db - .get_workflow_run(community_id, run_id) - .await - .map_err(|e| { - WorkflowError::WebhookError(format!( - "SendMessage: failed to load workflow run {run_id}: {e}" - )) - })?; - let workflow = engine - .db - .get_workflow(community_id, wf_run.workflow_id) - .await - .map_err(|e| { - WorkflowError::WebhookError(format!( - "SendMessage: failed to load workflow {}: {e}", - wf_run.workflow_id - )) - })?; - let channel_id = resolve_send_message_channel( - channel.as_deref(), - &trigger_ctx.channel_id, - workflow.channel_id, - )?; - let owner_pubkey_hex = hex::encode(&workflow.owner_pubkey); - - info!( - run_id = %run_id, - step = step_id, - channel = %channel_id, - "SendMessage → {channel_id}: {text}" - ); - - let event_id = engine - .action_sink()? - .send_message(community_id, &channel_id, text, &owner_pubkey_hex) - .await - .map_err(WorkflowError::from)?; - - Ok(StepResult::Completed(serde_json::json!({ - "sent": true, - "event_id": event_id, - }))) - } - - SendDm { to, text: _ } => { - warn!(run_id = %run_id, step = step_id, "SendDm not yet implemented (to={to})"); - // TODO (WF-07): emit DM event. - Err(WorkflowError::NotImplemented("SendDm".into())) - } - - SetChannelTopic { topic: _ } => { - warn!(run_id = %run_id, step = step_id, "SetChannelTopic not yet implemented"); - // TODO (WF-07): update channel topic via DB. - Err(WorkflowError::NotImplemented("SetChannelTopic".into())) - } - - AddReaction { emoji } => { - info!(run_id = %run_id, step = step_id, "AddReaction → :{emoji}:"); - if trigger_ctx.message_id.is_empty() { - return Err(WorkflowError::InvalidDefinition( - "AddReaction: no trigger.message_id available".into(), - )); - } - - #[cfg(feature = "reqwest")] - { - let result = add_reaction_impl(&trigger_ctx.message_id, emoji).await?; - Ok(StepResult::Completed(result)) - } - - #[cfg(not(feature = "reqwest"))] - { - warn!( - run_id = %run_id, - step = step_id, - "AddReaction: reqwest feature not enabled, skipping HTTP call" - ); - Ok(StepResult::Completed( - serde_json::json!({ "added": false, "skipped": true }), + // The workflow engine can outlive the serving request that spawned it. + // Revalidate the durable community fence immediately before every external + // side effect (message publish, webhook, delay/resume). A storage failure is + // a denial, never permission to continue. + let serving_write = + buzz_deletion::acquire_serving_write(&engine.db, community_id, "workflow_action") + .await + .map_err(|error| { + WorkflowError::WebhookError(format!( + "community write fence rejected workflow side effect: {error}" )) - } - } + })?; - CallWebhook { - url, - method, - headers, - body, - } => { - let method_str = method.as_deref().unwrap_or("POST"); - info!(run_id = %run_id, step = step_id, "CallWebhook → {method_str} {url}"); + serving_write.verify().await.map_err(|error| { + WorkflowError::WebhookError(format!("community write lease lost: {error}")) + })?; - #[cfg(feature = "reqwest")] - { - let result = call_webhook_impl(url, method_str, headers, body).await?; - Ok(StepResult::Completed(result)) - } + let result = serving_write + .protect(async { + match action { + SendMessage { text, channel } => { + // Look up workflow metadata for destination validation and + // attribution, scoped to the run's community — the same run/workflow + // UUID may exist in another community, so a bare-id lookup could + // load the wrong row and drive a side effect under it. + let wf_run = engine + .db + .get_workflow_run(community_id, run_id) + .await + .map_err(|e| { + WorkflowError::WebhookError(format!( + "SendMessage: failed to load workflow run {run_id}: {e}" + )) + })?; + let workflow = engine + .db + .get_workflow(community_id, wf_run.workflow_id) + .await + .map_err(|e| { + WorkflowError::WebhookError(format!( + "SendMessage: failed to load workflow {}: {e}", + wf_run.workflow_id + )) + })?; + let channel_id = resolve_send_message_channel( + channel.as_deref(), + &trigger_ctx.channel_id, + workflow.channel_id, + )?; + let owner_pubkey_hex = hex::encode(&workflow.owner_pubkey); + + info!( + run_id = %run_id, + step = step_id, + channel = %channel_id, + "SendMessage → {channel_id}: {text}" + ); + + let event_id = engine + .action_sink()? + .send_message(community_id, &channel_id, text, &owner_pubkey_hex) + .await + .map_err(WorkflowError::from)?; + + Ok(StepResult::Completed(serde_json::json!({ + "sent": true, + "event_id": event_id, + }))) + } - #[cfg(not(feature = "reqwest"))] - { - // reqwest not enabled — log and return placeholder. - warn!( - run_id = %run_id, step = step_id, - "CallWebhook: reqwest feature not enabled, skipping HTTP call" - ); - let _ = (headers, body); // suppress unused warnings - Ok(StepResult::Completed(serde_json::json!({ - "status": 0, - "body": null, - "skipped": true - }))) - } - } + SendDm { to, text: _ } => { + warn!(run_id = %run_id, step = step_id, "SendDm not yet implemented (to={to})"); + // TODO (WF-07): emit DM event. + Err(WorkflowError::NotImplemented("SendDm".into())) + } - RequestApproval { - from, - message, - timeout, - } => { - let timeout_str = timeout.as_deref().unwrap_or("24h"); - info!( - run_id = %run_id, step = step_id, - "RequestApproval from={from} timeout={timeout_str}: {message}" - ); + SetChannelTopic { topic: _ } => { + warn!(run_id = %run_id, step = step_id, "SetChannelTopic not yet implemented"); + // TODO (WF-07): update channel topic via DB. + Err(WorkflowError::NotImplemented("SetChannelTopic".into())) + } + + AddReaction { emoji } => { + info!(run_id = %run_id, step = step_id, "AddReaction → :{emoji}:"); + if trigger_ctx.message_id.is_empty() { + Err(WorkflowError::InvalidDefinition( + "AddReaction: no trigger.message_id available".into(), + )) + } else { + #[cfg(feature = "reqwest")] + { + let result = add_reaction_impl(&trigger_ctx.message_id, emoji).await?; + Ok(StepResult::Completed(result)) + } + + #[cfg(not(feature = "reqwest"))] + { + warn!( + run_id = %run_id, + step = step_id, + "AddReaction: reqwest feature not enabled, skipping HTTP call" + ); + Ok(StepResult::Completed( + serde_json::json!({ "added": false, "skipped": true }), + )) + } + } + } - let token = generate_approval_token(run_id, step_id); + CallWebhook { + url, + method, + headers, + body, + } => { + let method_str = method.as_deref().unwrap_or("POST"); + info!(run_id = %run_id, step = step_id, "CallWebhook → {method_str} {url}"); + + #[cfg(feature = "reqwest")] + { + let result = call_webhook_impl(url, method_str, headers, body).await?; + Ok(StepResult::Completed(result)) + } - // TODO (WF-08): create approval record in DB, emit kind:46010. - // For now, return Suspended with the token so the caller can persist state. + #[cfg(not(feature = "reqwest"))] + { + // reqwest not enabled — log and return placeholder. + warn!( + run_id = %run_id, step = step_id, + "CallWebhook: reqwest feature not enabled, skipping HTTP call" + ); + let _ = (headers, body); // suppress unused warnings + Ok(StepResult::Completed(serde_json::json!({ + "status": 0, + "body": null, + "skipped": true + }))) + } + } - Ok(StepResult::Suspended { - approval_token: token, - }) - } + RequestApproval { + from, + message, + timeout, + } => { + let timeout_str = timeout.as_deref().unwrap_or("24h"); + info!( + run_id = %run_id, step = step_id, + "RequestApproval from={from} timeout={timeout_str}: {message}" + ); + + let token = generate_approval_token(run_id, step_id); + + // TODO (WF-08): create approval record in DB, emit kind:46010. + // For now, return Suspended with the token so the caller can persist state. + + Ok(StepResult::Suspended { + approval_token: token, + }) + } - Delay { duration } => { - let secs = parse_duration_secs(duration)?; - // Cap delay at 270 seconds (4.5 minutes) — must be less than default_timeout_secs (300s) - // to avoid non-deterministic StepTimeout. Long delays (hours/days) - // should use the scheduled resume pattern (future work: WF-09). - const MAX_DELAY_SECS: u64 = 270; - if secs > MAX_DELAY_SECS { - return Err(WorkflowError::InvalidDefinition(format!( - "delay exceeds maximum of {MAX_DELAY_SECS} seconds (got {secs}s); \ + Delay { duration } => { + let secs = parse_duration_secs(duration)?; + // Cap delay at 270 seconds (4.5 minutes) — must be less than default_timeout_secs (300s) + // to avoid non-deterministic StepTimeout. Long delays (hours/days) + // should use the scheduled resume pattern (future work: WF-09). + const MAX_DELAY_SECS: u64 = 270; + if secs > MAX_DELAY_SECS { + return Err(WorkflowError::InvalidDefinition(format!( + "delay exceeds maximum of {MAX_DELAY_SECS} seconds (got {secs}s); \ use the scheduled resume pattern for long delays" - ))); + ))); + } + info!(run_id = %run_id, step = step_id, "Delay {duration} ({secs}s)"); + tokio::time::sleep(std::time::Duration::from_secs(secs)).await; + Ok(StepResult::Completed( + serde_json::json!({ "slept_secs": secs }), + )) + } } - info!(run_id = %run_id, step = step_id, "Delay {duration} ({secs}s)"); - tokio::time::sleep(std::time::Duration::from_secs(secs)).await; - Ok(StepResult::Completed( - serde_json::json!({ "slept_secs": secs }), - )) + }) + .await + .map_err(|error| { + WorkflowError::WebhookError(format!("community write lease lost: {error}")) + })?; + let release = serving_write.finish().await.map_err(|error| { + WorkflowError::WebhookError(format!("community write lease release failed: {error}")) + }); + match result { + Ok(value) => { + release?; + Ok(value) + } + Err(error) => { + let _ = release; + Err(error) } } } diff --git a/deny.toml b/deny.toml index c432a20ea4f..d3c5fcd4bc7 100644 --- a/deny.toml +++ b/deny.toml @@ -15,10 +15,6 @@ ignore = [ # remove these when upstream catches up. { id = "RUSTSEC-2026-0194", reason = "transitive via rust-s3 and mesh-llm→plist; trusted-input XML only; no upstream fix available yet" }, { id = "RUSTSEC-2026-0195", reason = "transitive via rust-s3 and mesh-llm→plist; trusted-input XML only; no upstream fix available yet" }, - # nostr-relay-pool 0.44.3 — informational/unmaintained, not a vulnerability. - # Transitive via mesh-llm 0.74 → nostr-sdk 0.44.1. Remove after mesh-llm - # migrates to nostr-sdk >= 0.45, which absorbed the standalone relay pool. - { id = "RUSTSEC-2026-0243", reason = "transitive via mesh-llm; upstream nostr-sdk 0.45 migration requires API changes" }, ] [licenses] diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index b2778df28b5..86989676604 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -62,9 +62,15 @@ Buzz uses one URL style for both media and Git/CAS object-store requests: | `virtual` | `https://bucket.endpoint/key` | AWS-style providers and new Railway Storage Buckets | The chart always renders `s3.addressingStyle` as -`BUZZ_S3_ADDRESSING_STYLE`. It renders `s3.region` as `BUZZ_S3_REGION` only -when explicitly set, preserving the relay's existing `AWS_REGION` fallback for -upgrades. Only `path` and `virtual` addressing styles are accepted; invalid +`BUZZ_S3_ADDRESSING_STYLE` and `s3.region` as `BUZZ_S3_REGION`. The region +defaults to `us-east-1`, keeping bundled MinIO and the in-pod +`buzz-admin deletions` workflow operable without an ambient `AWS_REGION`. +Production providers must set their credential region explicitly when it +differs. Existing releases that previously omitted `s3.region` will begin +rendering `BUZZ_S3_REGION=us-east-1` after upgrade, even if an image or +`relay.extraEnv` entry supplied `AWS_REGION`; set `s3.region` to the provider's +actual credential region before upgrading. Only `path` and `virtual` addressing +styles are accepted; invalid values fail chart rendering and relay startup. The bundled MinIO quickstart deliberately keeps `path` because its Service DNS resolves one endpoint hostname, not arbitrary `.` names. diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 0ad41ac4611..451ebb1cded 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -170,9 +170,7 @@ spec: - { name: BUZZ_S3_ENDPOINT, value: {{ $s3Endpoint | quote }} } {{- end }} - { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} } - {{- if .Values.s3.region }} - { name: BUZZ_S3_REGION, value: {{ .Values.s3.region | quote }} } - {{- end }} - { name: BUZZ_S3_ADDRESSING_STYLE, value: {{ .Values.s3.addressingStyle | quote }} } # ── Secrets (from chart-managed or existing) ───────────── diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 196a4a53032..10a1a34d1fd 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -30,11 +30,11 @@ tests: path: kind value: Service template: templates/service.yaml - - notContains: + - contains: path: spec.template.spec.containers[0].env content: name: BUZZ_S3_REGION - any: true + value: "us-east-1" template: templates/deployment.yaml - contains: path: spec.template.spec.containers[0].env diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index d3670595b5b..94d369c8903 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -200,7 +200,8 @@ "bucket": { "type": "string", "minLength": 1 }, "region": { "type": "string", - "description": "Optional S3 region used for SigV4 signing. When empty, BUZZ_S3_REGION is omitted so the relay can use AWS_REGION or its own default." + "minLength": 1, + "description": "S3 region used for SigV4 signing by the relay and deletion operator. Defaults to us-east-1 for bundled MinIO/local deployments; set the provider region explicitly when it differs." }, "addressingStyle": { "type": "string", diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 8131aef4321..ca3403a633f 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -342,9 +342,10 @@ externalRedis: s3: endpoint: "" bucket: "buzz-media" - # Optional SigV4 signing region. Leave empty to preserve the relay's - # AWS_REGION fallback; set the provider's credential value when needed. - region: "" + # SigV4 signing region shared by the relay and `buzz-admin deletions`. + # Keep the MinIO/local default operable; production providers should set + # their credential region explicitly when it differs. + region: "us-east-1" # path: https://endpoint/bucket/key (bundled MinIO-compatible default) # virtual: https://bucket.endpoint/key (standard S3; required by new Railway buckets) addressingStyle: path diff --git a/desktop/package.json b/desktop/package.json index 14c412ff1de..3601f25185e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.8", + "version": "0.5.11", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b9..e930f0ef612 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", @@ -62,6 +63,7 @@ export default defineConfig({ "**/video-attachment.spec.ts", "**/spoiler.spec.ts", "**/composer-link-shortcut.spec.ts", + "**/entity-link-recipient-cards.spec.ts", "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", @@ -75,6 +77,7 @@ export default defineConfig({ "**/relay-connectivity.spec.ts", "**/unread-pill.spec.ts", "**/sidebar-more-unread-overlap.spec.ts", + "**/sidebar-snapshot.spec.ts", "**/home-collapsed-top-chrome.spec.ts", "**/top-chrome-zoom-clearance.spec.ts", "**/thread-unread.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index a69f4250249..7fa6c4cb7e0 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -210,7 +210,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -221,7 +221,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -447,20 +447,21 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +checksum = "2c713e1f14c7b82e32ea159af1c6e2f070cfadbdf23fb2512acce9af0a26f1a2" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", "tokio", + "tokio-happy-eyeballs", "tokio-rustls", "tokio-socks", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.28.0", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -507,12 +508,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -762,6 +757,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "beef" version = "0.5.2" @@ -774,7 +775,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "serde", "unicode-normalization", ] @@ -815,7 +816,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.5.0", ] [[package]] @@ -827,6 +828,12 @@ dependencies = [ "hex-conservative 0.3.2", ] +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + [[package]] name = "bitcoin-io" version = "0.1.101" @@ -847,6 +854,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals 0.6.0", + "hex-conservative 1.2.0", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1047,7 +1066,7 @@ dependencies = [ "chrono", "hex", "hmac 0.13.0", - "nostr", + "nostr 0.44.7", "percent-encoding", "rand 0.10.2", "serde", @@ -1062,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.8" +version = "0.5.11" dependencies = [ "anyhow", "arboard", @@ -1101,13 +1120,14 @@ dependencies = [ "mesh-llm-sdk", "mesh-llm-system", "neteq", - "nostr", + "nostr 0.44.7", "notify-rust", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-user-notifications", "opus", + "percent-encoding", "plist", "png 0.18.1", "portable-pty", @@ -1169,7 +1189,7 @@ dependencies = [ "imagesize", "infer", "mp4", - "nostr", + "nostr 0.44.7", "rust-s3", "serde", "serde_json", @@ -1198,7 +1218,7 @@ name = "buzz-sdk" version = "0.1.0" dependencies = [ "buzz-core", - "nostr", + "nostr 0.44.7", "serde", "serde_json", "thiserror 2.0.18", @@ -1604,7 +1624,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -2271,7 +2291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -2449,7 +2469,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2730,7 +2750,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2820,6 +2840,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3238,7 +3268,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", + "windows-link 0.1.3", "windows-result 0.4.1", ] @@ -3563,6 +3593,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3621,6 +3660,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -3664,29 +3713,12 @@ dependencies = [ ] [[package]] -name = "hf-hub" -version = "1.0.0-rc.1" +name = "hex-conservative" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", + "arrayvec", ] [[package]] @@ -4449,7 +4481,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.1", + "lru", "n0-error", "n0-future", "noq", @@ -4931,12 +4963,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - [[package]] name = "lru" version = "0.18.1" @@ -5134,8 +5160,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "mesh-llm-client", @@ -5144,8 +5170,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5155,13 +5181,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-llm-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5192,8 +5218,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5208,8 +5234,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5218,8 +5244,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "clap", @@ -5230,12 +5256,9 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ - "anyhow", - "cc", - "libc", "serde", "serde_json", "tracing", @@ -5243,8 +5266,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -5252,16 +5275,45 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "mesh-llm-native-runtime", ] +[[package]] +name = "mesh-llm-hf-hub" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43088a838cf0c6715c65f65a5ac99045fd6d6e90949a8a4183b8104ab791e96b" +dependencies = [ + "base64 0.22.1", + "bon", + "bytes", + "futures", + "getrandom 0.2.17", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + [[package]] name = "mesh-llm-host-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "argon2", @@ -5279,7 +5331,6 @@ dependencies = [ "flate2", "futures-util", "hex", - "hf-hub", "http", "http-body-util", "httparse", @@ -5294,6 +5345,7 @@ dependencies = [ "mesh-llm-config", "mesh-llm-events", "mesh-llm-guardrails", + "mesh-llm-hf-hub", "mesh-llm-identity", "mesh-llm-native-runtime", "mesh-llm-node", @@ -5306,6 +5358,7 @@ dependencies = [ "mesh-llm-types", "mesh-llm-ui", "mesh-mixture-of-agents", + "mesh-native-serving-plugin-host", "model-artifact", "model-hf", "model-package", @@ -5353,8 +5406,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "argon2", "base64 0.22.1", @@ -5375,8 +5428,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "serde", @@ -5386,8 +5439,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-types", @@ -5400,8 +5453,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5417,8 +5470,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5436,8 +5489,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "hex", @@ -5447,18 +5500,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "mesh-llm-release-footer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "hex", + "sha2 0.10.9", +] + [[package]] name = "mesh-llm-routing" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5480,8 +5542,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5495,8 +5557,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5506,8 +5568,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "chrono", @@ -5515,8 +5577,12 @@ dependencies = [ "dirs", "hex", "libc", + "libloading 0.8.9", "mesh-llm-build-info", "mesh-llm-gpu-bench", + "mesh-llm-native-runtime", + "mesh-llm-release-footer", + "mesh-llm-runtime-install", "reqwest 0.12.28", "semver", "serde", @@ -5529,8 +5595,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "serde", @@ -5540,13 +5606,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-mixture-of-agents" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5557,6 +5623,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "mesh-native-serving-plugin-api" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" + +[[package]] +name = "mesh-native-serving-plugin-host" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "anyhow", + "libloading 0.8.9", + "mesh-native-serving-plugin-api", + "skippy-server", +] + [[package]] name = "miette" version = "7.6.0" @@ -5644,13 +5726,13 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "model-artifact" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5660,14 +5742,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", - "hf-hub", + "mesh-llm-hf-hub", "model-artifact", "model-ref", "serde", @@ -5678,14 +5760,14 @@ dependencies = [ [[package]] name = "model-package" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "bytes", "chrono", "futures", - "hf-hub", + "mesh-llm-hf-hub", "model-hf", "model-ref", "reqwest 0.12.28", @@ -5698,16 +5780,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "model-artifact", @@ -5803,7 +5885,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6161,6 +6243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", + "aws-lc-rs", "bytes", "derive_more", "enum-assoc", @@ -6201,9 +6284,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.11.1", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -6211,7 +6294,7 @@ dependencies = [ "hex", "instant", "scrypt", - "secp256k1", + "secp256k1 0.29.1", "serde", "serde_json", "unicode-normalization", @@ -6219,56 +6302,71 @@ dependencies = [ ] [[package]] -name = "nostr-database" -version = "0.44.0" +name = "nostr" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +checksum = "5dde8c76076d334409d86c2e1db3e97abe5deb8cb92744f939cbc1fa45bd69e7" dependencies = [ - "lru 0.16.4", - "nostr", - "tokio", + "base64 0.22.1", + "bech32 0.12.0", + "bip39", + "bitcoin_hashes 1.2.0", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "faster-hex", + "opaquerr", + "rand 0.10.2", + "secp256k1 0.30.0", + "serde", + "serde_json", + "unicode-normalization", + "universal-time", + "url", + "zeroize", ] [[package]] -name = "nostr-gossip" -version = "0.44.0" +name = "nostr-database" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf" dependencies = [ - "nostr", + "nostr 0.45.1", + "opaquerr", ] [[package]] -name = "nostr-relay-pool" -version = "0.44.3" +name = "nostr-gossip" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336" dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru 0.16.4", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", + "nostr 0.45.1", + "opaquerr", ] [[package]] name = "nostr-sdk" -version = "0.44.1" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +checksum = "26c86342f367bd9b173ec4a697e936e3a82d6dad5b4aa06c0d35d9b4f88a8e72" dependencies = [ "async-utility", - "nostr", + "async-wsocket", + "faster-hex", + "futures", + "lru", + "negentropy", + "nostr 0.45.1", "nostr-database", "nostr-gossip", - "nostr-relay-pool", + "opaquerr", + "rand 0.10.2", "tokio", + "tokio-stream", "tracing", + "universal-time", ] [[package]] @@ -6300,7 +6398,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6773,6 +6871,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaquerr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" + [[package]] name = "open" version = "5.3.6" @@ -6786,8 +6890,8 @@ dependencies = [ [[package]] name = "openai-frontend" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "axum", @@ -6998,7 +7102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -8024,7 +8128,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8180,7 +8284,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools", "kasuari", - "lru 0.18.1", + "lru", "palette", "serde", "strum", @@ -8755,7 +8859,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8825,7 +8929,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9010,6 +9114,17 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.101", + "rand 0.8.6", + "secp256k1-sys", +] + [[package]] name = "secp256k1-sys" version = "0.10.1" @@ -9081,7 +9196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9566,8 +9681,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "blake3", @@ -9576,40 +9691,41 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "libloading 0.8.9", ] [[package]] name = "skippy-metrics" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "skippy-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "prost 0.14.4", "prost-build 0.14.4", "protoc-bin-vendored", "serde", + "skippy-tokenizer", ] [[package]] name = "skippy-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "libc", @@ -9622,8 +9738,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "ahash", "anyhow", @@ -9634,6 +9750,8 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-native-serving-plugin-api", + "model-artifact", "openai-frontend", "opentelemetry-proto", "serde", @@ -9643,16 +9761,25 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-tokenizer", "socket2", "tokio", "tokio-stream", "tonic", ] +[[package]] +name = "skippy-tokenizer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "serde", +] + [[package]] name = "skippy-topology" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -9688,7 +9815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10702,10 +10829,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10727,7 +10854,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook 0.3.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10973,6 +11100,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-happy-eyeballs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8564c32dfb6f4257f8bc6edfc178a34af97520e0b7b9815500c55eb3d092f29f" +dependencies = [ + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -11041,9 +11177,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -11051,7 +11187,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.26.2", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -11091,6 +11227,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" dependencies = [ + "aws-lc-rs", "base64 0.22.1", "bytes", "futures-core", @@ -11459,7 +11596,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11481,9 +11618,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -11560,7 +11697,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11701,6 +11838,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -12165,15 +12308,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "core-foundation 0.10.1", "jni 0.22.4", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys", @@ -12405,7 +12548,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9b2de6a575f..54676458737 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.8" +version = "0.5.11" description = "Buzz desktop app" authors = ["you"] edition = "2021" @@ -98,6 +98,7 @@ nostr = { version = "0.44", features = ["nip44", "nip49"] } # transitive dependency; pinned here for direct use). getrandom = "0.2" zeroize = "1" +percent-encoding = "2" reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } url = "2" @@ -109,14 +110,14 @@ buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" } portable-pty = "0.9" iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 7f40b64328b..2cdd785c735 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -18,21 +18,8 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); - println!("cargo:rerun-if-env-changed=BUZZ_BUILD_CANDIDATE_ID"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); - if let Ok(candidate_id) = std::env::var("BUZZ_BUILD_CANDIDATE_ID") { - let valid = !candidate_id.is_empty() - && candidate_id.len() <= 48 - && candidate_id.chars().all(|character| { - character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' - }); - if !valid { - panic!("BUZZ_BUILD_CANDIDATE_ID must match [a-z0-9-] and be at most 48 characters"); - } - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_CANDIDATE_ID={candidate_id}"); - } - // Explicit owner-only agent-access capability. Release packaging sets this // presence-only marker; OSS/custom builds leave agent access configurable. if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() { diff --git a/desktop/src-tauri/src/app_state_keyring.rs b/desktop/src-tauri/src/app_state_keyring.rs index 375903a8c32..68d24e87f58 100644 --- a/desktop/src-tauri/src/app_state_keyring.rs +++ b/desktop/src-tauri/src/app_state_keyring.rs @@ -7,12 +7,7 @@ fn dev_keyring_service(configured: Option) -> String { } pub(crate) fn keyring_service() -> &'static str { - if let Some(candidate_id) = option_env!("BUZZ_DESKTOP_BUILD_CANDIDATE_ID") { - static CANDIDATE_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); - CANDIDATE_SERVICE - .get_or_init(|| format!("buzz-desktop-candidate.{candidate_id}")) - .as_str() - } else if cfg!(debug_assertions) { + if cfg!(debug_assertions) { static DEV_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); DEV_SERVICE .get_or_init(|| dev_keyring_service(std::env::var("BUZZ_DEV_KEYRING_SERVICE").ok())) diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index 4daf0065938..bba0f338602 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -113,34 +113,11 @@ fn run_buzz_acp_auth_command( ) -> Result { let runtime = known_acp_runtime_exact(runtime_id) .ok_or_else(|| format!("unknown ACP runtime: {runtime_id}"))?; - let (adapter_name, adapter_path, runtime_plan) = if runtime.id == "codex" && !cfg!(windows) { - let mut planned_runtime = None; - let mut last_plan_error = None; - for adapter_name in runtime.commands { - match crate::managed_agents::runtime_plan::resolve_runtime_execution_plan(adapter_name) - { - Ok(Some(plan)) => { - planned_runtime = Some((*adapter_name, plan)); - break; - } - Ok(None) => {} - Err(error) => last_plan_error = Some(error), - } - } - let (adapter_name, plan) = planned_runtime.ok_or_else(|| { - last_plan_error - .unwrap_or_else(|| format!("{} ACP adapter is not installed", runtime.label)) - })?; - let adapter_path = plan.harness_path()?.to_path_buf(); - (adapter_name, adapter_path, Some(plan)) - } else { - let (adapter_name, adapter_path) = runtime - .commands - .iter() - .find_map(|command| resolve_command(command).map(|path| (*command, path))) - .ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?; - (adapter_name, adapter_path, None) - }; + let adapter_command = runtime + .commands + .iter() + .find_map(|command| resolve_command(command).map(|path| (*command, path))) + .ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?; let acp_path = std::env::current_exe() .map(|path| path.with_file_name(format!("buzz-acp{}", std::env::consts::EXE_SUFFIX))) @@ -152,11 +129,10 @@ fn run_buzz_acp_auth_command( let augmented_path = auth_command_path(); run_buzz_acp_auth_command_with_paths( &acp_path, - adapter_name, - &adapter_path, + adapter_command.0, + &adapter_command.1, args, augmented_path.as_deref(), - runtime_plan.as_ref(), ) } @@ -203,13 +179,11 @@ fn run_buzz_acp_auth_command_with_paths( adapter_path: &Path, args: [&str; N], augmented_path: Option<&str>, - runtime_plan: Option<&crate::managed_agents::runtime_plan::RuntimeExecutionPlan>, ) -> Result { let agent_args = normalize_agent_args(adapter_name, Vec::new()); let mut command = Command::new(acp_path); command .args(args) - .env_remove(crate::managed_agents::runtime_plan::AGENT_IDENTITY_ENV) .env("BUZZ_ACP_AGENT_COMMAND", adapter_path.as_os_str()) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")) .stdout(Stdio::piped()) @@ -220,10 +194,6 @@ fn run_buzz_acp_auth_command_with_paths( if let Some(path) = augmented_path { command.env("PATH", path); } - if let Some(plan) = runtime_plan { - plan.verify()?; - plan.apply_environment(&mut command); - } crate::util::configure_no_window(&mut command); command @@ -284,47 +254,9 @@ fn launch_terminal_auth(runtime_id: &str, method: &AcpAuthMethod) -> Result<(), .iter() .find_map(|command| resolve_command(command).map(|path| (*command, path))) .ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?; - let runtime_plan = if runtime.id == "codex" { - crate::managed_agents::runtime_plan::resolve_runtime_execution_plan(adapter_command.0)? - } else { - None - }; let fallback_command = adapter_command.1.display().to_string(); - let mut argv = adapter_terminal_argv(runtime.label, method, &fallback_command)?; - if let Some(plan) = runtime_plan.as_ref() { - let provider_path = plan - .provider_cli_path() - .ok_or_else(|| "Codex runtime plan has no provider CLI".to_string())?; - let command = argv - .first_mut() - .ok_or_else(|| "Codex terminal login command is empty".to_string())?; - *command = provider_path.display().to_string(); - plan.verify()?; - } - let terminal_prelude = runtime_plan - .as_ref() - .map(runtime_plan_shell_prelude) - .unwrap_or_default(); - launch_visible_terminal(&argv, &terminal_prelude) -} - -fn runtime_plan_shell_prelude( - plan: &crate::managed_agents::runtime_plan::RuntimeExecutionPlan, -) -> String { - let mut prelude = String::new(); - for key in plan.denied_environment() { - prelude.push_str("unset "); - prelude.push_str(key); - prelude.push('\n'); - } - for (key, value) in plan.generated_environment_entries() { - prelude.push_str("export "); - prelude.push_str(key); - prelude.push('='); - prelude.push_str(&shell_escape(value)); - prelude.push('\n'); - } - prelude + let argv = adapter_terminal_argv(runtime.label, method, &fallback_command)?; + launch_visible_terminal(&argv) } fn adapter_terminal_argv( @@ -429,7 +361,7 @@ fn spawn_without_stdio(mut command: Command) -> Result<(), String> { } #[cfg(target_os = "macos")] -fn launch_visible_terminal(argv: &[String], shell_prelude: &str) -> Result<(), String> { +fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { let mut script = tempfile::Builder::new() .prefix("buzz-auth-") .suffix(".command") @@ -437,8 +369,7 @@ fn launch_visible_terminal(argv: &[String], shell_prelude: &str) -> Result<(), S .map_err(|error| format!("failed to create terminal login script: {error}"))?; writeln!( script, - "#!/bin/sh\ntrap 'rm -f -- \"$0\"' EXIT\n{}{}", - shell_prelude, + "#!/bin/sh\ntrap 'rm -f -- \"$0\"' EXIT\n{}", shell_join(argv) ) .map_err(|error| format!("failed to write terminal login script: {error}"))?; @@ -464,8 +395,8 @@ fn launch_visible_terminal(argv: &[String], shell_prelude: &str) -> Result<(), S } #[cfg(target_os = "linux")] -fn launch_visible_terminal(argv: &[String], shell_prelude: &str) -> Result<(), String> { - let command = format!("{}{}", shell_prelude, shell_join(argv)); +fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { + let command = shell_join(argv); let candidates: [(&str, &[&str]); 4] = [ ("x-terminal-emulator", &["-e", "sh", "-lc"]), ("gnome-terminal", &["--", "sh", "-lc"]), @@ -483,7 +414,7 @@ fn launch_visible_terminal(argv: &[String], shell_prelude: &str) -> Result<(), S } #[cfg(target_os = "windows")] -fn launch_visible_terminal(argv: &[String], _shell_prelude: &str) -> Result<(), String> { +fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { use std::os::windows::process::CommandExt; const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; @@ -505,7 +436,7 @@ fn windows_terminal_args(argv: &[String]) -> Vec { } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn launch_visible_terminal(_argv: &[String], _shell_prelude: &str) -> Result<(), String> { +fn launch_visible_terminal(_argv: &[String]) -> Result<(), String> { Err("opening a terminal is not supported on this platform".to_string()) } @@ -611,7 +542,6 @@ mod tests { &adapter_path, ["auth-methods", "--json"], Some(&augmented_path), - None, ) .expect("run auth command"); diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index f22c1cdfe7d..9609db5f2df 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -138,6 +138,7 @@ pub async fn save_custom_harness( // so concurrent saves never produce a stale registry snapshot (B-6). custom_harnesses::save_and_warm(&custom_dir, &definition, rename_old_id.as_deref())?; + // Resolve availability for the returned catalog entry. let (availability, command_opt, binary_path) = match crate::managed_agents::find_command(&definition.command) { Some(path) => ( @@ -147,6 +148,7 @@ pub async fn save_custom_harness( ), None => (AcpAvailabilityStatus::NotInstalled, None, None), }; + let default_args = crate::managed_agents::normalize_agent_args(&definition.command, definition.args.clone()); @@ -157,8 +159,6 @@ pub async fn save_custom_harness( availability, command: command_opt, binary_path, - runtime_plan_id: None, - runtime_plan_source: None, default_args, mcp_command: None, model_env_var: None, diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 259840fb397..998edeca27d 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -14,13 +14,6 @@ pub(super) async fn run_agent_models_command( persisted_model: Option, merged_env: BTreeMap, ) -> Result { - let runtime_plan = - crate::managed_agents::runtime_plan::resolve_runtime_execution_plan(&agent_command)?; - let agent_command = match runtime_plan.as_ref() { - Some(plan) => plan.harness_path()?.display().to_string(), - None => agent_command, - }; - // Clone the env map for redaction below — `merged_env` is moved // into the spawn_blocking closure and we still need the values to // scrub any user-supplied secrets that the child surfaces in stderr. @@ -45,7 +38,6 @@ pub(super) async fn run_agent_models_command( } cmd.arg("models") .arg("--json") - .env_remove(crate::managed_agents::runtime_plan::AGENT_IDENTITY_ENV) .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); if let Some(meta) = known_acp_runtime(&agent_command) { @@ -62,15 +54,7 @@ pub(super) async fn run_agent_models_command( for (k, v) in &merged_env { cmd.env(k, v); } - if let Some(plan) = runtime_plan.as_ref() { - plan.verify()?; - plan.apply_environment(&mut cmd); - } else { - crate::managed_agents::configure_runtime_cli( - &mut cmd, - known_acp_runtime(&agent_command), - ); - } + crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command)); crate::util::configure_no_window(&mut cmd); cmd.stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs index 63b4564e61d..4b6e512c059 100644 --- a/desktop/src-tauri/src/commands/agent_models_databricks.rs +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -1,7 +1,8 @@ //! Databricks v1/v2 model discovery and interactive reauthentication. -use std::collections::BTreeMap; -use std::sync::LazyLock; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{LazyLock, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; use crate::commands::agent_models_env::{ env_or_process_value, redaction_env_with_value, DiscoveryProvider, @@ -13,6 +14,77 @@ use crate::managed_agents::AgentModelsResponse; // callback listener/browser flow for the process-wide OAuth cache. static AUTH_GATE: LazyLock> = LazyLock::new(|| tokio::sync::Mutex::new(())); +// Hard cap on the interactive browser flow launched from a discovery surface. +// An abandoned SSO tab must fail discovery cleanly rather than wedge the +// dropdown forever. (`authenticate_databricks` has its own 60s callback wait; +// this outer bound also covers endpoint discovery and token exchange.) +const AUTH_FLOW_TIMEOUT: Duration = Duration::from_secs(150); + +// How long a failed/cancelled interactive sign-in suppresses re-launching the +// browser from passive surfaces. +pub(super) const AUTH_COOLDOWN: Duration = Duration::from_secs(5 * 60); + +/// Per-host record of a recently failed, cancelled, or timed-out interactive +/// sign-in. +/// +/// Passive discovery surfaces fire on every form-state change, so without this +/// a cancelled SSO page would re-pop the browser on the very next keystroke. +/// Entries expire so a genuine later retry still launches; the saved-model +/// picker bypasses the cooldown and a success clears it. +#[derive(Default)] +pub(super) struct AuthCooldown { + until: Mutex>, +} + +impl AuthCooldown { + fn map(&self) -> MutexGuard<'_, HashMap> { + // The critical sections below are panic-free map ops, so recover from a + // poisoned lock rather than wedge every future sign-in on one panic. + self.until + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub(super) fn is_active(&self, host: &str, now: Instant) -> bool { + let mut map = self.map(); + match map.get(host) { + Some(&expiry) if now < expiry => true, + Some(_) => { + map.remove(host); + false + } + None => false, + } + } + + pub(super) fn record(&self, host: &str, now: Instant) { + self.map().insert(host.to_string(), now + AUTH_COOLDOWN); + } + + pub(super) fn clear(&self, host: &str) { + self.map().remove(host); + } + + /// Whether the interactive browser flow may launch now under `auth_intent`. + /// Passive surfaces are suppressed while a per-host cooldown is active; the + /// explicit picker path always launches and clears any stale suppression. + pub(super) fn permits_launch( + &self, + auth_intent: DatabricksAuthIntent, + host: &str, + now: Instant, + ) -> bool { + if auth_intent.respects_cooldown() { + !self.is_active(host, now) + } else { + self.clear(host); + true + } + } +} + +static AUTH_COOLDOWNS: LazyLock = LazyLock::new(AuthCooldown::default); + pub(super) fn is_databricks_provider(provider: Option<&str>) -> bool { matches!( provider @@ -50,8 +122,14 @@ pub(super) enum DatabricksAuthIntent { } impl DatabricksAuthIntent { - fn allows_interactive_auth(self) -> bool { - matches!(self, Self::InteractiveModelPicker) + /// Passive draft discovery honors (and, on failure, writes) the per-host + /// cooldown so a cancelled SSO page does not re-pop on the next form + /// keystroke. The saved-model picker is an explicit user action, so it + /// bypasses the cooldown and clears it before launching. Both surfaces + /// launch the browser flow (Phase 2 goose-parity); this predicate is the + /// only behavioral difference between them. + fn respects_cooldown(self) -> bool { + matches!(self, Self::PassiveDraftDiscovery) } } @@ -60,11 +138,16 @@ pub(super) fn databricks_sign_in_required_error() -> String { .to_string() } -pub(super) fn should_start_interactive_auth( - api_key: &str, - auth_intent: DatabricksAuthIntent, -) -> bool { - api_key.is_empty() && auth_intent.allows_interactive_auth() +pub(super) fn databricks_sign_in_timed_out_error() -> String { + "Databricks sign-in timed out; open the model picker to retry, or run `buzz-agent auth databricks`" + .to_string() +} + +pub(super) fn should_start_interactive_auth(api_key: &str) -> bool { + // Phase 2: both discovery surfaces launch the browser flow when no static + // token is configured. Which surface is allowed to actually pop the browser + // (vs. respect a cooldown) is decided via `AuthCooldown::permits_launch`. + api_key.is_empty() } pub(super) async fn discover_databricks_models( @@ -93,22 +176,26 @@ pub(super) async fn discover_databricks_models( let entries = match buzz_agent_pkg::discover_databricks_models(&config).await { Ok(entries) => entries, - Err(buzz_agent_pkg::AgentError::LlmAuth(_)) - if should_start_interactive_auth(&api_key, auth_intent) => - { + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) if should_start_interactive_auth(&api_key) => { let _auth = AUTH_GATE.lock().await; match buzz_agent_pkg::discover_databricks_models(&config).await { + // A peer sign-in under the gate already succeeded. Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { - buzz_agent_pkg::authenticate_databricks(&host) - .await - .map_err(|error| { - format_redacted_error( - "Databricks sign-in failed", - &error, - &redaction_env, - ) - })?; + // Passive surfaces suppress the browser while a recent + // failure/cancel is cooling down; the explicit picker path + // always launches (and clears any stale cooldown). + if !AUTH_COOLDOWNS.permits_launch(auth_intent, &host, Instant::now()) { + return Err(databricks_sign_in_required_error()); + } + run_interactive_databricks_auth( + buzz_agent_pkg::authenticate_databricks(&host), + AUTH_FLOW_TIMEOUT, + &AUTH_COOLDOWNS, + &host, + &redaction_env, + ) + .await?; buzz_agent_pkg::discover_databricks_models(&config) .await .map_err(|error| { @@ -172,3 +259,43 @@ fn format_redacted_error( let message = crate::managed_agents::redact_env_values_in(&error.to_string(), redaction_env); format!("{context}: {message}") } + +/// Run the interactive browser OAuth flow under a hard timeout and maintain the +/// per-host cooldown. Success clears the cooldown; a failure, cancel, or +/// timeout records it so passive surfaces stop re-launching the browser on the +/// next form keystroke. `timeout` is injected (production passes +/// [`AUTH_FLOW_TIMEOUT`]) so the timeout/cooldown policy is unit-testable +/// without a live browser. +pub(super) async fn run_interactive_databricks_auth( + auth: Fut, + timeout: Duration, + cooldowns: &AuthCooldown, + host: &str, + redaction_env: &BTreeMap, +) -> Result<(), String> +where + Fut: std::future::Future>, +{ + match tokio::time::timeout(timeout, auth).await { + Ok(Ok(())) => { + cooldowns.clear(host); + Ok(()) + } + Ok(Err(error)) => { + cooldowns.record(host, Instant::now()); + Err(format_redacted_error( + "Databricks sign-in failed", + &error, + redaction_env, + )) + } + Err(_elapsed) => { + cooldowns.record(host, Instant::now()); + Err(databricks_sign_in_timed_out_error()) + } + } +} + +#[cfg(test)] +#[path = "agent_models_databricks_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_models_databricks_tests.rs b/desktop/src-tauri/src/commands/agent_models_databricks_tests.rs new file mode 100644 index 00000000000..cb530ec59bd --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_databricks_tests.rs @@ -0,0 +1,109 @@ +//! Cooldown and interactive-auth policy tests for Databricks discovery. +//! +//! Housed as a child of `agent_models_databricks` (not the shared +//! `agent_models_tests`) so the async timeout/cooldown cases sit next to the +//! code they exercise and reach its `pub(super)` items directly via +//! `use super::*` — and so the shared test file stays under its size ratchet. + +use super::*; + +#[test] +fn databricks_cooldown_suppresses_passive_relaunch_but_never_the_picker() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let now = Instant::now(); + + // A fresh host permits either surface to launch. + assert!(cooldowns.permits_launch(DatabricksAuthIntent::PassiveDraftDiscovery, host, now)); + assert!(cooldowns.permits_launch(DatabricksAuthIntent::InteractiveModelPicker, host, now)); + + // After a failed/cancelled attempt, passive discovery must NOT re-pop the + // browser while the window is active... + cooldowns.record(host, now); + assert!(!cooldowns.permits_launch(DatabricksAuthIntent::PassiveDraftDiscovery, host, now)); + + // ...but an explicit picker click always launches, and clears the window so + // a later passive read is unblocked too. + assert!(cooldowns.permits_launch(DatabricksAuthIntent::InteractiveModelPicker, host, now)); + assert!(cooldowns.permits_launch(DatabricksAuthIntent::PassiveDraftDiscovery, host, now)); +} + +#[test] +fn databricks_cooldown_expires_after_its_window_and_is_host_scoped() { + let cooldowns = AuthCooldown::default(); + let host = "https://a.cloud.databricks.com"; + let other = "https://b.cloud.databricks.com"; + let now = Instant::now(); + + cooldowns.record(host, now); + // A cooldown on one host never suppresses another. + assert!(!cooldowns.is_active(other, now)); + assert!(cooldowns.is_active(host, now)); + + // The window is closed the instant it elapses, so a genuine later retry + // launches again. + let after = now + AUTH_COOLDOWN; + assert!(!cooldowns.is_active(host, after)); +} + +#[tokio::test] +async fn databricks_interactive_auth_success_clears_a_prior_cooldown() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let redaction = BTreeMap::new(); + cooldowns.record(host, Instant::now()); + + let result = run_interactive_databricks_auth( + async { Ok(()) }, + Duration::from_secs(150), + &cooldowns, + host, + &redaction, + ) + .await; + + assert!(result.is_ok()); + assert!(!cooldowns.is_active(host, Instant::now())); +} + +#[tokio::test] +async fn databricks_interactive_auth_failure_records_a_cooldown() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let redaction = BTreeMap::new(); + + let result = run_interactive_databricks_auth( + async { Err(buzz_agent_pkg::AgentError::LlmAuth("closed the tab".into())) }, + Duration::from_secs(150), + &cooldowns, + host, + &redaction, + ) + .await; + + let error = result.expect_err("a failed sign-in must surface an error"); + assert!(error.contains("Databricks sign-in failed")); + assert!(cooldowns.is_active(host, Instant::now())); +} + +#[tokio::test(start_paused = true)] +async fn databricks_interactive_auth_timeout_records_cooldown_and_returns_timeout_copy() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let redaction = BTreeMap::new(); + + // An abandoned SSO tab: the flow never resolves. Under the paused clock the + // injected timeout fires deterministically without real waiting. + let result = run_interactive_databricks_auth( + std::future::pending::>(), + Duration::from_secs(150), + &cooldowns, + host, + &redaction, + ) + .await; + + let error = result.expect_err("a timed-out sign-in must surface an error"); + assert_eq!(error, databricks_sign_in_timed_out_error()); + assert!(cooldowns.is_active(host, Instant::now())); +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index e7d0e70fd0b..6226acfd964 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -577,19 +577,12 @@ fn is_databricks_provider_matches_both_variants() { } #[test] -fn databricks_interactive_auth_requires_explicit_intent_and_no_static_token() { - assert!(should_start_interactive_auth( - "", - DatabricksAuthIntent::InteractiveModelPicker - )); - assert!(!should_start_interactive_auth( - "", - DatabricksAuthIntent::PassiveDraftDiscovery - )); - assert!(!should_start_interactive_auth( - "static-token", - DatabricksAuthIntent::InteractiveModelPicker - )); +fn databricks_interactive_auth_launches_only_without_a_static_token() { + // Phase 2: both surfaces launch the browser flow when the token is empty; + // the surface distinction is now cooldown-only (asserted separately). A + // configured static token still short-circuits interactive auth entirely. + assert!(should_start_interactive_auth("")); + assert!(!should_start_interactive_auth("static-token")); } #[test] diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59c80c48076..2688346ffd0 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -3,7 +3,7 @@ use tauri::State; use crate::{ app_state::AppState, events, - models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse}, + models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse, GetChannelsPayload}, nostr_convert, relay::{query_relay, relay_api_base_url_with_override, submit_event, submit_event_with_keys}, }; @@ -75,79 +75,189 @@ fn classify_pending_owner(state: &AppState, my_pubkey: &str, d_tag: Option<&str> d_tag.is_some_and(|d| state.is_pending_owned_channel(my_pubkey, d)) } -#[tauri::command] -pub async fn get_channels(state: State<'_, AppState>) -> Result, String> { - let _profile_start = std::time::Instant::now(); - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; +// ── FNV-1a hash for the not-modified short-circuit ─────────────────────────── + +/// FNV-1a 64-bit hash over arbitrary bytes. Used in preference to +/// `std::collections::hash_map::DefaultHasher` because the standard library +/// does not guarantee cross-invocation stability. +fn fnv1a_64(data: &[u8]) -> u64 { + const OFFSET: u64 = 14695981039346656037; + const PRIME: u64 = 1099511628211; + let mut hash = OFFSET; + for &byte in data { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(PRIME); + } + hash +} - // Step 1: find all kind:39002 (members) events that mention me, then - // pull the channel ids out of their `d` tags. - let member_events = query_relay_all( - &state, - serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}), - ) - .await?; +/// Stable projection of `ChannelInfo` for hashing. Excludes `last_message_at` +/// so routine message traffic does not invalidate the not-modified short-circuit +/// for the channel list. +#[derive(serde::Serialize)] +struct ChannelInfoForHash<'a> { + id: &'a str, + name: &'a str, + channel_type: &'a str, + visibility: &'a str, + description: &'a str, + topic: &'a Option, + purpose: &'a Option, + member_count: i64, + member_pubkeys: &'a Vec, + archived_at: &'a Option, + participants: &'a Vec, + participant_pubkeys: &'a Vec, + is_member: bool, + ttl_seconds: &'a Option, + ttl_deadline: &'a Option, +} - #[cfg(debug_assertions)] - let t_members = _profile_start.elapsed(); +/// Compute a stable 64-bit FNV-1a hash over the channel list, canonicalized +/// by sorting on channel id and excluding `last_message_at`. Returns a +/// 16-character lowercase hex string. +fn compute_channels_hash(channels: &[ChannelInfo]) -> String { + let mut sorted: Vec<&ChannelInfo> = channels.iter().collect(); + sorted.sort_by(|a, b| a.id.cmp(&b.id)); - let mut channel_ids: Vec = member_events + let projections: Vec> = sorted .iter() - .filter_map(|ev| { - ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "d" { - Some(s[1].clone()) - } else { - None - } - }) + .map(|c| ChannelInfoForHash { + id: &c.id, + name: &c.name, + channel_type: &c.channel_type, + visibility: &c.visibility, + description: &c.description, + topic: &c.topic, + purpose: &c.purpose, + member_count: c.member_count, + member_pubkeys: &c.member_pubkeys, + archived_at: &c.archived_at, + participants: &c.participants, + participant_pubkeys: &c.participant_pubkeys, + is_member: c.is_member, + ttl_seconds: &c.ttl_seconds, + ttl_deadline: &c.ttl_deadline, }) .collect(); - channel_ids.sort(); - channel_ids.dedup(); - - // The real kind:39002 membership has now resolved for these channels — - // drop them from the pending-owner overlay (see `AppState::pending_owned_channels`) - // so a channel this identity created no longer speaks through the overlay - // once genuine membership is observable, and a later leave correctly - // flips it back to `is_member=false`. - for id in &channel_ids { - state.clear_pending_owned_channel(&my_pubkey, id); - } - // Step 2: fetch channel metadata events (kind:39000) for member channels. - // kind:39000 is addressable: exactly one event per `d` tag, so a limit - // equal to the number of ids is both necessary and sufficient. Without - // an explicit limit, multi-value `#d` filters fall through to the relay's - // default LIMIT and can drop results when there are many channels. - let meta_events = if !channel_ids.is_empty() { - query_relay( - &state, - &[serde_json::json!({ - "kinds": [39000], - "#d": channel_ids, - "limit": channel_ids.len(), - })], - ) - .await? - } else { - Vec::new() - }; + let canonical = serde_json::to_string(&projections).unwrap_or_default(); + format!("{:016x}", fnv1a_64(canonical.as_bytes())) +} +// ── Core fetch implementation ───────────────────────────────────────────────── + +/// Fetch the full channel list from the relay. Called by both `get_channels` +/// (the Tauri command, which wraps the result with hash-based short-circuit +/// logic) and `ensure_starter_channels` (which needs the raw list directly). +/// +/// Relay round-trips run in two concurrent phases: +/// - Phase 1 (parallel): member-chain (kind:39002→kind:39000), open directory +/// (kind:39000 all-open), and hidden-DM snapshot (kind:30622). +/// - Phase 2 (parallel): member counts (kind:39002 batch) and last-message +/// timestamps (per-channel kind:9/40002). +async fn fetch_channels(state: &AppState) -> Result, String> { #[cfg(debug_assertions)] - let t_member_meta = _profile_start.elapsed(); + let _profile_start = std::time::Instant::now(); + + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; - // Step 3: fetch ALL open channel metadata so the channel browser can show - // discoverable channels the user hasn't joined yet. The relay's access - // control allows reading kind:39000 for open channels regardless of membership. - let open_meta_events = query_relay_all(&state, serde_json::json!({"kinds": [39000]})).await?; + // Phase 1 — concurrent: member-chain (steps 1→2), open directory (step 3), + // and hidden-DM snapshot (step 6). These three have no mutual dependencies. + let (member_chain_result, open_meta_result, hidden_dms) = tokio::join!( + // Steps 1+2: find the channels this identity belongs to, then fetch + // their metadata events. + async { + // Step 1: kind:39002 events listing my pubkey as a member. + let member_events = query_relay_all( + state, + serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}), + ) + .await?; + + let mut member_channel_ids: Vec = member_events + .iter() + .filter_map(|ev| { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }) + }) + .collect(); + member_channel_ids.sort(); + member_channel_ids.dedup(); + + // Real kind:39002 membership has landed — clear the pending-owner + // overlay so a subsequent leave correctly flips `is_member` back + // to false. See `AppState::pending_owned_channels`. + for id in &member_channel_ids { + state.clear_pending_owned_channel(&my_pubkey, id); + } + + // Step 2: fetch channel metadata events (kind:39000) for member channels. + // kind:39000 is addressable: exactly one event per `d` tag, so a limit + // equal to the number of ids is both necessary and sufficient. + let meta_events = if !member_channel_ids.is_empty() { + query_relay( + state, + &[serde_json::json!({ + "kinds": [39000], + "#d": &member_channel_ids, + "limit": member_channel_ids.len(), + })], + ) + .await? + } else { + Vec::new() + }; + + Ok::<_, String>(meta_events) + }, + // Step 3: fetch ALL open channel metadata so the channel browser can show + // discoverable channels the user hasn't joined yet. + query_relay_all(state, serde_json::json!({"kinds": [39000]})), + // Step 6: NIP-DV hidden-DM snapshot. Tolerant — a failure means no DMs + // are hidden rather than aborting the whole fetch. + async { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [buzz_core_pkg::kind::KIND_DM_VISIBILITY], + "#p": [&my_pubkey], + "limit": 1, + })], + ) + .await + .unwrap_or_default(); + events + .iter() + .max_by_key(|e| e.created_at.as_secs()) + .map(|e| { + e.tags + .iter() + .filter_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) + }) + .collect::>() + }) + .unwrap_or_default() + }, + ); #[cfg(debug_assertions)] - let t_open_meta = _profile_start.elapsed(); + let t_phase1 = _profile_start.elapsed(); + + let meta_events = member_chain_result?; + let open_meta_events = open_meta_result?; + // hidden_dms is already a resolved HashSet (tolerant path above) // Merge: member channels (marked as member) + open channels (not yet joined). let member_d_tags: std::collections::HashSet = meta_events @@ -187,58 +297,19 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result } // The overlay (`AppState::pending_owned_channels`) marks channels this // identity just created via `create_channel` whose kind:39002 owner - // membership hasn't propagated yet (#1761) — a fresh channel has no - // member event and would otherwise fall through to `is_member=false` - // here, disabling the owner's own composer until that snapshot lands. - // The overlay can only be populated by this process's own - // `create_channel` call (never by relay data) and is keyed by - // `(my_pubkey, d_tag)`, so it adds no trust-boundary risk and can - // never speak for a channel a different identity created; `channel_ids` - // above clears it once real membership is observed for `my_pubkey`. - let is_pending_owner = classify_pending_owner(&state, &my_pubkey, d_tag.as_deref()); + // membership hasn't propagated yet (#1761). + let is_pending_owner = classify_pending_owner(state, &my_pubkey, d_tag.as_deref()); if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(is_pending_owner)) { channels.push(info); } } - // Populate member_count by batch-fetching kind:39002 for every listed - // channel and counting unique p-tag pubkeys. The kind:40901 summary - // sidecar that channel_info_from_event prefers isn't emitted by the - // relay today, so without this step every channel reports 0 members - // in the channel browser (the active-channel top bar masks this with - // its own live members query). - let all_d_tags: Vec = channels.iter().map(|c| c.id.clone()).collect(); - if !all_d_tags.is_empty() { - let members_events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [39002], - "#d": all_d_tags, - "limit": all_d_tags.len(), - })], - ) - .await - .unwrap_or_default(); - - let membership = collect_members_by_channel(&members_events); - for channel in &mut channels { - if let Some(info) = membership.get(&channel.id) { - channel.member_count = info.count; - channel.member_pubkeys = info.pubkeys.clone(); - } - } - } - - #[cfg(debug_assertions)] - let t_member_counts = _profile_start.elapsed(); - - // Populate last_message_at by fetching the most recent human message per - // channel. Uses per-channel filters (single #h value each) so the relay can - // push the query to its indexed channel_id column. Multi-value #h is NOT - // SQL-pushed and would silently drop quieter channels under the global limit. - let channel_ids: Vec = channels.iter().map(|c| c.id.clone()).collect(); - if !channel_ids.is_empty() { - let filters: Vec = channel_ids + // Phase 2 — concurrent: member counts (step 4) and last-message timestamps + // (step 5). Both tolerate failures — empty defaults leave counts at 0 and + // timestamps at None rather than aborting. + let all_channel_ids: Vec = channels.iter().map(|c| c.id.clone()).collect(); + if !all_channel_ids.is_empty() { + let last_msg_filters: Vec = all_channel_ids .iter() .map(|id| { serde_json::json!({ @@ -249,11 +320,32 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result }) .collect(); - let message_events = query_relay(&state, &filters).await.unwrap_or_default(); + // Bind both filter arrays before the join so their lifetimes cover + // both branches of the concurrent pair. + let member_count_filters = [serde_json::json!({ + "kinds": [39002], + "#d": &all_channel_ids, + "limit": all_channel_ids.len(), + })]; + let (members_result, message_result) = tokio::join!( + // Step 4: batch-fetch kind:39002 for member counts. + query_relay(state, &member_count_filters), + // Step 5: per-channel last-message filter. Uses per-channel `#h` + // so the relay can push each query to its indexed channel_id column. + query_relay(state, &last_msg_filters), + ); + + let membership = collect_members_by_channel(&members_result.unwrap_or_default()); + for channel in &mut channels { + if let Some(info) = membership.get(&channel.id) { + channel.member_count = info.count; + channel.member_pubkeys = info.pubkeys.clone(); + } + } let mut last_message_by_channel: std::collections::HashMap = std::collections::HashMap::new(); - for ev in &message_events { + for ev in &message_result.unwrap_or_default() { if let Some(ch_id) = ev.tags.iter().find_map(|t| { let s = t.as_slice(); (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) @@ -269,7 +361,6 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result .or_insert(ts); } } - for channel in &mut channels { if let Some(&ts) = last_message_by_channel.get(&channel.id) { channel.last_message_at = Some(nostr_convert::timestamp_to_iso(ts)); @@ -277,63 +368,71 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result } } - #[cfg(debug_assertions)] - let t_last_message = _profile_start.elapsed(); - - // NIP-DV: drop DMs the viewer has hidden. The relay maintains a per-viewer - // parameterized-replaceable snapshot (kind:30622, d=my pubkey) whose `h` - // tags list currently-hidden DM channel ids. The snapshot also carries - // `p`=my pubkey so the relay's #p read-gate scopes it to me; we query by - // `#p` for that reason. Reading the latest one is the only way the client - // learns hide state, which the relay tracks privately. - let hidden_dms: std::collections::HashSet = { - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [buzz_core_pkg::kind::KIND_DM_VISIBILITY], - "#p": [&my_pubkey], - "limit": 1, - })], - ) - .await - .unwrap_or_default(); - events - .iter() - .max_by_key(|e| e.created_at.as_secs()) - .map(|e| { - e.tags - .iter() - .filter_map(|t| { - let s = t.as_slice(); - (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) - }) - .collect() - }) - .unwrap_or_default() - }; - if !hidden_dms.is_empty() { - channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id)); - } - #[cfg(debug_assertions)] { let total = _profile_start.elapsed(); eprintln!( - "buzz-desktop: get_channels profile channels={} members={:?} member_meta={:?} open_meta={:?} member_counts={:?} last_message={:?} hidden_dm={:?} total={:?}", + "buzz-desktop: get_channels profile channels={} phase1(member_chain+open_meta+hidden_dm)={:?} phase2(member_counts+last_msg)={:?} total={:?}", channels.len(), - t_members, - t_member_meta - t_members, - t_open_meta - t_member_meta, - t_member_counts - t_open_meta, - t_last_message - t_member_counts, - total - t_last_message, + t_phase1, + total - t_phase1, total, ); } + // NIP-DV: drop DMs the viewer has hidden. + if !hidden_dms.is_empty() { + channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id)); + } + Ok(channels) } +// ── Tauri commands ──────────────────────────────────────────────────────────── + +/// Return the full channel list for the active identity. +/// +/// `known_hash` is a previously returned `hash` value. When it matches the +/// computed stable hash (which excludes `last_message_at`), the response +/// carries `channels: null` so the multi-MB list is not serialized across IPC. +/// `last_messages` is always included because it is cheap and changes with +/// every new message. +#[tauri::command] +pub async fn get_channels( + known_hash: Option, + state: State<'_, AppState>, +) -> Result { + let channels = fetch_channels(&state).await?; + + let last_messages: std::collections::HashMap = channels + .iter() + .filter_map(|c| { + c.last_message_at + .as_ref() + .map(|ts| (c.id.clone(), ts.clone())) + }) + .collect(); + + let hash = compute_channels_hash(&channels); + + // Not-modified short-circuit: skip the multi-MB IPC payload when the + // caller's hash matches. `last_messages` still ships so the TS side can + // update sidebar timestamps without re-rendering the full list. + if known_hash.as_deref() == Some(hash.as_str()) { + return Ok(GetChannelsPayload { + hash, + channels: None, + last_messages, + }); + } + + Ok(GetChannelsPayload { + hash, + channels: Some(channels), + last_messages, + }) +} + struct ChannelMembership { count: i64, pubkeys: Vec, @@ -612,7 +711,7 @@ pub async fn create_channel( pub async fn ensure_starter_channels( state: State<'_, AppState>, ) -> Result, String> { - let mut existing_channels = get_channels(state.clone()).await?; + let mut existing_channels = fetch_channels(&state).await?; let relay_scope = relay_api_base_url_with_override(&state); let creator_keys = state.signing_keys()?; let creator_pubkey = creator_keys.public_key().to_hex(); @@ -671,7 +770,7 @@ pub async fn ensure_starter_channels( } if !has_all_starter_channels(&existing_channels) { - existing_channels = get_channels(state.clone()).await?; + existing_channels = fetch_channels(&state).await?; } if !has_all_starter_channels(&existing_channels) { diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index 5b65695a913..43da15703c8 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -2,6 +2,7 @@ // channels.rs under the per-file line cap. use super::*; +use crate::models::ChannelInfo; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; /// Build a signed event for testing with the given kind, content, and tags. @@ -266,6 +267,131 @@ fn duplicate_channel_rejection_is_ensure_success_only() { )); } +// ── compute_channels_hash ───────────────────────────────────────────────────── + +fn make_channel(id: &str, name: &str, last_message_at: Option) -> ChannelInfo { + ChannelInfo { + id: id.to_string(), + name: name.to_string(), + channel_type: "stream".to_string(), + visibility: "open".to_string(), + description: "".to_string(), + topic: None, + purpose: None, + member_count: 0, + member_pubkeys: Vec::new(), + last_message_at, + archived_at: None, + participants: Vec::new(), + participant_pubkeys: Vec::new(), + is_member: true, + ttl_seconds: None, + ttl_deadline: None, + } +} + +#[test] +fn hash_is_order_insensitive() { + let c1 = make_channel("aaa", "Alpha", None); + let c2 = make_channel("bbb", "Beta", None); + let c3 = make_channel("aaa", "Alpha", None); + let c4 = make_channel("bbb", "Beta", None); + + assert_eq!( + compute_channels_hash(&[c1, c2]), + compute_channels_hash(&[c4, c3]), + "hash must be insensitive to channel list ordering", + ); +} + +#[test] +fn hash_ignores_last_message_at() { + let c_none = make_channel("chan-1", "Alpha", None); + let c_some = make_channel("chan-1", "Alpha", Some("2026-01-01T00:00:00Z".to_string())); + + assert_eq!( + compute_channels_hash(&[c_none]), + compute_channels_hash(&[c_some]), + "hash must be insensitive to last_message_at", + ); +} + +#[test] +fn hash_changes_on_metadata_change() { + let c1 = make_channel("chan-1", "Alpha", None); + let c2 = make_channel("chan-1", "AlphaRenamed", None); + + assert_ne!( + compute_channels_hash(&[c1]), + compute_channels_hash(&[c2]), + "hash must change when channel name changes", + ); +} + +#[test] +fn hash_changes_on_membership_change() { + let mut c1 = make_channel("chan-1", "Alpha", None); + let mut c2 = make_channel("chan-1", "Alpha", None); + c1.member_pubkeys = vec![PK_A.to_string()]; + c2.member_pubkeys = vec![PK_A.to_string(), PK_B.to_string()]; + + assert_ne!( + compute_channels_hash(&[c1]), + compute_channels_hash(&[c2]), + "hash must change when member_pubkeys changes", + ); +} + +#[test] +fn not_modified_returns_none_when_hash_matches() { + let channels = vec![make_channel("chan-1", "General", None)]; + let hash = compute_channels_hash(&channels); + + // Mirror the get_channels command decision logic. + let known_hash = Some(hash.clone()); + let is_not_modified = known_hash.as_deref() == Some(hash.as_str()); + + assert!( + is_not_modified, + "identical hash must trigger the not-modified short-circuit", + ); +} + +#[test] +fn not_modified_does_not_trigger_on_hash_mismatch() { + let channels = vec![make_channel("chan-1", "General", None)]; + let hash = compute_channels_hash(&channels); + let known_hash = Some("0000000000000000".to_string()); + + let is_not_modified = known_hash.as_deref() == Some(hash.as_str()); + + assert!( + !is_not_modified, + "stale hash must NOT trigger the not-modified short-circuit", + ); +} + +#[test] +fn hash_is_stable_for_same_input() { + // Verifies that the FNV-1a output is deterministic across calls within + // the same process (unlike std DefaultHasher which uses random seeds). + let channels = vec![ + make_channel("aaa", "General", Some("2026-01-01T00:00:00Z".to_string())), + make_channel("bbb", "Random", None), + ]; + let first = compute_channels_hash(&channels); + let channels2 = vec![ + make_channel("aaa", "General", None), // last_message_at change is ignored + make_channel("bbb", "Random", None), + ]; + let second = compute_channels_hash(&channels2); + + assert_eq!( + first, second, + "hash must be deterministic and ignore last_message_at" + ); +} + #[test] fn starter_match_requires_open_unarchived_stream_by_normalized_name() { let spec = &STARTER_CHANNELS[0]; diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5ada1b38840..732c750a135 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -1,9 +1,8 @@ use std::{io::Cursor, net::IpAddr, time::Duration}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use image::ImageDecoder; - use futures_util::StreamExt; +use image::ImageDecoder; use reqwest::{ header::{ACCEPT, CONTENT_TYPE, LOCATION, USER_AGENT}, redirect::Policy, @@ -13,6 +12,8 @@ use url::Url; #[path = "link_preview_rate_limit.rs"] mod rate_limit; +#[path = "link_preview_youtube.rs"] +mod youtube; use rate_limit::{image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown}; @@ -67,6 +68,10 @@ async fn fetch_link_preview_metadata_inner( let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; validate_public_https_url(&url).await?; + if youtube::is_video_url(&url) { + return youtube::fetch_oembed_metadata(&url).await; + } + for redirect_count in 0..=MAX_REDIRECTS { let response = send_pinned_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; diff --git a/desktop/src-tauri/src/commands/link_preview_youtube.rs b/desktop/src-tauri/src/commands/link_preview_youtube.rs new file mode 100644 index 00000000000..722c481b5e9 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_youtube.rs @@ -0,0 +1,361 @@ +use percent_encoding::percent_decode_str; +use reqwest::header::CONTENT_TYPE; +use serde::Deserialize; +use url::Url; + +use super::{ + apply_image_result, fetch_sanitized_image, normalize_metadata_description, + normalize_metadata_text, read_limited_bytes, send_pinned_request, ImageFetchError, + LinkPreviewImageFetchState, LinkPreviewMetadata, PREVIEW_FETCH_TIMEOUT, +}; + +const MAX_OEMBED_FETCH_BYTES: usize = 64 * 1024; +const OEMBED_ENDPOINT: &str = "https://www.youtube.com/oembed"; + +#[derive(Deserialize)] +struct OEmbedResponse { + title: String, + author_name: Option, + provider_name: Option, + thumbnail_url: Option, +} + +pub(super) fn is_video_url(url: &Url) -> bool { + let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else { + return false; + }; + match host.as_str() { + "youtu.be" | "www.youtu.be" => url + .path_segments() + .and_then(|mut segments| segments.next()) + .is_some_and(|segment| !segment.is_empty()), + "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com" => { + (url.path() == "/watch" + && url + .query_pairs() + .any(|(key, value)| key == "v" && !value.is_empty())) + || ["shorts", "live", "embed"].iter().any(|prefix| { + url.path_segments().is_some_and(|mut segments| { + segments.next() == Some(prefix) + && segments.next().is_some_and(|segment| !segment.is_empty()) + }) + }) + } + _ => false, + } +} + +pub(super) async fn fetch_oembed_metadata( + video_url: &Url, +) -> Result, String> { + let oembed_url = oembed_url(video_url)?; + let response = send_pinned_request(&oembed_url, "application/json").await?; + let Some((mut metadata, thumbnail_url)) = parse_oembed_response(response).await? else { + return Ok(None); + }; + let image_result = match thumbnail_url { + Some(thumbnail_url) => Some( + tokio::time::timeout( + PREVIEW_FETCH_TIMEOUT, + fetch_sanitized_image(thumbnail_url, false), + ) + .await + .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), + ), + None => None, + }; + apply_image_result(&mut metadata, image_result); + Ok(Some(metadata)) +} + +fn oembed_url(video_url: &Url) -> Result { + let mut canonical_video_url = video_url.clone(); + if matches!( + video_url.host_str(), + Some("youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com") + ) { + let mut segments = video_url.path_segments(); + if segments.as_mut().and_then(|segments| segments.next()) == Some("embed") { + let encoded_video_id = segments + .and_then(|mut segments| segments.next()) + .ok_or_else(|| "YouTube embed URL has no video ID".to_string())?; + let video_id = percent_decode_str(encoded_video_id) + .decode_utf8() + .map_err(|_| "YouTube embed URL has an invalid video ID".to_string())?; + if !video_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) { + return Err("YouTube embed URL has an invalid video ID".to_string()); + } + canonical_video_url.set_path("/watch"); + canonical_video_url.set_query(None); + canonical_video_url + .query_pairs_mut() + .append_pair("v", &video_id); + canonical_video_url.set_fragment(None); + } + } + + let mut oembed_url = Url::parse(OEMBED_ENDPOINT) + .map_err(|error| format!("invalid YouTube oEmbed endpoint: {error}"))?; + oembed_url + .query_pairs_mut() + .append_pair("format", "json") + .append_pair("url", canonical_video_url.as_str()); + Ok(oembed_url) +} + +async fn parse_oembed_response( + response: reqwest::Response, +) -> Result)>, String> { + if !response.status().is_success() || !is_json_response(&response) { + return Ok(None); + } + let body = read_limited_bytes(response, MAX_OEMBED_FETCH_BYTES).await?; + let response: OEmbedResponse = match serde_json::from_slice(&body) { + Ok(response) => response, + Err(_) => return Ok(None), + }; + Ok(oembed_metadata(response)) +} + +fn oembed_metadata(response: OEmbedResponse) -> Option<(LinkPreviewMetadata, Option)> { + let title = normalize_metadata_text(&response.title)?; + let thumbnail_url = response + .thumbnail_url + .as_deref() + .and_then(|thumbnail| Url::parse(thumbnail).ok()); + let metadata = LinkPreviewMetadata { + title, + site_name: response + .provider_name + .as_deref() + .and_then(normalize_metadata_text) + .or_else(|| Some("YouTube".to_string())), + description: response + .author_name + .as_deref() + .and_then(normalize_metadata_description), + image_data_url: None, + image_domain: None, + image_fetch_state: LinkPreviewImageFetchState::None, + image_retry_after_ms: None, + favicon_data_url: None, + }; + Some((metadata, thumbnail_url)) +} + +fn is_json_response(response: &reqwest::Response) -> bool { + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Response, routing::get, Router}; + + async fn test_response(router: Router, path: &str) -> reqwest::Response { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + reqwest::get(format!("http://{address}{path}")) + .await + .unwrap() + } + + #[test] + fn recognizes_supported_video_urls_only() { + for href in [ + "https://www.youtube.com/watch?v=hLFs9JtMaRg", + "https://m.youtube.com/watch?v=hLFs9JtMaRg&feature=share", + "https://music.youtube.com/watch?v=hLFs9JtMaRg", + "https://youtu.be/hLFs9JtMaRg?t=10", + "https://www.youtube.com/shorts/hLFs9JtMaRg", + "https://www.youtube.com/live/hLFs9JtMaRg", + "https://www.youtube.com/embed/hLFs9JtMaRg", + ] { + assert!(is_video_url(&Url::parse(href).unwrap()), "{href}"); + } + for href in [ + "https://www.youtube.com/", + "https://www.youtube.com/watch", + "https://www.youtube.com/watch?v=", + "https://www.youtube.com/@buzz", + "https://youtube.com.evil.example/watch?v=hLFs9JtMaRg", + "https://notyoutube.com/watch?v=hLFs9JtMaRg", + ] { + assert!(!is_video_url(&Url::parse(href).unwrap()), "{href}"); + } + } + + #[test] + fn canonicalizes_embed_url_for_oembed() { + for (video_url, expected_video_id) in [ + ( + "https://www.youtube.com/embed/dQw4w9WgXcQ?start=10#player", + "dQw4w9WgXcQ", + ), + ("https://www.youtube.com/embed/%64Qw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://www.youtube.com/embed/dQw4w9WgX%63Q", "dQw4w9WgXcQ"), + ] { + let oembed_url = oembed_url(&Url::parse(video_url).unwrap()).unwrap(); + let params = oembed_url + .query_pairs() + .collect::>(); + assert_eq!( + params.get("format").map(|value| value.as_ref()), + Some("json") + ); + let provider_video_url = + Url::parse(params.get("url").expect("oEmbed URL parameter")).unwrap(); + assert_eq!(provider_video_url.path(), "/watch"); + assert_eq!( + provider_video_url + .query_pairs() + .find(|(key, _)| key == "v") + .map(|(_, value)| value.into_owned()), + Some(expected_video_id.to_string()) + ); + } + } + + #[test] + fn rejects_invalid_encoded_embed_video_ids() { + for href in [ + "https://www.youtube.com/embed/video%2Fid", + "https://www.youtube.com/embed/video%5Cid", + "https://www.youtube.com/embed/video%00id", + "https://www.youtube.com/embed/video%25id", + "https://www.youtube.com/embed/video%252Fid", + "https://www.youtube.com/embed/video%FFid", + ] { + assert!(oembed_url(&Url::parse(href).unwrap()).is_err(), "{href}"); + } + } + + #[tokio::test] + async fn response_requires_successful_bounded_json() { + let valid_json = + r#"{"title":"Video title","author_name":"Creator","provider_name":"YouTube"}"#; + let response = test_response( + Router::new().route( + "/valid", + get(move || async move { + Response::builder() + .header("content-type", "application/json; charset=UTF-8") + .body(Body::from(valid_json)) + .unwrap() + }), + ), + "/valid", + ) + .await; + let (metadata, _) = parse_oembed_response(response).await.unwrap().unwrap(); + assert_eq!(metadata.title, "Video title"); + + for response in [ + test_response( + Router::new().route( + "/not-found", + get(|| async { + Response::builder() + .status(404) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap() + }), + ), + "/not-found", + ) + .await, + test_response( + Router::new().route( + "/html", + get(|| async { + Response::builder() + .header("content-type", "text/html") + .body(Body::from("Not JSON")) + .unwrap() + }), + ), + "/html", + ) + .await, + ] { + assert_eq!(parse_oembed_response(response).await.unwrap(), None); + } + + let oversized = vec![b' '; MAX_OEMBED_FETCH_BYTES + 1]; + let response = test_response( + Router::new().route( + "/oversized", + get(move || { + let oversized = oversized.clone(); + async move { + Response::builder() + .header("content-type", "application/json") + .body(Body::from(oversized)) + .unwrap() + } + }), + ), + "/oversized", + ) + .await; + assert!(parse_oembed_response(response).await.is_err()); + } + + #[test] + fn converts_response_to_bounded_preview_metadata() { + let (metadata, thumbnail_url) = oembed_metadata(OEmbedResponse { + title: " Video title ".to_string(), + author_name: Some("Buzz Creator".to_string()), + provider_name: Some("YouTube".to_string()), + thumbnail_url: Some("https://i.ytimg.com/vi/example/hqdefault.jpg".to_string()), + }) + .unwrap(); + assert_eq!(metadata.title, "Video title"); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(metadata.description.as_deref(), Some("Buzz Creator")); + assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); + assert_eq!( + thumbnail_url.unwrap().as_str(), + "https://i.ytimg.com/vi/example/hqdefault.jpg" + ); + } + + #[test] + fn rejects_titleless_response_and_ignores_invalid_thumbnail() { + assert!(oembed_metadata(OEmbedResponse { + title: " ".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: None, + }) + .is_none()); + + let (metadata, thumbnail_url) = oembed_metadata(OEmbedResponse { + title: "Video title".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: Some("not a URL".to_string()), + }) + .unwrap(); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(thumbnail_url, None); + } +} diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 070381f55e8..8da845c07d4 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -115,11 +115,10 @@ fn fd_real_path(_file: &std::fs::File) -> Result { /// MIME types blocked from upload — mirrors the server's generic-file deny-list. /// -/// Active-content XSS carriers and native executables. Everything else (images, -/// video, documents, archives, audio, text, data) is accepted; un-sniffable -/// files fall back to `application/octet-stream` and are served as downloads. +/// Active-content XSS carriers (JS, SVG) and native executables. Other types, +/// including HTML, are accepted as downloads; un-sniffable files fall back to +/// `application/octet-stream`. XHTML remains blocked in lockstep with the relay. const BLOCKED_MIME: &[&str] = &[ - "text/html", "application/xhtml+xml", "image/svg+xml", "application/javascript", @@ -895,9 +894,29 @@ mod tests { } #[test] - fn test_detect_and_validate_mime_rejects_html() { + fn test_detect_and_validate_mime_accepts_html_as_inert_download() { let html = b""; - assert!(detect_and_validate_mime(html).is_err()); + assert_eq!(detect_and_validate_mime(html).unwrap(), "text/html"); + } + + #[test] + fn test_detect_and_validate_mime_still_rejects_executable() { + let elf = [b"\x7fELF".as_slice(), &[0u8; 60]].concat(); + assert!(detect_and_validate_mime(&elf).is_err()); + } + + #[test] + fn test_blocked_mime_keeps_active_content_and_executables() { + for kept in [ + "image/svg+xml", + "application/xhtml+xml", + "application/javascript", + "text/javascript", + "application/x-executable", + "application/x-mach-binary", + ] { + assert!(BLOCKED_MIME.contains(&kept), "{kept} must stay blocked"); + } } #[test] diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 528ca387679..7356cd7fc0c 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use sha2::{Digest, Sha256}; use tauri::{AppHandle, Manager, State}; +use super::mesh_readiness::wait_for_mesh_inference; use crate::{app_state::AppState, mesh_llm, relay}; #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] @@ -530,142 +531,6 @@ pub async fn mesh_start_node( Ok(status) } -/// Mesh can bind its HTTP ingress and advertise a model shortly before the -/// router has installed a usable target. Probe the exact chat path agents use -/// so startup cannot race that gap (`single target None unavailable`). -/// Which startup stage a mesh client is stuck at when it never becomes -/// inference-ready. The two live-observed failure modes are physically -/// distinct and want different user copy: -/// -/// * `CatalogNeverSynced` — the local client node came up and connected to -/// the host at the control level (ping/RTT fine), but the served model -/// never appeared in the local `/v1/models` catalog. That catalog is -/// populated by the peer gossip exchange; when the gossip bi-stream can't -/// establish across the network (observed as iroh -/// `MultipathNotNegotiated` / unreachable direct path), the catalog stays -/// empty forever and every request is rejected "model not available". -/// Root cause is the network path between this machine and the host. -/// * `RoutingNeverCompleted` — the model *did* sync into the catalog, but -/// inference requests never completed (routing/transport to the host -/// failing per-request). The host is discoverable and advertised but not -/// actually serving us. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MeshReadinessFailure { - CatalogNeverSynced, - RoutingNeverCompleted, -} - -/// Pure classifier: given whether the served model was ever observed in the -/// local `/v1/models` catalog during the wait, decide which stage failed. -/// Split out so the diagnosis is unit-testable without a live mesh. -fn classify_mesh_readiness_failure(model_ever_visible: bool) -> MeshReadinessFailure { - if model_ever_visible { - MeshReadinessFailure::RoutingNeverCompleted - } else { - MeshReadinessFailure::CatalogNeverSynced - } -} - -/// Actionable, non-technical copy for a readiness failure. `last_detail` is the -/// last raw transport/HTTP error, appended for support triage. -fn mesh_readiness_failure_message( - failure: MeshReadinessFailure, - model_id: &str, - last_detail: &str, -) -> String { - match failure { - MeshReadinessFailure::CatalogNeverSynced => format!( - "Buzz shared compute connected to the serving member but could not sync \ - the model list for \"{model_id}\" — this is a network path problem \ - between this machine and the host (the compute node is reachable for \ - pings but the model-sync stream did not establish). Try again, or have \ - the host and this machine on a more direct network. (last: {last_detail})" - ), - MeshReadinessFailure::RoutingNeverCompleted => format!( - "Buzz shared compute found \"{model_id}\" on a serving member but inference \ - requests did not complete — the host is discoverable but not currently \ - reachable for requests. Try again shortly. (last: {last_detail})" - ), - } -} - -/// Poll the local mesh OpenAI ingress until a real inference for `model_id` -/// succeeds, or a deadline elapses. On failure, returns a stage-specific, -/// actionable message (see [`MeshReadinessFailure`]) rather than a raw -/// `HTTP 429`, so the UI can tell "still warming up" apart from "can't reach -/// the host". -async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|error| format!("failed to build mesh readiness client: {error}"))?; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); - let models_url = format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL); - let chat_url = format!( - "{}/chat/completions", - crate::managed_agents::RELAY_MESH_API_BASE_URL - ); - let mut last_error = "mesh inference is not ready".to_string(); - // Track whether the served model ever reached the local catalog — the - // signal that splits "catalog never synced" from "routing never completed". - let mut model_ever_visible = false; - - while tokio::time::Instant::now() < deadline { - // Refresh catalog visibility. "auto" delegates model choice to the - // router, so any advertised model counts as the catalog having synced. - if let Ok(response) = client - .get(&models_url) - .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) - .send() - .await - { - if let Ok(body) = response.json::().await { - if let Some(data) = body.get("data").and_then(|d| d.as_array()) { - let wanted = model_id.trim().replace("@main", ""); - let visible = !data.is_empty() - && (model_id == crate::mesh_llm::AUTO_MODEL_ID - || data.iter().any(|m| { - m.get("id") - .and_then(|id| id.as_str()) - .map(|id| id.replace("@main", "") == wanted) - .unwrap_or(false) - })); - model_ever_visible |= visible; - } - } - } - - match client - .post(&chat_url) - .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) - .json(&serde_json::json!({ - "model": model_id, - "messages": [{"role": "user", "content": "Reply OK"}], - "max_tokens": 1, - "stream": false - })) - .send() - .await - { - Ok(response) if response.status().is_success() => return Ok(()), - Ok(response) => { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - last_error = format!("HTTP {status}: {body}"); - } - Err(error) => last_error = error.to_string(), - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - - let failure = classify_mesh_readiness_failure(model_ever_visible); - Err(mesh_readiness_failure_message( - failure, - model_id, - &last_error, - )) -} - pub(crate) async fn ensure_client_node_for_model( state: &AppState, model_id: impl AsRef, diff --git a/desktop/src-tauri/src/commands/mesh_llm_tests.rs b/desktop/src-tauri/src/commands/mesh_llm_tests.rs index 26eb1f5fbae..c4e1ae2e425 100644 --- a/desktop/src-tauri/src/commands/mesh_llm_tests.rs +++ b/desktop/src-tauri/src/commands/mesh_llm_tests.rs @@ -178,42 +178,6 @@ fn role_switch_checkpoint_starts_exactly_once_after_restart() { assert_eq!(consumed.relay_url, config.relay_url); } -#[test] -fn readiness_failure_is_catalog_sync_when_model_never_visible() { - assert_eq!( - classify_mesh_readiness_failure(false), - MeshReadinessFailure::CatalogNeverSynced - ); -} - -#[test] -fn readiness_failure_is_routing_when_model_was_visible() { - assert_eq!( - classify_mesh_readiness_failure(true), - MeshReadinessFailure::RoutingNeverCompleted - ); -} - -#[test] -fn readiness_messages_are_distinct_and_actionable() { - let catalog = mesh_readiness_failure_message( - MeshReadinessFailure::CatalogNeverSynced, - "auto", - "HTTP 429", - ); - let routing = mesh_readiness_failure_message( - MeshReadinessFailure::RoutingNeverCompleted, - "auto", - "HTTP 503", - ); - // Distinct diagnoses, each names the model and carries the raw detail. - assert_ne!(catalog, routing); - assert!(catalog.contains("network path")); - assert!(catalog.contains("HTTP 429")); - assert!(routing.contains("did not complete")); - assert!(routing.contains("HTTP 503")); -} - #[test] fn mesh_status_cursor_uses_relay_composite_tiebreak() { let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status") diff --git a/desktop/src-tauri/src/commands/mesh_readiness.rs b/desktop/src-tauri/src/commands/mesh_readiness.rs new file mode 100644 index 00000000000..023695a6f9c --- /dev/null +++ b/desktop/src-tauri/src/commands/mesh_readiness.rs @@ -0,0 +1,251 @@ +//! Startup readiness for Buzz shared compute. +//! +//! Mesh can bind its HTTP ingress and advertise a model shortly before the +//! router has installed a usable target. These helpers probe the exact chat +//! path agents use, so startup cannot race that gap +//! (`single target None unavailable`), and classify a timeout into copy that +//! names the actual stage rather than a raw `HTTP 429`. + +use super::CmdResult; + +/// Which startup stage a mesh client is stuck at when it never becomes +/// inference-ready. The two live-observed failure modes are physically +/// distinct and want different user copy: +/// +/// * `CatalogNeverSynced` — the local client node came up and connected to +/// the host at the control level (ping/RTT fine), but the served model +/// never appeared in the local `/v1/models` catalog. That catalog is +/// populated by the peer gossip exchange; when the gossip bi-stream can't +/// establish across the network (observed as iroh +/// `MultipathNotNegotiated` / unreachable direct path), the catalog stays +/// empty forever and every request is rejected "model not available". +/// Root cause is the network path between this machine and the host. +/// * `RoutingNeverCompleted` — the model *did* sync into the catalog, but +/// inference requests never completed (routing/transport to the host +/// failing per-request). The host is discoverable and advertised but not +/// actually serving us. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeshReadinessFailure { + CatalogNeverSynced, + RoutingNeverCompleted, +} + +/// Pure classifier: given whether the served model was ever observed in the +/// local `/v1/models` catalog during the wait, decide which stage failed. +/// Split out so the diagnosis is unit-testable without a live mesh. +/// Whether the catalog has synced enough to count, for the model actually being +/// requested (a wire name — see `relay_mesh_wire_model`). +/// +/// The virtual `mesh` model delegates the choice to the router, so any +/// advertised model proves the catalog synced. It has to work that way: MeshLLM +/// only advertises `mesh` itself once two non-virtual models are reachable +/// (`should_advertise_virtual_mesh`), so requiring it by name would leave a +/// single-worker mesh looking permanently unsynced and misreport a slow model +/// load as a network path problem. +fn mesh_catalog_shows_model(advertised: &[String], wire_model: &str) -> bool { + if advertised.is_empty() { + return false; + } + if wire_model == crate::managed_agents::RELAY_MESH_VIRTUAL_MODEL_ID { + return true; + } + let wanted = wire_model.trim().replace("@main", ""); + advertised + .iter() + .any(|id| id.replace("@main", "") == wanted) +} + +fn classify_mesh_readiness_failure(model_ever_visible: bool) -> MeshReadinessFailure { + if model_ever_visible { + MeshReadinessFailure::RoutingNeverCompleted + } else { + MeshReadinessFailure::CatalogNeverSynced + } +} + +/// Actionable, non-technical copy for a readiness failure. `last_detail` is the +/// last raw transport/HTTP error, appended for support triage. +fn mesh_readiness_failure_message( + failure: MeshReadinessFailure, + model_id: &str, + last_detail: &str, +) -> String { + match failure { + MeshReadinessFailure::CatalogNeverSynced => format!( + "Buzz shared compute connected to the serving member but could not sync \ + the model list for \"{model_id}\" — this is a network path problem \ + between this machine and the host (the compute node is reachable for \ + pings but the model-sync stream did not establish). Try again, or have \ + the host and this machine on a more direct network. (last: {last_detail})" + ), + MeshReadinessFailure::RoutingNeverCompleted => format!( + "Buzz shared compute found \"{model_id}\" on a serving member but inference \ + requests did not complete — the host is discoverable but not currently \ + reachable for requests. Try again shortly. (last: {last_detail})" + ), + } +} + +/// Poll the local mesh OpenAI ingress until a real inference for `model_id` +/// succeeds, or a deadline elapses. On failure, returns a stage-specific, +/// actionable message (see [`MeshReadinessFailure`]) rather than a raw +/// `HTTP 429`, so the UI can tell "still warming up" apart from "can't reach +/// the host". +pub(crate) async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> { + // Probe the name that will actually be requested. Callers pass a stored + // value, which for shared-compute `auto` is not a model the mesh + // advertises: probing it would validate a route no agent uses, and could + // fail readiness while the real route works. Named models pass through + // unchanged, so this is safe for the serve-side callers too. + let requested_model = model_id; + let model_id = crate::managed_agents::relay_mesh_wire_model(model_id); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| format!("failed to build mesh readiness client: {error}"))?; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); + let models_url = format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL); + let chat_url = format!( + "{}/chat/completions", + crate::managed_agents::RELAY_MESH_API_BASE_URL + ); + let mut last_error = "mesh inference is not ready".to_string(); + // Track whether the served model ever reached the local catalog — the + // signal that splits "catalog never synced" from "routing never completed". + let mut model_ever_visible = false; + + while tokio::time::Instant::now() < deadline { + // Refresh catalog visibility. The virtual `mesh` model delegates the + // choice to the router, so any advertised model counts as the catalog + // having synced — and it must, because MeshLLM only advertises `mesh` + // itself once two non-virtual models are reachable + // (`should_advertise_virtual_mesh`). Requiring it by name would leave a + // single-worker mesh looking permanently unsynced. + if let Ok(response) = client + .get(&models_url) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .send() + .await + { + if let Ok(body) = response.json::().await { + if let Some(data) = body.get("data").and_then(|d| d.as_array()) { + let advertised: Vec = data + .iter() + .filter_map(|m| m.get("id").and_then(|id| id.as_str())) + .map(str::to_owned) + .collect(); + model_ever_visible |= mesh_catalog_shows_model(&advertised, model_id); + } + } + } + + match client + .post(&chat_url) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .json(&serde_json::json!({ + "model": model_id, + "messages": [{"role": "user", "content": "Reply OK"}], + "max_tokens": 1, + "stream": false + })) + .send() + .await + { + Ok(response) if response.status().is_success() => return Ok(()), + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + last_error = format!("HTTP {status}: {body}"); + } + Err(error) => last_error = error.to_string(), + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + + let failure = classify_mesh_readiness_failure(model_ever_visible); + Err(mesh_readiness_failure_message( + failure, + requested_model, + &last_error, + )) +} +#[cfg(test)] +mod tests { + use super::*; + + /// The regression this guards: MeshLLM only advertises the virtual `mesh` model + /// once two non-virtual models are reachable, so a single-worker mesh never + /// lists it by name. Keying visibility on the literal name would report a lone + /// host that is still loading weights as a network path problem. + #[test] + fn virtual_mesh_counts_any_advertised_model_as_a_synced_catalog() { + let one_worker = vec!["unsloth/gemma-4-E4B-it-GGUF:Q4_K_M".to_string()]; + assert!(mesh_catalog_shows_model( + &one_worker, + crate::managed_agents::RELAY_MESH_VIRTUAL_MODEL_ID + )); + } + + #[test] + fn an_empty_catalog_is_never_synced_even_for_the_virtual_model() { + assert!(!mesh_catalog_shows_model( + &[], + crate::managed_agents::RELAY_MESH_VIRTUAL_MODEL_ID + )); + } + + #[test] + fn a_named_model_must_actually_be_advertised() { + let advertised = vec!["unsloth/gemma-4-E4B-it-GGUF:Q4_K_M".to_string()]; + assert!(mesh_catalog_shows_model( + &advertised, + "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" + )); + assert!(!mesh_catalog_shows_model(&advertised, "some/other-model")); + } + + #[test] + fn a_named_model_ignores_the_main_revision_suffix() { + let advertised = vec!["unsloth/gemma-4-E4B-it-GGUF:Q4_K_M@main".to_string()]; + assert!(mesh_catalog_shows_model( + &advertised, + "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" + )); + } + + #[test] + fn readiness_failure_is_catalog_sync_when_model_never_visible() { + assert_eq!( + classify_mesh_readiness_failure(false), + MeshReadinessFailure::CatalogNeverSynced + ); + } + + #[test] + fn readiness_failure_is_routing_when_model_was_visible() { + assert_eq!( + classify_mesh_readiness_failure(true), + MeshReadinessFailure::RoutingNeverCompleted + ); + } + + #[test] + fn readiness_messages_are_distinct_and_actionable() { + let catalog = mesh_readiness_failure_message( + MeshReadinessFailure::CatalogNeverSynced, + "auto", + "HTTP 429", + ); + let routing = mesh_readiness_failure_message( + MeshReadinessFailure::RoutingNeverCompleted, + "auto", + "HTTP 503", + ); + // Distinct diagnoses, each names the model and carries the raw detail. + assert_ne!(catalog, routing); + assert!(catalog.contains("network path")); + assert!(catalog.contains("HTTP 429")); + assert!(routing.contains("did not complete")); + assert!(routing.contains("HTTP 503")); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 7b4b9b785f1..4f839638b93 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -486,6 +486,7 @@ pub async fn send_channel_message( emoji_tags: Option>>, mention_tags: Option>>, link_preview_tags: Option>>, + sent_from_thread_tag: Option>, mention_pubkeys: Option>, kind: Option, state: State<'_, AppState>, @@ -500,6 +501,9 @@ pub async fn send_channel_message( let link_previews = link_preview_tags.unwrap_or_default(); let relay_base = crate::relay::relay_api_base_url_with_override(&state); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); + if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE { + return Err("sent-from-thread provenance requires a stream message".into()); + } let mut resolved_root: Option = None; @@ -544,6 +548,7 @@ pub async fn send_channel_message( &emoji, &mention_refs_only, &link_previews, + sent_from_thread_tag.as_deref(), &relay_base, )? } @@ -712,6 +717,7 @@ fn build_managed_agent_channel_message( &[], &[], &[], + None, &crate::relay::relay_api_base_url(), client_tags, ) @@ -890,6 +896,10 @@ pub struct EditMessageInput { // tag, so a typo-fix edit never re-wakes existing mentions. #[serde(default)] mention_pubkeys: Vec, + // Full stable mention identity set selected in the edited composer. `None` + // means a partial edit that must preserve the existing snapshot; `Some`, + // including an empty set, authoritatively replaces it. + mention_tags: Option>>, #[serde(default)] suppress_link_previews: bool, } @@ -914,9 +924,12 @@ pub async fn edit_message( channel_uuid, target_eid, trimmed, - &input.media_tags, - &input.emoji_tags, - &mention_refs, + events::MessageEditTags { + media: &input.media_tags, + custom_emoji: &input.emoji_tags, + mentions: &mention_refs, + mention_refs: input.mention_tags.as_deref(), + }, input.suppress_link_previews, )?; submit_event(builder, &state).await?; diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 322834630a3..1ab3bb70d74 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -35,6 +35,8 @@ mod media_transcode; mod media_upload_progress; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; +#[cfg(feature = "mesh-llm")] +pub(crate) mod mesh_readiness; mod messages; mod notifications; mod observer_archive; diff --git a/desktop/src-tauri/src/commands/window_vibrancy.rs b/desktop/src-tauri/src/commands/window_vibrancy.rs index 5eb1f16b8b9..39dcef3c6e5 100644 --- a/desktop/src-tauri/src/commands/window_vibrancy.rs +++ b/desktop/src-tauri/src/commands/window_vibrancy.rs @@ -1,15 +1,29 @@ //! Runtime macOS window vibrancy (blur-behind) toggle. //! +//! **Invariant:** the main window is created opaque (`tauri.conf.json` +//! `transparent: false`) and the NSWindow is never made transparent at runtime. +//! Behind-window vibrancy renders correctly inside opaque windows — this is +//! exactly how Finder and Notes render vibrant sidebars — so glass only requires +//! runtime webview-canvas transparency, which this command sets on the enable +//! path. When glass is disabled the webview canvas may remain non-drawing +//! (wry's `drawsBackground` flag is one-way at runtime), but that is harmless: +//! glass-off CSS paints the full background opaque and the always-opaque NSWindow +//! is beneath it. +//! +//! Why not `transparent: true`? A creation-time transparent window causes tao to +//! call `NSWindow.setOpaque(false)` and `setBackgroundColor(clearColor)`. The +//! runtime `Window::set_background_color(None)` then resolves `None` to +//! `clearColor` instead of the opaque system default — and there is no runtime +//! `setOpaque(true)` path through tauri — leaving the compositor blending the +//! whole window even with glass off. +//! //! Vibrancy applies an `NSVisualEffectView` behind the webview so the desktop -//! (and windows behind Buzz) blur through wherever the app's CSS is +//! (and windows behind Buzz) blurs through wherever the WKWebView canvas is //! transparent. It is a native, macOS-only effect: there is no "intensity" //! setting at the OS level, only a set of material presets. The frontend tunes -//! perceived intensity by changing CSS surface opacity while this command -//! handles the native material. -//! -//! This is fully reversible at runtime: enabling applies the chosen material, -//! disabling clears it. On non-macOS platforms the command is a no-op so the -//! shared frontend can call it unconditionally. +//! perceived intensity by adjusting CSS surface opacity while this command +//! handles the native material. On non-macOS platforms the command is a no-op +//! so the shared frontend can call it unconditionally. #[cfg(target_os = "macos")] use tauri::Manager; @@ -35,6 +49,16 @@ pub fn set_window_vibrancy( .ok_or_else(|| "main window not found".to_string())?; if !enabled { + // The NSWindow layer is permanently opaque, so no window-layer + // reset is needed here. Skipping `set_background_color(None)` at + // the webview layer also avoids tauri mapping `None` to opaque + // white, which would still force `drawsBackground=false` on the + // WKWebView (counterproductive). After a glass session the webview + // canvas may stay non-drawing — wry's `drawsBackground` flag is + // one-way at runtime — but that is harmless: glass-off CSS paints + // the full background opaque and the always-opaque NSWindow is + // beneath it. If `clear_vibrancy` fails, the opaque CSS already + // covers everything, so no see-through state is reachable. clear_vibrancy(&window).map_err(|e| e.to_string())?; return Ok(()); } @@ -58,7 +82,22 @@ pub fn set_window_vibrancy( // clear is a no-op (returns `false`) when none is present. let _ = clear_vibrancy(&window); + // Install the blur layer first: a failure of the canvas write leaves + // the window with vibrancy behind an opaque webview, not a see-through + // one. Either mixed state self-corrects on the next toggle. apply_vibrancy(&window, material, None, None).map_err(|e| e.to_string())?; + + // Make only the WKWebView canvas transparent so native vibrancy shows + // through; the NSWindow layer stays opaque by design. Targeting the + // webview layer directly (via `AsRef`) avoids the + // `WebviewWindow::set_background_color` path, which also writes the + // NSWindow layer. Must follow `apply_vibrancy` so the blur layer is + // present before the canvas becomes see-through. + let webview: &tauri::Webview<_> = window.as_ref(); + webview + .set_background_color(Some(tauri::window::Color(0, 0, 0, 0))) + .map_err(|e| e.to_string())?; + Ok(()) } diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 23cb2ba220c..1513742beaf 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -165,6 +165,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) .unwrap(); @@ -181,6 +182,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) .unwrap(); diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index b7937419bf1..df814afb36f 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -11,6 +11,12 @@ use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; + +mod message_tags; + +use message_tags::{ + append_client_tags, append_sent_from_thread_tag, emoji_tags, imeta_tags, mention_reference_tags, +}; // ── Constants ──────────────────────────────────────────────────────────────── /// Maximum content size — matches buzz-sdk (64 KiB). @@ -74,56 +80,6 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { Ok(tags) } -fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Result<(), String> { - for mention in mentions { - if mention.first().map(String::as_str) != Some("mention") { - return Err(format!( - "mention reference tags must use 'mention' prefix (got {:?})", - mention.first() - )); - } - let Some(pubkey) = mention.get(1) else { - return Err("mention reference tag missing pubkey".into()); - }; - check_pubkey(pubkey)?; - tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?); - } - Ok(()) -} - -/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" -/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). -fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for mt in media_tags { - if mt.first().map(String::as_str) != Some("imeta") { - return Err(format!( - "media tags must use 'imeta' prefix (got {:?})", - mt.first() - )); - } - let parts: Vec<&str> = mt.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid imeta tag: {e}"))?); - } - Ok(()) -} - -/// Validate and append NIP-30 custom-emoji tags. Mirrors `imeta_tags`: rejects -/// any tag whose first element is not "emoji" so this path can't be used to -/// smuggle forged "h"/"e"/"p" tags. Each tag is `["emoji", shortcode, url]`. -fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for et in emoji_tags { - if et.first().map(String::as_str) != Some("emoji") { - return Err(format!( - "emoji tags must use 'emoji' prefix (got {:?})", - et.first() - )); - } - let parts: Vec<&str> = et.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?); - } - Ok(()) -} - /// Validate a hex pubkey is exactly 64 hex characters. fn check_pubkey(pubkey: &str) -> Result<(), String> { if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { @@ -302,6 +258,7 @@ pub fn build_message( custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], link_preview_tags: &[Vec], + sent_from_thread_tag: Option<&[String]>, relay_base: &str, ) -> Result { build_message_with_client_tags( @@ -313,6 +270,7 @@ pub fn build_message( custom_emoji_tags, mention_ref_tags, link_preview_tags, + sent_from_thread_tag, relay_base, &[], ) @@ -333,9 +291,13 @@ pub fn build_message_with_client_tags( custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], link_preview_tags: &[Vec], + sent_from_thread_tag: Option<&[String]>, relay_base: &str, client_tags: &[Vec], ) -> Result { + if sent_from_thread_tag.is_some() && thread_ref.is_some() { + return Err("sent-from-thread provenance requires a top-level message".into()); + } check_content(content)?; let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; if let Some(tr) = thread_ref { @@ -346,27 +308,11 @@ pub fn build_message_with_client_tags( emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?; + append_sent_from_thread_tag(sent_from_thread_tag, &mut tags)?; append_client_tags(client_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } -fn append_client_tags(client_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for client_tag in client_tags { - if client_tag.first().map(String::as_str) != Some("client") { - return Err(format!( - "client tags must use 'client' prefix (got {:?})", - client_tag.first() - )); - } - if client_tag.len() < 2 { - return Err("client tag missing marker".into()); - } - let parts: Vec<&str> = client_tag.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid client tag: {e}"))?); - } - Ok(()) -} - /// Kind 45001 — forum post. pub fn build_forum_post( channel_id: Uuid, @@ -401,15 +347,20 @@ pub fn build_forum_comment( Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } +pub struct MessageEditTags<'a> { + pub media: &'a [Vec], + pub custom_emoji: &'a [Vec], + pub mentions: &'a [&'a str], + pub mention_refs: Option<&'a [Vec]>, +} + /// Kind 40003 — edit a message with full content, media, emoji, mentions, /// and optional monotonic link-preview suppression. pub fn build_message_edit( channel_id: Uuid, target_event_id: EventId, content: &str, - media_tags: &[Vec], - custom_emoji_tags: &[Vec], - mentions: &[&str], + edit_tags: MessageEditTags<'_>, suppress_link_previews: bool, ) -> Result { check_content(content)?; @@ -417,9 +368,13 @@ pub fn build_message_edit( tag(vec!["h", &channel_id.to_string()])?, tag(vec!["e", &target_event_id.to_hex()])?, ]; - tags.extend(mention_tags(mentions)?); - imeta_tags(media_tags, &mut tags)?; - emoji_tags(custom_emoji_tags, &mut tags)?; + tags.extend(mention_tags(edit_tags.mentions)?); + imeta_tags(edit_tags.media, &mut tags)?; + emoji_tags(edit_tags.custom_emoji, &mut tags)?; + if let Some(mention_refs) = edit_tags.mention_refs { + mention_reference_tags(mention_refs, &mut tags)?; + tags.push(tag(vec!["buzz:mention-snapshot"])?); + } if suppress_link_previews { tags.push(tag(vec!["link-preview", "none"])?); } @@ -930,25 +885,35 @@ mod tests { assert_eq!(event.pubkey.to_hex(), TARGET_HEX); } - // ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── - // - // The composer diffs the edited body's mentions against the original and - // hands `build_message_edit` only the *newly added* pubkeys. These tests - // pin the builder's contract given that contract: emit a `p` per added - // mention (deduped, lowercased), and none when the added set is empty - // (typo-fix edit) — so an unchanged mention set re-wakes nobody. - const CH_ID: &str = "11111111-1111-4111-8111-111111111111"; const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; fn edit_tags(mentions: &[&str]) -> Vec> { + edit_tags_with_refs(mentions, Some(&[])) + } + + fn edit_tags_with_refs( + mentions: &[&str], + mention_refs: Option<&[Vec]>, + ) -> Vec> { let channel = Uuid::parse_str(CH_ID).unwrap(); let target = EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") .unwrap(); - let builder = - build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap(); + let builder = build_message_edit( + channel, + target, + "hi @alice", + MessageEditTags { + media: &[], + custom_emoji: &[], + mentions, + mention_refs, + }, + false, + ) + .unwrap(); let secret = nostr::SecretKey::from_hex( "0000000000000000000000000000000000000000000000000000000000000003", ) @@ -962,7 +927,6 @@ mod tests { let tags = edit_tags(&[ALICE_HEX]); assert_eq!(tags[0][0], "h"); assert_eq!(tags[1][0], "e"); - // The `p` tag rides right after the `e` tag (insertion order). assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); } @@ -979,6 +943,42 @@ mod tests { ); } + #[test] + fn edit_emits_full_mention_reference_snapshot() { + let tags = edit_tags_with_refs(&[], Some(&[vec!["mention".into(), ALICE_HEX.into()]])); + assert!( + tags.iter().any(|tag| tag == &["mention", ALICE_HEX]), + "stable mention reference must be present: {tags:?}" + ); + assert!( + tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]), + "snapshot marker must be present: {tags:?}" + ); + } + + #[test] + fn empty_edit_mention_snapshot_is_explicit() { + let tags = edit_tags_with_refs(&[], Some(&[])); + assert!( + tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]), + "empty snapshot must still clear stale references: {tags:?}" + ); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("mention"))); + } + + #[test] + fn partial_edit_omits_mention_snapshot() { + let tags = edit_tags_with_refs(&[], None); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("mention"))); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("buzz:mention-snapshot"))); + } + #[test] fn edit_mentions_are_deduped_and_lowercased() { let alice_upper = ALICE_HEX.to_ascii_uppercase(); diff --git a/desktop/src-tauri/src/events/message_tags.rs b/desktop/src-tauri/src/events/message_tags.rs new file mode 100644 index 00000000000..c43a8874def --- /dev/null +++ b/desktop/src-tauri/src/events/message_tags.rs @@ -0,0 +1,140 @@ +use nostr::{EventId, Tag}; + +use super::check_pubkey; + +const MAX_THREAD_ROOT_EXCERPT_CHARS: usize = 64; +const SENT_FROM_THREAD_TAG: &str = "buzz:sent-from-thread"; + +pub(super) fn mention_reference_tags( + mentions: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + for mention in mentions { + if mention.first().map(String::as_str) != Some("mention") { + return Err(format!( + "mention reference tags must use 'mention' prefix (got {:?})", + mention.first() + )); + } + let Some(pubkey) = mention.get(1) else { + return Err("mention reference tag missing pubkey".into()); + }; + check_pubkey(pubkey)?; + tags.push( + Tag::parse(vec!["mention", &pubkey.to_ascii_lowercase()]) + .map_err(|error| format!("invalid mention reference tag: {error}"))?, + ); + } + Ok(()) +} + +pub(super) fn append_sent_from_thread_tag( + source_tag: Option<&[String]>, + tags: &mut Vec, +) -> Result<(), String> { + let Some(source_tag) = source_tag else { + return Ok(()); + }; + if !matches!(source_tag.len(), 2 | 3) + || source_tag.first().map(String::as_str) != Some(SENT_FROM_THREAD_TAG) + { + return Err("invalid sent-from-thread tag shape".into()); + } + + EventId::from_hex(source_tag[1].trim()) + .map_err(|_| "sent-from-thread tag has invalid root event ID")?; + + if let Some(excerpt) = source_tag.get(2) { + if excerpt.trim().is_empty() + || excerpt.chars().count() > MAX_THREAD_ROOT_EXCERPT_CHARS + || excerpt.chars().any(char::is_control) + { + return Err("sent-from-thread tag has invalid root excerpt".into()); + } + } + + let parts: Vec<&str> = source_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid sent-from-thread tag: {e}"))?); + Ok(()) +} + +/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" +/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). +pub(super) fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { + for media_tag in media_tags { + if media_tag.first().map(String::as_str) != Some("imeta") { + return Err(format!( + "media tags must use 'imeta' prefix (got {:?})", + media_tag.first() + )); + } + let parts: Vec<&str> = media_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid imeta tag: {e}"))?); + } + Ok(()) +} + +/// Validate and append NIP-30 custom-emoji tags. Mirrors `imeta_tags`: rejects +/// any tag whose first element is not "emoji" so this path can't be used to +/// smuggle forged "h"/"e"/"p" tags. Each tag is `["emoji", shortcode, url]`. +pub(super) fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { + for emoji_tag in emoji_tags { + if emoji_tag.first().map(String::as_str) != Some("emoji") { + return Err(format!( + "emoji tags must use 'emoji' prefix (got {:?})", + emoji_tag.first() + )); + } + let parts: Vec<&str> = emoji_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?); + } + Ok(()) +} + +pub(super) fn append_client_tags( + client_tags: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + for client_tag in client_tags { + if client_tag.first().map(String::as_str) != Some("client") { + return Err(format!( + "client tags must use 'client' prefix (got {:?})", + client_tag.first() + )); + } + if client_tag.len() < 2 { + return Err("client tag missing marker".into()); + } + let parts: Vec<&str> = client_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid client tag: {e}"))?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_HEX: &str = "d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1"; + + #[test] + fn message_accepts_only_valid_sent_from_thread_provenance() { + let source_tag = vec![ + SENT_FROM_THREAD_TAG.to_string(), + ROOT_HEX.to_string(), + "Root message excerpt".to_string(), + ]; + let mut tags = Vec::new(); + append_sent_from_thread_tag(Some(&source_tag), &mut tags).unwrap(); + assert_eq!(tags[0].as_slice(), source_tag); + + let forged_channel_tag = vec!["h".to_string(), "channel-id".to_string()]; + assert!(append_sent_from_thread_tag(Some(&forged_channel_tag), &mut Vec::new()).is_err()); + + let invalid_root_tag = vec![ + SENT_FROM_THREAD_TAG.to_string(), + "not-an-event-id".to_string(), + ]; + assert!(append_sent_from_thread_tag(Some(&invalid_root_tag), &mut Vec::new()).is_err()); + } +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 4d4e840104e..b05b6b7fe47 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -668,6 +668,7 @@ pub(crate) fn spawn_transcription_task( &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) { Ok(b) => b, diff --git a/desktop/src-tauri/src/initial_window.rs b/desktop/src-tauri/src/initial_window.rs index b1245515125..f6d88259b2e 100644 --- a/desktop/src-tauri/src/initial_window.rs +++ b/desktop/src-tauri/src/initial_window.rs @@ -15,9 +15,16 @@ pub(crate) fn reveal_initial_window(window: &tauri::Window #[cfg(target_os = "macos")] pub(crate) fn set_initial_window_backing(window: &tauri::Window) { - // The window remains transparent at runtime for vibrancy. Use an opaque - // native backing only across the first visible frames so the previous app - // cannot show through before WebKit has submitted its first surface. + // Both this write and the deferred clear target the Window (NSWindow) + // backing color only; they never touch the webview canvas or the + // NSVisualEffectView, so they are not load-bearing for glass. Glass state + // — the effect view and webview-canvas transparency — is managed entirely + // by `set_window_vibrancy`, which the ThemeProvider calls after mount. The + // 250ms-delayed clear cannot clobber a persisted-glass-on cold boot + // regardless of ordering with that call. + // + // Write an opaque dark backing so the previous app cannot show through + // before WebKit submits its first composited surface. if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { eprintln!("buzz-desktop: failed to set initial window backing: {error}"); } @@ -26,6 +33,10 @@ pub(crate) fn set_initial_window_backing(window: &tauri::Wind #[cfg(target_os = "macos")] pub(crate) async fn clear_initial_window_backing(window: &tauri::Window) { tokio::time::sleep(std::time::Duration::from_millis(250)).await; + // Restore the default system window background so fast-resize gutter + // flashes match the platform theme rather than the hardcoded dark color + // written at reveal. Targets the Window (NSWindow) layer only; webview + // canvas and glass state are unaffected. if let Err(error) = window.set_background_color(None) { eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); } diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index 05979e76cbf..59b300d9d17 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -8,6 +8,25 @@ use std::collections::BTreeMap; use base64::Engine as _; +/// Seconds a woken lazy harness stays warm before it releases its worker +/// subprocesses back to the empty-slot state (via `BUZZ_ACP_IDLE_POOL_SLEEP`). +/// The next accepted event re-wakes it through the same lazy path. Matches the +/// harness's own 15-minute per-turn idle window so a warm pool survives a +/// normal back-and-forth but a truly quiet harness stops paying for workers. +const IDLE_POOL_SLEEP_SECS: &str = "900"; + +/// Value for `BUZZ_ACP_IDLE_POOL_SLEEP`. Idle re-sleep is only meaningful for +/// lazy harnesses (the harness ignores it otherwise); gate to `lazy` here so +/// the env reads inert (`"0"` = disabled) for eager harnesses. This is a +/// desktop-owned lifetime policy (reserved key), not user-tunable. +pub(super) fn idle_pool_sleep_env(lazy: bool) -> &'static str { + if lazy { + IDLE_POOL_SLEEP_SECS + } else { + "0" + } +} + /// Return the baked-in build-time env pairs as a map. /// /// Internal builds (buzz-releases) bake provider/model defaults and arbitrary diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 797c6689814..91e6124fffd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -995,25 +995,16 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { /// background threads to prevent pipe-buffer deadlock. On timeout the child is /// killed and `Unknown` is returned; no orphaned threads or processes are left /// behind. Returns `Unknown` on timeout. -fn probe_auth_status( - binary_path: &Path, - probe_args: &[&str], - runtime_plan: Option<&super::runtime_plan::RuntimeExecutionPlan>, -) -> AuthStatus { +fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { use crate::managed_agents::readiness::cli_probe; - let augmented_path = runtime_plan - .and_then(|plan| plan.generated_environment("PATH").map(str::to_string)) - .or_else(cli_probe::augmented_path); + let augmented_path = cli_probe::augmented_path(); let mut command = std::process::Command::new(binary_path); command.args(&probe_args[1..]); if let Some(ref path) = augmented_path { command.env("PATH", path); } - if let Some(plan) = runtime_plan { - plan.apply_environment(&mut command); - } command .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) @@ -1142,16 +1133,153 @@ pub(crate) fn classify_runtime( } } -mod codex_version; +/// The oldest `codex-acp` version supported by Buzz managed agents. +/// +/// Older 1.x adapters are detected successfully, but can still bundle a Codex runtime +/// that does not reliably give `buzz` CLI subprocesses outbound relay access. +/// +/// Bump policy: raise this only when a newer adapter fixes a defect that breaks managed +/// agents, and only to a version already published on npm — every user below the floor is +/// offered a reinstall on their next discovery pass. +pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 1, 7); + +/// Probe the full version of a `codex-acp` binary by running `--version`. +/// +/// The 1.x adapter (`@agentclientprotocol/codex-acp`) outputs +/// `@agentclientprotocol/codex-acp ..` on stdout and exits 0. +/// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does +/// not recognise `--version` and exits non-zero. +/// +/// Returns the `(major, minor, patch)` triple on success, `None` on any failure +/// (non-zero exit, unparseable output, timeout, or missing binary). +/// +/// The parse is deliberately strict: exactly three numeric dot-separated components. +/// Partial versions (`1.2`) and prerelease tags (`1.2.0-rc1`) return `None` and so +/// classify as [`AcpAvailabilityStatus::AdapterOutdated`] — failing closed offers a +/// reinstall rather than running an adapter whose version cannot be compared. +/// +/// The probe is bounded by a 5-second deadline. The child is polled with +/// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and +/// killed if it does not exit in time. +/// +/// Stdout is redirected to a temporary file rather than a pipe, so forked +/// descendants cannot hold EOF open. Reads from a regular file return EOF at its +/// current write position regardless of inherited file descriptors, cross-platform. +pub(crate) fn probe_codex_acp_version(binary_path: &Path) -> Option<(u64, u64, u64)> { + probe_codex_acp_version_with_path( + binary_path, + crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), + ) +} +pub(crate) fn probe_codex_acp_version_with_path( + binary_path: &Path, + augmented_path: Option<&str>, +) -> Option<(u64, u64, u64)> { + use std::io::{Read as _, Seek as _, SeekFrom}; + use std::time::{Duration, Instant}; + const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + + // A regular file returns EOF at its current size even when a descendant + // inherits its descriptor, bounding the post-exit read cross-platform. + let mut tmp = tempfile::tempfile().ok()?; + + let mut command = Command::new(binary_path); + command.arg("--version"); + if let Some(path) = augmented_path { + command.env("PATH", path); + } + crate::util::configure_no_window(&mut command); + let mut child = command + .stdout(tmp.try_clone().ok()?) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + + // Poll until the deadline rather than blocking on stdout EOF. + let deadline = Instant::now() + VERSION_PROBE_TIMEOUT; + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + + if !exit_status.success() { + return None; + } + + // Read at most 4 KiB from the regular file without blocking. + tmp.seek(SeekFrom::Start(0)).ok()?; + let mut buf = Vec::with_capacity(128); + let _ = (&mut tmp as &mut dyn std::io::Read) + .take(4096) + .read_to_end(&mut buf); + + let stdout = String::from_utf8_lossy(&buf); + // Output format: " .." + let version_str = stdout.split_whitespace().last()?; + let mut components = version_str.split('.'); + let major = components.next()?.parse::().ok()?; + let minor = components.next()?.parse::().ok()?; + let patch = components.next()?.parse::().ok()?; + if components.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`] +/// or [`AcpAvailabilityStatus::AdapterOutdated`]. +/// +/// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version` +/// and exits non-zero — that probe failure yields `AdapterOutdated`. An adapter is +/// available only when its version is at least [`MIN_CODEX_ACP_VERSION`]. +/// +/// Used by `discover_acp_runtimes`, `cli_login_requirements`, and +/// `install_acp_runtime_blocking` so the version-gate logic is not duplicated. +pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus { + match probe_codex_acp_version(path) { + Some(version) if version >= MIN_CODEX_ACP_VERSION => AcpAvailabilityStatus::Available, + _ => AcpAvailabilityStatus::AdapterOutdated, + } +} +/// Returns `true` when the codex-acp binary at `path` is below +/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed using `augmented_path`. Thin wrapper +/// around [`codex_adapter_is_outdated_with_path`]. #[cfg(test)] -pub(crate) use codex_version::codex_adapter_is_outdated; -pub(crate) use codex_version::{ - codex_adapter_availability, codex_adapter_availability_with_plan, - codex_adapter_is_outdated_with_path, probe_codex_acp_version, - probe_codex_acp_version_with_path, MIN_CODEX_ACP_VERSION, -}; +pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { + codex_adapter_is_outdated_with_path( + path, + crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), + ) +} +/// Returns `true` when the codex-acp binary at `path` is below +/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed with the supplied PATH. +pub(crate) fn codex_adapter_is_outdated_with_path( + path: &Path, + augmented_path: Option<&str>, +) -> bool { + !matches!( + probe_codex_acp_version_with_path(path, augmented_path), + Some(version) if version >= MIN_CODEX_ACP_VERSION + ) +} + +/// Intermediate struct built before the (potentially slow) auth probe phase. struct PartialEntry { runtime: &'static KnownAcpRuntime, entry: AcpRuntimeCatalogEntry, @@ -1170,35 +1298,15 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr let (mut availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - let runtime_plan = - command.as_deref().and_then( - |cmd| match super::runtime_plan::resolve_runtime_execution_plan(cmd) { - Ok(plan) => plan, - Err(error) => { - tracing::warn!(runtime = runtime.id, %error, "runtime plan resolution failed"); - None - } - }, - ); - - // For codex-acp, version probing is execution and therefore consumes the - // same verified plan and sanitized environment as every later operation. + // For codex-acp: when the adapter resolves as Available, probe its full + // version. An adapter below MIN_CODEX_ACP_VERSION is treated as outdated. if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available && command.as_deref() == Some("codex-acp") { - availability = if cfg!(windows) { - binary_path - .as_deref() - .map(Path::new) - .map(codex_adapter_availability) - .unwrap_or(AcpAvailabilityStatus::AdapterOutdated) - } else { - runtime_plan - .as_ref() - .map(codex_adapter_availability_with_plan) - .unwrap_or(AcpAvailabilityStatus::AdapterOutdated) - }; + if let Some(path_str) = &binary_path { + availability = codex_adapter_availability(&PathBuf::from(path_str)); + } } // Warm the adapter-availability cache for the badge fallback. @@ -1267,10 +1375,6 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr availability, command, binary_path, - runtime_plan_id: runtime_plan.as_ref().map(|plan| plan.id.clone()), - runtime_plan_source: runtime_plan - .as_ref() - .map(|plan| plan.source_label().to_string()), default_args, mcp_command: runtime.mcp_command.map(str::to_string), model_env_var: runtime.model_env_var.map(str::to_string), @@ -1341,31 +1445,13 @@ pub fn discover_acp_runtimes_from( return None; } let probe_args = partial.runtime.auth_probe_args?; - // Codex consumes the content-identified provider component and - // plan-owned PATH. Other runtime families retain their legacy - // discovery path until they gain a complete execution plan. - let runtime_command = partial.runtime.commands.first().copied()?; - let plan = match super::runtime_plan::resolve_runtime_execution_plan(runtime_command) { - Ok(plan) => plan, - Err(_) => return None, - }; - if partial.runtime.id == "codex" && !cfg!(windows) && plan.is_none() { - return None; - } - let binary_path = if let Some(plan) = plan.as_ref() { - plan.provider_cli_path()?.to_path_buf() - } else { - let provider_command = partial.runtime.underlying_cli?; - resolve_command(provider_command)? - }; + // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). + let binary_path = resolve_command(probe_args[0])?; let probe_args_owned: Vec = probe_args.iter().map(|s| s.to_string()).collect(); let handle = std::thread::spawn(move || { - if plan.as_ref().is_some_and(|plan| plan.verify().is_err()) { - return AuthStatus::Unknown; - } let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); - probe_auth_status(&binary_path, &refs, plan.as_ref()) + probe_auth_status(&binary_path, &refs) }); Some((idx, handle)) }) @@ -1448,8 +1534,6 @@ pub fn discover_acp_runtimes_from( availability, command, binary_path, - runtime_plan_id: None, - runtime_plan_source: None, default_args, // Custom harnesses are plain ACP — no MCP sidecar, no env-var // model switching, no thinking knobs. diff --git a/desktop/src-tauri/src/managed_agents/discovery/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/codex_version.rs deleted file mode 100644 index b44868319a8..00000000000 --- a/desktop/src-tauri/src/managed_agents/discovery/codex_version.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::{path::Path, process::Command}; - -use super::AcpAvailabilityStatus; - -/// The oldest `codex-acp` version supported by Buzz managed agents. -/// -/// Raise only for a published adapter that fixes a managed-agent defect. -pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 1, 7); - -/// Probe the full version of a `codex-acp` binary by running `--version`. -/// -/// The 1.x adapter (`@agentclientprotocol/codex-acp`) outputs -/// `@agentclientprotocol/codex-acp ..` on stdout and exits 0. -/// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does -/// not recognise `--version` and exits non-zero. -/// -/// Returns the `(major, minor, patch)` triple on success, `None` on any failure -/// (non-zero exit, unparseable output, timeout, or missing binary). -/// -/// The parse is deliberately strict: exactly three numeric dot-separated components. -/// Partial versions (`1.2`) and prerelease tags (`1.2.0-rc1`) return `None` and so -/// classify as [`AcpAvailabilityStatus::AdapterOutdated`] — failing closed offers a -/// reinstall rather than running an adapter whose version cannot be compared. -/// -/// The probe is bounded by a 5-second deadline. The child is polled with -/// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and -/// killed if it does not exit in time. -/// -/// Stdout is redirected to a temporary file rather than a pipe, so forked -/// descendants cannot hold EOF open. Reads from a regular file return EOF at its -/// current write position regardless of inherited file descriptors, cross-platform. -pub(crate) fn probe_codex_acp_version(binary_path: &Path) -> Option<(u64, u64, u64)> { - probe_codex_acp_version_with_path( - binary_path, - crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), - ) -} -pub(crate) fn probe_codex_acp_version_with_path( - binary_path: &Path, - augmented_path: Option<&str>, -) -> Option<(u64, u64, u64)> { - probe_codex_acp_version_inner(binary_path, augmented_path, None) -} - -fn probe_codex_acp_version_with_plan( - binary_path: &Path, - plan: &crate::managed_agents::runtime_plan::RuntimeExecutionPlan, -) -> Option<(u64, u64, u64)> { - probe_codex_acp_version_inner(binary_path, plan.generated_environment("PATH"), Some(plan)) -} - -fn probe_codex_acp_version_inner( - binary_path: &Path, - augmented_path: Option<&str>, - runtime_plan: Option<&crate::managed_agents::runtime_plan::RuntimeExecutionPlan>, -) -> Option<(u64, u64, u64)> { - use std::io::{Read as _, Seek as _, SeekFrom}; - use std::time::{Duration, Instant}; - const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); - - // A regular file returns EOF at its current size even when a descendant - // inherits its descriptor, bounding the post-exit read cross-platform. - let mut tmp = tempfile::tempfile().ok()?; - - let mut command = Command::new(binary_path); - command.arg("--version"); - if let Some(path) = augmented_path { - command.env("PATH", path); - } - if let Some(plan) = runtime_plan { - plan.apply_environment(&mut command); - } - crate::util::configure_no_window(&mut command); - let mut child = command - .stdout(tmp.try_clone().ok()?) - .stderr(std::process::Stdio::null()) - .spawn() - .ok()?; - - // Poll until the deadline rather than blocking on stdout EOF. - let deadline = Instant::now() + VERSION_PROBE_TIMEOUT; - let exit_status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) => { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - } - }; - - if !exit_status.success() { - return None; - } - - // Read at most 4 KiB from the regular file without blocking. - tmp.seek(SeekFrom::Start(0)).ok()?; - let mut buf = Vec::with_capacity(128); - let _ = (&mut tmp as &mut dyn std::io::Read) - .take(4096) - .read_to_end(&mut buf); - - let stdout = String::from_utf8_lossy(&buf); - // Output format: " .." - let version_str = stdout.split_whitespace().last()?; - let mut components = version_str.split('.'); - let major = components.next()?.parse::().ok()?; - let minor = components.next()?.parse::().ok()?; - let patch = components.next()?.parse::().ok()?; - if components.next().is_some() { - return None; - } - Some((major, minor, patch)) -} - -/// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`] -/// or [`AcpAvailabilityStatus::AdapterOutdated`]. -/// -/// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version` -/// and exits non-zero — that probe failure yields `AdapterOutdated`. An adapter is -/// available only when its version is at least [`MIN_CODEX_ACP_VERSION`]. -/// -/// Used by `discover_acp_runtimes`, `cli_login_requirements`, and -/// `install_acp_runtime_blocking` so the version-gate logic is not duplicated. -pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus { - match probe_codex_acp_version(path) { - Some(version) if version >= MIN_CODEX_ACP_VERSION => AcpAvailabilityStatus::Available, - _ => AcpAvailabilityStatus::AdapterOutdated, - } -} - -pub(crate) fn codex_adapter_availability_with_plan( - plan: &crate::managed_agents::runtime_plan::RuntimeExecutionPlan, -) -> AcpAvailabilityStatus { - let version = plan.harness_path().ok().and_then(|path| { - plan.verify() - .ok() - .and_then(|()| probe_codex_acp_version_with_plan(path, plan)) - }); - match version { - Some(version) if version >= MIN_CODEX_ACP_VERSION => AcpAvailabilityStatus::Available, - _ => AcpAvailabilityStatus::AdapterOutdated, - } -} - -/// Returns `true` when the codex-acp binary at `path` is below -/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed using `augmented_path`. Thin wrapper -/// around [`codex_adapter_is_outdated_with_path`]. -#[cfg(test)] -pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { - codex_adapter_is_outdated_with_path( - path, - crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), - ) -} - -/// Returns `true` when the codex-acp binary at `path` is below -/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed with the supplied PATH. -pub(crate) fn codex_adapter_is_outdated_with_path( - path: &Path, - augmented_path: Option<&str>, -) -> bool { - !matches!( - probe_codex_acp_version_with_path(path, augmented_path), - Some(version) if version >= MIN_CODEX_ACP_VERSION - ) -} diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index dea7155aea5..d86e5f33f05 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -59,8 +59,6 @@ pub(super) fn preset_catalog_entry( availability, command, binary_path, - runtime_plan_id: None, - runtime_plan_source: None, default_args: normalize_agent_args( def.command, def.args.iter().map(|arg| arg.to_string()).collect(), diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index 87981d852f4..f3de11ad242 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -164,7 +164,11 @@ fn reserved_keys_include_respond_to_gate() { #[test] fn reserved_keys_include_remote_lifetime_policy() { - for key in ["BUZZ_ACP_EXIT_AFTER_INACTIVITY", "BUZZ_ACP_NO_PRESENCE"] { + for key in [ + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_IDLE_POOL_SLEEP", + "BUZZ_ACP_NO_PRESENCE", + ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); let agent = map(&[(key, "0")]); assert!(merged_user_env(&BTreeMap::new(), &agent).is_empty()); @@ -177,7 +181,6 @@ fn reserved_keys_include_code_execution_surface() { // Overriding lets the user run arbitrary code as the agent. for key in [ "BUZZ_ACP_AGENT_COMMAND", - "BUZZ_ACP_AGENT_IDENTITY", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", ] { diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c6ec1513128..fe90ce430fd 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -32,7 +32,6 @@ mod restore; pub mod retention; mod runtime; mod runtime_commands; -pub(crate) mod runtime_plan; mod runtime_types; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 2bdc8d344c0..a57676f0a97 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -67,16 +67,6 @@ const NEST_DIR_PROD: &str = ".buzz"; /// `.repos-dir` dotfile and `REPOS` symlink. const NEST_DIR_DEV: &str = ".buzz-dev"; -fn configured_nest_suffix(is_dev: bool) -> String { - if let Some(candidate_id) = option_env!("BUZZ_DESKTOP_BUILD_CANDIDATE_ID") { - format!(".buzz-candidate-{candidate_id}") - } else if is_dev { - NEST_DIR_DEV.to_string() - } else { - NEST_DIR_PROD.to_string() - } -} - /// Process-lifetime nest directory. Initialized once at startup via /// [`init_nest_dir`] before any call to [`nest_dir`]. /// @@ -96,7 +86,7 @@ static NEST_DIR: std::sync::OnceLock> = std::sync::OnceLock::new /// when the Tauri app-data directory name starts with `"xyz.block.buzz.app.dev"`. /// Pass `false` for production (signed DMG) builds. pub fn init_nest_dir(is_dev: bool) { - let suffix = configured_nest_suffix(is_dev); + let suffix = if is_dev { NEST_DIR_DEV } else { NEST_DIR_PROD }; let path = dirs::home_dir().map(|h| h.join(suffix)); // set() is a no-op when already initialized, which is correct: only the // first call (at boot, before any filesystem work) should win. @@ -112,7 +102,7 @@ pub fn nest_dir() -> Option { match NEST_DIR.get() { Some(path) => path.clone(), // Not yet initialized — fall back to prod path. Covers test code. - None => dirs::home_dir().map(|h| h.join(configured_nest_suffix(false))), + None => dirs::home_dir().map(|h| h.join(NEST_DIR_PROD)), } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index d32a90e93d4..c072448ff13 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1284,10 +1284,11 @@ mod tests { crate::managed_agents::clear_resolve_cache(); } - /// Thin-v6 refuses a shell adapter before running its version probe. + /// Codex readiness: outdated adapter (exits non-zero) → AdapterOutdated, + /// login probe skipped. #[cfg(unix)] #[test] - fn cli_login_requirements_codex_shell_adapter_emits_plan_invalid() { + fn cli_login_requirements_codex_outdated_adapter_emits_adapter_outdated() { let _guard = crate::managed_agents::lock_path_mutex(); let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n"); @@ -1310,20 +1311,27 @@ mod tests { assert!( !reqs.is_empty(), - "unsupported codex adapter must produce a requirement; got {reqs:?}" - ); - assert!( - matches!(&reqs[0], Requirement::CliConfigInvalid { diagnostic, .. } - if diagnostic.contains("supported Node runtime")), - "unsupported shell adapter must fail at the plan boundary; got {:?}", - reqs[0] + "outdated codex adapter must produce a requirement; got {reqs:?}" ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, + "0.x codex adapter must yield AdapterOutdated; got {availability:?}" + ); + } else { + panic!("expected CliLogin requirement; got {:?}", reqs[0]); + } } - /// Unsupported launchers are refused before their output can influence readiness. + /// Codex readiness: adapter exits 0 but output is not a parseable version + /// → AdapterOutdated (garbage output treated as outdated, same as non-zero). #[cfg(unix)] #[test] - fn cli_login_requirements_codex_shell_garbage_output_emits_plan_invalid() { + fn cli_login_requirements_codex_garbage_version_output_emits_adapter_outdated() { let _guard = crate::managed_agents::lock_path_mutex(); let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n"); @@ -1345,12 +1353,18 @@ mod tests { !reqs.is_empty(), "garbage version output must produce a requirement; got {reqs:?}" ); - assert!( - matches!(&reqs[0], Requirement::CliConfigInvalid { diagnostic, .. } - if diagnostic.contains("supported Node runtime")), - "unsupported shell adapter must fail at the plan boundary; got {:?}", - reqs[0] - ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, + "unparseable version output must yield AdapterOutdated; got {availability:?}" + ); + } else { + panic!("expected CliLogin requirement; got {:?}", reqs[0]); + } } // ── custom/unknown command ───────────────────────────────────────────── diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs index 6235aa575a9..4036d9f2393 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -2,8 +2,8 @@ use std::path::Path; use crate::managed_agents::{ discovery::{ - classify_runtime, codex_adapter_availability, codex_adapter_availability_with_plan, - find_command, resolve_command, KnownAcpRuntime, + classify_runtime, codex_adapter_availability, find_command, resolve_command, + KnownAcpRuntime, }, AcpAvailabilityStatus, }; @@ -25,68 +25,29 @@ pub(super) fn requirements( .map(|cli| find_command(cli).is_some()) .unwrap_or(false); - let (mut availability, adapter_command, adapter_path) = + let (availability, _cmd, adapter_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - let runtime_plan = if runtime.id == "codex" - && availability == AcpAvailabilityStatus::Available - && !cfg!(windows) + let availability = if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available { - let Some(adapter_command) = adapter_command.as_deref() else { - return vec![invalid_plan_requirement( - setup_copy, - "Codex adapter command disappeared during readiness", - )]; - }; - let plan = match crate::managed_agents::runtime_plan::resolve_runtime_execution_plan( - adapter_command, - ) { - Ok(Some(plan)) => plan, - Ok(None) => { - return vec![invalid_plan_requirement( - setup_copy, - "Codex runtime plan is unavailable on this platform", - )]; - } - Err(error) => return vec![invalid_plan_requirement(setup_copy, &error)], - }; - if let Err(error) = plan.verify() { - return vec![invalid_plan_requirement(setup_copy, &error)]; - } - availability = codex_adapter_availability_with_plan(&plan); - Some(plan) - } else { - None - }; - if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available && cfg!(windows) { - availability = adapter_path + adapter_path .as_deref() .map(|path| codex_adapter_availability(Path::new(path))) - .unwrap_or(AcpAvailabilityStatus::AdapterOutdated); - } + .unwrap_or(availability) + } else { + availability + }; match availability { AcpAvailabilityStatus::Available => { - let binary_path = runtime_plan - .as_ref() - .and_then(|plan| plan.provider_cli_path().map(Path::to_path_buf)) - .or_else(|| resolve_command(probe_args[0])); - let Some(binary_path) = binary_path else { + let Some(binary_path) = resolve_command(probe_args[0]) else { return vec![missing_requirement( probe_args, setup_copy, AcpAvailabilityStatus::Available, )]; }; - let augmented_path = runtime_plan - .as_ref() - .and_then(|plan| plan.generated_environment("PATH").map(str::to_string)) - .or_else(cli_probe::augmented_path); - let probe_outcome = if let Some(plan) = runtime_plan.as_ref() { - cli_probe::login_probe_with_runtime_plan(&binary_path, probe_args, plan) - } else { - cli_probe::login_probe(&binary_path, probe_args, augmented_path.as_deref()) - }; - match probe_outcome { + let augmented_path = cli_probe::augmented_path(); + match cli_probe::login_probe(&binary_path, probe_args, augmented_path.as_deref()) { cli_probe::ProbeOutcome::LoggedIn => vec![], cli_probe::ProbeOutcome::LoggedOut => vec![missing_requirement( probe_args, @@ -106,14 +67,6 @@ pub(super) fn requirements( } } -fn invalid_plan_requirement(setup_copy: &str, diagnostic: &str) -> Requirement { - Requirement::CliConfigInvalid { - probe_args: Vec::new(), - setup_copy: setup_copy.to_string(), - diagnostic: format!("runtime execution plan refused readiness: {diagnostic}"), - } -} - fn missing_requirement( probe_args: &[&str], setup_copy: &str, diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 2beae09ea05..513da4e2a85 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -57,37 +57,12 @@ pub(crate) fn login_probe( binary_path: &Path, probe_args: &[&str], augmented_path: Option<&str>, -) -> ProbeOutcome { - login_probe_inner(binary_path, probe_args, augmented_path, None) -} - -pub(crate) fn login_probe_with_runtime_plan( - binary_path: &Path, - probe_args: &[&str], - runtime_plan: &crate::managed_agents::runtime_plan::RuntimeExecutionPlan, -) -> ProbeOutcome { - login_probe_inner( - binary_path, - probe_args, - runtime_plan.generated_environment("PATH"), - Some(runtime_plan), - ) -} - -fn login_probe_inner( - binary_path: &Path, - probe_args: &[&str], - augmented_path: Option<&str>, - runtime_plan: Option<&crate::managed_agents::runtime_plan::RuntimeExecutionPlan>, ) -> ProbeOutcome { let mut command = std::process::Command::new(binary_path); command.args(&probe_args[1..]); if let Some(path) = augmented_path { command.env("PATH", path); } - if let Some(plan) = runtime_plan { - plan.apply_environment(&mut command); - } crate::util::configure_no_window(&mut command); match command.output() { diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 5c246feedc5..3858212bbba 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -1,9 +1,33 @@ pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1"; pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh"; +/// Stored value for "let the mesh decide", kept as the user-facing word. pub const RELAY_MESH_AUTO_MODEL_ID: &str = "auto"; +/// MeshLLM's virtual model. It resolves per request: a Mixture-of-Agents +/// committee when two or more workers are reachable, and otherwise degrades to +/// a single served model rather than erroring +/// (`moa_gateway::degrade_to_single_model`). That degradation is a pre-flight +/// capacity decision, so a committee that forms and *then* loses a worker still +/// surfaces as a failed turn — MoA repairs partial results internally +/// (`repair_tool_result_answer`) before it gets that far. Buzz translates the +/// stored `auto` here rather than teaching buzz-agent anything about meshes. #[cfg(feature = "mesh-llm")] -pub const RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV: &str = "BUZZ_AGENT_PREFER_MESH_FOR_AUTO"; +pub const RELAY_MESH_VIRTUAL_MODEL_ID: &str = "mesh"; + +/// The wire name for a stored shared-compute model: `auto` (and a blank legacy +/// value) means "let the mesh decide" and becomes MeshLLM's virtual `mesh` +/// model; anything else is a model the user named and is passed through. +/// +/// The single place this mapping happens. Every consumer that has to name a +/// model to the mesh — the LLM transport env and the ACP harness — goes through +/// here, so they cannot disagree. +#[cfg(feature = "mesh-llm")] +pub fn relay_mesh_wire_model(stored: &str) -> &str { + match stored.trim() { + "" | RELAY_MESH_AUTO_MODEL_ID => RELAY_MESH_VIRTUAL_MODEL_ID, + named => named, + } +} /// Translate the native Buzz shared compute provider into the OpenAI-compatible /// transport understood by buzz-agent. These are derived runtime details, not @@ -17,11 +41,7 @@ pub fn apply_relay_mesh_env( if provider.map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { return; } - let model = model - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) - .to_string(); + let model = relay_mesh_wire_model(model.unwrap_or(RELAY_MESH_AUTO_MODEL_ID)).to_string(); env.insert("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()); env.insert("BUZZ_AGENT_MODEL".to_string(), model.clone()); env.insert( @@ -34,14 +54,6 @@ pub fn apply_relay_mesh_env( RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), ); env.insert("OPENAI_COMPAT_API".to_string(), "chat".to_string()); - // Buzz owns the meaning of relay-mesh `auto`: buzz-agent dynamically uses - // mesh-llm's virtual Mixture-of-Agents model whenever the live catalog says - // at least two distinct models are available, and otherwise keeps the - // router's normal single-model `auto` behavior. - env.insert( - RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV.to_string(), - "1".to_string(), - ); // Keep the requested response inside smaller local-model context windows. // These are defaults, not policy: the effective agent/persona/global env // may deliberately choose a smaller cap or a different effort. This function @@ -128,10 +140,76 @@ mod tests { // stops gemma tool-calling; enabling thinking makes Qwen3 burn ~4x the // output budget). assert_eq!(env.get("BUZZ_AGENT_THINKING_EFFORT"), None); + } + + /// Stored `auto` is translated here, so buzz-agent receives a plain model + /// name and needs no knowledge of the mesh. MeshLLM decides per request + /// whether `mesh` becomes a committee or a single served model. + #[test] + fn stored_auto_becomes_the_virtual_mesh_model_on_the_wire() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + assert_eq!( - env.get(RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV) - .map(String::as_str), - Some("1") + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some(RELAY_MESH_VIRTUAL_MODEL_ID) + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some(RELAY_MESH_VIRTUAL_MODEL_ID) + ); + } + + /// A blank stored model is the legacy encoding of the same intent. + #[test] + fn blank_stored_model_becomes_the_virtual_mesh_model() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env(&mut env, Some(RELAY_MESH_PROVIDER_ID), Some(" ")); + + assert_eq!( + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some(RELAY_MESH_VIRTUAL_MODEL_ID) + ); + } + + /// Every consumer that names a model to the mesh goes through one helper, + /// so the LLM transport and the ACP harness cannot be told different things. + #[test] + fn wire_model_maps_auto_and_blank_but_passes_named_through() { + assert_eq!( + relay_mesh_wire_model(RELAY_MESH_AUTO_MODEL_ID), + RELAY_MESH_VIRTUAL_MODEL_ID + ); + assert_eq!(relay_mesh_wire_model(""), RELAY_MESH_VIRTUAL_MODEL_ID); + assert_eq!(relay_mesh_wire_model(" "), RELAY_MESH_VIRTUAL_MODEL_ID); + assert_eq!( + relay_mesh_wire_model("unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"), + "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" + ); + } + + /// A named model is sent verbatim: picking one is an explicit choice to + /// bypass mesh routing, and must not be rewritten. + #[test] + fn a_named_model_is_sent_verbatim() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some("unsloth/Qwen3-8B-GGUF:Q4_K_M"), + ); + + assert_eq!( + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some("unsloth/Qwen3-8B-GGUF:Q4_K_M") + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some("unsloth/Qwen3-8B-GGUF:Q4_K_M") ); } diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index 97fe562cdf2..afaaa2b4eb3 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -39,14 +39,8 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // Code-execution surface: overriding would let the user run arbitrary // binaries/args as the agent process. "BUZZ_ACP_AGENT_COMMAND", - // Logical builtin identity is projected only by an immutable runtime plan. - "BUZZ_ACP_AGENT_IDENTITY", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", - // Provider executable selection belongs exclusively to an immutable - // RuntimeExecutionPlan. Saved or baked values must never redirect it. - "CLAUDE_CODE_EXECUTABLE", - "CODEX_PATH", // Control-plane parallelism: the Desktop resolves the effective // worker-pool size (applying any per-harness cap) and writes it into // launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the @@ -65,6 +59,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // Remote lifetime/presence policy: user env must not disable the // desktop/provider-owned bounds while the saved record still promises them. "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + // Desktop-owned pool lifetime policy: user env must not disable or reset + // the idle worker-reclamation window while the desktop launcher sets it. + "BUZZ_ACP_IDLE_POOL_SLEEP", "BUZZ_ACP_NO_PRESENCE", // Readiness handoff: desktop is the ONLY readiness source. A saved or // ambient env var must not be able to forge setup mode (NotReady) on a diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 88cb7445ee0..b1c342e9955 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use tauri::AppHandle; -use super::agent_env::build_buzz_agent_provider_defaults; +use super::agent_env::{build_buzz_agent_provider_defaults, idle_pool_sleep_env}; use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, - runtime_plan::resolve_runtime_execution_plan, spawn_key_refusal, KnownAcpRuntime, - ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, + spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, + ManagedAgentRuntimeKey, ManagedAgentSummary, }, util::now_iso, }; @@ -458,9 +458,6 @@ pub fn spawn_agent_child( let effective_command = &descriptor.command; let agent_args = &descriptor.args; - let runtime_plan = resolve_runtime_execution_plan(effective_command) - .map_err(|error| format!("agent {} runtime plan: {error}", record.pubkey))?; - let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( &log_path, @@ -494,13 +491,10 @@ pub fn spawn_agent_child( } } }; - // Custom harnesses remain on the legacy resolver until they gain a trust flow. - let resolved_agent_command = match runtime_plan.as_ref() { - Some(plan) => plan.harness_path()?.display().to_string(), - None => resolve_command(effective_command) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| effective_command.clone()), - }; + // Resolve agent command to a full path (DMG launches have minimal PATH). + let resolved_agent_command = resolve_command(effective_command) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| effective_command.clone()); // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. @@ -537,8 +531,8 @@ pub fn spawn_agent_child( command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); + command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy)); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); - command.env_remove(super::runtime_plan::AGENT_IDENTITY_ENV); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { Some(mcp_cmd) => { @@ -720,7 +714,19 @@ pub fn spawn_agent_child( } else { command.env_remove("BUZZ_ACP_SYSTEM_PROMPT"); } - if let Some(model) = effective_model.as_deref() { + // Shared compute stores `auto`, but the wire name is MeshLLM's virtual + // `mesh` model. Translate here too, so the harness and the LLM client are + // told the same thing: `BUZZ_ACP_MODEL=auto` would name a model the mesh + // never advertises, leaving buzz-acp to warn and fall back on every new + // session while `BUZZ_AGENT_MODEL` said `mesh`. + #[cfg(feature = "mesh-llm")] + let acp_model = match (&mesh_model_id, effective_model.as_deref()) { + (Some(mesh_model_id), _) => Some(super::relay_mesh_wire_model(mesh_model_id).to_string()), + (None, model) => model.map(str::to_owned), + }; + #[cfg(not(feature = "mesh-llm"))] + let acp_model = effective_model.as_deref().map(str::to_owned); + if let Some(model) = acp_model.as_deref() { command.env("BUZZ_ACP_MODEL", model); } else { command.env_remove("BUZZ_ACP_MODEL"); @@ -816,11 +822,7 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } - if let Some(plan) = runtime_plan.as_ref() { - plan.apply_environment(&mut command); - } else { - configure_runtime_cli(&mut command, runtime_meta); - } + configure_runtime_cli(&mut command, runtime_meta); // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible // transport at spawn time and scrub any unrelated ambient OpenAI key. @@ -875,11 +877,6 @@ pub fn spawn_agent_child( command.creation_flags(CREATE_NO_WINDOW); } - // Make the final filesystem operation before exec a complete plan - // revalidation. Any detected drift blocks the spawn without rediscovery. - if let Some(plan) = runtime_plan.as_ref() { - plan.verify()?; - } let child = command.spawn().map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", diff --git a/desktop/src-tauri/src/managed_agents/runtime_plan.rs b/desktop/src-tauri/src/managed_agents/runtime_plan.rs deleted file mode 100644 index c8147cdfb4f..00000000000 --- a/desktop/src-tauri/src/managed_agents/runtime_plan.rs +++ /dev/null @@ -1,801 +0,0 @@ -//! Immutable execution plans for the first managed-agent runtime family. -//! -//! This is the first thin slice of ADR 0001. It resolves the existing runtime -//! catalog into content-identified absolute component paths, denies ambient -//! executable-selection overrides, and revalidates every component before a -//! child process is spawned. Managed packages, snapshots, persistence, signing, -//! update activation, and rollback are deliberately deferred. - -use serde::Serialize; -use sha2::{Digest, Sha256}; -use std::{ - collections::BTreeMap, - fs::File, - io::{BufRead, BufReader, Read}, - path::{Path, PathBuf}, - process::Command, -}; - -use super::{known_acp_runtime, resolve_command}; - -/// Logical runtime identity trusted only when projected by a verified plan. -pub(crate) const AGENT_IDENTITY_ENV: &str = "BUZZ_ACP_AGENT_IDENTITY"; - -/// Environment variables that may redirect a known provider executable. -pub(crate) const DENIED_EXECUTABLE_ENV: &[&str] = &[ - "CLAUDE_CODE_EXECUTABLE", - "CODEX_PATH", - "DYLD_FALLBACK_FRAMEWORK_PATH", - "DYLD_FALLBACK_LIBRARY_PATH", - "DYLD_FORCE_FLAT_NAMESPACE", - "DYLD_FRAMEWORK_PATH", - "DYLD_IMAGE_SUFFIX", - "DYLD_INSERT_LIBRARIES", - "DYLD_LIBRARY_PATH", - "DYLD_ROOT_PATH", - "DYLD_VERSIONED_FRAMEWORK_PATH", - "DYLD_VERSIONED_LIBRARY_PATH", - "LD_AUDIT", - "LD_DEBUG", - "LD_DEBUG_OUTPUT", - "LD_LIBRARY_PATH", - "LD_PRELOAD", - "LD_PROFILE", - "NODE_OPTIONS", - "NODE_PATH", - "PATH", -]; - -/// Runtime bytes are shipped with Buzz, held in Buzz's managed prefix, or -/// explicitly reused in place. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum RuntimePlanSource { - Bundled, - Managed, - VerifiedExternal, -} - -/// Role played by one content-identified runtime component. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum RuntimeComponentRole { - Harness, - ProviderCli, - Interpreter, - RuntimeDependency, -} - -/// One immutable component in a runtime execution plan. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RuntimePlanComponent { - pub role: RuntimeComponentRole, - pub source: RuntimePlanSource, - pub path: PathBuf, - pub sha256: String, - pub bytes: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RuntimePackageInventory { - pub root: PathBuf, - pub tree_sha256: String, - pub files: usize, -} - -/// The sole executable identity consumed by a known runtime operation. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RuntimeExecutionPlan { - pub id: String, - pub provider_family: String, - pub platform: String, - pub architecture: String, - pub source: RuntimePlanSource, - pub components: Vec, - pub packages: Vec, - pub generated_env: BTreeMap, - pub denied_env: Vec, -} - -impl RuntimeExecutionPlan { - /// Stable wire label for the plan's selected source. - pub(crate) fn source_label(&self) -> &'static str { - match self.source { - RuntimePlanSource::Bundled => "bundled", - RuntimePlanSource::Managed => "managed", - RuntimePlanSource::VerifiedExternal => "verified_external", - } - } - - /// Return the planned harness/adapter executable. - pub(crate) fn harness_path(&self) -> Result<&Path, String> { - self.components - .iter() - .find(|component| component.role == RuntimeComponentRole::Harness) - .map(|component| component.path.as_path()) - .ok_or_else(|| format!("runtime plan {} has no harness component", self.id)) - } - - /// Return the planned provider CLI, when the family has a separate one. - pub(crate) fn provider_cli_path(&self) -> Option<&Path> { - self.components - .iter() - .find(|component| component.role == RuntimeComponentRole::ProviderCli) - .map(|component| component.path.as_path()) - } - - pub(crate) fn generated_environment(&self, key: &str) -> Option<&str> { - self.generated_env.get(key).map(String::as_str) - } - - pub(crate) fn generated_environment_entries(&self) -> impl Iterator { - self.generated_env - .iter() - .map(|(key, value)| (key.as_str(), value.as_str())) - } - - pub(crate) fn denied_environment(&self) -> impl Iterator { - self.denied_env.iter().map(String::as_str) - } - - /// Re-hash every component immediately before execution and refuse drift. - pub(crate) fn verify(&self) -> Result<(), String> { - for component in &self.components { - let current = component_identity(component.role, component.source, &component.path)?; - if current.sha256 != component.sha256 || current.bytes != component.bytes { - return Err(format!( - "runtime plan {} drifted: {} no longer matches approved SHA-256 {}", - self.id, - component.path.display(), - component.sha256 - )); - } - } - for package in &self.packages { - let current = package_inventory(&package.root)?; - if current.tree_sha256 != package.tree_sha256 || current.files != package.files { - return Err(format!( - "runtime plan {} drifted: package tree {} no longer matches SHA-256 {}", - self.id, - package.root.display(), - package.tree_sha256 - )); - } - } - Ok(()) - } - - /// Remove every executable override and then project plan-owned values. - pub(crate) fn apply_environment(&self, command: &mut Command) { - for key in &self.denied_env { - command.env_remove(key); - } - for (key, value) in &self.generated_env { - command.env(key, value); - } - command.env(AGENT_IDENTITY_ENV, &self.provider_family); - } -} - -/// Resolve Codex into an immutable plan. Other known runtimes, plus custom and -/// preset harnesses, remain on the legacy path until they gain complete plans. -pub(crate) fn resolve_runtime_execution_plan( - effective_command: &str, -) -> Result, String> { - let Some(runtime) = known_acp_runtime(effective_command) else { - return Ok(None); - }; - // The first candidate proves the plan boundary for one external family. - // Other built-ins stay on their existing path until their complete runtime - // dependency closure can be represented without pretending it is verified. - if runtime.id != "codex" || cfg!(windows) { - return Ok(None); - } - - let harness_path = resolve_command(effective_command).ok_or_else(|| { - format!( - "cannot resolve {} runtime harness `{effective_command}`", - runtime.label - ) - })?; - let source = component_source(&harness_path, runtime.id == "buzz-agent"); - let mut generated_env = BTreeMap::new(); - let mut packages = Vec::new(); - let harness = component_identity(RuntimeComponentRole::Harness, source, &harness_path)?; - let mut components = vec![harness]; - append_node_runtime_closure( - &harness_path, - source, - &mut components, - &mut packages, - &mut generated_env, - )?; - - if let Some(provider_command) = runtime.underlying_cli { - let provider_path = resolve_command(provider_command).ok_or_else(|| { - format!( - "cannot resolve {} provider CLI `{provider_command}`", - runtime.label - ) - })?; - let provider = component_identity( - RuntimeComponentRole::ProviderCli, - RuntimePlanSource::VerifiedExternal, - &provider_path, - )?; - if provider.path != components[0].path { - components.push(provider.clone()); - append_provider_runtime_closure( - &provider.path, - RuntimePlanSource::VerifiedExternal, - &mut components, - &mut packages, - &mut generated_env, - )?; - match runtime.id { - "claude" => { - generated_env.insert( - "CLAUDE_CODE_EXECUTABLE".to_string(), - provider.path.display().to_string(), - ); - } - "codex" => { - generated_env.insert( - "CODEX_PATH".to_string(), - provider.path.display().to_string(), - ); - } - _ => {} - } - } - } - - let id = plan_identity(runtime.id, source, &components, &packages, &generated_env); - Ok(Some(RuntimeExecutionPlan { - id, - provider_family: runtime.id.to_string(), - platform: std::env::consts::OS.to_string(), - architecture: std::env::consts::ARCH.to_string(), - source, - components, - packages, - generated_env, - denied_env: DENIED_EXECUTABLE_ENV - .iter() - .map(|key| (*key).to_string()) - .collect(), - })) -} - -fn append_provider_runtime_closure( - provider: &Path, - source: RuntimePlanSource, - components: &mut Vec, - packages: &mut Vec, - generated_env: &mut BTreeMap, -) -> Result<(), String> { - let mut file = File::open(provider) - .map_err(|error| format!("failed to inspect {}: {error}", provider.display()))?; - let mut prefix = [0_u8; 4]; - file.read_exact(&mut prefix) - .map_err(|error| format!("failed to inspect {}: {error}", provider.display()))?; - if prefix.starts_with(b"#!") { - return append_node_runtime_closure(provider, source, components, packages, generated_env); - } - const NATIVE_MAGICS: [[u8; 4]; 9] = [ - *b"\x7fELF", - [0xfe, 0xed, 0xfa, 0xce], - [0xfe, 0xed, 0xfa, 0xcf], - [0xce, 0xfa, 0xed, 0xfe], - [0xcf, 0xfa, 0xed, 0xfe], - [0xca, 0xfe, 0xba, 0xbe], - [0xbe, 0xba, 0xfe, 0xca], - [0xca, 0xfe, 0xba, 0xbf], - [0xbf, 0xba, 0xfe, 0xca], - ]; - if NATIVE_MAGICS.contains(&prefix) { - // The provider executable itself is already a plan component. Dynamic - // loader injection variables are removed by apply_environment(); OS - // system libraries remain part of the platform trust boundary. - return Ok(()); - } - Err(format!( - "Codex provider {} is neither a Node package launcher nor a supported native executable", - provider.display() - )) -} - -fn append_node_runtime_closure( - launcher: &Path, - source: RuntimePlanSource, - components: &mut Vec, - packages: &mut Vec, - generated_env: &mut BTreeMap, -) -> Result<(), String> { - let file = File::open(launcher).map_err(|error| { - format!( - "failed to inspect Codex adapter launcher {}: {error}", - launcher.display() - ) - })?; - let mut first_line = String::new(); - BufReader::new(file) - .read_line(&mut first_line) - .map_err(|error| format!("failed to read Codex adapter shebang: {error}"))?; - let shebang = first_line - .strip_prefix("#!") - .ok_or_else(|| { - "Codex adapter is not a shebang launcher; refusing an incomplete plan".to_string() - })? - .trim(); - let words: Vec<&str> = shebang.split_whitespace().collect(); - let (interpreter_launcher, interpreter_name) = match words.as_slice() { - [env, name, ..] if *env == "/usr/bin/env" => (Some(PathBuf::from(env)), *name), - [interpreter, ..] => (None, *interpreter), - [] => return Err("Codex adapter has an empty shebang".to_string()), - }; - if Path::new(interpreter_name) - .file_name() - .and_then(|name| name.to_str()) - != Some("node") - { - return Err(format!( - "Codex adapter interpreter `{interpreter_name}` is not the supported Node runtime" - )); - } - - if let Some(env_path) = interpreter_launcher { - push_unique_component( - components, - component_identity( - RuntimeComponentRole::Interpreter, - RuntimePlanSource::VerifiedExternal, - &env_path, - )?, - ); - } - let node_path = resolve_command(interpreter_name) - .ok_or_else(|| "cannot resolve the Node interpreter for Codex adapter".to_string())?; - push_unique_component( - components, - component_identity( - RuntimeComponentRole::Interpreter, - component_source(&node_path, false), - &node_path, - )?, - ); - generated_env.insert("PATH".to_string(), planned_path(&node_path)?); - - let canonical_launcher = launcher.canonicalize().map_err(|error| { - format!( - "failed to canonicalize Codex adapter launcher {}: {error}", - launcher.display() - ) - })?; - let package_root = canonical_launcher - .parent() - .into_iter() - .flat_map(Path::ancestors) - .take(10) - .find(|directory| directory.join("package.json").is_file()) - .ok_or_else(|| { - format!( - "cannot identify the npm package containing Codex adapter {}", - canonical_launcher.display() - ) - })?; - let mut package_files = Vec::new(); - collect_package_files(package_root, &mut package_files, 20_000)?; - package_files.sort(); - for path in &package_files { - push_unique_component( - components, - component_identity(RuntimeComponentRole::RuntimeDependency, source, path)?, - ); - } - let inventory = package_inventory_from_files(package_root, &package_files)?; - if !packages - .iter() - .any(|package| package.root == inventory.root) - { - packages.push(inventory); - } - Ok(()) -} - -fn collect_package_files( - directory: &Path, - files: &mut Vec, - limit: usize, -) -> Result<(), String> { - // npm's `.bin` directory is only a set of alternate launcher symlinks; - // the selected canonical launcher and package payload are inventoried - // separately, so following or accepting those mutable aliases is unsafe. - if directory.file_name().and_then(|name| name.to_str()) == Some(".bin") { - return Ok(()); - } - let entries = std::fs::read_dir(directory).map_err(|error| { - format!( - "failed to read runtime package {}: {error}", - directory.display() - ) - })?; - for entry in entries { - let entry = entry.map_err(|error| { - format!( - "failed to enumerate runtime package {}: {error}", - directory.display() - ) - })?; - let file_type = entry - .file_type() - .map_err(|error| format!("failed to inspect {}: {error}", entry.path().display()))?; - if file_type.is_symlink() { - return Err(format!( - "runtime package contains unsupported symlink: {}", - entry.path().display() - )); - } else if file_type.is_dir() { - collect_package_files(&entry.path(), files, limit)?; - } else if file_type.is_file() { - files.push(entry.path()); - } - if files.len() > limit { - return Err(format!( - "runtime package exceeds the {limit}-file verification limit" - )); - } - } - Ok(()) -} - -fn package_inventory(root: &Path) -> Result { - let mut files = Vec::new(); - collect_package_files(root, &mut files, 20_000)?; - files.sort(); - package_inventory_from_files(root, &files) -} - -fn package_inventory_from_files( - root: &Path, - files: &[PathBuf], -) -> Result { - let canonical_root = root.canonicalize().map_err(|error| { - format!( - "failed to canonicalize runtime package {}: {error}", - root.display() - ) - })?; - let mut hasher = Sha256::new(); - for path in files { - let canonical = path.canonicalize().map_err(|error| { - format!( - "failed to canonicalize runtime package file {}: {error}", - path.display() - ) - })?; - let relative = canonical.strip_prefix(&canonical_root).map_err(|_| { - format!( - "runtime package file escaped its root: {}", - canonical.display() - ) - })?; - let identity = component_identity( - RuntimeComponentRole::RuntimeDependency, - RuntimePlanSource::VerifiedExternal, - &canonical, - )?; - let relative = relative.to_string_lossy(); - hasher.update(relative.len().to_le_bytes()); - hasher.update(relative.as_bytes()); - hasher.update(identity.bytes.to_le_bytes()); - hasher.update(identity.sha256.as_bytes()); - } - Ok(RuntimePackageInventory { - root: canonical_root, - tree_sha256: hex::encode(hasher.finalize()), - files: files.len(), - }) -} - -fn planned_path(node_path: &Path) -> Result { - let node_dir = node_path.parent().ok_or_else(|| { - format!( - "Node runtime has no parent directory: {}", - node_path.display() - ) - })?; - let mut paths = vec![node_dir.to_path_buf()]; - for system_path in ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] { - let path = PathBuf::from(system_path); - if path.is_dir() && !paths.contains(&path) { - paths.push(path); - } - } - std::env::join_paths(paths) - .map(|path| path.to_string_lossy().into_owned()) - .map_err(|error| format!("failed to construct plan-owned PATH: {error}")) -} - -fn push_unique_component( - components: &mut Vec, - component: RuntimePlanComponent, -) { - if !components - .iter() - .any(|existing| existing.path == component.path) - { - components.push(component); - } -} - -fn component_source(path: &Path, bundled: bool) -> RuntimePlanSource { - if bundled { - return RuntimePlanSource::Bundled; - } - let managed_prefix = - super::buzz_managed_npm_prefix().and_then(|prefix| prefix.canonicalize().ok()); - let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - if managed_prefix.is_some_and(|prefix| canonical.starts_with(prefix)) { - RuntimePlanSource::Managed - } else { - RuntimePlanSource::VerifiedExternal - } -} - -fn component_identity( - role: RuntimeComponentRole, - source: RuntimePlanSource, - path: &Path, -) -> Result { - let canonical = path.canonicalize().map_err(|error| { - format!( - "failed to canonicalize runtime component {}: {error}", - path.display() - ) - })?; - let metadata = canonical.metadata().map_err(|error| { - format!( - "failed to inspect runtime component {}: {error}", - canonical.display() - ) - })?; - if !metadata.is_file() { - return Err(format!( - "runtime component is not a file: {}", - canonical.display() - )); - } - let sha256 = sha256_file(&canonical)?; - Ok(RuntimePlanComponent { - role, - source, - path: canonical, - sha256, - bytes: metadata.len(), - }) -} - -fn sha256_file(path: &Path) -> Result { - let mut file = File::open(path).map_err(|error| { - format!( - "failed to open runtime component {}: {error}", - path.display() - ) - })?; - let mut digest = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = file.read(&mut buffer).map_err(|error| { - format!( - "failed to hash runtime component {}: {error}", - path.display() - ) - })?; - if read == 0 { - break; - } - digest.update(&buffer[..read]); - } - Ok(hex::encode(digest.finalize())) -} - -fn plan_identity( - provider_family: &str, - source: RuntimePlanSource, - components: &[RuntimePlanComponent], - packages: &[RuntimePackageInventory], - generated_env: &BTreeMap, -) -> String { - let mut digest = Sha256::new(); - digest.update(b"buzz-runtime-plan-v2\0"); - digest.update(provider_family.as_bytes()); - digest.update([0]); - digest.update(format!("{source:?}").as_bytes()); - digest.update([0]); - digest.update(std::env::consts::OS.as_bytes()); - digest.update([0]); - digest.update(std::env::consts::ARCH.as_bytes()); - for component in components { - digest.update([0]); - digest.update(format!("{:?}", component.role).as_bytes()); - digest.update([0]); - digest.update(component.path.to_string_lossy().as_bytes()); - digest.update([0]); - digest.update(component.sha256.as_bytes()); - digest.update(component.bytes.to_le_bytes()); - } - for package in packages { - digest.update([0]); - digest.update(package.root.to_string_lossy().as_bytes()); - digest.update([0]); - digest.update(package.tree_sha256.as_bytes()); - digest.update(package.files.to_le_bytes()); - } - for (key, value) in generated_env { - digest.update([0]); - digest.update(key.as_bytes()); - digest.update([0]); - digest.update(value.as_bytes()); - } - hex::encode(digest.finalize()) -} - -#[cfg(test)] -mod tests { - use super::{ - collect_package_files, component_identity, package_inventory, plan_identity, - resolve_runtime_execution_plan, RuntimeComponentRole, RuntimeExecutionPlan, - RuntimePlanSource, AGENT_IDENTITY_ENV, DENIED_EXECUTABLE_ENV, - }; - use std::{collections::BTreeMap, fs}; - - #[test] - fn plan_identity_changes_with_component_bytes() { - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("adapter"); - fs::write(&path, b"first").expect("write component"); - let first = component_identity( - RuntimeComponentRole::Harness, - RuntimePlanSource::VerifiedExternal, - &path, - ) - .expect("first identity"); - fs::write(&path, b"second").expect("replace component"); - let second = component_identity( - RuntimeComponentRole::Harness, - RuntimePlanSource::VerifiedExternal, - &path, - ) - .expect("second identity"); - assert_ne!( - plan_identity( - "codex", - RuntimePlanSource::VerifiedExternal, - &[first], - &[], - &BTreeMap::new() - ), - plan_identity( - "codex", - RuntimePlanSource::VerifiedExternal, - &[second], - &[], - &BTreeMap::new() - ) - ); - } - - #[test] - fn verification_fails_closed_after_drift() { - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("adapter"); - fs::write(&path, b"approved").expect("write component"); - let component = component_identity( - RuntimeComponentRole::Harness, - RuntimePlanSource::VerifiedExternal, - &path, - ) - .expect("component identity"); - let plan = RuntimeExecutionPlan { - id: "test-plan".to_string(), - provider_family: "codex".to_string(), - platform: std::env::consts::OS.to_string(), - architecture: std::env::consts::ARCH.to_string(), - source: RuntimePlanSource::VerifiedExternal, - components: vec![component], - packages: vec![], - generated_env: BTreeMap::new(), - denied_env: DENIED_EXECUTABLE_ENV - .iter() - .map(|key| (*key).to_string()) - .collect(), - }; - plan.verify().expect("approved bytes verify"); - fs::write(&path, b"drifted").expect("replace component"); - assert!(plan.verify().is_err()); - } - - #[cfg(unix)] - #[test] - fn resolved_codex_plan_projects_catalog_identity_with_canonical_executable() { - use std::os::unix::fs::{symlink, PermissionsExt}; - - let dir = tempfile::tempdir().expect("temp dir"); - let package = dir.path().join("codex-acp"); - let dist = package.join("dist"); - let bin = dir.path().join("bin"); - fs::create_dir_all(&dist).expect("create package dist directory"); - fs::create_dir_all(&bin).expect("create launcher directory"); - fs::write(package.join("package.json"), b"{}").expect("write package manifest"); - let executable = dist.join("index.js"); - fs::write(&executable, b"#!/usr/bin/env node\n").expect("write generic executable"); - let mut permissions = fs::metadata(&executable) - .expect("stat generic executable") - .permissions(); - permissions.set_mode(0o700); - fs::set_permissions(&executable, permissions).expect("chmod generic executable"); - let launcher = bin.join("codex-acp"); - symlink(&executable, &launcher).expect("link logical Codex launcher"); - - let plan = resolve_runtime_execution_plan(launcher.to_str().expect("UTF-8 launcher")) - .expect("resolve Codex runtime plan") - .expect("Codex has a verified runtime plan"); - let canonical_executable = executable.canonicalize().expect("canonical executable"); - assert_eq!(plan.provider_family, "codex"); - assert_eq!( - plan.harness_path().expect("planned harness"), - canonical_executable - ); - - let mut command = std::process::Command::new("buzz-acp"); - command - .env("BUZZ_ACP_AGENT_COMMAND", &canonical_executable) - .env(AGENT_IDENTITY_ENV, "forged-runtime"); - plan.apply_environment(&mut command); - - assert!(command.get_envs().any(|(key, value)| { - key == AGENT_IDENTITY_ENV && value == Some(std::ffi::OsStr::new("codex")) - })); - assert!(command.get_envs().any(|(key, value)| { - key == "BUZZ_ACP_AGENT_COMMAND" && value == Some(canonical_executable.as_os_str()) - })); - } - - #[test] - fn package_inventory_includes_payload_and_skips_launcher_aliases() { - let dir = tempfile::tempdir().expect("temp dir"); - let nested = dir.path().join("dist"); - let aliases = dir.path().join("node_modules/.bin"); - fs::create_dir_all(&nested).expect("create payload directory"); - fs::create_dir_all(&aliases).expect("create alias directory"); - let payload = nested.join("index.js"); - let alias = aliases.join("codex-acp"); - fs::write(&payload, b"export {};").expect("write payload"); - fs::write(&alias, b"ignored launcher alias").expect("write alias"); - - let mut files = Vec::new(); - collect_package_files(dir.path(), &mut files, 20).expect("collect package"); - assert!(files.contains(&payload)); - assert!(!files.contains(&alias)); - - let inventory = package_inventory(dir.path()).expect("inventory package"); - let plan = RuntimeExecutionPlan { - id: "package-plan".to_string(), - provider_family: "codex".to_string(), - platform: std::env::consts::OS.to_string(), - architecture: std::env::consts::ARCH.to_string(), - source: RuntimePlanSource::VerifiedExternal, - components: vec![], - packages: vec![inventory], - generated_env: BTreeMap::new(), - denied_env: vec![], - }; - plan.verify().expect("package tree verifies"); - fs::write(dir.path().join("added.js"), b"unexpected").expect("add package file"); - assert!(plan.verify().is_err()); - } -} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 8084be0cc77..e5be105fed0 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -625,8 +625,53 @@ pub enum HarnessSource { /// Loaded at runtime from the user's `custom_harnesses/` directory. Custom, } -mod runtime_catalog; -pub use runtime_catalog::AcpRuntimeCatalogEntry; + +#[derive(Debug, Clone, Serialize)] +pub struct AcpRuntimeCatalogEntry { + pub id: String, + pub label: String, + pub avatar_url: String, + pub availability: AcpAvailabilityStatus, + pub command: Option, + pub binary_path: Option, + pub default_args: Vec, + pub mcp_command: Option, + /// Environment variable used to apply the initial model, when supported. + pub model_env_var: Option, + /// Environment variable used to apply the selected LLM provider, when supported. + pub provider_env_var: Option, + /// Environment variable used to apply thinking effort, when supported. + pub thinking_env_var: Option, + pub max_tokens_env_var: Option, + pub context_limit_env_var: Option, + pub max_rounds_env_var: Option, + pub install_hint: String, + pub install_instructions_url: String, + /// true when at least one automated install step is available + pub can_auto_install: bool, + /// true when this runtime depends on a separately installed vendor CLI. + pub requires_external_cli: bool, + pub underlying_cli_path: Option, + /// true when an npm adapter step is pending but Node.js / npm is absent. + /// The UI hides the Install button and shows a Node.js install callout. + pub node_required: bool, + /// Login/authentication status for CLI-based runtimes. + pub auth_status: AuthStatus, + /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. + #[serde(skip_serializing_if = "Option::is_none")] + pub login_hint: Option, + /// Whether this entry came from the compiled-in catalog or a user-supplied + /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. + pub source: HarnessSource, + /// Definition-level env vars for `source: custom` entries; populated from + /// `HarnessDefinition.env` so saves don't silently erase existing vars. + /// Absent for builtin/preset entries. Skipped when empty in serialization. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub definition_env: BTreeMap, + /// Spawn-time parallelism cap; absent for uncapped harnesses. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_parallelism: Option, +} /// Result of a single install step (CLI or adapter). #[derive(Debug, Clone, Serialize)] diff --git a/desktop/src-tauri/src/managed_agents/types/runtime_catalog.rs b/desktop/src-tauri/src/managed_agents/types/runtime_catalog.rs deleted file mode 100644 index 33ccb0c7b5b..00000000000 --- a/desktop/src-tauri/src/managed_agents/types/runtime_catalog.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::collections::BTreeMap; - -use serde::Serialize; - -use super::{AcpAvailabilityStatus, AuthStatus, HarnessSource}; - -#[derive(Debug, Clone, Serialize)] -pub struct AcpRuntimeCatalogEntry { - pub id: String, - pub label: String, - pub avatar_url: String, - pub availability: AcpAvailabilityStatus, - pub command: Option, - pub binary_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub runtime_plan_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub runtime_plan_source: Option, - pub default_args: Vec, - pub mcp_command: Option, - /// Environment variable used to apply the initial model, when supported. - pub model_env_var: Option, - /// Environment variable used to apply the selected LLM provider, when supported. - pub provider_env_var: Option, - /// Environment variable used to apply thinking effort, when supported. - pub thinking_env_var: Option, - pub max_tokens_env_var: Option, - pub context_limit_env_var: Option, - pub max_rounds_env_var: Option, - pub install_hint: String, - pub install_instructions_url: String, - /// true when at least one automated install step is available - pub can_auto_install: bool, - /// true when this runtime depends on a separately installed vendor CLI. - pub requires_external_cli: bool, - pub underlying_cli_path: Option, - /// true when an npm adapter step is pending but Node.js / npm is absent. - /// The UI hides the Install button and shows a Node.js install callout. - pub node_required: bool, - /// Login/authentication status for CLI-based runtimes. - pub auth_status: AuthStatus, - /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. - #[serde(skip_serializing_if = "Option::is_none")] - pub login_hint: Option, - /// Whether this entry came from the compiled-in catalog or a user-supplied - /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. - pub source: HarnessSource, - /// Definition-level env vars for `source: custom` entries; populated from - /// `HarnessDefinition.env` so saves don't silently erase existing vars. - /// Absent for builtin/preset entries. Skipped when empty in serialization. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub definition_env: BTreeMap, - /// Spawn-time parallelism cap; absent for uncapped harnesses. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_parallelism: Option, -} diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 3f04d3d7a1e..768b2ad7db3 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -358,6 +358,21 @@ fn default_true() -> bool { true } +/// Response payload for `get_channels`. When the caller supplies a hash that +/// matches the computed stable hash, `channels` is `None` so the multi-MB +/// channel list is not serialized across IPC. `last_messages` is always +/// included — it is cheap and changes frequently (every new message). +#[derive(Serialize)] +pub struct GetChannelsPayload { + pub hash: String, + /// `None` on a not-modified response (hash matched); `Some` with the full + /// sorted list otherwise. + pub channels: Option>, + /// Map of channel id → ISO-8601 timestamp of its most recent message. + /// Empty for channels with no messages. + pub last_messages: std::collections::HashMap, +} + // ── Social / Contact list ─────────────────────────────────────────────────── #[derive(Serialize, Deserialize)] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 3f332ddbf1a..2986edadaf2 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.8", + "version": "0.5.11", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { @@ -21,7 +21,7 @@ "height": 600, "maximized": true, "visible": false, - "transparent": true, + "transparent": false, "titleBarStyle": "Overlay", "hiddenTitle": true, "dragDropEnabled": false, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e4233a8fae6..db10c12dc95 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -15,6 +15,7 @@ import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions"; import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog"; import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; +import { useAppShellKeyboardShortcuts } from "@/app/useAppShellKeyboardShortcuts"; import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications"; import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; import { useChannelActivityProjection } from "@/app/useChannelActivityProjection"; @@ -89,7 +90,6 @@ import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBou import { joinChannel } from "@/shared/api/tauri"; import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; -import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; @@ -127,6 +127,8 @@ export function AppShell() { null, ); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); + const [scopeSearchFocusRequest, setScopeSearchFocusRequest] = + React.useState(0); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false); const mainInsetRef = React.useRef(null); @@ -494,6 +496,10 @@ export function AppShell() { setSearchFocusRequest((request) => request + 1); void refetchChannels(); }, [refetchChannels]); + const handleOpenChannelSearch = React.useCallback(() => { + setScopeSearchFocusRequest((request) => request + 1); + void refetchChannels(); + }, [refetchChannels]); const handleBrowseChannelJoin = React.useCallback( async (channelId: string) => { @@ -640,70 +646,17 @@ export function AppShell() { () => setIsCreateChannelOpen(true), [], ); - React.useLayoutEffect(() => { - if (settingsOpen || isHuddleRoom) { - return; - } - - function handleKeyDown(event: KeyboardEvent) { - if (!hasPrimaryShortcutModifier(event) || event.altKey || event.repeat) { - return; - } - - // A focused surface may claim the shortcut first — e.g. the composer - // consumes ⌘K to open the link editor when text is selected. Its - // element-level handler runs before this window-level bubble listener - // and calls `preventDefault()`; respect that instead of also opening - // the global dialog. - if (event.defaultPrevented) { - return; - } - - const key = event.key.toLowerCase(); - if (key === "k" && !event.shiftKey) { - event.preventDefault(); - handleOpenSearch(); - return; - } - - if (key === "k" && event.shiftKey) { - event.preventDefault(); - void goNewMessage(); - return; - } - - if (key === "n" && event.shiftKey) { - event.preventDefault(); - handleOpenCreateChannel(); - return; - } - - if (key === "o" && event.shiftKey) { - event.preventDefault(); - handleOpenBrowseChannels(); - return; - } - - if (key === "a" && event.shiftKey) { - event.preventDefault(); - void goHome(); - return; - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [ - handleOpenBrowseChannels, - handleOpenCreateChannel, - handleOpenSearch, - goNewMessage, - goHome, - isHuddleRoom, - settingsOpen, - ]); + useAppShellKeyboardShortcuts({ + canSearchCurrentChannel: + selectedView === "channel" && Boolean(activeChannel), + disabled: settingsOpen || isHuddleRoom, + onBrowseChannels: handleOpenBrowseChannels, + onCreateChannel: handleOpenCreateChannel, + onGoHome: goHome, + onNewMessage: goNewMessage, + onSearchCurrentChannel: handleOpenChannelSearch, + onSearchEverything: handleOpenSearch, + }); useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, @@ -897,7 +850,10 @@ export function AppShell() { onSelectChannel={handleSidebarChannelSelect} onOpenSearchResult={handleOpenSearchResult} searchChannels={channels} - searchFocusRequest={searchFocusRequest} + searchFocusRequests={[ + searchFocusRequest, + scopeSearchFocusRequest, + ]} onSelectHome={() => void goHome()} onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} @@ -983,6 +939,7 @@ export function AppShell() { onSelectChannel={(channelId) => { void goChannel(channelId); }} + relayUrl={communitiesHook.activeCommunity?.relayUrl} /> { return { default: module.ChannelManagementSheet }; }); +const MembersSidebar = React.lazy(async () => { + const module = await import("@/features/channels/ui/MembersSidebar"); + return { default: module.MembersSidebar }; +}); + export type BrowseDialogType = "stream" | "forum" | null; type AppShellOverlaysProps = { @@ -30,6 +35,7 @@ type AppShellOverlaysProps = { onChannelManagementOpenChange: (open: boolean) => void; onDeleteActiveChannel: () => void; onSelectChannel: (channelId: string) => void; + relayUrl?: string; }; export function AppShellOverlays({ @@ -45,7 +51,11 @@ export function AppShellOverlays({ onChannelManagementOpenChange, onDeleteActiveChannel, onSelectChannel, + relayUrl, }: AppShellOverlaysProps) { + const [membersChannel, setMembersChannel] = React.useState( + null, + ); const [visibleBrowseDialogType, setVisibleBrowseDialogType] = React.useState(null); const { cancelDeferredModalOpen, openNextFrame: openModalNextFrame } = @@ -89,11 +99,28 @@ export function AppShellOverlays({ channel={activeChannel} currentPubkey={currentPubkey} onDeleted={onDeleteActiveChannel} + onOpenMembers={() => setMembersChannel(activeChannel)} onOpenChange={onChannelManagementOpenChange} open={true} /> ) : null} + + {membersChannel ? ( + + { + if (!nextOpen) { + setMembersChannel(null); + } + }} + open={true} + relayUrl={relayUrl} + /> + + ) : null} ); } diff --git a/desktop/src/app/useAppShellKeyboardShortcuts.ts b/desktop/src/app/useAppShellKeyboardShortcuts.ts new file mode 100644 index 00000000000..26887963cbe --- /dev/null +++ b/desktop/src/app/useAppShellKeyboardShortcuts.ts @@ -0,0 +1,88 @@ +import * as React from "react"; + +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; + +type AppShellKeyboardShortcutsOptions = { + canSearchCurrentChannel: boolean; + disabled: boolean; + onBrowseChannels: () => void; + onCreateChannel: () => void; + onGoHome: () => unknown; + onNewMessage: () => unknown; + onSearchCurrentChannel: () => void; + onSearchEverything: () => void; +}; + +export function useAppShellKeyboardShortcuts({ + canSearchCurrentChannel, + disabled, + onBrowseChannels, + onCreateChannel, + onGoHome, + onNewMessage, + onSearchCurrentChannel, + onSearchEverything, +}: AppShellKeyboardShortcutsOptions) { + React.useLayoutEffect(() => { + if (disabled) return; + + function handleKeyDown(event: KeyboardEvent) { + if ( + !hasPrimaryShortcutModifier(event) || + event.altKey || + event.repeat || + event.defaultPrevented + ) { + return; + } + + const key = event.key.toLowerCase(); + if (key === "f" && !event.shiftKey && canSearchCurrentChannel) { + event.preventDefault(); + onSearchCurrentChannel(); + return; + } + + if (key === "k" && !event.shiftKey) { + event.preventDefault(); + onSearchEverything(); + return; + } + + if (key === "k" && event.shiftKey) { + event.preventDefault(); + void onNewMessage(); + return; + } + + if (key === "n" && event.shiftKey) { + event.preventDefault(); + onCreateChannel(); + return; + } + + if (key === "o" && event.shiftKey) { + event.preventDefault(); + onBrowseChannels(); + return; + } + + if (key === "a" && event.shiftKey) { + event.preventDefault(); + void onGoHome(); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + canSearchCurrentChannel, + disabled, + onBrowseChannels, + onCreateChannel, + onGoHome, + onNewMessage, + onSearchCurrentChannel, + onSearchEverything, + ]); +} diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 2d56d24cb37..ae62ae3e224 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; +import { useForegroundQueryRefresh } from "@/features/workflows/hooks"; import { relayClient } from "@/shared/api/relayClient"; import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers"; @@ -20,6 +21,7 @@ export function useAppShellLifecycleEffects({ // Event-driven reconnect: network online / focus / visibility short-circuit // the backoff timer when the relay session is degraded (CMD+R gap G1). useRelayResumeTriggers(); + useForegroundQueryRefresh(); // Prevent webview file:/// navigation on file drop outside the composer. // Scoped to file drags only (text drag-and-drop into inputs still works). diff --git a/desktop/src/app/useTauriWindowDrag.ts b/desktop/src/app/useTauriWindowDrag.ts index 1ac7acc93c0..8d9342ab57d 100644 --- a/desktop/src/app/useTauriWindowDrag.ts +++ b/desktop/src/app/useTauriWindowDrag.ts @@ -15,6 +15,10 @@ export function useTauriWindowDrag() { return; } + // A native window drag replaces the browser's normal pointer gesture. + // Cancel that gesture before handing control to Tauri so moving from the + // titlebar across page copy cannot start a text selection. + event.preventDefault(); void getCurrentWindow().startDragging(); } diff --git a/desktop/src/features/agent-memory/ui/MemorySection.tsx b/desktop/src/features/agent-memory/ui/MemorySection.tsx index cfc5ed7bd92..d63ceeb3cd9 100644 --- a/desktop/src/features/agent-memory/ui/MemorySection.tsx +++ b/desktop/src/features/agent-memory/ui/MemorySection.tsx @@ -10,6 +10,7 @@ import { Skeleton } from "@/shared/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const MEMORY_LIST_PREVIEW_LIMIT = 3; +type MemorySectionVariant = "cards" | "grouped"; const MEMORY_TRUNCATED_TOOLTIP = "This list may be incomplete — the relay returned the maximum number of memories."; @@ -41,15 +42,17 @@ const MEMORY_DANGLING_REF_TOOLTIP = */ export function MemorySection({ agentPubkey, + variant = "cards", viewerIsOwner, }: { agentPubkey: string; + variant?: MemorySectionVariant; viewerIsOwner: boolean; }): React.ReactElement | null { // Hide entirely for non-owners. if (!viewerIsOwner) return null; - return ; + return ; } export function MemoryRefreshButton({ @@ -92,7 +95,13 @@ export function MemoryRefreshButton({ ); } -function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { +function MemorySectionForOwner({ + agentPubkey, + variant, +}: { + agentPubkey: string; + variant: MemorySectionVariant; +}) { const { query, graph } = useAgentMemoryGraph(agentPubkey); // Order matters here. We want: @@ -107,13 +116,14 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { return (
- {showInitialSkeleton ? : null} + {showInitialSkeleton ? : null} {showInitialError ? ( query.refetch()} retrying={query.isFetching} + variant={variant} /> ) : null} @@ -123,10 +133,17 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { still have prior data on screen. Distinct from the initial error state above. */} {query.isError && !query.isFetching ? ( - query.refetch()} /> + query.refetch()} + variant={variant} + /> ) : null} - + ) : null}
@@ -135,11 +152,11 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { // ── Subviews ──────────────────────────────────────────────────────────────── -function MemorySkeleton() { +function MemorySkeleton({ variant }: { variant: MemorySectionVariant }) { return (
@@ -154,16 +171,21 @@ function MemoryErrorState({ error, onRetry, retrying, + variant, }: { error: unknown; onRetry: () => void; retrying: boolean; + variant: MemorySectionVariant; }) { const message = error instanceof Error ? error.message : String(error ?? "unknown error"); return (
@@ -189,10 +211,19 @@ function MemoryErrorState({ ); } -function MemoryStaleErrorBanner({ onRetry }: { onRetry: () => void }) { +function MemoryStaleErrorBanner({ + onRetry, + variant, +}: { + onRetry: () => void; + variant: MemorySectionVariant; +}) { return (
@@ -211,9 +242,11 @@ function MemoryStaleErrorBanner({ onRetry }: { onRetry: () => void }) { function MemoryGraphView({ graph, truncated, + variant, }: { graph: NonNullable["graph"]>; truncated: boolean; + variant: MemorySectionVariant; }) { const { rootedTree, orphans, dangling } = graph; const [showAllEntries, setShowAllEntries] = React.useState(false); @@ -250,10 +283,13 @@ function MemoryGraphView({ : entries.slice(0, MEMORY_LIST_PREVIEW_LIMIT); return ( -
+
{!core && memories.length > 0 ? (

No core memory yet — agent @@ -261,12 +297,18 @@ function MemoryGraphView({

) : null} -
+
{visibleEntries.map((entry) => ( ))}
@@ -276,14 +318,22 @@ function MemoryGraphView({ count={entries.length} onClick={() => setShowAllEntries(true)} truncated={truncated} + variant={variant} /> ) : null} - {truncated && !hasMoreEntries ? : null} + {truncated && !hasMoreEntries ? ( + + ) : null} {hasMoreEntries && showAllEntries ? ( + ) : channel.channelType !== "dm" ? ( +
+

+ {channel.name} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ ) : null}
); } -export function ChannelQuickAction({ - active, - disabled, - icon: Icon, - label, - onClick, +export function FieldGroup({ + children, + description, testId, + title, }: { - active?: boolean; - disabled?: boolean; - icon: LucideIcon; - label: string; - onClick: () => void; + children: React.ReactNode; + description?: React.ReactNode; testId?: string; + title?: React.ReactNode; }) { return ( - - ); -} - -export function FieldGroup({ children }: { children: React.ReactNode }) { - return ( -
{children}
+ + {children} + ); } @@ -114,26 +137,51 @@ export function getMarkdownPreviewText(content: string) { .join(" "); } +function truncateIdentifier(value: string) { + if (value.length <= 12) return value; + return `${value.slice(0, 8)}…${value.slice(-4)}`; +} + export function CopyFieldRow({ icon: Icon, label, value, testId, }: { - icon: LucideIcon; + icon?: LucideIcon; label: string; value: string; testId?: string; }) { + const [copied, setCopied] = React.useState(false); + const resetTimerRef = React.useRef(null); + + React.useEffect( + () => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, + [], + ); + async function handleCopy() { await writeTextToClipboard(value); + setCopied(true); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + setCopied(false); + resetTimerRef.current = null; + }, 1_500); toast.success(`Copied ${label.toLowerCase()}`); } return ( ); } @@ -160,73 +235,203 @@ export function CopyFieldRow({ export function InfoFieldRow({ icon: Icon, label, + multiline = false, + onClick, + trailing, value, testId, }: { - icon: LucideIcon; + icon?: LucideIcon; label: string; + multiline?: boolean; + onClick?: () => void; + trailing?: React.ReactNode; value: string; testId?: string; }) { - return ( -
- - - + const content = ( + <> + {Icon ? ( + + ) : null} - + {label} - + {value} -
+ {trailing} + ); -} -export function NarrativeGroup({ children }: { children: React.ReactNode }) { + if (onClick) { + return ( + + ); + } + return ( -
{children}
+
+ {content} +
); } -export function NarrativeField({ +export function EditableInfoFieldRow({ + editTestId, icon: Icon, label, + multiline = false, + onEdit, value, testId, }: { - icon: LucideIcon; + editTestId?: string; + icon?: LucideIcon; label: string; + multiline?: boolean; + onEdit?: () => void; value: string; testId: string; }) { - return ( -
- - - - - + const content = ( + <> + {Icon ? ( + + ) : null} + + {label} - + {value} + {onEdit ? ( + + ) : null} + + ); + + if (onEdit) { + return ( + + ); + } + + return ( +
+ {content}
); } +type ActionFieldRowProps = { + destructive?: boolean; + description?: string; + disabled?: boolean; + icon: LucideIcon; + label: string; + onClick?: () => void; + testId: string; +}; + +export const ActionFieldRow = React.forwardRef< + HTMLButtonElement, + ActionFieldRowProps +>(function ActionFieldRow( + { + destructive = false, + description, + disabled, + icon: Icon, + label, + onClick, + testId, + ...triggerProps + }, + ref, +) { + return ( + + ); +}); + export function IngressRow({ description, + helpText, icon: Icon, label, onClick, @@ -234,6 +439,7 @@ export function IngressRow({ trailing, }: { description?: string; + helpText?: string; icon: LucideIcon; label: string; onClick: () => void; @@ -241,29 +447,51 @@ export function IngressRow({ trailing?: string; }) { return ( - + + + {helpText} + + + ) : null} +
+ {description ? ( + + {description} + + ) : null}
- {description ? ( - - {description} + {trailing ? ( + + {trailing} ) : null} - - {trailing ? ( - {trailing} - ) : null} - - + +
+ ); } diff --git a/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx new file mode 100644 index 00000000000..a9662bbf047 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx @@ -0,0 +1,75 @@ +import * as React from "react"; + +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import type { ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const MAX_VISIBLE_AVATARS = 3; + +export function ChannelMemberAvatarStack({ + currentPubkey, + members, +}: { + currentPubkey?: string; + members: ChannelMember[]; +}) { + const visibleMembers = members.slice(0, MAX_VISIBLE_AVATARS); + const visiblePubkeys = React.useMemo( + () => members.slice(0, MAX_VISIBLE_AVATARS).map((member) => member.pubkey), + [members], + ); + const profilesQuery = useUsersBatchQuery(visiblePubkeys); + const profiles = profilesQuery.data?.profiles; + const overflowCount = members.length - visibleMembers.length; + const stackItemCount = visibleMembers.length + (overflowCount > 0 ? 1 : 0); + + if (members.length === 0) { + return null; + } + + return ( +
+ {visibleMembers.map((member, index) => { + const normalizedPubkey = normalizePubkey(member.pubkey); + const profile = profiles?.[normalizedPubkey]; + const label = resolveUserLabel({ + currentPubkey, + fallbackName: member.displayName, + profiles, + pubkey: member.pubkey, + }); + + return ( + 0 ? "-ml-2" : ""} + data-testid="channel-management-member-avatar" + key={normalizedPubkey} + style={{ zIndex: index + 1 }} + > + + + ); + })} + {overflowCount > 0 ? ( + + +{overflowCount} + + ) : null} +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 410b05a2ccd..8fc7cfaf51a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -25,7 +25,6 @@ import { import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; -import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; @@ -73,7 +72,6 @@ export const ChannelPane = React.memo(function ChannelPane({ autoSendDraftKey = null, onAutoSendComplete = null, botTypingEntries, - channelFind, channelManagementOpen = false, currentPubkey, editTarget = null, @@ -127,6 +125,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onResetThreadPanelWidth, onSelectThreadReplyTarget, onSendMessage, + onSendToChannel, onSendVideoReviewComment, onSendThreadReply, onThreadScrollTargetResolved, @@ -265,9 +264,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onEdit(target); return true; }, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]); - 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. @@ -558,19 +555,6 @@ export const ChannelPane = React.memo(function ChannelPane({ } > {isHuddleTranscript ? null : header} - {channelFind.isOpen ? ( -
- -
- ) : null} resolveScrollTarget()} onScrollTargetSettled={resolveScrollTarget} onToggleReaction={onToggleReaction} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 763b8bf3797..030229dc24f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -7,7 +7,7 @@ import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channel import type { TimelineMessage } from "@/features/messages/types"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import type { useChannelFind } from "@/features/search/useChannelFind"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import type { ProfilePanelTab, ProfilePanelView, @@ -34,7 +34,6 @@ export type ChannelPaneProps = { */ onAutoSendComplete?: (() => void) | null; botTypingEntries: TypingIndicatorEntry[]; - channelFind: ReturnType; channelManagementOpen?: boolean; currentPubkey?: string; editTarget?: { @@ -42,6 +41,7 @@ export type ChannelPaneProps = { body: string; id: string; imetaMedia?: ImetaMedia[]; + mentionRefs?: DraftMentionRef[]; } | null; fetchOlder?: () => Promise; header?: React.ReactNode; @@ -107,6 +107,11 @@ export type ChannelPaneProps = { mediaTags?: string[][], channelId?: string | null, ) => Promise; + onSendToChannel: ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => Promise; onSendVideoReviewComment?: ( message: TimelineMessage, content: string, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 86663949384..8150f6df7de 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { useAppShell } from "@/app/AppShellContext"; -import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers"; @@ -42,9 +41,9 @@ import { useSendMessageMutation, useToggleReactionMutation, } from "@/features/messages/hooks"; +import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { getThreadReference } from "@/features/messages/lib/threading"; import { resolveTimelineLoadingLatch, @@ -57,8 +56,7 @@ import { useChannelTyping } from "@/features/messages/useChannelTyping"; import type { TimelineMessage } from "@/features/messages/types"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; -import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types"; -import { useChannelFind } from "@/features/search/useChannelFind"; +import type { RelayEvent, RespondToMode } from "@/shared/api/types"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; import { useHuddleChannelMessages, @@ -84,8 +82,7 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; -const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760, - EMPTY_RELAY_EVENTS: RelayEvent[] = []; +const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, autoSendDraftKey, @@ -204,9 +201,8 @@ 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 (getThreadReference(messages[index].tags).parentId === null) return messages[index]; - } } return null; }, [messagesQuery.data]); @@ -245,14 +241,8 @@ export function ChannelScreen({ const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); const joinChannelMutation = useJoinChannelMutation(activeChannelId); - const [findEvents, setFindEvents] = React.useState([]); - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - React.useEffect(() => { - setFindEvents([]); - }, [activeChannelId]); const { resolvedMessages, threadSummaries } = useHuddleChannelMessages({ activeChannel, - findEvents, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, targetMessageEvents, @@ -420,19 +410,6 @@ export function ChannelScreen({ resolvedMessages, ], ); - const handleFindSearchHit = React.useCallback((hit: SearchHit) => { - const event = cacheSearchHitEvent(hit); - setFindEvents((currentEvents) => - currentEvents.some((currentEvent) => currentEvent.id === event.id) - ? currentEvents - : [...currentEvents, event], - ); - }, []); - const channelFind = useChannelFind({ - channelId: activeChannelId, - messages: timelineMessages, - onSearchHit: handleFindSearchHit, - }); const threadPanelData = useIndependentThreadPanel({ activeChannel, channelEvents: resolvedMessages, @@ -495,6 +472,7 @@ export function ChannelScreen({ handleExpandThreadReplies, handleOpenThread, handleSendMessage, + handleSendToChannel, handleSendThreadReply, handleSelectThreadReplyTarget, handleToggleReaction, @@ -506,6 +484,7 @@ export function ChannelScreen({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + profiles: messageProfiles, recordThreadInteraction, openThreadHeadId: effectiveOpenThreadHeadId, onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId, @@ -713,7 +692,7 @@ export function ChannelScreen({ const shouldCompactHeaderActions = hasAuxiliaryPanel && channelContentWidthPx > 0 && - channelContentWidthPx < HEADER_ACTIONS_COMPACT_BREAKPOINT_PX; + channelContentWidthPx < 760; const channelHeaderChromeRef = useMeasuredCssVariable({ targetRef: mainInsetRef, ...channelContentTopPaddingMeasurement, @@ -859,7 +838,6 @@ export function ChannelScreen({ autoSendDraftKey={autoSendDraftKey} onAutoSendComplete={clearAutoSend} botTypingEntries={botTypingEntries} - channelFind={channelFind} channelManagementOpen={channelManagementOpen} currentPubkey={currentPubkey} canResetThreadPanelWidth={canResetThreadPanelWidth} @@ -879,14 +857,13 @@ export function ChannelScreen({ welcomeKickoffSettingUp={welcomeKickoffSettingUp} editTarget={ editTargetMessage - ? { - author: editTargetMessage.author, - body: editTargetMessage.body, - id: editTargetMessage.id, - imetaMedia: imetaMediaFromTags( - editTargetMessage.tags, - ), - } + ? buildMessageComposerEditTarget( + editTargetMessage, + messageProfiles, + (pubkey) => + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) : null } followThreadById={followThread} @@ -940,6 +917,7 @@ export function ChannelScreen({ onOpenThread={handleOpenThreadAndCloseAgentSession} onSelectThreadReplyTarget={handleSelectThreadReplyTarget} onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} onThreadScrollTargetResolved={ diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 8f431fb0afe..9ec3151bb09 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -251,8 +251,8 @@ export function MembersSidebar({ visibility: channel?.visibility, selfRole: selfMember?.role, }); - // Distinguish "you can't add here" from "nothing to add" so a plain member of - // a private channel gets the reason instead of a silently missing affordance. + // Distinguish "you can't add here" from "nothing to add" so a non-member + // viewing a private channel gets the reason instead of a silently missing affordance. const showPrivateAddDeniedNotice = !canAddMembers && selfMember !== null && diff --git a/desktop/src/features/channels/ui/channelFormStyles.ts b/desktop/src/features/channels/ui/channelFormStyles.ts index 60e4053901a..df07001293c 100644 --- a/desktop/src/features/channels/ui/channelFormStyles.ts +++ b/desktop/src/features/channels/ui/channelFormStyles.ts @@ -2,4 +2,4 @@ export const CHANNEL_FORM_FIELD_SHELL_CLASS = "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; export const CHANNEL_FORM_FIELD_CONTROL_CLASS = - "border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0"; + "border-0 bg-transparent text-foreground shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0"; diff --git a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts index acbf831dd2a..2a90971ddec 100644 --- a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts +++ b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts @@ -19,7 +19,6 @@ export function useIsHuddleTranscript(activeChannelId: string | null) { type HuddleChannelMessagesOptions = { activeChannel: Channel | null; - findEvents: RelayEvent[]; isHuddleTranscript: boolean; messages: RelayEvent[]; targetMessageEvents: RelayEvent[]; @@ -28,17 +27,16 @@ type HuddleChannelMessagesOptions = { export function useHuddleChannelMessages({ activeChannel, - findEvents, isHuddleTranscript, messages, targetMessageEvents, windowStore, }: HuddleChannelMessagesOptions) { const resolvedChannelMessages = React.useMemo(() => { - const extraEvents = [...targetMessageEvents, ...findEvents]; + const extraEvents = targetMessageEvents; if (!activeChannel || extraEvents.length === 0) return messages; return extraEvents.reduce(mergeMessages, messages); - }, [activeChannel, findEvents, messages, targetMessageEvents]); + }, [activeChannel, messages, targetMessageEvents]); const threadSummaries = React.useMemo( () => (windowStore ? channelWindowThreadSummaries(windowStore) : new Map()), diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 7e78d9601f1..d7b2e5a6fd7 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -7,7 +7,10 @@ import type { useToggleReactionMutation, } from "@/features/messages/hooks"; import { resolveThreadReplyTarget } from "@/features/messages/hooks"; +import { getSendToChannelSemantics } from "@/features/messages/lib/sendToChannelSemantics"; +import { summarizeThreadRoot } from "@/features/messages/lib/sentFromThread"; import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; /** * Stable callback references for ChannelPane so that keystroke-driven @@ -25,6 +28,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + profiles, recordThreadInteraction, onOptimisticOpenThreadHeadIdChange, onRequestEmptyEditDelete, @@ -45,6 +49,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; markRevealedRepliesRead: (messageId: string) => void; + profiles: UserProfileLookup | undefined; recordThreadInteraction: (rootId: string) => void; onOptimisticOpenThreadHeadIdChange: React.Dispatch< React.SetStateAction @@ -73,6 +78,9 @@ export function useChannelPaneHandlers({ const expandedThreadReplyIdsRef = React.useRef(expandedThreadReplyIds); expandedThreadReplyIdsRef.current = expandedThreadReplyIds; + const profilesRef = React.useRef(profiles); + profilesRef.current = profiles; + const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); sendMutateRef.current = sendMessageMutation.mutateAsync; @@ -287,6 +295,28 @@ export function useChannelPaneHandlers({ [], ); + const handleSendToChannel = React.useCallback( + async ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => { + const { mentionPubkeys, semanticTags } = getSendToChannelSemantics( + message, + profilesRef.current, + ); + await sendMutateRef.current({ + channelId, + content: message.body, + mediaTags: semanticTags, + mentionPubkeys, + sentFromThreadRootExcerpt: summarizeThreadRoot(threadRoot.body), + sentFromThreadRootId: threadRoot.id, + }); + }, + [], + ); + const handleSendThreadReply = React.useCallback( async ( content: string, @@ -376,6 +406,7 @@ export function useChannelPaneHandlers({ handleExpandThreadReplies, handleOpenThread, handleSendMessage, + handleSendToChannel, handleSendThreadReply, handleSelectThreadReplyTarget, handleToggleReaction, diff --git a/desktop/src/features/channels/useThreadActivityPersistence.test.mjs b/desktop/src/features/channels/useThreadActivityPersistence.test.mjs new file mode 100644 index 00000000000..686a08fcc42 --- /dev/null +++ b/desktop/src/features/channels/useThreadActivityPersistence.test.mjs @@ -0,0 +1,276 @@ +/** + * Integration tests for useThreadActivityPersistence. + * + * These mount the REAL production hook via createRoot + act to exercise the + * actual lifecycle: pagehide flush, visibilitychange→hidden flush, unmount + * cleanup, scope-switch hydration (flush-before-reseed), legacy-key cleanup, + * and the read-live-buffer-at-flush-time contract that distinguishes this + * scheduler from a snapshot-at-schedule one. Debounce timing is covered by + * fake timers in threadActivityWriteScheduler.test.mjs. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + installDOMShim, + installFreshStorage, +} from "./observedUnreadTestHarness.mjs"; + +installDOMShim(); +installFreshStorage(); + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { + activityStorageKey, + readActivityFromStorage, + writeActivityToStorage, +} from "./threadActivityStorage.ts"; +import { useThreadActivityPersistence } from "./useThreadActivityPersistence.ts"; + +const RELAY = "wss://relay.example.com"; + +// Mount the hook with a caller-owned buffer ref (mirrors useUnreadChannels, +// which owns threadActivityRef and passes it in). +async function mountHook(itemsRef, props) { + const apiRef = { current: null }; + + function Harness({ pubkey, relay }) { + apiRef.current = useThreadActivityPersistence(pubkey, relay, itemsRef); + return null; + } + + const root = createRoot(document.createElement("div")); + const render = async (p) => { + await act(async () => { + root.render(React.createElement(Harness, p)); + }); + }; + await render(props); + + return { + get api() { + return apiRef.current; + }, + render, + unmount: async () => { + await act(async () => root.unmount()); + }, + }; +} + +// ── flush contract: read the live buffer at flush time ─────────────────────── + +test("pagehide flush persists the live buffer's final state, not a schedule-time snapshot", async () => { + installFreshStorage(); + const itemsRef = { current: [] }; + const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY }); + + // A reply lands, the writer buffers it in place and arms a coalesced write. + itemsRef.current = [{ id: "early" }]; + harness.api.schedule(harness.api.currentScope); + // A second reply lands within the debounce window — buffer grows in place. + itemsRef.current = [{ id: "early" }, { id: "late" }]; + + await act(async () => { + globalThis.dispatchEvent({ type: "pagehide" }); + }); + + assert.deepEqual( + readActivityFromStorage("pk1", RELAY).map((item) => item.id), + ["early", "late"], + "flush must persist the buffer's final state including the late reply", + ); + + await harness.unmount(); +}); + +test("visibilitychange to hidden flushes a pending write", async () => { + installFreshStorage(); + const itemsRef = { current: [] }; + const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY }); + + itemsRef.current = [{ id: "hidden-flush" }]; + harness.api.schedule(harness.api.currentScope); + + await act(async () => { + globalThis.document.visibilityState = "hidden"; + globalThis.document.dispatchEvent({ type: "visibilitychange" }); + }); + + assert.deepEqual( + readActivityFromStorage("pk1", RELAY).map((item) => item.id), + ["hidden-flush"], + "backgrounding the webview must persist the pending buffer", + ); + + await harness.unmount(); +}); + +test("visibilitychange to visible does not write", async () => { + installFreshStorage(); + const itemsRef = { current: [] }; + const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY }); + + itemsRef.current = [{ id: "still-buffered" }]; + harness.api.schedule(harness.api.currentScope); + + await act(async () => { + globalThis.document.visibilityState = "visible"; + globalThis.document.dispatchEvent({ type: "visibilitychange" }); + }); + + assert.deepEqual( + readActivityFromStorage("pk1", RELAY), + [], + "a visible transition must not flush", + ); + + await harness.unmount(); +}); + +test("unmount with a pending write flushes before teardown", async () => { + installFreshStorage(); + const itemsRef = { current: [] }; + const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY }); + + itemsRef.current = [{ id: "unmount-flush" }]; + harness.api.schedule(harness.api.currentScope); + + await harness.unmount(); + + assert.deepEqual( + readActivityFromStorage("pk1", RELAY).map((item) => item.id), + ["unmount-flush"], + "unmount cleanup must flush the pending write", + ); +}); + +// ── scope switch: flush-before-reseed under the OLD key ────────────────────── + +test("scope switch flushes A synchronously under A's key and does not leak A into B", async () => { + installFreshStorage(); + + const pkA = "pkA"; + const relayA = "wss://relay-a.example.com"; + const pkB = "pkB"; + const relayB = "wss://relay-b.example.com"; + + const itemsRef = { current: [] }; + const harness = await mountHook(itemsRef, { pubkey: pkA, relay: relayA }); + + // A accumulates a buffered reply with a pending coalesced write. + itemsRef.current = [{ id: "a-only" }]; + harness.api.schedule(harness.api.currentScope); + + // Switch identity to B: the hydration effect must flush A first. + await harness.render({ pubkey: pkB, relay: relayB }); + + assert.deepEqual( + readActivityFromStorage(pkA, relayA).map((item) => item.id), + ["a-only"], + "A's pending write must land under A's key on scope switch", + ); + assert.deepEqual( + readActivityFromStorage(pkB, relayB), + [], + "B's bucket must not contain A's rows", + ); + assert.ok( + harness.api.currentScope.includes(pkB), + "currentScope must reflect B after the switch", + ); + + await harness.unmount(); +}); + +test("scope switch hydrates B's buffer from B's persisted bucket", async () => { + installFreshStorage(); + + const pkA = "pkA"; + const relayA = "wss://relay-a.example.com"; + const pkB = "pkB"; + const relayB = "wss://relay-b.example.com"; + writeActivityToStorage(pkB, relayB, [{ id: "b-persisted" }]); + + const itemsRef = { current: [] }; + const harness = await mountHook(itemsRef, { pubkey: pkA, relay: relayA }); + + itemsRef.current = [{ id: "a-only" }]; + await harness.render({ pubkey: pkB, relay: relayB }); + + assert.deepEqual( + itemsRef.current.map((item) => item.id), + ["b-persisted"], + "the buffer must be reseeded from B's bucket, dropping A's rows", + ); + + await harness.unmount(); +}); + +// ── legacy key cleanup ─────────────────────────────────────────────────────── + +test("mounting removes the orphaned legacy pubkey-only key", async () => { + const ls = installFreshStorage(); + const pubkey = "pk-legacy"; + const legacyKey = `buzz-thread-activity.v1:${pubkey}`; + ls.setItem(legacyKey, JSON.stringify([{ id: "stale" }])); + + const itemsRef = { current: [] }; + const harness = await mountHook(itemsRef, { pubkey, relay: RELAY }); + + assert.equal( + ls.getItem(legacyKey), + null, + "hydration must drop the orphaned legacy key", + ); + + await harness.unmount(); +}); + +// ── scope fence: never write before a valid scope is loaded ────────────────── + +test("isScopeLoaded is false without an identity and true after hydration", async () => { + installFreshStorage(); + const itemsRef = { current: [] }; + const harness = await mountHook(itemsRef, { pubkey: null, relay: RELAY }); + + assert.equal( + harness.api.isScopeLoaded(), + false, + "an absent pubkey must never pass the scope fence", + ); + + await harness.render({ pubkey: "pk1", relay: RELAY }); + assert.equal( + harness.api.isScopeLoaded(), + true, + "a valid scope must pass once its hydration effect commits", + ); + + await harness.unmount(); +}); + +test("schedule under an empty scope never writes", async () => { + const ls = installFreshStorage(); + const itemsRef = { current: [] }; + const harness = await mountHook(itemsRef, { pubkey: null, relay: RELAY }); + + itemsRef.current = [{ id: "orphan" }]; + harness.api.schedule(harness.api.currentScope); + + await act(async () => { + globalThis.dispatchEvent({ type: "pagehide" }); + }); + + assert.equal( + ls.getItem(activityStorageKey("", RELAY)), + null, + "no write may land when identity is unknown", + ); + + await harness.unmount(); +}); diff --git a/desktop/src/features/channels/useThreadActivityPersistence.ts b/desktop/src/features/channels/useThreadActivityPersistence.ts new file mode 100644 index 00000000000..025a82467e5 --- /dev/null +++ b/desktop/src/features/channels/useThreadActivityPersistence.ts @@ -0,0 +1,113 @@ +import * as React from "react"; +import { + activityScopeKey, + flushThreadActivityWrite, + readActivityFromStorage, + removeLegacyThreadActivityKey, + scheduleThreadActivityWrite, + type ThreadActivityItem, + type ThreadActivityRefs, +} from "@/features/channels/threadActivityStorage"; + +export type ThreadActivityPersistence = { + /** Scope key loaded into the buffer ("" until identity is known). */ + scopeLoadedRef: React.MutableRefObject; + /** Current scope derived from normalized pubkey + relay. */ + currentScope: string; + /** + * True only when the hydration effect has committed for the current scope + * AND that scope is non-empty. Reads the ref at call time so it is never a + * stale snapshot. Use as the write/merge guard: an empty scope must never + * pass, or a writer could fire before the first valid scope is seeded. + */ + isScopeLoaded: () => boolean; + /** Arm a coalesced write after mutating the buffer for `scope`. */ + schedule: (scope: string) => void; +}; + +/** + * Manages the thread-activity localStorage persistence layer for + * useUnreadChannels: owns the loaded-scope ref, the coalescing timer, the + * pagehide/visibility flush, and hydration on identity/relay change. The buffer + * itself (`itemsRef`) is owned by the parent and merged in place by its writers; + * this hook only decides when the buffer is durably persisted. + * + * Sibling of useObservedUnreadPersistence — same scope-fence and flush shape, + * minus marker-prune/removeChannel/clearAll, which thread activity has no + * analog for. + */ +export function useThreadActivityPersistence( + normalizedPubkey: string | null, + normalizedRelayUrl: string, + itemsRef: React.MutableRefObject, +): ThreadActivityPersistence { + const currentScope = activityScopeKey(normalizedPubkey, normalizedRelayUrl); + + const scopeLoadedRef = React.useRef(""); + const timerRef = React.useRef | null>(null); + + const persistRefs = React.useRef({ + itemsRef, + scopeLoadedRef, + timerRef, + }); + persistRefs.current.itemsRef = itemsRef; + + // pagehide + visibilitychange→hidden: synchronously persist any pending write + // before the webview unloads or is backgrounded. Cmd+R and #5588's idle + // reload both tear the webview down within the coalescing window; without + // these flushes the last burst of replies would be lost. + React.useEffect(() => { + const refs = persistRefs.current; + const flush = () => flushThreadActivityWrite(refs); + const onVisibility = () => { + if (document.visibilityState === "hidden") flush(); + }; + window.addEventListener("pagehide", flush); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("pagehide", flush); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, []); + + // Hydrate the buffer whenever identity/relay changes. Flush the OLD scope + // first so an in-flight coalesced write lands under the old key before the + // buffer is clobbered, then drop the orphaned legacy key for this pubkey. + // biome-ignore lint/correctness/useExhaustiveDependencies: normalizedRelayUrl is an intentional reset signal alongside normalizedPubkey + React.useEffect(() => { + flushThreadActivityWrite(persistRefs.current); + + if (normalizedPubkey && normalizedRelayUrl) { + removeLegacyThreadActivityKey(normalizedPubkey); + itemsRef.current = readActivityFromStorage( + normalizedPubkey, + normalizedRelayUrl, + ); + } else { + itemsRef.current = []; + } + scopeLoadedRef.current = currentScope; + + // Flush the current scope on unmount / before the next run so a pending + // write is never dropped when refs are clobbered. + return () => { + flushThreadActivityWrite(persistRefs.current); + }; + }, [normalizedPubkey, normalizedRelayUrl]); + + const schedule = React.useCallback( + (scope: string) => scheduleThreadActivityWrite(scope, persistRefs.current), + [], + ); + + const isScopeLoaded = React.useCallback( + () => currentScope !== "" && scopeLoadedRef.current === currentScope, + [currentScope], + ); + + return React.useMemo( + () => ({ scopeLoadedRef, currentScope, isScopeLoaded, schedule }), + [currentScope, isScopeLoaded, schedule], + ); +} diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index d46cc71cdbe..0464a00fe0b 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -38,11 +38,8 @@ import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference"; import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind"; import { - activityScopeKey, addThreadActivityItems, projectActivityForScope, - readActivityFromStorage, - writeActivityToStorage, type ThreadActivityItem, } from "@/features/channels/threadActivityStorage"; export type { ThreadActivityItem } from "@/features/channels/threadActivityStorage"; @@ -55,6 +52,7 @@ export { writeActivityToStorage, } from "@/features/channels/threadActivityStorage"; import { useObservedUnreadPersistence } from "@/features/channels/useObservedUnreadPersistence"; +import { useThreadActivityPersistence } from "@/features/channels/useThreadActivityPersistence"; type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { pubkey?: string; @@ -147,14 +145,6 @@ export function useUnreadChannels( const normalizedRelayUrl = relayUrlOption ? normalizeRelayUrl(relayUrlOption) : ""; - // Single identity for the in-memory thread-activity buffer — computed once - // per render and used at reset, both writers, and the return fence. The - // helper returns "" when either value is absent, which never matches a valid - // loaded scope, so the fence returns [] until the buffer is seeded. - const currentActivityScope = activityScopeKey( - normalizedPubkey, - normalizedRelayUrl, - ); const { getEffectiveTimestamp, @@ -227,12 +217,10 @@ export function useUnreadChannels( mutedChannelIdsRef.current = mutedChannelIdsOption ?? new Set(); // Thread reply events that triggered notifications — surfaced in the Home - // activity feed as synthetic FeedItems. + // activity feed as synthetic FeedItems. The buffer is the source of truth + // between coalesced writes; useThreadActivityPersistence owns the loaded + // scope, the write timer, flush, and hydration. const threadActivityRef = React.useRef([]); - // Tracks the (pubkey:relayUrl) scope currently loaded into threadActivityRef. - // Writers guard against this before merging so in-flight writes from a prior - // scope cannot corrupt the new one; renders return [] until it matches. - const threadActivityScopeRef = React.useRef(""); // Tracks which channels we've already issued a catch-up REQ for this // session. Prevents re-fetching on every channels-list refetch, while still @@ -266,6 +254,15 @@ export function useUnreadChannels( { onPruned: bumpLatestVersion }, ); + // Thread-activity persistence: coalesced writes, pagehide/visibility flush, + // hydration + legacy-key cleanup. Owns the loaded scope for the buffer above. + const activityPersistence = useThreadActivityPersistence( + normalizedPubkey, + normalizedRelayUrl, + threadActivityRef, + ); + const currentActivityScope = activityPersistence.currentScope; + // Reset all in-session state when the identity or relay changes. In-memory // caches are cleared; persisted stores are loaded for the new pubkey (so // forced-unread, participation, etc. are correct for the new identity). @@ -285,11 +282,6 @@ export function useUnreadChannels( ? mentionedStore.read(pubkey) : new Set(); mutedRootIdsRef.current = pubkey ? mutedStore.read(pubkey) : new Set(); - threadActivityRef.current = - normalizedPubkey && normalizedRelayUrl - ? readActivityFromStorage(normalizedPubkey, normalizedRelayUrl) - : []; - threadActivityScopeRef.current = currentActivityScope; bumpLatestVersion(); bumpMembershipVersion(); }, [pubkey, relayClient, normalizedRelayUrl]); @@ -490,15 +482,10 @@ export function useUnreadChannels( const handleThreadReplyNotification = React.useCallback( (channelId: string, event: RelayEvent) => { - // Guard: don't merge into a ref whose scope has drifted from the current - // identity. Also reject an empty scope — activityScopeKey() returns "" - // when pubkey or relay is absent, and "" !== "" is false, so without this - // guard a writer could fire before the first valid scope is established. - if ( - !currentActivityScope || - threadActivityScopeRef.current !== currentActivityScope - ) - return; + // Guard: don't merge into a buffer whose scope has drifted from the + // current identity. isScopeLoaded() also rejects an empty scope, so a + // writer can never fire before the first valid scope is seeded. + if (!activityPersistence.isScopeLoaded()) return; const channelName = channels.find((ch) => ch.id === channelId)?.name ?? ""; @@ -516,25 +503,13 @@ export function useUnreadChannels( if (!added.didAdd) return; const didRecordMentionedRoot = recordMentionedRoot(event); threadActivityRef.current = added.items; - if (normalizedPubkey !== null && normalizedRelayUrl) { - writeActivityToStorage( - normalizedPubkey, - normalizedRelayUrl, - added.items, - ); - } + activityPersistence.schedule(currentActivityScope); if (didRecordMentionedRoot) { bumpMembershipVersion(); } bumpLatestVersion(); }, - [ - channels, - currentActivityScope, - normalizedPubkey, - normalizedRelayUrl, - recordMentionedRoot, - ], + [channels, currentActivityScope, activityPersistence, recordMentionedRoot], ); const muteThread = React.useCallback( @@ -785,13 +760,7 @@ export function useUnreadChannels( ); if (added.didAdd) { threadActivityRef.current = added.items; - if (normalizedPubkey && normalizedRelayUrl) { - writeActivityToStorage( - normalizedPubkey, - normalizedRelayUrl, - added.items, - ); - } + activityPersistence.schedule(currentActivityScope); didAdvance = true; } } @@ -1009,7 +978,7 @@ export function useUnreadChannels( mentionedRootIds, recordThreadInteraction, threadActivityItems: projectActivityForScope( - threadActivityScopeRef.current, + activityPersistence.scopeLoadedRef.current, currentActivityScope, threadActivityRef.current, ), diff --git a/desktop/src/features/communities/communityIconCache.test.mjs b/desktop/src/features/communities/communityIconCache.test.mjs new file mode 100644 index 00000000000..bc25e4b4075 --- /dev/null +++ b/desktop/src/features/communities/communityIconCache.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + boundCommunityIconCache, + loadCachedCommunityIcon, + MAX_CACHED_COMMUNITY_ICON_LENGTH, + MAX_CACHED_COMMUNITY_ICONS, + saveCachedCommunityIcon, +} from "./communityIconCache.ts"; + +test("community icon cache caps entries and rejects oversized icons", () => { + const cache = Object.fromEntries( + Array.from({ length: MAX_CACHED_COMMUNITY_ICONS + 1 }, (_, index) => [ + `relay-${index}`, + `icon-${index}`, + ]), + ); + cache.oversized = "x".repeat(MAX_CACHED_COMMUNITY_ICON_LENGTH + 1); + + const bounded = boundCommunityIconCache(cache); + + assert.equal(Object.keys(bounded).length, MAX_CACHED_COMMUNITY_ICONS); + assert.equal(bounded["relay-0"], undefined); + assert.equal(bounded.oversized, undefined); + assert.equal(bounded[`relay-${MAX_CACHED_COMMUNITY_ICONS}`], "icon-32"); +}); + +test("community icon cache accepts relay-sized icons above 64 KiB", () => { + const values = new Map([ + ["buzz-community-icons", JSON.stringify({ relay: "prior-icon" })], + ]); + globalThis.localStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }; + const acceptedIcon = "x".repeat(80 * 1024); + + saveCachedCommunityIcon("relay", acceptedIcon); + + assert.equal(loadCachedCommunityIcon("relay"), acceptedIcon); +}); diff --git a/desktop/src/features/communities/communityIconCache.ts b/desktop/src/features/communities/communityIconCache.ts index 704b89ed27e..2c391f8ddc1 100644 --- a/desktop/src/features/communities/communityIconCache.ts +++ b/desktop/src/features/communities/communityIconCache.ts @@ -5,13 +5,28 @@ */ const ICON_CACHE_KEY = "buzz-community-icons"; +export const MAX_CACHED_COMMUNITY_ICONS = 32; +// Keep aligned with MAX_WORKSPACE_ICON_DATA_URL_LEN in +// crates/buzz-relay/src/handlers/relay_admin.rs. +export const MAX_CACHED_COMMUNITY_ICON_LENGTH = 98_304; + +export function boundCommunityIconCache( + cache: Record, +): Record { + const entries = Object.entries(cache).filter( + ([, icon]) => + typeof icon === "string" && + icon.length <= MAX_CACHED_COMMUNITY_ICON_LENGTH, + ); + return Object.fromEntries(entries.slice(-MAX_CACHED_COMMUNITY_ICONS)); +} function loadCache(): Record { try { const raw = localStorage.getItem(ICON_CACHE_KEY); const parsed: unknown = raw ? JSON.parse(raw) : null; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; + return boundCommunityIconCache(parsed as Record); } } catch { // Corrupt cache — fall through to empty. @@ -28,13 +43,17 @@ export function saveCachedCommunityIcon( icon: string | null, ): void { const cache = loadCache(); - if (icon) { + if (icon && icon.length <= MAX_CACHED_COMMUNITY_ICON_LENGTH) { + delete cache[relayUrl]; cache[relayUrl] = icon; } else { delete cache[relayUrl]; } try { - localStorage.setItem(ICON_CACHE_KEY, JSON.stringify(cache)); + localStorage.setItem( + ICON_CACHE_KEY, + JSON.stringify(boundCommunityIconCache(cache)), + ); } catch { // Quota exceeded — the icon still renders from the in-memory query. } diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 530933acc26..3ff00df51b4 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -5,10 +5,7 @@ import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommu import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome"; -import { - OnboardingFooter, - OnboardingFooterProvider, -} from "@/features/onboarding/ui/OnboardingFooter"; +import { OnboardingFooterProvider } from "@/features/onboarding/ui/OnboardingFooter"; import { type OnboardingTransitionDirection, OnboardingSlideTransition, @@ -92,27 +89,49 @@ export function WelcomeSetup({ [communityOnboarding, page], ); + const beginHostedCommunity = React.useCallback( + () => setIsHostedSignInOpen(true), + [], + ); + const transitionDirection = transitionMode === "backward" ? "backward" : "forward"; - const welcomeEffect = - transitionMode === "backward" ? "line-slide" : "mask-reveal-up"; + const backAction = + page === "welcome" && onBack + ? { onClick: onBack, testId: "welcome-setup-back" } + : page === "existing" + ? { + onClick: () => showPage("welcome"), + testId: "existing-back", + } + : page === "join" + ? { + onClick: () => showPage("welcome"), + testId: "welcome-join-back", + } + : page === "member" + ? { + onClick: () => showPage("existing"), + testId: "welcome-member-back", + } + : undefined; return (
- +
{page === "welcome" ? (

@@ -144,7 +163,7 @@ export function WelcomeSetup({ >

- {onBack ? ( - - - - ) : null}
) : page === "existing" ? (
- - - ) : page === "owned" ? ( ) : null}
-
+
{member.role}
+
+
+

+ Members + {members.length > 0 ? ( + + {members.length} + + ) : null} +

+
-
-

- Members - {members.length > 0 ? ( - - {members.length} - - ) : null} -

-
-
{ + focusManager.setFocused(undefined); +}); + +async function focusRefetchCount({ ageMs, policy }) { + focusManager.setFocused(false); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const queryKey = ["focus-refetch-policy", policy.staleTime, ageMs]; + queryClient.setQueryData(queryKey, "cached", { + updatedAt: Date.now() - ageMs, + }); + let fetchCount = 0; + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => { + fetchCount += 1; + return "refetched"; + }, + refetchOnMount: false, + ...policy, + }); + const unsubscribe = observer.subscribe(() => {}); + + focusManager.setFocused(true); + await new Promise((resolve) => setImmediate(resolve)); + + unsubscribe(); + queryClient.unmount(); + return fetchCount; +} + +test("custom-emoji: skips fresh focus refetch", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: customEmojiFocusRefetchPolicy.staleTime - 1_000, + policy: customEmojiFocusRefetchPolicy, + }), + 0, + ); +}); + +test("custom-emoji: does not refetch stale data on focus", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: customEmojiFocusRefetchPolicy.staleTime + 1, + policy: customEmojiFocusRefetchPolicy, + }), + 0, + ); +}); diff --git a/desktop/src/features/custom-emoji/hooks.ts b/desktop/src/features/custom-emoji/hooks.ts index de36e11e16c..22016496f2f 100644 --- a/desktop/src/features/custom-emoji/hooks.ts +++ b/desktop/src/features/custom-emoji/hooks.ts @@ -10,6 +10,7 @@ import { setCustomEmoji, } from "@/shared/api/customEmoji"; import { relayClient } from "@/shared/api/relayClient"; +import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; /** @@ -22,19 +23,35 @@ import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; * live event is missed. Mirrors `user-status/hooks.ts`. */ +/** Keeps focused polling at the established 2-minute backstop cadence. */ +export const CUSTOM_EMOJI_REFETCH_INTERVAL_MS = 120_000; +/** Suppresses the focus refetch until emoji data is genuinely stale. + * The live subscription (invalidateQueries) is the primary freshness path. */ +export const CUSTOM_EMOJI_FOCUS_STALE_TIME_MS = 5 * 60_000; + +/** Focus-refetch policy for the custom emoji query; consumed by focusRefetchPolicy.test.mjs. */ +export const customEmojiFocusRefetchPolicy = { + staleTime: CUSTOM_EMOJI_FOCUS_STALE_TIME_MS, + refetchOnWindowFocus: false, +} as const; + export const customEmojiQueryKey = ["custom-emoji"] as const; /** Query key for the caller's OWN editable 30030 set (distinct from the union). */ export const ownCustomEmojiQueryKey = ["custom-emoji-own"] as const; export function useCustomEmojiQuery() { + const refetchInterval = useFocusedRefetchInterval( + CUSTOM_EMOJI_REFETCH_INTERVAL_MS, + ); + return useQuery({ queryKey: customEmojiQueryKey, queryFn: listCustomEmoji, // The palette changes rarely; avoid refetch storms while the picker is open, // but poll every 2 minutes as a backstop for any missed live event. - staleTime: 60_000, - refetchInterval: 120_000, + refetchInterval, + ...customEmojiFocusRefetchPolicy, }); } diff --git a/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx b/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx index 4e189a7953e..37199297f2e 100644 --- a/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx +++ b/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx @@ -143,11 +143,14 @@ export function CustomEmojiSettingsCard() { if (canSubmit) void handleAdd(); }} > - +

Upload an image

-

+

Square images work best. GIF, PNG, JPEG, and WebP files are supported.

@@ -191,7 +194,10 @@ export function CustomEmojiSettingsCard() {

Give it a name

-

+

This is what you’ll type to add this emoji to messages and reactions.

@@ -221,12 +227,18 @@ export function CustomEmojiSettingsCard() { Use only letters, numbers, hyphen, or underscore.

) : pendingUpload === null ? ( -

+

Choose an image first; Buzz will suggest a name from the filename.

) : ownDuplicate ? ( -

+

You already have :{normalized}: — saving will replace its image.

@@ -256,24 +268,21 @@ export function CustomEmojiSettingsCard() { -
-

- My emoji{own.length > 0 ? ` (${own.length})` : ""} -

+
{ownLoading ? ( - +
Loading…
) : own.length === 0 ? ( - +
You haven't added any emoji yet. Add one above.
) : ( - + {own.map((e) => (
{!communityLoading && othersEmoji.length > 0 ? ( -
-

- Community emoji ({othersEmoji.length}) -

-

- Added by other members. You can use these, but only their owner - can remove them. -

- +
+ {othersEmoji.map((e) => (
{ + focusManager.setFocused(undefined); +}); + +async function focusRefetchCount({ ageMs, policy }) { + focusManager.setFocused(false); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const queryKey = ["focus-refetch-policy", policy.staleTime, ageMs]; + queryClient.setQueryData(queryKey, "cached", { + updatedAt: Date.now() - ageMs, + }); + let fetchCount = 0; + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => { + fetchCount += 1; + return "refetched"; + }, + refetchOnMount: false, + ...policy, + }); + const unsubscribe = observer.subscribe(() => {}); + + focusManager.setFocused(true); + await new Promise((resolve) => setImmediate(resolve)); + + unsubscribe(); + queryClient.unmount(); + return fetchCount; +} + +test("forum: skips fresh focus refetch", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: forumFocusRefetchPolicy.staleTime - 1_000, + policy: forumFocusRefetchPolicy, + }), + 0, + ); +}); + +test("forum: does not refetch stale data on focus", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: forumFocusRefetchPolicy.staleTime + 1, + policy: forumFocusRefetchPolicy, + }), + 0, + ); +}); diff --git a/desktop/src/features/forum/hooks.ts b/desktop/src/features/forum/hooks.ts index f4537e92906..2918a694fd0 100644 --- a/desktop/src/features/forum/hooks.ts +++ b/desktop/src/features/forum/hooks.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getForumPosts, getForumThread } from "@/shared/api/forum"; +import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; import type { @@ -10,6 +11,20 @@ import type { } from "@/shared/api/types"; import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds"; +/** Keeps focused polling for forum posts at the established 15-second cadence. */ +export const FORUM_POSTS_REFETCH_INTERVAL_MS = 15_000; +/** Keeps focused polling for forum threads at the established 10-second cadence. */ +export const FORUM_THREAD_REFETCH_INTERVAL_MS = 10_000; +/** Suppresses the focus refetch until forum data is genuinely stale. + * Both families poll while focused and have push-invalidation from mutations. */ +export const FORUM_FOCUS_STALE_TIME_MS = 5 * 60_000; + +/** Focus-refetch policy shared by forum-posts and forum-thread queries; consumed by focusRefetchPolicy.test.mjs. */ +export const forumFocusRefetchPolicy = { + staleTime: FORUM_FOCUS_STALE_TIME_MS, + refetchOnWindowFocus: false, +} as const; + export function forumPostsQueryKey(channelId: string) { return ["forum-posts", channelId] as const; } @@ -19,6 +34,10 @@ export function forumThreadQueryKey(channelId: string, eventId: string) { } export function useForumPostsQuery(channel: Channel | null) { + const refetchInterval = useFocusedRefetchInterval( + FORUM_POSTS_REFETCH_INTERVAL_MS, + ); + const channelId = channel?.id ?? ""; const enabled = channel !== null && channel.channelType === "forum"; const relaySelfPubkey = useRelaySelfQuery(enabled).data; @@ -27,8 +46,8 @@ export function useForumPostsQuery(channel: Channel | null) { enabled, queryKey: [...forumPostsQueryKey(channelId), relaySelfPubkey ?? null], queryFn: () => getForumPosts(channelId, 50, undefined, relaySelfPubkey), - staleTime: 15_000, - refetchInterval: 15_000, + refetchInterval, + ...forumFocusRefetchPolicy, }); } @@ -36,6 +55,10 @@ export function useForumThreadQuery( channelId: string | null, eventId: string | null, ) { + const refetchInterval = useFocusedRefetchInterval( + FORUM_THREAD_REFETCH_INTERVAL_MS, + ); + const enabled = channelId !== null && eventId !== null; const relaySelfPubkey = useRelaySelfQuery(enabled).data; @@ -53,8 +76,8 @@ export function useForumThreadQuery( undefined, relaySelfPubkey, ), - staleTime: 10_000, - refetchInterval: 10_000, + refetchInterval, + ...forumFocusRefetchPolicy, }); } diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 625dc173600..6204186abe9 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -114,6 +114,7 @@ export function ForumComposer({ editable: !disabled, mentionNames: mentions.knownNames, channelNames: channelLinks.knownChannelNames, + messageLinkChannels: channelLinks.channels, onSubmit: () => submitMessageRef.current(), isAutocompleteOpen: isAutocompleteOpenRef, onEditLink: (info) => onEditLinkRef.current?.(info), diff --git a/desktop/src/features/home/focusRefetchPolicy.test.mjs b/desktop/src/features/home/focusRefetchPolicy.test.mjs new file mode 100644 index 00000000000..1b9da3922a4 --- /dev/null +++ b/desktop/src/features/home/focusRefetchPolicy.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { + focusManager, + QueryClient, + QueryObserver, +} from "@tanstack/react-query"; + +import { + channelsFocusRefetchPolicy, + CHANNELS_REFETCH_INTERVAL_MS, +} from "@/features/channels/hooks.ts"; +import { + homeFeedFocusRefetchPolicy, + HOME_FEED_REFETCH_INTERVAL_MS, +} from "./hooks.ts"; + +afterEach(() => { + focusManager.setFocused(undefined); +}); + +async function focusRefetchCount({ ageMs, policy }) { + focusManager.setFocused(false); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const queryKey = ["focus-refetch-policy", policy.staleTime, ageMs]; + queryClient.setQueryData(queryKey, "cached", { + updatedAt: Date.now() - ageMs, + }); + let fetchCount = 0; + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => { + fetchCount += 1; + return "refetched"; + }, + // Keep setup from fetching stale cache before the simulated focus return. + refetchOnMount: false, + ...policy, + }); + const unsubscribe = observer.subscribe(() => {}); + + focusManager.setFocused(true); + await new Promise((resolve) => setImmediate(resolve)); + + unsubscribe(); + queryClient.unmount(); + return fetchCount; +} + +for (const entry of [ + { + name: "channels", + focusPolicy: channelsFocusRefetchPolicy, + refetchInterval: CHANNELS_REFETCH_INTERVAL_MS, + expectedRefetchInterval: 60_000, + }, + { + name: "home feed", + focusPolicy: homeFeedFocusRefetchPolicy, + refetchInterval: HOME_FEED_REFETCH_INTERVAL_MS, + expectedRefetchInterval: 30_000, + }, +]) { + test(`${entry.name} skips fresh focus refetch and preserves polling`, async () => { + assert.equal(entry.refetchInterval, entry.expectedRefetchInterval); + assert.equal( + await focusRefetchCount({ + ageMs: entry.focusPolicy.staleTime - 1_000, + policy: entry.focusPolicy, + }), + 0, + ); + }); + + test(`${entry.name} does not refetch stale data on focus`, async () => { + assert.equal( + await focusRefetchCount({ + ageMs: entry.focusPolicy.staleTime + 1, + policy: entry.focusPolicy, + }), + 0, + ); + }); +} diff --git a/desktop/src/features/home/hooks.ts b/desktop/src/features/home/hooks.ts index 7d18fb80d8e..ce4a0a3056a 100644 --- a/desktop/src/features/home/hooks.ts +++ b/desktop/src/features/home/hooks.ts @@ -2,10 +2,25 @@ import { useQuery } from "@tanstack/react-query"; import { getHomeFeed } from "@/shared/api/tauri"; import { useRelayConnection } from "@/shared/api/useRelayConnection"; +import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; + +/** Keeps focused polling at the established 30-second cadence. */ +export const HOME_FEED_REFETCH_INTERVAL_MS = 30_000; +/** Suppresses the expensive focus refetch until the home feed is old. */ +export const HOME_FEED_FOCUS_STALE_TIME_MS = 5 * 60_000; + +/** Focus-refetch policy for the home feed query; consumed by focusRefetchPolicy.test.mjs. */ +export const homeFeedFocusRefetchPolicy = { + staleTime: HOME_FEED_FOCUS_STALE_TIME_MS, + refetchOnWindowFocus: false, +} as const; export function useHomeFeedQuery() { const connectionState = useRelayConnection(); const connected = connectionState === "connected"; + const refetchInterval = useFocusedRefetchInterval( + connected ? HOME_FEED_REFETCH_INTERVAL_MS : false, + ); return useQuery({ queryKey: ["home-feed"], @@ -14,11 +29,11 @@ export function useHomeFeedQuery() { limit: 50, types: "mentions,needs_action,activity,agent_activity", }), - staleTime: 15_000, gcTime: 5 * 60 * 1_000, // Pause background polling on degraded/stalled/disconnected connections. // The relay can't serve the request anyway, and the spurious failures // consume quota that the recovery path needs. - refetchInterval: connected ? 30_000 : false, + refetchInterval, + ...homeFeedFocusRefetchPolicy, }); } diff --git a/desktop/src/features/home/ui/HomeMembersSidebarOverlay.tsx b/desktop/src/features/home/ui/HomeMembersSidebarOverlay.tsx new file mode 100644 index 00000000000..88df346af43 --- /dev/null +++ b/desktop/src/features/home/ui/HomeMembersSidebarOverlay.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import type { Channel } from "@/shared/api/types"; + +const MembersSidebar = React.lazy(async () => { + const module = await import("@/features/channels/ui/MembersSidebar"); + return { default: module.MembersSidebar }; +}); + +export function HomeMembersSidebarOverlay({ + channel, + currentPubkey, + onClose, +}: { + channel: Channel | null; + currentPubkey?: string; + onClose: () => void; +}) { + const { activeCommunity } = useCommunities(); + + if (!channel) return null; + + return ( + + { + if (!nextOpen) onClose(); + }} + open={true} + relayUrl={activeCommunity?.relayUrl} + /> + + ); +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 4ffbbaddc53..3b7bff44ea0 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -61,7 +61,7 @@ import { useRelaySelfQuery } from "@/features/moderation/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; -import type { HomeFeedResponse } from "@/shared/api/types"; +import type { Channel, HomeFeedResponse } from "@/shared/api/types"; import { KIND_REACTION } from "@/shared/constants/kinds"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; @@ -72,6 +72,7 @@ import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/Aux import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { Button } from "@/shared/ui/button"; +import { HomeMembersSidebarOverlay } from "./HomeMembersSidebarOverlay"; const INBOX_SEARCH_KEYS = [ "item", @@ -167,6 +168,9 @@ export function HomeView({ const [managedChannelId, setManagedChannelId] = React.useState( null, ); + const [membersChannel, setMembersChannel] = React.useState( + null, + ); const { goChannel } = useAppNavigation(); const openDmMutation = useOpenDmMutation(); const openDm = openDmMutation.mutateAsync; @@ -972,6 +976,7 @@ export function HomeView({ channel={managedChannel} currentPubkey={currentPubkey} layout="split" + onOpenMembers={() => setMembersChannel(managedChannel)} onOpenChange={(nextOpen) => { if (!nextOpen) { setManagedChannelId(null); @@ -983,6 +988,11 @@ export function HomeView({ ) : null}
+ setMembersChannel(null)} + /> ); } diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 54b47820eb0..c1ad2607528 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -29,9 +29,11 @@ import { formatTime } from "@/features/messages/lib/dateFormatters"; import { hasSameMessageAuthor, isWithinGroupingWindow, + startsNewMessageGroup, } from "@/features/messages/lib/messageGrouping"; import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import { buildEditMentionState } from "@/features/messages/lib/draftMentionRefs"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { getThreadReference } from "@/features/messages/lib/threading"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -401,12 +403,21 @@ function InboxMessageDetailPane({ displayMessages.find((message) => message.id === replyTargetId) ?? null; const editTarget = displayMessages.find((message) => message.id === editTargetId) ?? null; + const editMentionState = editTarget + ? buildEditMentionState( + editTarget.content, + editTarget.tags, + profiles, + (pubkey) => agentPubkeys?.has(pubkey) === true, + ) + : null; const composerEditTarget = editTarget ? { author: editTarget.authorLabel, body: editTarget.content, id: editTarget.id, imetaMedia: imetaMediaFromTags(editTarget.tags), + ...editMentionState, } : null; // Explicit sub-message reply wins. Otherwise use the captured default parent @@ -614,6 +625,7 @@ function InboxMessageDetailPane({ const previousMessage = displayMessages[index - 1]; const isContinuation = !isAfterSeparator && + !startsNewMessageGroup(message) && hasSameMessageAuthor( { pubkey: previousMessage?.authorPubkey }, { pubkey: message.authorPubkey }, diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index fa214dc730e..17b06bf284d 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -82,13 +82,14 @@ function InboxLabel({
{label.text} {label.channelLabel ? ( diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index d63b669f155..12007731894 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { setupAudioWorklet, type AudioWorkletHandle } from "./lib/audioWorklet"; import { type AudioInputDevice, useAudioDevices } from "./lib/useAudioDevices"; +import { usePipelineHotstart } from "./lib/usePipelineHotstart"; import { formatHuddleActionError } from "./lib/huddleError"; import { type VoiceInputMode, @@ -47,7 +48,6 @@ const HUDDLE_AUDIO_STATE_EVENT = "huddle-audio-state"; const HUDDLE_AUDIO_LEVEL_EVENT = "huddle-audio-level"; const MIC_ANALYSER_UPDATE_INTERVAL_MS = 33; -const PIPELINE_HOTSTART_INTERVAL_MS = 15_000; const MIC_INITIAL_NOISE_FLOOR = 0.01; const MIC_VOICE_GATE_ON_RMS = 0.018; const MIC_VOICE_GATE_OFF_RMS = 0.012; @@ -778,16 +778,7 @@ export function HuddleProvider({ selfPubkeyRef, ); - // Pipeline hot-start — check if voice models finished downloading mid-huddle - React.useEffect(() => { - if (!ephemeralChannelId) return; - const id = window.setInterval(() => { - invoke("check_pipeline_hotstart").catch(() => { - /* best-effort */ - }); - }, PIPELINE_HOTSTART_INTERVAL_MS); - return () => window.clearInterval(id); - }, [ephemeralChannelId]); + usePipelineHotstart(ephemeralChannelId); // Mic level analyser — drives the voice activity indicator React.useEffect(() => { diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index 2bf4c709fe3..81920e5ea94 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -22,6 +22,7 @@ import type { RelayEvent } from "@/shared/api/types"; import { KIND_HUDDLE_REACTION } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { useDocumentVisible } from "@/shared/lib/useDocumentVisible"; import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; @@ -150,6 +151,7 @@ export function HuddleBar({ onOpenHuddleWindow, onVisibilityChange, }: HuddleBarProps) { + const documentVisible = useDocumentVisible(); const { leaveHuddle, micConnected, @@ -238,7 +240,7 @@ export function HuddleBar({ } } - void fetchState(); + if (documentVisible) void fetchState(); // Primary: listen for Rust-emitted state change events listen("huddle-state-changed", (event) => { @@ -253,21 +255,27 @@ export function HuddleBar({ // Fallback in case events are missed; keep it slow so normal huddle use is // event-driven and does not keep a sync IPC command warm on the main thread. - const id = window.setInterval( - () => void fetchState(), - HUDDLE_STATE_FALLBACK_INTERVAL_MS, - ); + const id = documentVisible + ? window.setInterval( + () => void fetchState(), + HUDDLE_STATE_FALLBACK_INTERVAL_MS, + ) + : null; return () => { cancelled = true; unlisten?.(); - window.clearInterval(id); + if (id !== null) window.clearInterval(id); }; - }, [applyIncomingState]); + }, [applyIncomingState, documentVisible]); const huddlePhase = state?.phase; React.useEffect(() => { - if (huddlePhase !== "active" && huddlePhase !== "connected") return; + if ( + !documentVisible || + (huddlePhase !== "active" && huddlePhase !== "connected") + ) + return; let cancelled = false; @@ -310,8 +318,12 @@ export function HuddleBar({ return () => { cancelled = true; window.clearInterval(id); - setModelStatus(null); // Clear stale status on huddle end/phase change. }; + }, [documentVisible, huddlePhase]); + + React.useEffect(() => { + if (huddlePhase === "active" || huddlePhase === "connected") return; + setModelStatus(null); }, [huddlePhase]); const isHuddleVisible = isVisibleHuddleState(state); diff --git a/desktop/src/features/huddle/lib/usePipelineHotstart.ts b/desktop/src/features/huddle/lib/usePipelineHotstart.ts new file mode 100644 index 00000000000..d23732ec610 --- /dev/null +++ b/desktop/src/features/huddle/lib/usePipelineHotstart.ts @@ -0,0 +1,22 @@ +import { invoke } from "@tauri-apps/api/core"; +import * as React from "react"; + +const PIPELINE_HOTSTART_INTERVAL_MS = 15_000; + +/** Check if voice models finished downloading mid-huddle. */ +export function usePipelineHotstart(ephemeralChannelId: string | null) { + React.useEffect(() => { + if (!ephemeralChannelId) return; + const checkPipelineHotstart = () => { + invoke("check_pipeline_hotstart").catch(() => { + /* best-effort */ + }); + }; + checkPipelineHotstart(); + const id = window.setInterval( + checkPipelineHotstart, + PIPELINE_HOTSTART_INTERVAL_MS, + ); + return () => window.clearInterval(id); + }, [ephemeralChannelId]); +} diff --git a/desktop/src/features/huddle/lib/useTtsSubscription.ts b/desktop/src/features/huddle/lib/useTtsSubscription.ts index 768b5119899..1744cd7c1bc 100644 --- a/desktop/src/features/huddle/lib/useTtsSubscription.ts +++ b/desktop/src/features/huddle/lib/useTtsSubscription.ts @@ -2,6 +2,10 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import * as React from "react"; +import { + isDocumentVisible, + subscribeDocumentVisibility, +} from "@/shared/lib/useDocumentVisible"; import { buildHuddleTtsLiveFilter } from "@/shared/api/relayChannelFilters"; import { relayClient } from "@/shared/api/relayClient"; import { @@ -209,10 +213,29 @@ export function useTtsSubscription( } // Initial load + periodic refresh (catches mid-huddle agent additions). + // Keep the live subscription installed while hidden, but quiesce its REST + // membership backstop and refresh immediately when the window returns. + let agentRefreshId: number | null = null; + const startAgentRefresh = (refreshNow: boolean) => { + if (agentRefreshId !== null) window.clearInterval(agentRefreshId); + agentRefreshId = null; + if (!isDocumentVisible()) return; + if (refreshNow) void loadAgentPubkeys(); + agentRefreshId = window.setInterval(() => { + void loadAgentPubkeys(); + }, AGENT_PUBKEY_REFRESH_INTERVAL_MS); + }; void loadAgentPubkeys(true); - const agentRefreshId = window.setInterval(() => { - void loadAgentPubkeys(); - }, AGENT_PUBKEY_REFRESH_INTERVAL_MS); + startAgentRefresh(false); + const unsubscribeDocumentVisibility = subscribeDocumentVisibility( + (visible) => { + if (visible) startAgentRefresh(true); + else if (agentRefreshId !== null) { + window.clearInterval(agentRefreshId); + agentRefreshId = null; + } + }, + ); // Install the state listener before requesting a snapshot. If a newer // event arrives while IPC is pending, it supersedes the stale snapshot. @@ -302,7 +325,8 @@ export function useTtsSubscription( speakInOrder.setEnabled(false); cleanup?.(); unlistenHuddleState?.(); - window.clearInterval(agentRefreshId); + unsubscribeDocumentVisibility(); + if (agentRefreshId !== null) window.clearInterval(agentRefreshId); if (agentVerificationRetryId !== null) { window.clearTimeout(agentVerificationRetryId); } diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index f7dd331c2f4..c03faa09481 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -77,11 +77,8 @@ function ObserverArchiveSection({ }: ObserverSectionProps) { const toggleDisabled = toggling; return ( -
-

- Agent observer feed -

- +
+
-

+

{`Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`}

@@ -121,11 +121,8 @@ function AgentMetricArchiveSection({ onToggle, }: AgentMetricSectionProps) { return ( -
-

- Agent turn metrics -

- +
+
-

+

Saves kind {KIND_AGENT_TURN_METRIC} turn-metric events addressed to your pubkey. Stored as plaintext in your local archive so token-usage calculators can read them directly. @@ -240,7 +240,10 @@ function CustomKindsInput({ value, onChange }: CustomKindsInputProps) { type="text" value={value} /> -

+

Space- or comma-separated non-negative integers. Kinds already in the checklist above are ignored.

@@ -266,11 +269,17 @@ function CustomKindsInput({ value, onChange }: CustomKindsInputProps) { type AddFormProps = { channels: Array<{ id: string; name: string }>; + title?: React.ReactNode; onSaved: () => void; onCancel: () => void; }; -function AddSubscriptionForm({ channels, onSaved, onCancel }: AddFormProps) { +function AddSubscriptionForm({ + channels, + title, + onSaved, + onCancel, +}: AddFormProps) { const [selectedChannelId, setSelectedChannelId] = React.useState(""); const [checkedKinds, setCheckedKinds] = React.useState>( new Set(), @@ -316,7 +325,7 @@ function AddSubscriptionForm({ channels, onSaved, onCancel }: AddFormProps) { }; return ( - +
{/* Channel picker */}
@@ -535,25 +544,23 @@ export function LocalArchiveSettingsCard() { /> {/* Channel subscriptions */} -
-

- Channel subscriptions - {channelSubs.length > 0 ? ` (${channelSubs.length})` : ""} -

+
{isLoading ? ( - +
Loading…
) : channelSubs.length === 0 ? ( - +
No channel subscriptions yet. Add one below.
) : ( - + {channelSubs.map((sub) => { const key = `${sub.scopeType}:${sub.scopeValue}`; return ( @@ -567,7 +574,10 @@ export function LocalArchiveSettingsCard() {

{scopeLabel(sub, channelNameById)}

-

+

{sub.scopeType} · kinds: {kindSummary(sub.kinds)}

@@ -590,10 +600,7 @@ export function LocalArchiveSettingsCard() {
{/* Add channel subscription */} -
-

- Add channel subscription -

+
{isAddingOpen ? ( ) : ( - +

Subscribe to a channel

-

+

Choose a channel and select which event types to archive.

diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index bf2d8e7c225..c27c5eef3ac 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -27,6 +27,7 @@ import type { MeshModelOption, MeshNodeStatus, } from "@/shared/api/tauriMesh"; +import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { classifyModelRef } from "../classifyModelRef"; import { @@ -244,159 +245,161 @@ export function MeshComputeSettingsCard() { ) : null} -
-
-
- - {!isSharing ? ( - - ) : null} -
- -
- - { - setModelInput(next); - writeDraft(MODEL_DRAFT_STORAGE_KEY, next); - }} - /> - -
- - {advancedOpen ? ( -
-
+ ) : null} +
+ + + {showSharingControls ? ( + +
+

Status

+
+ + {servingIndicator.show ? ( +

+ {servingIndicator.label} + {servingIndicator.detail ? ( + + {" "} + · {servingIndicator.detail} + + ) : null} +

+ ) : null} +
+
+
+ ) : null} +
+
+
); } @@ -571,7 +574,10 @@ function MeshModelPicker({ value={model} /> ) : null} -

+

{catalog ? `Recommended for this machine${catalog.gpuName ? ` (${catalog.gpuName}, ${catalog.vramDisplay} AI memory)` : ""}.` : "Choose a model or enter a model reference or local file."}{" "} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 545500bfe41..e59cd72a919 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -1,5 +1,10 @@ import { useEffect, useEffectEvent } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -27,6 +32,7 @@ import { export { mergeMessages, mergeTimelineCacheMessages }; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { messageMentionPubkeys } from "@/features/messages/lib/messageMentionPubkeys"; +import { buildSentFromThreadTag } from "@/features/messages/lib/sentFromThread"; import { clearTimeoutState, recordTimeoutFromRejection, @@ -87,6 +93,8 @@ export function createOptimisticMessage( mentionPubkeys: string[] = [], parentEventId: string | null = null, mediaTags: string[][] = [], + sentFromThreadRootId: string | null = null, + sentFromThreadRootExcerpt: string | null = null, ): RelayEvent { const localKey = `optimistic-${crypto.randomUUID()}`; const tags: string[][] = []; @@ -115,6 +123,11 @@ export function createOptimisticMessage( for (const tag of mediaTags) { tags.push(tag); } + if (sentFromThreadRootId) { + tags.push( + buildSentFromThreadTag(sentFromThreadRootId, sentFromThreadRootExcerpt), + ); + } return { id: localKey, @@ -226,26 +239,46 @@ export function useChannelWindowQuery(channel: Channel | null) { }); } +export function reconcileFetchedChannelWindow( + queryClient: QueryClient, + channelId: string, + events: Awaited>, + previousMessages: RelayEvent[], + signal: AbortSignal, +): RelayEvent[] { + // Tauri invokes cannot be canceled after dispatch. A replacement refetch can + // therefore win while this older request is still in flight. Never let that + // canceled request commit its stale page into the authoritative window. + signal.throwIfAborted(); + const windowKey = channelWindowKey(channelId); + const page = parseChannelWindowResponse(events, channelId, null); + const current = + queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(); + const next = replaceNewestChannelWindow(current, page); + queryClient.setQueryData(windowKey, next); + return reconcileChannelWindowMessages(next, previousMessages); +} + export function useChannelMessagesQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); - const windowKey = channelWindowKey(channel?.id ?? "none"); return useQuery({ enabled: channel !== null && channel.channelType !== "forum", queryKey, - queryFn: async () => { + queryFn: async ({ signal }) => { if (!channel) throw new Error("No channel selected."); const previousMessages = queryClient.getQueryData(queryKey) ?? []; const events = await getChannelWindowEvents(channel.id); - const page = parseChannelWindowResponse(events, channel.id, null); - const current = - queryClient.getQueryData(windowKey) ?? - emptyChannelWindowStore(); - const next = replaceNewestChannelWindow(current, page); - queryClient.setQueryData(windowKey, next); - return reconcileChannelWindowMessages(next, previousMessages); + return reconcileFetchedChannelWindow( + queryClient, + channel.id, + events, + previousMessages, + signal, + ); }, staleTime: 5 * 60 * 1_000, gcTime: 60 * 60 * 1_000, @@ -373,6 +406,10 @@ export function useChannelSubscription(channel: Channel | null) { } cleanup = dispose; + // The live subscription starts at "now", so it cannot close the gap + // between the last page snapshot and subscription establishment. Always + // refresh after the subscription is active; freshness alone is not a + // proof that no relay events landed in that interval. void refreshNewestWindow().catch((error) => { if (!isDisposed) { console.error( @@ -413,6 +450,8 @@ export function useSendMessageMutation( mentionPubkeys?: string[]; parentEventId?: string | null; mediaTags?: string[][]; + sentFromThreadRootId?: string | null; + sentFromThreadRootExcerpt?: string | null; }, MessageQueryContext | undefined >({ @@ -423,6 +462,8 @@ export function useSendMessageMutation( mentionPubkeys, parentEventId, mediaTags, + sentFromThreadRootId, + sentFromThreadRootExcerpt, }) => { // Prefer a channel captured by the caller at compose time. Otherwise, // resolve a captured id from the shared channel cache so navigation @@ -465,6 +506,18 @@ export function useSendMessageMutation( identity.pubkey, mentionPubkeys, ); + if (sentFromThreadRootId && parentEventId) { + throw new Error( + "A thread message can only be sent as a top-level message.", + ); + } + + const sentFromThreadTag = sentFromThreadRootId + ? buildSentFromThreadTag( + sentFromThreadRootId, + sentFromThreadRootExcerpt, + ) + : undefined; // Messages carrying media OR custom-emoji tags MUST go through REST so // the relay's tag validation runs. The WebSocket path emits no extra @@ -489,6 +542,7 @@ export function useSendMessageMutation( emojiTags, mentionTags, linkPreviewTags, + sentFromThreadTag, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -527,6 +581,7 @@ export function useSendMessageMutation( ...emojiTags, ...mentionTags, ...linkPreviewTags, + ...(sentFromThreadTag ? [sentFromThreadTag] : []), ], content: content.trim(), sig: "", @@ -537,7 +592,7 @@ export function useSendMessageMutation( effectiveChannel.id, content, recipientPubkeys, - mentionTags, + [...mentionTags, ...(sentFromThreadTag ? [sentFromThreadTag] : [])], ); }, onMutate: async ({ @@ -547,6 +602,8 @@ export function useSendMessageMutation( mentionPubkeys, parentEventId, mediaTags, + sentFromThreadRootId, + sentFromThreadRootExcerpt, }) => { // Mirror mutationFn's target resolution so the optimistic message lands // in the cache for the same channel as the real send. A caller-supplied @@ -582,6 +639,8 @@ export function useSendMessageMutation( mentionPubkeys ?? [], parentEventId ?? null, mediaTags ?? [], + sentFromThreadRootId ?? null, + sentFromThreadRootExcerpt ?? null, ); const nextWindow = mergeLiveChannelWindowEvent( @@ -718,7 +777,11 @@ export function useEditMessageMutation(channel: Channel | null) { // Split so each rides its own validated Tauri arg — emoji tags must NOT // go through the imeta-only `mediaTags` channel (the Rust `imeta_tags` // guard rejects any non-imeta prefix), mirroring the send path. - const { mediaTags: imetaTags, emojiTags } = splitOutgoingTags(mediaTags); + const { + mediaTags: imetaTags, + emojiTags, + mentionTags, + } = splitOutgoingTags(mediaTags); await editMessage( channel.id, @@ -727,9 +790,11 @@ export function useEditMessageMutation(channel: Channel | null) { imetaTags, emojiTags, mentionPubkeys, + false, + mentionTags, ); }, - onSuccess: (_data, { eventId, content, mediaTags }) => { + onSuccess: (_data, { eventId, content, mediaTags, mentionPubkeys }) => { if (!channel) { return; } @@ -742,9 +807,15 @@ export function useEditMessageMutation(channel: Channel | null) { // only because the edit event round-trip can lag perceptibly.) const applyEdit = (message: RelayEvent): RelayEvent => { if (message.id !== eventId) return message; - const nextTags = mediaTags - ? applyEditTagOverlay(message.tags, mediaTags) - : message.tags; + const editTags = [ + ...(mediaTags ?? []), + ...(mentionPubkeys ?? []).map((pubkey) => ["p", pubkey]), + ["buzz:mention-snapshot"], + ]; + const nextTags = + mediaTags !== undefined || editTags.length > 0 + ? applyEditTagOverlay(message.tags, editTags) + : message.tags; return { ...message, content, tags: nextTags }; }; diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs index becd3203be0..809dcac3bd4 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs @@ -12,6 +12,12 @@ /** * Merge the original event's tags with an edit's tags so that: * - `imeta` tags come exclusively from the edit (full new attachment set); + * - `p` tags from the edit join the original set because only newly added + * mentions notify. Reference-only `mention` tags, by contrast, are a full + * snapshot from the edited composer (marked by `buzz:mention-snapshot`) + * and therefore replace the original set; this preserves the edited body's + * stable recipient identities even before profiles load or after an alias + * changes; * - `emoji` (NIP-30 custom-emoji) tags come from the edit *when the edit * supplies any* — the edited body may add or remove custom emoji, so a * supplied set rebuilds the shortcode→url map. But when the edit supplies @@ -22,22 +28,37 @@ * `:shortcode:` that the original rendered fine. Preserving on empty is * strictly safe: an orphaned emoji tag whose shortcode is no longer in the * body resolves nothing, so it can't cause a stale render. - * - all other tag kinds (`h`, `e`, `p` mentions, etc.) come exclusively - * from the original — the edit can't rewrite channel membership, - * thread refs, or mention targets. + * - all other tag kinds (`h`, `e`, etc.) come exclusively from the original + * so the edit can't rewrite channel membership or thread references. * * When `editTags` is undefined, returns `originalTags` unchanged. */ export function applyEditTagOverlay(originalTags, editTags) { if (!editTags) return originalTags; const editEmoji = editTags.filter((t) => t[0] === "emoji"); + const hasMentionSnapshot = editTags.some( + (t) => t[0] === "buzz:mention-snapshot", + ); + const editMentions = editTags.filter((t) => t[0] === "mention"); // imeta is always fully replaced by the edit. emoji is replaced only when // the edit actually supplies emoji tags; otherwise the original's are kept. - const droppedFromOriginal = - editEmoji.length > 0 - ? (t) => t[0] !== "imeta" && t[0] !== "emoji" - : (t) => t[0] !== "imeta"; + // An edit carrying the private snapshot marker is authoritative, including + // an empty mention set. Legacy edits without the marker preserve original + // references so older clients remain compatible. + const droppedFromOriginal = (tag) => { + if (tag[0] === "imeta") return false; + if (editEmoji.length > 0 && tag[0] === "emoji") return false; + if (hasMentionSnapshot && tag[0] === "mention") return false; + return true; + }; const baseFromOriginal = originalTags.filter(droppedFromOriginal); - const overlaidFromEdit = editTags.filter((t) => t[0] === "imeta"); - return [...baseFromOriginal, ...overlaidFromEdit, ...editEmoji]; + const overlaidFromEdit = editTags.filter( + (t) => t[0] === "imeta" || t[0] === "p" || t[0] === "buzz:mention-snapshot", + ); + return [ + ...baseFromOriginal, + ...overlaidFromEdit, + ...editEmoji, + ...editMentions, + ]; } diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs index 783586be68c..d77bf9d940a 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs @@ -104,6 +104,70 @@ test("edit's non-imeta tags are dropped (only imeta wins)", () => { assert.equal(out.filter((t) => t[0] === "imeta").length, 1); }); +test("edit overlays newly added mention tags without replacing original routing", () => { + const original = [ + ["h", "uuid"], + ["p", "original-mention"], + ]; + const edit = [ + ["h", "uuid"], + ["e", "x"], + ["p", "added-mention"], + ]; + + assert.deepEqual( + applyEditTagOverlay(original, edit).filter((tag) => tag[0] === "p"), + [ + ["p", "original-mention"], + ["p", "added-mention"], + ], + ); +}); + +test("edit mention snapshot replaces original references, including removals", () => { + const original = [ + ["h", "uuid"], + ["mention", "original-mention"], + ]; + const replacement = applyEditTagOverlay(original, [ + ["buzz:mention-snapshot"], + ["mention", "replacement-mention"], + ]); + assert.deepEqual( + replacement.filter((tag) => tag[0] === "mention"), + [["mention", "replacement-mention"]], + ); + assert.deepEqual( + replacement.filter((tag) => tag[0] === "buzz:mention-snapshot"), + [["buzz:mention-snapshot"]], + ); + + const removed = applyEditTagOverlay(original, [["buzz:mention-snapshot"]]); + assert.deepEqual( + removed.filter((tag) => tag[0] === "mention"), + [], + ); + assert.deepEqual( + removed.filter((tag) => tag[0] === "buzz:mention-snapshot"), + [["buzz:mention-snapshot"]], + ); +}); + +test("legacy edits preserve original mention references", () => { + const original = [ + ["h", "uuid"], + ["mention", "original-mention"], + ]; + const out = applyEditTagOverlay(original, [ + ["h", "uuid"], + ["e", "x"], + ]); + assert.deepEqual( + out.filter((tag) => tag[0] === "mention"), + [["mention", "original-mention"]], + ); +}); + const EMOJI = (shortcode, url) => ["emoji", shortcode, url]; test("edit replaces the original's emoji tags with the edit's set", () => { diff --git a/desktop/src/features/messages/lib/canSendToChannel.test.mjs b/desktop/src/features/messages/lib/canSendToChannel.test.mjs new file mode 100644 index 00000000000..dd7eb488907 --- /dev/null +++ b/desktop/src/features/messages/lib/canSendToChannel.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertCanSendMessageToChannel, + canSendMessageToChannel, +} from "./canSendToChannel.ts"; + +const CURRENT = "a".repeat(64); +const OWNED_AGENT = "b".repeat(64); +const OTHER_PERSON = "c".repeat(64); +const OTHER_AGENT = "d".repeat(64); + +const message = (pubkey) => ({ kind: 9, pubkey }); +const profiles = { + [OWNED_AGENT]: { isAgent: true, ownerPubkey: CURRENT }, + [OTHER_AGENT]: { isAgent: true, ownerPubkey: OTHER_PERSON }, +}; + +test("send-to-channel permits self-authored messages", () => { + assert.equal( + canSendMessageToChannel(message(CURRENT), CURRENT, profiles), + true, + ); +}); + +test("send-to-channel permits messages from an agent owned by the viewer", () => { + assert.equal( + canSendMessageToChannel(message(OWNED_AGENT), CURRENT, profiles), + true, + ); +}); + +test("send-to-channel rejects specialized message kinds", () => { + const diffMessage = { ...message(CURRENT), kind: 40008 }; + + assert.equal(canSendMessageToChannel(diffMessage, CURRENT, profiles), false); + assert.throws( + () => assertCanSendMessageToChannel(diffMessage, CURRENT, profiles), + /Only ordinary channel messages/, + ); +}); + +test("send-to-channel rejects pending messages", () => { + const pendingMessage = { ...message(CURRENT), pending: true }; + + assert.equal( + canSendMessageToChannel(pendingMessage, CURRENT, profiles), + false, + ); + assert.throws( + () => assertCanSendMessageToChannel(pendingMessage, CURRENT, profiles), + /finish sending first/, + ); +}); + +test("send-to-channel rejects third-party people and agents", () => { + assert.equal( + canSendMessageToChannel(message(OTHER_PERSON), CURRENT, profiles), + false, + ); + assert.equal( + canSendMessageToChannel(message(OTHER_AGENT), CURRENT, profiles), + false, + ); + assert.throws( + () => + assertCanSendMessageToChannel(message(OTHER_AGENT), CURRENT, profiles), + /only send your own or your agents' messages/, + ); +}); diff --git a/desktop/src/features/messages/lib/canSendToChannel.ts b/desktop/src/features/messages/lib/canSendToChannel.ts new file mode 100644 index 00000000000..d3f040689e3 --- /dev/null +++ b/desktop/src/features/messages/lib/canSendToChannel.ts @@ -0,0 +1,34 @@ +import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import type { TimelineMessage } from "@/features/messages/types"; +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; + +export function canSendMessageToChannel( + message: TimelineMessage, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): boolean { + return ( + message.kind === KIND_STREAM_MESSAGE && + !message.pending && + canManageMessageForCurrentUser(message, currentPubkey, profiles) + ); +} + +export function assertCanSendMessageToChannel( + message: TimelineMessage, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): void { + if (message.kind !== KIND_STREAM_MESSAGE) { + throw new Error( + "Only ordinary channel messages can be sent to the channel.", + ); + } + if (message.pending) { + throw new Error("Wait for the message to finish sending first."); + } + if (!canManageMessageForCurrentUser(message, currentPubkey, profiles)) { + throw new Error("You can only send your own or your agents' messages."); + } +} diff --git a/desktop/src/features/messages/lib/channelWindowReconciliation.ts b/desktop/src/features/messages/lib/channelWindowReconciliation.ts index cc2c0f034c8..f6a7e4df21f 100644 --- a/desktop/src/features/messages/lib/channelWindowReconciliation.ts +++ b/desktop/src/features/messages/lib/channelWindowReconciliation.ts @@ -28,6 +28,18 @@ export function reconcileChannelWindowMessages( messages: RelayEvent[], ) { const windowEvents = flattenChannelWindowEvents(window); + if (window.pages.length === 0) { + // A pageless window is unresolved, not authoritative. This state can exist + // briefly when the companion window query mounts beside an already-cached + // rendered timeline. Preserve that cache while admitting live events; + // otherwise the first live event projects a one-row overlay over the + // entire conversation until reload refetches page zero. + let merged = messages; + for (const event of windowEvents) { + merged = reconcileIncomingMessage(merged, event); + } + return [...merged].sort((left, right) => compareRelayOrder(right, left)); + } const authoritativeIds = new Set(windowEvents.map((event) => event.id)); const retained = retainRefetchReconciliationEvents(messages).filter( (event) => !authoritativeIds.has(event.id), diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs new file mode 100644 index 00000000000..867f37677da --- /dev/null +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +import { + registerComposerMessageLinkMarkdownIt, + resolveComposerMessageLinkAttributes, +} from "./composerMessageLinkNode.ts"; + +const requireFromTiptap = createRequire(import.meta.resolve("tiptap-markdown")); +const MarkdownIt = requireFromTiptap("markdown-it"); + +const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const MESSAGE_ID = "root-event"; +const HREF = `buzz://message?channel=${CHANNEL_ID}&id=${MESSAGE_ID}`; + +test("resolves a composer preview and canonicalizes the underlying href", () => { + assert.deepEqual( + resolveComposerMessageLinkAttributes( + HREF.replace("buzz://", "BUZZ://"), + (channelId) => (channelId === CHANNEL_ID ? "general" : undefined), + ), + { channelName: "general", href: HREF }, + ); +}); + +test("rejects malformed message links", () => { + assert.equal( + resolveComposerMessageLinkAttributes( + `buzz://message?channel=${CHANNEL_ID}`, + () => "general", + ), + null, + ); +}); + +function captureMarkdownRule() { + let capturedAnchor = null; + let capturedRule = null; + const md = { + renderer: { rules: {} }, + inline: { + ruler: { + before(anchor, _name, rule) { + capturedAnchor = anchor; + capturedRule = rule; + }, + }, + }, + utils: { + escapeHtml: (value) => value.replaceAll("&", "&"), + }, + }; + registerComposerMessageLinkMarkdownIt(md, { + resolveChannelName: (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + }); + return { anchor: capturedAnchor, md, rule: capturedRule }; +} + +test("markdown parsing materializes a bare message link in composer content", () => { + const { anchor, rule } = captureMarkdownRule(); + assert.equal(anchor, "text"); + let token = null; + const state = { + src: `See ${HREF}.`, + pos: 4, + push: () => { + token = { meta: null }; + return token; + }, + }; + + assert.equal(rule(state, false), true); + assert.equal(state.pos, 4 + HREF.length); + assert.deepEqual(token.meta, { channelName: "general", href: HREF }); +}); + +test("real markdown-it parsing materializes a restored message link", () => { + const md = new MarkdownIt(); + registerComposerMessageLinkMarkdownIt(md, { + resolveChannelName: (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + }); + + const html = md.renderInline(`See ${HREF}.`); + assert.match(html, /See { + const { rule } = captureMarkdownRule(); + let token = null; + const state = { + pending: "See buzz", + src: `See ${HREF}`, + pos: "See buzz".length, + push: () => { + token = { meta: null }; + return token; + }, + }; + + assert.equal(rule(state, false), true); + assert.equal(state.pending, "See "); + assert.equal(state.pos, state.src.length); + assert.deepEqual(token.meta, { channelName: "general", href: HREF }); +}); + +test("markdown parsing stops message links before emphasis delimiters", () => { + const { rule } = captureMarkdownRule(); + let token = null; + const state = { + src: `${HREF}*`, + pos: 0, + push: () => { + token = { meta: null }; + return token; + }, + }; + + assert.equal(rule(state, false), true); + assert.equal(state.pos, HREF.length); + assert.deepEqual(token.meta, { channelName: "general", href: HREF }); +}); + +test("markdown rendering stores identity in attributes, not visible id text", () => { + const { md } = captureMarkdownRule(); + const render = md.renderer.rules.buzz_composer_message_link; + const html = render([{ meta: { channelName: "general", href: HREF } }], 0); + + assert.match(html, /data-composer-message-link=""/); + assert.match(html, /data-channel-name="general"/); + assert.match(html, /data-href="buzz:\/\/message\?channel=.*&id=/); + assert.doesNotMatch(html, />[^<]*root-event/); +}); diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts new file mode 100644 index 00000000000..5431e2c9605 --- /dev/null +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -0,0 +1,242 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +import { MENTION_CHIP_BASE_CLASSES } from "@/shared/ui/mentionChip"; +import { + getMessageLinkChannelLabel, + getMessageLinkLabel, + MESSAGE_LINK_PREFIX, +} from "./messageLinkLabel"; +import { buildMessageLink, parseMessageLink } from "./messageLink"; + +export const COMPOSER_MESSAGE_LINK_NODE_NAME = "composerMessageLink"; + +export type ComposerMessageLinkNodeOptions = { + resolveChannelName: (channelId: string) => string | undefined; +}; + +export type ComposerMessageLinkAttributes = { + channelName: string; + href: string; +}; + +const BARE_MESSAGE_LINK_AT_START = /^(?:buzz):\/\/message\?[^\s<>"')\]}*_]+/i; +const TRAILING_PUNCTUATION = /[.,;:!?]+$/; + +function trimBareMessageLink(value: string): string { + let trimmed = value.replace(TRAILING_PUNCTUATION, ""); + while (/[)\]]$/.test(trimmed)) { + const closing = trimmed.at(-1) ?? ""; + const opening = closing === ")" ? "(" : "["; + if (trimmed.split(closing).length <= trimmed.split(opening).length) break; + trimmed = trimmed.slice(0, -1).replace(TRAILING_PUNCTUATION, ""); + } + return trimmed; +} + +export function resolveComposerMessageLinkAttributes( + href: string, + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +): ComposerMessageLinkAttributes | null { + const parsed = parseMessageLink(href); + if (!parsed.ok) return null; + return { + channelName: resolveChannelName(parsed.value.channelId) ?? "", + href: buildMessageLink({ + channelId: parsed.value.channelId, + messageId: parsed.value.messageId, + threadRootId: parsed.value.threadRootId, + }), + }; +} + +function unwrapExactMessageLink(text: string): string | null { + const href = + text.startsWith("<") && text.endsWith(">") ? text.slice(1, -1) : text; + if (!href || /\s/.test(href)) return null; + return parseMessageLink(href).ok ? href : null; +} + +function unwrapExactHttpLink(text: string): string | null { + const match = /^(?:<(https?:\/\/[^\s<>]+)>|(https?:\/\/\S+))$/i.exec(text); + return match?.[1] ?? match?.[2] ?? null; +} + +function replaceSelectionWithNode(view: EditorView, node: ProseMirrorNode) { + const { from, to } = view.state.selection; + let transaction = view.state.tr.replaceRangeWith(from, to, node); + const end = transaction.mapping.map(to); + transaction = transaction.insertText(" ", end); + const linkMark = view.state.schema.marks.link; + if (linkMark) transaction = transaction.removeMark(end, end + 1, linkMark); + transaction = transaction.setSelection( + TextSelection.create(transaction.doc, end + 1), + ); + view.dispatch(transaction.setStoredMarks([]).scrollIntoView()); + view.focus(); +} + +export function createComposerLinkPasteHandler( + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +) { + return (view: EditorView, event: ClipboardEvent): boolean => { + const text = event.clipboardData?.getData("text/plain") ?? ""; + const messageHref = unwrapExactMessageLink(text); + const messageLinkType = + view.state.schema.nodes[COMPOSER_MESSAGE_LINK_NODE_NAME]; + if (messageHref && messageLinkType) { + const attrs = resolveComposerMessageLinkAttributes( + messageHref, + resolveChannelName, + ); + if (attrs) { + replaceSelectionWithNode(view, messageLinkType.create(attrs)); + event.preventDefault(); + return true; + } + } + + const httpHref = unwrapExactHttpLink(text); + const linkMark = view.state.schema.marks.link; + if (!httpHref || !linkMark) return false; + replaceSelectionWithNode( + view, + view.state.schema.text(httpHref, [linkMark.create({ href: httpHref })]), + ); + event.preventDefault(); + return true; + }; +} + +export function registerComposerMessageLinkMarkdownIt( + // biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here + md: any, + options: ComposerMessageLinkNodeOptions, +): void { + const ruleName = "buzz_composer_message_link"; + const tokenType = "buzz_composer_message_link"; + if (md.renderer.rules[tokenType]) return; + + // biome-ignore lint/suspicious/noExplicitAny: markdown-it state/silent + const rule = (state: any, silent: boolean): boolean => { + const remaining = state.src.slice(state.pos); + const fullMatch = BARE_MESSAGE_LINK_AT_START.exec(remaining); + const suffixMatch = /^:\/\/message\?[^\s<>"')\]}*_]+/i.exec(remaining); + const resumesTextToken = + !fullMatch && suffixMatch && /buzz$/i.test(state.pending ?? ""); + const rawHref = + fullMatch?.[0] ?? (resumesTextToken ? `buzz${suffixMatch[0]}` : null); + if (!rawHref) return false; + const href = trimBareMessageLink(rawHref); + const attrs = resolveComposerMessageLinkAttributes( + href, + options.resolveChannelName, + ); + if (!attrs) return false; + if (!silent) { + if (resumesTextToken) state.pending = state.pending.slice(0, -4); + const token = state.push(tokenType, "span", 0); + token.meta = attrs; + } + state.pos += href.length - (resumesTextToken ? 4 : 0); + return true; + }; + + md.inline.ruler.before("text", ruleName, rule); + // biome-ignore lint/suspicious/noExplicitAny: markdown-it token + md.renderer.rules[tokenType] = (tokens: any[], index: number): string => { + const attrs = tokens[index].meta as ComposerMessageLinkAttributes; + const escapeHtml = md.utils.escapeHtml; + return ``; + }; +} + +export const ComposerMessageLinkNode = + Node.create({ + name: COMPOSER_MESSAGE_LINK_NODE_NAME, + group: "inline", + inline: true, + atom: true, + selectable: true, + + addOptions() { + return { resolveChannelName: () => undefined }; + }, + + addAttributes() { + return { + channelName: { + default: "", + parseHTML: (element) => + (element as HTMLElement).getAttribute("data-channel-name") ?? "", + renderHTML: () => ({}), + }, + href: { + default: "", + parseHTML: (element) => + (element as HTMLElement).getAttribute("data-href") ?? "", + renderHTML: () => ({}), + }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-composer-message-link]" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + const href = String(node.attrs.href ?? ""); + const parsed = parseMessageLink(href); + const channelName = parsed.ok + ? (this.options.resolveChannelName(parsed.value.channelId) ?? + (String(node.attrs.channelName ?? "") || "channel")) + : "channel"; + const label = getMessageLinkLabel({ channelName }); + const channelLinkLabel = getMessageLinkChannelLabel(channelName); + return [ + "span", + mergeAttributes(HTMLAttributes, { + "aria-label": label, + class: + "inline-flex min-w-0 max-w-80 items-center gap-1.5 align-baseline", + "data-channel-name": channelName, + "data-composer-message-link": "", + "data-href": href, + "data-message-link": "", + title: label, + }), + ["span", { class: "shrink-0" }, MESSAGE_LINK_PREFIX], + [ + "span", + { + class: `${MENTION_CHIP_BASE_CLASSES} min-w-0 max-w-full truncate`, + "data-channel-link": "", + }, + channelLinkLabel, + ], + ]; + }, + + renderText({ node }) { + return String(node.attrs.href ?? ""); + }, + + addStorage() { + return { + markdown: { + // biome-ignore lint/suspicious/noExplicitAny: prosemirror-markdown is untyped here + serialize(state: any, node: any) { + state.write(String(node.attrs.href ?? "")); + }, + parse: { + // biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here + setup(this: { options: ComposerMessageLinkNodeOptions }, md: any) { + registerComposerMessageLinkMarkdownIt(md, this.options); + }, + }, + }, + }; + }, + }); diff --git a/desktop/src/features/messages/lib/draftMentionRefs.test.mjs b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs new file mode 100644 index 00000000000..87ec604464b --- /dev/null +++ b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildEditMentionState, + buildMessageComposerEditTarget, + resolveEditMentionRefs, +} from "./draftMentionRefs.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); +const message = (body, tags) => ({ + author: "Alice", + body, + id: "message-id", + tags, +}); + +const profiles = { + [ALICE]: { displayName: "Alice" }, + [BOB]: { displayName: "Bob" }, +}; + +test("edit mention refs resolve from visible text and loaded profiles", () => { + assert.deepEqual( + resolveEditMentionRefs( + "Please review this, @Alice.", + [["p", ALICE]], + profiles, + () => false, + ), + [{ displayName: "Alice", isAgent: false, pubkey: ALICE }], + ); + + const target = buildMessageComposerEditTarget( + message("Please review this, @Alice.", [["p", ALICE]]), + profiles, + () => false, + ); + assert.deepEqual(target.unresolvedMentionPubkeys, []); +}); + +test("shared edit mention state preserves tagged identities while profiles are unavailable", () => { + assert.deepEqual( + buildEditMentionState( + "Please review this, @Alice and @Bob.", + [ + ["p", ALICE], + ["mention", BOB], + ], + undefined, + () => false, + ), + { mentionRefs: [], unresolvedMentionPubkeys: [ALICE, BOB] }, + ); +}); + +test("edit target preserves tagged identities while profiles are unavailable", () => { + const target = buildMessageComposerEditTarget( + message("Please review this, @Alice and @Bob.", [ + ["p", ALICE], + ["mention", BOB], + ]), + undefined, + () => false, + ); + + assert.deepEqual(target.mentionRefs, []); + assert.deepEqual(target.unresolvedMentionPubkeys, [ALICE, BOB]); +}); + +test("edit target separates resolved refs from identities missing profiles", () => { + const target = buildMessageComposerEditTarget( + message("Please review this, @Alice and @Bob.", [ + ["p", ALICE], + ["mention", BOB], + ]), + { [ALICE]: profiles[ALICE] }, + () => false, + ); + + assert.deepEqual(target.mentionRefs, [ + { displayName: "Alice", isAgent: false, pubkey: ALICE }, + ]); + assert.deepEqual(target.unresolvedMentionPubkeys, [BOB]); +}); diff --git a/desktop/src/features/messages/lib/draftMentionRefs.ts b/desktop/src/features/messages/lib/draftMentionRefs.ts index 861d3b8e956..65c7a68fec9 100644 --- a/desktop/src/features/messages/lib/draftMentionRefs.ts +++ b/desktop/src/features/messages/lib/draftMentionRefs.ts @@ -1,7 +1,104 @@ -import type { DraftMentionRef } from "./useDrafts"; - +import { hasMention } from "@/features/messages/lib/hasMention"; +import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { hasMention } from "./hasMention"; +import { + getMentionTagPubkey, + resolveMentionProps, +} from "@/shared/lib/resolveMentionNames"; + +export function resolveEditMentionRefs( + content: string, + tags: string[][] | undefined, + profiles: UserProfileLookup | undefined, + isAgentPubkey: (pubkey: string) => boolean, +): DraftMentionRef[] { + const { mentionNames, mentionPubkeysByName } = resolveMentionProps( + tags, + profiles, + ); + const refs = (mentionNames ?? []) + .filter((displayName) => hasMention(content, displayName)) + .flatMap((displayName) => { + const pubkey = mentionPubkeysByName?.[displayName.toLowerCase()]; + return pubkey + ? [ + { + displayName, + pubkey, + isAgent: isAgentPubkey(normalizePubkey(pubkey)), + }, + ] + : []; + }); + return refs; +} + +function unresolvedEditMentionPubkeys( + content: string, + tags: string[][] | undefined, + refs: readonly DraftMentionRef[], +): string[] { + if (!content.includes("@")) { + return []; + } + + const resolved = new Set(refs.map((ref) => normalizePubkey(ref.pubkey))); + return [ + ...new Set( + (tags ?? []) + .map(getMentionTagPubkey) + .filter((pubkey): pubkey is string => Boolean(pubkey)) + .map(normalizePubkey) + .filter((pubkey) => pubkey && !resolved.has(pubkey)), + ), + ]; +} + +export function buildEditMentionState( + content: string, + tags: string[][] | undefined, + profiles: UserProfileLookup | undefined, + isAgentPubkey: (pubkey: string) => boolean, +): Pick { + const mentionRefs = resolveEditMentionRefs( + content, + tags, + profiles, + isAgentPubkey, + ); + return { + mentionRefs, + unresolvedMentionPubkeys: unresolvedEditMentionPubkeys( + content, + tags, + mentionRefs, + ), + }; +} + +export function buildMessageComposerEditTarget( + message: TimelineMessage, + profiles: UserProfileLookup | undefined, + isAgentPubkey: (pubkey: string) => boolean, +): MessageComposerEditTarget { + const mentionState = buildEditMentionState( + message.body, + message.tags, + profiles, + isAgentPubkey, + ); + return { + author: message.author, + body: message.body, + id: message.id, + imetaMedia: imetaMediaFromTags(message.tags), + ...mentionState, + }; +} export function snapshotDraftMentionRefs( content: string, diff --git a/desktop/src/features/messages/lib/messageGrouping.test.mjs b/desktop/src/features/messages/lib/messageGrouping.test.mjs index 2b25df5b6d8..106792767f8 100644 --- a/desktop/src/features/messages/lib/messageGrouping.test.mjs +++ b/desktop/src/features/messages/lib/messageGrouping.test.mjs @@ -5,8 +5,20 @@ import { MESSAGE_GROUPING_WINDOW_SECONDS, hasSameMessageAuthor, isWithinGroupingWindow, + startsNewMessageGroup, } from "./messageGrouping.ts"; +test("startsNewMessageGroup: sent-from-thread messages start a fresh group", () => { + assert.equal( + startsNewMessageGroup({ + tags: [["buzz:sent-from-thread", "root-event", "Root summary"]], + }), + true, + ); + assert.equal(startsNewMessageGroup({ tags: [["h", "channel-id"]] }), false); + assert.equal(startsNewMessageGroup(undefined), false); +}); + test("hasSameMessageAuthor: matches case-insensitively and trims", () => { assert.equal( hasSameMessageAuthor({ pubkey: " ABC " }, { pubkey: "abc" }), diff --git a/desktop/src/features/messages/lib/messageGrouping.ts b/desktop/src/features/messages/lib/messageGrouping.ts index 8864f60098a..df8f125bb0e 100644 --- a/desktop/src/features/messages/lib/messageGrouping.ts +++ b/desktop/src/features/messages/lib/messageGrouping.ts @@ -1,7 +1,13 @@ +import { getSentFromThreadRootId } from "@/features/messages/lib/sentFromThread"; + type MessageAuthorCandidate = { pubkey?: string | null; }; +type MessageGroupingCandidate = { + tags?: readonly (readonly string[])[] | null; +}; + /** * Max gap (seconds) between two same-author messages for the later one to still * render as a continuation (time-only, no avatar). Beyond this the message @@ -11,6 +17,16 @@ type MessageAuthorCandidate = { */ export const MESSAGE_GROUPING_WINDOW_SECONDS = 10 * 60; +/** + * Shared thread messages introduce context from another conversation, so they + * always start a fresh visual message group even beside the same author. + */ +export function startsNewMessageGroup( + message: MessageGroupingCandidate | null | undefined, +) { + return getSentFromThreadRootId(message?.tags) !== null; +} + export function hasSameMessageAuthor( previous: MessageAuthorCandidate | null | undefined, current: MessageAuthorCandidate | null | undefined, diff --git a/desktop/src/features/messages/lib/messageLinkLabel.test.mjs b/desktop/src/features/messages/lib/messageLinkLabel.test.mjs new file mode 100644 index 00000000000..f9c075e6804 --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkLabel.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getMessageLinkChannelLabel, + getMessageLinkLabel, + MESSAGE_LINK_PREFIX, +} from "./messageLinkLabel.ts"; + +test("ordinary message links expose an Inbox-style prefix and channel label", () => { + assert.equal(MESSAGE_LINK_PREFIX, "Thread in"); + assert.equal(getMessageLinkChannelLabel("general"), "#general"); +}); + +test("ordinary message links name their target thread", () => { + assert.equal( + getMessageLinkLabel({ channelName: "general" }), + "Thread in #general", + ); +}); + +test("ordinary message links include a provided root excerpt", () => { + assert.equal( + getMessageLinkLabel({ + channelName: "general", + threadExcerpt: "Release notes", + }), + "Thread in #general — Release notes", + ); +}); + +test("sent-from-thread links use the excerpt as their visible link", () => { + assert.equal( + getMessageLinkLabel({ + channelName: "general", + threadExcerpt: "Release notes", + variant: "sent-from-thread", + }), + "Release notes", + ); +}); diff --git a/desktop/src/features/messages/lib/messageLinkLabel.ts b/desktop/src/features/messages/lib/messageLinkLabel.ts new file mode 100644 index 00000000000..249988e3d28 --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkLabel.ts @@ -0,0 +1,24 @@ +export type MessageLinkLabelVariant = "default" | "sent-from-thread"; + +export const MESSAGE_LINK_PREFIX = "Thread in"; + +export function getMessageLinkChannelLabel(channelName: string): string { + return `#${channelName}`; +} + +export function getMessageLinkLabel({ + channelName, + threadExcerpt, + variant = "default", +}: { + channelName: string; + threadExcerpt?: string | null; + variant?: MessageLinkLabelVariant; +}): string { + const normalizedExcerpt = threadExcerpt?.trim(); + const baseLabel = `${MESSAGE_LINK_PREFIX} ${getMessageLinkChannelLabel(channelName)}`; + if (variant === "sent-from-thread") { + return normalizedExcerpt ?? baseLabel; + } + return normalizedExcerpt ? `${baseLabel} — ${normalizedExcerpt}` : baseLabel; +} diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs index eae840a40eb..ac3cc5b0d16 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs +++ b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; -function createStorage() { +function createStorage(onSetItem = () => {}) { const values = new Map(); return { getItem: (key) => values.get(key) ?? null, - setItem: (key, value) => values.set(key, String(value)), + setItem: (key, value) => { + onSetItem(key, value); + values.set(key, String(value)); + }, }; } @@ -207,6 +210,114 @@ test("new recipients retain explicit mention order", async () => { assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] }); }); +test("persistent audiences retain only the 200 most recently touched scopes", async () => { + const store = await loadStore(11); + for ( + let index = 0; + index < store.MAX_PERSISTENT_AGENT_AUDIENCES + 2; + index++ + ) { + store.setPersistentAgentAudience(`scope-${index}`, [agentA]); + } + + const saved = savedAudiences(); + assert.equal(Object.keys(saved).length, store.MAX_PERSISTENT_AGENT_AUDIENCES); + assert.equal(saved["scope-0"], undefined); + assert.equal(saved["scope-1"], undefined); + assert.deepEqual(saved["scope-201"], [agentA]); + + store.setPersistentAgentAudience("scope-2", [agentB]); + store.setPersistentAgentAudience("scope-new", [agentC]); + const retouched = savedAudiences(); + assert.equal(retouched["scope-3"], undefined); + assert.deepEqual(retouched["scope-2"], [agentB]); + assert.deepEqual(retouched["scope-new"], [agentC]); +}); + +test("an unchanged touch refreshes LRU without revision or emit", async () => { + const { JSDOM } = await import("jsdom"); + const dom = new JSDOM( + "

", + { + url: "http://localhost", + }, + ); + const writes = []; + Object.defineProperty(dom.window, "localStorage", { + configurable: true, + value: createStorage((key, value) => writes.push([key, String(value)])), + }); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + loadSequence += 1; + const store = await import( + `./persistentAgentAudience.ts?test=${Date.now()}-touch-${loadSequence}` + ); + const touchedScope = "scope-0"; + store.setPersistentAgentAudience(touchedScope, [agentA]); + for (let index = 1; index < store.MAX_PERSISTENT_AGENT_AUDIENCES; index++) { + store.setPersistentAgentAudience(`scope-${index}`, [agentA]); + } + + const React = await import("react"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.getElementById("root")); + let renderCount = 0; + function Probe() { + store.usePersistentAgentAudience(touchedScope); + renderCount += 1; + return null; + } + await React.act(async () => root.render(React.createElement(Probe))); + const revision = store.getPersistentAgentAudienceRevision(touchedScope); + const renderCountBeforeTouch = renderCount; + writes.length = 0; + + await React.act(async () => { + store.setPersistentAgentAudience(touchedScope, [agentA]); + }); + + assert.equal(writes.length, 1); + assert.equal(writes[0][0], storageKey); + assert.deepEqual(JSON.parse(writes[0][1])[touchedScope], [agentA]); + assert.equal(Object.keys(JSON.parse(writes[0][1])).at(-1), touchedScope); + assert.equal( + store.getPersistentAgentAudienceRevision(touchedScope), + revision, + ); + assert.equal(renderCount, renderCountBeforeTouch); + + writes.length = 0; + await React.act(async () => { + store.setPersistentAgentAudience(touchedScope, [agentA]); + }); + assert.equal(writes.length, 0); + assert.equal( + store.getPersistentAgentAudienceRevision(touchedScope), + revision, + ); + assert.equal(renderCount, renderCountBeforeTouch); + + await React.act(async () => { + store.setPersistentAgentAudience("scope-new", [agentB]); + }); + const saved = savedAudiences(); + assert.deepEqual(saved[touchedScope], [agentA]); + assert.equal(saved["scope-1"], undefined); + assert.deepEqual(saved["scope-new"], [agentB]); + assert.equal( + store.getPersistentAgentAudienceRevision(touchedScope), + revision, + ); + + await React.act(async () => root.unmount()); + dom.window.close(); +}); + test("timeline scope is intentionally unsupported", async () => { const store = await loadStore(7); assert.equal( diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts index d57b4854223..a16163ed1f3 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -2,6 +2,7 @@ import * as React from "react"; const ENABLED_STORAGE_KEY = "buzz:keep-addressed-agents-active"; const AUDIENCES_STORAGE_KEY = "buzz:persistent-agent-audiences:v2"; +export const MAX_PERSISTENT_AGENT_AUDIENCES = 200; const listeners = new Set<() => void>(); const revisions = new Map(); @@ -39,6 +40,15 @@ function readEnabled(): boolean { } } +function boundAudiences( + value: Record, +): Record { + const entries = Object.entries(value); + return entries.length <= MAX_PERSISTENT_AGENT_AUDIENCES + ? value + : Object.fromEntries(entries.slice(-MAX_PERSISTENT_AGENT_AUDIENCES)); +} + function readAudiences(): Record { if (typeof window === "undefined") return {}; try { @@ -56,7 +66,7 @@ function readAudiences(): Record { ); } } - return result; + return boundAudiences(result); } catch { return {}; } @@ -129,8 +139,11 @@ export function initializePersistentAgentAudience( scope: string, pubkeys: Iterable, ): void { - if (!enabled || !scope || Object.hasOwn(audiences, scope)) return; - setPersistentAgentAudience(scope, pubkeys); + if (!enabled || !scope) return; + setPersistentAgentAudience( + scope, + Object.hasOwn(audiences, scope) ? audiences[scope] : pubkeys, + ); } export function setPersistentAgentAudience( @@ -145,10 +158,20 @@ export function setPersistentAgentAudience( current.length === normalized.length && current.every((pubkey, index) => pubkey === normalized[index]) ) { + if (Object.keys(audiences).at(-1) === scope) return; + const nextAudiences = { ...audiences }; + delete nextAudiences[scope]; + audiences = boundAudiences({ ...nextAudiences, [scope]: current }); + persistAudiences(); return; } - audiences = { ...audiences, [scope]: normalized }; + const nextAudiences = { ...audiences }; + delete nextAudiences[scope]; + audiences = boundAudiences({ ...nextAudiences, [scope]: normalized }); + for (const revisedScope of revisions.keys()) { + if (!Object.hasOwn(audiences, revisedScope)) revisions.delete(revisedScope); + } advanceRevision(scope); persistAudiences(); emit(); diff --git a/desktop/src/features/messages/lib/plainTextProjection.test.mjs b/desktop/src/features/messages/lib/plainTextProjection.test.mjs index f3ecb18ce3b..f914cd85430 100644 --- a/desktop/src/features/messages/lib/plainTextProjection.test.mjs +++ b/desktop/src/features/messages/lib/plainTextProjection.test.mjs @@ -277,6 +277,7 @@ test("round-trip: text offset → PM → text offset is identity", () => { // mismatch so cursor math and autocomplete offsets stay correct. import { CustomEmojiNode } from "./customEmojiNode.ts"; +import { ComposerMessageLinkNode } from "./composerMessageLinkNode.ts"; const schemaWithEmoji = getSchema([ StarterKit.configure({ @@ -286,6 +287,9 @@ const schemaWithEmoji = getSchema([ link: false, }), CustomEmojiNode, + ComposerMessageLinkNode.configure({ + resolveChannelName: () => "general", + }), ]); const eDoc = (...content) => schemaWithEmoji.nodes.doc.create(null, content); @@ -293,6 +297,11 @@ const ePara = (...c) => schemaWithEmoji.nodes.paragraph.create(null, c); const eText = (s) => schemaWithEmoji.text(s); const emoji = (shortcode) => schemaWithEmoji.nodes.customEmoji.create({ shortcode, src: "" }); +const messageLink = (href) => + schemaWithEmoji.nodes.composerMessageLink.create({ + channelName: "general", + href, + }); test("atom: projects to its full :shortcode: text", () => { const d = eDoc(ePara(eText("hi "), emoji("wave"), eText(" there"))); @@ -359,3 +368,10 @@ test("atom: caret offsets around an atom round-trip", () => { assert.equal(back, offset, `caret offset ${offset} → pm ${pm} → ${back}`); } }); + +test("message-link atom projects to its full underlying deep link", () => { + const href = "buzz://message?channel=general-id&id=root-id"; + const d = eDoc(ePara(eText("See "), messageLink(href), eText(" now"))); + const p = buildPlainTextProjection(d); + assert.equal(p.text, `See ${href} now`); +}); diff --git a/desktop/src/features/messages/lib/plainTextProjection.ts b/desktop/src/features/messages/lib/plainTextProjection.ts index bbafacffe0c..2a670cdcc92 100644 --- a/desktop/src/features/messages/lib/plainTextProjection.ts +++ b/desktop/src/features/messages/lib/plainTextProjection.ts @@ -1,6 +1,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; +import { COMPOSER_MESSAGE_LINK_NODE_NAME } from "./composerMessageLinkNode"; /** * Plain-text projection of a ProseMirror document. @@ -174,9 +175,14 @@ export function buildPlainTextProjection( // 1 PM position wide, projects to its full `:shortcode:` text. Keeps // the two mappings consistent with what `renderText` emits, so cursor // math and autocomplete offsets see the shortcode at its natural width. - if (node.type.name === CUSTOM_EMOJI_NODE_NAME) { - const shortcode = String(node.attrs.shortcode ?? ""); - const projected = `:${shortcode}:`; + if ( + node.type.name === CUSTOM_EMOJI_NODE_NAME || + node.type.name === COMPOSER_MESSAGE_LINK_NODE_NAME + ) { + const projected = + node.type.name === CUSTOM_EMOJI_NODE_NAME + ? `:${String(node.attrs.shortcode ?? "")}:` + : String(node.attrs.href ?? ""); segments.push({ kind: "atom", pmFrom: pos, diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 5c76330972e..14ec110addf 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { reconcileFetchedChannelWindow } from "../hooks.ts"; import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys.ts"; import { appendOlderChannelWindow, @@ -28,6 +29,18 @@ function event(id, createdAt) { }; } +function wirePage(rows) { + return [ + ...rows, + { + ...event("bounds", 0), + kind: 39006, + tags: [["d", "channel:head"]], + content: JSON.stringify({ has_more: false, next_cursor: null }), + }, + ]; +} + function newestPage(rows) { return { startCursor: null, @@ -273,3 +286,80 @@ test("test_live_projection_retains_pending_send_and_non_broadcast_thread_reply", "live", ]); }); + +test("test_canceled_stale_fetch_cannot_overwrite_catch_up_window", async () => { + const harness = createHarness(); + const requests = []; + let resolveRequestStarted; + let requestStarted = new Promise((resolve) => { + resolveRequestStarted = resolve; + }); + const observer = new QueryObserver(harness.client, { + queryKey: harness.messagesKey, + queryFn: async ({ signal }) => { + const previousMessages = harness.client.getQueryData(harness.messagesKey); + let resolveFetch; + const fetch = new Promise((resolve) => { + resolveFetch = resolve; + }); + requests.push({ resolveFetch, signal }); + resolveRequestStarted(); + const events = await fetch; + return reconcileFetchedChannelWindow( + harness.client, + harness.channelId, + events, + previousMessages, + signal, + ); + }, + }); + const unsubscribe = observer.subscribe(() => {}); + + await requestStarted; + requestStarted = new Promise((resolve) => { + resolveRequestStarted = resolve; + }); + const catchUp = refreshChannelWindowMessages( + harness.client, + harness.channelId, + ); + await requestStarted; + + assert.equal(requests[0].signal.aborted, true); + requests[1].resolveFetch( + wirePage([event("gap", 110), event("initial", 100)]), + ); + await catchUp; + assert.deepEqual(contents(harness), ["initial", "gap"]); + + requests[0].resolveFetch(wirePage([event("initial", 100)])); + await new Promise((resolve) => setImmediate(resolve)); + appendLiveEvent(harness, event("live", 120)); + + assert.deepEqual(contents(harness), ["initial", "gap", "live"]); + assert.deepEqual( + flattenChannelWindowEvents( + harness.client.getQueryData(harness.windowKey), + ).map((item) => item.content), + ["initial", "gap", "live"], + ); + unsubscribe(); +}); + +test("test_pageless_live_projection_preserves_cached_timeline", () => { + const harness = createHarness(); + const cached = harness.client.getQueryData(harness.messagesKey); + const pageless = emptyChannelWindowStore(); + harness.client.setQueryData(harness.windowKey, pageless); + + const next = mergeLiveChannelWindowEvent( + harness.client.getQueryData(harness.windowKey), + event("live", 110), + ); + harness.client.setQueryData(harness.windowKey, next); + projectChannelWindowMessages(harness.client, harness.channelId); + + assert.deepEqual(contents(harness), ["initial", "live"]); + assert.equal(harness.client.getQueryData(harness.messagesKey)[0], cached[0]); +}); diff --git a/desktop/src/features/messages/lib/sendToChannelSemantics.test.mjs b/desktop/src/features/messages/lib/sendToChannelSemantics.test.mjs new file mode 100644 index 00000000000..73dd101082a --- /dev/null +++ b/desktop/src/features/messages/lib/sendToChannelSemantics.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getSendToChannelSemantics } from "./sendToChannelSemantics.ts"; + +const SOURCE = "a".repeat(64); +const SIGNER = "b".repeat(64); +const MENTION = "c".repeat(64); + +test("send-to-channel preserves supported message semantics", () => { + const imeta = ["imeta", "url https://relay.example/media/file.png"]; + const emoji = ["emoji", "party", "https://relay.example/party.png"]; + const mention = ["mention", MENTION]; + const preview = ["link-preview", "none"]; + + assert.deepEqual( + getSendToChannelSemantics({ + pubkey: SOURCE, + signerPubkey: SIGNER, + tags: [ + ["h", "channel-id"], + ["e", "thread-root", "", "reply"], + ["p", SOURCE], + ["p", SIGNER.toUpperCase()], + ["p", MENTION.toUpperCase()], + ["p", MENTION], + ["p", "not-a-pubkey"], + imeta, + emoji, + mention, + preview, + ["client", "source-only-marker"], + ], + }), + { + mentionPubkeys: [MENTION], + semanticTags: [imeta, emoji, mention, preview], + }, + ); +}); + +test("edited messages recompute effective mention recipients from the body", () => { + const ADDED = "d".repeat(64); + const profiles = { + [MENTION]: { displayName: "Alice" }, + [ADDED]: { displayName: "Bob" }, + }; + + assert.deepEqual( + getSendToChannelSemantics( + { + body: "Now pinging @Bob", + edited: true, + pubkey: SOURCE, + tags: [ + ["p", MENTION], + ["p", ADDED], + ], + }, + profiles, + ), + { mentionPubkeys: [ADDED], semanticTags: [] }, + ); +}); + +test("edited messages preserve snapshotted mention recipients without profiles", () => { + assert.deepEqual( + getSendToChannelSemantics({ + body: "Now pinging @Renamed", + edited: true, + pubkey: SOURCE, + tags: [["p", MENTION], ["mention", MENTION], ["buzz:mention-snapshot"]], + }), + { + mentionPubkeys: [MENTION], + semanticTags: [["mention", MENTION]], + }, + ); +}); + +test("an empty edited mention snapshot drops stale original recipients", () => { + assert.deepEqual( + getSendToChannelSemantics({ + body: "No longer pinging anyone", + edited: true, + pubkey: SOURCE, + tags: [["p", MENTION], ["buzz:mention-snapshot"]], + }), + { mentionPubkeys: [], semanticTags: [] }, + ); +}); + +test("send-to-channel canonicalizes suppressed link previews", () => { + const snapshot = ["link-preview", "https://example.com", "snapshot"]; + const suppression = ["link-preview", "none"]; + + assert.deepEqual( + getSendToChannelSemantics({ + pubkey: SOURCE, + tags: [snapshot, suppression], + }), + { mentionPubkeys: [], semanticTags: [suppression] }, + ); +}); + +test("send-to-channel handles messages without semantic tags", () => { + assert.deepEqual( + getSendToChannelSemantics({ pubkey: SOURCE, tags: undefined }), + { mentionPubkeys: [], semanticTags: [] }, + ); +}); diff --git a/desktop/src/features/messages/lib/sendToChannelSemantics.ts b/desktop/src/features/messages/lib/sendToChannelSemantics.ts new file mode 100644 index 00000000000..f8cc79cac77 --- /dev/null +++ b/desktop/src/features/messages/lib/sendToChannelSemantics.ts @@ -0,0 +1,83 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; + +const PUBKEY_PATTERN = /^[0-9a-f]{64}$/; +const SHAREABLE_TAG_KINDS = new Set([ + "emoji", + "imeta", + "link-preview", + "mention", +]); + +export type SendToChannelSemantics = { + mentionPubkeys: string[]; + semanticTags: string[][]; +}; + +/** + * Preserve the source message metadata that gives its body meaning without + * copying structural channel/thread tags or the source event's self `p` tag. + */ +export function getSendToChannelSemantics( + message: TimelineMessage, + profiles?: UserProfileLookup, +): SendToChannelSemantics { + const sourceAuthors = new Set( + [message.pubkey, message.signerPubkey] + .filter((pubkey): pubkey is string => Boolean(pubkey)) + .map(normalizePubkey), + ); + const seenMentions = new Set(); + const mentionPubkeys: string[] = []; + const effectiveMentionPubkeys = message.edited + ? new Set( + (message.tags ?? []).some((tag) => tag[0] === "buzz:mention-snapshot") + ? (message.tags ?? []) + .filter((tag) => tag[0] === "mention") + .map((tag) => normalizePubkey(tag[1] ?? "")) + .filter((pubkey) => PUBKEY_PATTERN.test(pubkey)) + : orderMentionPubkeysByText( + message.body, + resolveMentionProps(message.tags, profiles).mentionPubkeysByName, + () => true, + ), + ) + : null; + const semanticTags: string[][] = []; + const hasPreviewSuppression = message.tags?.some( + (tag) => tag.length === 2 && tag[0] === "link-preview" && tag[1] === "none", + ); + + for (const tag of message.tags ?? []) { + if (tag[0] === "p") { + const pubkey = normalizePubkey(tag[1] ?? ""); + if ( + PUBKEY_PATTERN.test(pubkey) && + !sourceAuthors.has(pubkey) && + (effectiveMentionPubkeys === null || + effectiveMentionPubkeys.has(pubkey)) && + !seenMentions.has(pubkey) + ) { + seenMentions.add(pubkey); + mentionPubkeys.push(pubkey); + } + continue; + } + + if (SHAREABLE_TAG_KINDS.has(tag[0] ?? "")) { + if ( + tag[0] === "link-preview" && + hasPreviewSuppression && + !(tag.length === 2 && tag[1] === "none") + ) { + continue; + } + semanticTags.push([...tag]); + } + } + + return { mentionPubkeys, semanticTags }; +} diff --git a/desktop/src/features/messages/lib/sentFromThread.test.mjs b/desktop/src/features/messages/lib/sentFromThread.test.mjs new file mode 100644 index 00000000000..36d8786c3b0 --- /dev/null +++ b/desktop/src/features/messages/lib/sentFromThread.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildSentFromThreadTag, + getSentFromThreadReference, + getSentFromThreadRootId, + SENT_FROM_THREAD_TAG, + summarizeThreadRoot, +} from "./sentFromThread.ts"; + +test("buildSentFromThreadTag records the normalized root event ID", () => { + assert.deepEqual(buildSentFromThreadTag(" root-event "), [ + SENT_FROM_THREAD_TAG, + "root-event", + ]); +}); + +test("buildSentFromThreadTag includes a normalized human excerpt", () => { + assert.deepEqual(buildSentFromThreadTag("root-event", " Launch plan "), [ + SENT_FROM_THREAD_TAG, + "root-event", + "Launch plan", + ]); +}); + +test("buildSentFromThreadTag rejects an empty root event ID", () => { + assert.throws( + () => buildSentFromThreadTag(" "), + /thread root event ID is required/, + ); +}); + +test("sent-from-thread references accept an optional excerpt", () => { + assert.equal( + getSentFromThreadRootId([ + ["h", "channel-id"], + [SENT_FROM_THREAD_TAG, "root-event"], + ]), + "root-event", + ); + assert.deepEqual( + getSentFromThreadReference([ + [SENT_FROM_THREAD_TAG, "root-event", "Root summary"], + ]), + { rootEventId: "root-event", rootExcerpt: "Root summary" }, + ); + assert.equal( + getSentFromThreadRootId([ + [SENT_FROM_THREAD_TAG, "root-event", "summary", "extra"], + ]), + null, + ); + assert.equal(getSentFromThreadRootId([[SENT_FROM_THREAD_TAG, " "]]), null); + assert.equal(getSentFromThreadRootId(undefined), null); +}); + +test("summarizeThreadRoot keeps concise text and ignores media-only roots", () => { + assert.equal(summarizeThreadRoot(" **Launch** plan "), "Launch plan"); + assert.equal( + summarizeThreadRoot("![diagram](https://example.com/diagram.png)"), + null, + ); + assert.equal( + summarizeThreadRoot("See [the plan](https://example.com/plan) for details"), + "See the plan for details", + ); + assert.match(summarizeThreadRoot("word ".repeat(30)) ?? "", /…$/); +}); + +test("summarizeThreadRoot preserves Unicode boundaries, strips controls, and redacts spoilers", () => { + const summary = summarizeThreadRoot(`${"a".repeat(62)}😀 more`); + assert.equal(summary, `${"a".repeat(62)}😀…`); + assert.equal( + summarizeThreadRoot("Public\u0000\u001f\u007f\u0085 update"), + "Public update", + ); + assert.equal( + summarizeThreadRoot("Public ||confidential details|| update"), + "Public update", + ); +}); diff --git a/desktop/src/features/messages/lib/sentFromThread.ts b/desktop/src/features/messages/lib/sentFromThread.ts new file mode 100644 index 00000000000..90076c04c7e --- /dev/null +++ b/desktop/src/features/messages/lib/sentFromThread.ts @@ -0,0 +1,70 @@ +export const SENT_FROM_THREAD_TAG = "buzz:sent-from-thread"; +const THREAD_ROOT_EXCERPT_MAX_LENGTH = 64; + +export type SentFromThreadReference = { + rootEventId: string; + rootExcerpt: string | null; +}; + +export function summarizeThreadRoot(content: string): string | null { + const withoutControls = Array.from(content, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) + ? " " + : character; + }).join(""); + const normalized = withoutControls + .replace(/\|\|[^|]*(?:\|(?!\|)[^|]*)*\|\|/g, " ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/?/g, " ") + .replace(/[`*_~>#|]/g, " ") + .replace(/\s+/g, " ") + .trim(); + const characters = Array.from(normalized); + if (!normalized) return null; + if (characters.length <= THREAD_ROOT_EXCERPT_MAX_LENGTH) return normalized; + const clipped = characters + .slice(0, THREAD_ROOT_EXCERPT_MAX_LENGTH - 1) + .join(""); + const lastSpace = clipped.lastIndexOf(" "); + const excerpt = lastSpace > 32 ? clipped.slice(0, lastSpace) : clipped; + return `${excerpt.trimEnd()}…`; +} + +export function buildSentFromThreadTag( + rootEventId: string, + rootExcerpt?: string | null, +): string[] { + const normalizedRootEventId = rootEventId.trim(); + if (!normalizedRootEventId) { + throw new Error("A thread root event ID is required."); + } + + const normalizedExcerpt = rootExcerpt?.trim(); + return normalizedExcerpt + ? [SENT_FROM_THREAD_TAG, normalizedRootEventId, normalizedExcerpt] + : [SENT_FROM_THREAD_TAG, normalizedRootEventId]; +} + +export function getSentFromThreadReference( + tags: readonly (readonly string[])[] | null | undefined, +): SentFromThreadReference | null { + const tag = tags?.find( + (candidate) => + (candidate.length === 2 || candidate.length === 3) && + candidate[0] === SENT_FROM_THREAD_TAG, + ); + const rootEventId = tag?.[1]?.trim(); + if (!rootEventId) return null; + return { + rootEventId, + rootExcerpt: tag?.[2]?.trim() || null, + }; +} + +export function getSentFromThreadRootId( + tags: readonly (readonly string[])[] | null | undefined, +): string | null { + return getSentFromThreadReference(tags)?.rootEventId ?? null; +} diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 2b87b5ebc39..f7b91b6db0c 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -305,6 +305,36 @@ test("buildTimelineItems: pending messages remain standalone until acknowledged" ); }); +test("buildTimelineItems: sent-from-thread messages start a fresh author group", () => { + const entries = [ + entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }), + entry({ + id: "b", + pubkey: "author-a", + createdAt: dayAt(2026, 6, 14, 12, 2), + tags: [["buzz:sent-from-thread", "root-event", "Root summary"]], + }), + entry({ + id: "c", + pubkey: "author-a", + createdAt: dayAt(2026, 6, 14, 12, 3), + }), + ]; + + const messageItems = buildTimelineItems(entries, null).items.filter( + (item) => item.kind === "message", + ); + + assert.deepEqual( + messageItems.map((item) => item.isContinuation), + [false, false, true], + ); + assert.deepEqual( + messageItems.map((item) => item.isFollowedByContinuation), + [false, true, false], + ); +}); + test("buildTimelineItems: same-author messages past the window start a new group", () => { const author = "author-a"; const entries = [ diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index c2838710258..db5753bcf20 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -15,6 +15,7 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import { hasSameMessageAuthor, isWithinGroupingWindow, + startsNewMessageGroup, } from "@/features/messages/lib/messageGrouping"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; @@ -250,6 +251,7 @@ export function buildTimelineItems( // that same standalone state until the send acknowledgement arrives. const isContinuation = !message.pending && + !startsNewMessageGroup(message) && previousGroupEntry !== null && !previousGroupEntry.message.pending && hasSameMessageAuthor(previousGroupEntry.message, message) && diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index de410b5b8f2..a0374fbe2b0 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -420,11 +420,26 @@ test("timeline-body-surface: loading and deferred-pending both paint the single ); }); -test("timeline-body-surface: first deferred message preserves a persistent channel intro", () => { +test("timeline-body-surface: first authoritative rows wait for deferred paint", () => { + // A newly selected populated channel has already resolved live rows, but the + // deferred snapshot is still empty. It has never committed a settled empty + // surface, so showing its intro here would flash Create agent / Add people. assert.equal( selectTimelineBodySurface({ deferredCount: 0, - hasPersistentIntro: true, + preserveSettledEmptyIntro: false, + isLoading: false, + liveCount: 1, + }), + "skeleton", + ); +}); + +test("timeline-body-surface: append preserves a previously settled empty intro", () => { + assert.equal( + selectTimelineBodySurface({ + deferredCount: 0, + preserveSettledEmptyIntro: true, isLoading: false, liveCount: 1, }), diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index 14d415fb60b..3bfd9349476 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -185,12 +185,12 @@ export type TimelineBodySurface = "skeleton" | "empty" | "list"; export function selectTimelineBodySurface({ deferredCount, - hasPersistentIntro = false, + preserveSettledEmptyIntro = false, isLoading, liveCount, }: { deferredCount: number; - hasPersistentIntro?: boolean; + preserveSettledEmptyIntro?: boolean; isLoading: boolean; liveCount: number; }): TimelineBodySurface { @@ -200,10 +200,11 @@ export function selectTimelineBodySurface({ const renderState = selectDeferredListRenderState(deferredCount, liveCount); if (renderState === "pending") { - // A channel/DM intro is already meaningful stable content. Preserve it - // while React's deferred snapshot catches up to the first live message; - // replacing it with a skeleton makes an append look like a page reload. - return hasPersistentIntro ? "empty" : "skeleton"; + // Preserve a channel/DM intro across a new append only when this channel + // already committed an authoritative empty timeline. On first load, the + // live query can resolve before React's deferred rows commit; painting the + // intro in that gap flashes empty-channel actions over incoming messages. + return preserveSettledEmptyIntro ? "empty" : "skeleton"; } return renderState; } diff --git a/desktop/src/features/messages/lib/useChannelLinks.ts b/desktop/src/features/messages/lib/useChannelLinks.ts index 47d07017648..5ffc1b8c2ef 100644 --- a/desktop/src/features/messages/lib/useChannelLinks.ts +++ b/desktop/src/features/messages/lib/useChannelLinks.ts @@ -184,6 +184,7 @@ export function useChannelLinks() { ); return { + channels, channelQuery, channelSelectedIndex, channelSuggestions, diff --git a/desktop/src/features/messages/lib/useComposerMessageLinks.ts b/desktop/src/features/messages/lib/useComposerMessageLinks.ts new file mode 100644 index 00000000000..60dd3ec677c --- /dev/null +++ b/desktop/src/features/messages/lib/useComposerMessageLinks.ts @@ -0,0 +1,59 @@ +import type { Editor } from "@tiptap/react"; +import * as React from "react"; + +import { + COMPOSER_MESSAGE_LINK_NODE_NAME, + ComposerMessageLinkNode, +} from "./composerMessageLinkNode"; +import { parseMessageLink } from "./messageLink"; + +export type ComposerMessageLinkChannel = { id: string; name: string }; + +export function useComposerMessageLinks( + channels: readonly ComposerMessageLinkChannel[] | undefined, +) { + const channelsRef = React.useRef(channels ?? []); + channelsRef.current = channels ?? []; + + const resolveChannelName = React.useCallback( + (channelId: string) => + channelsRef.current.find((channel) => channel.id === channelId)?.name, + [], + ); + + const extension = React.useMemo( + () => ComposerMessageLinkNode.configure({ resolveChannelName }), + [resolveChannelName], + ); + + const syncChannelNames = React.useCallback( + (editor: Editor) => { + const channelNamesById = new Map( + (channels ?? []).map((channel) => [channel.id, channel.name]), + ); + let transaction = editor.state.tr; + let changed = false; + editor.state.doc.descendants((node, position) => { + if (node.type.name !== COMPOSER_MESSAGE_LINK_NODE_NAME) return; + const parsed = parseMessageLink(String(node.attrs.href ?? "")); + const nextName = parsed.ok + ? (channelNamesById.get(parsed.value.channelId) ?? "") + : ""; + if (nextName !== node.attrs.channelName) { + transaction = transaction.setNodeAttribute( + position, + "channelName", + nextName, + ); + changed = true; + } + }); + if (changed) { + editor.view.dispatch(transaction.setMeta("addToHistory", false)); + } + }, + [channels], + ); + + return { extension, resolveChannelName, syncChannelNames }; +} diff --git a/desktop/src/features/messages/lib/useFilePicker.ts b/desktop/src/features/messages/lib/useFilePicker.ts new file mode 100644 index 00000000000..a4963513a5e --- /dev/null +++ b/desktop/src/features/messages/lib/useFilePicker.ts @@ -0,0 +1,54 @@ +import * as React from "react"; + +type FilePickerOptions = { + accept?: string; + multiple?: boolean; +}; + +/** + * Owns one mounted file input for the hook lifetime. Reusing the node avoids + * detached-input presentation races when a native picker is canceled and + * immediately reopened. + */ +export function useFilePicker() { + const inputRef = React.useRef(null); + + React.useEffect( + () => () => { + const input = inputRef.current; + if (input) { + input.onchange = null; + input.remove(); + } + inputRef.current = null; + }, + [], + ); + + return React.useCallback( + (options: FilePickerOptions, onFiles: (files: File[]) => void) => { + let input = inputRef.current; + if (!input) { + input = document.createElement("input"); + input.type = "file"; + input.hidden = true; + document.body.append(input); + inputRef.current = input; + } + + // Cancel emits no `change`, so replace rather than stack callbacks. Reset + // before opening (and after selection) to permit choosing the same file. + input.accept = options.accept ?? ""; + input.multiple = options.multiple ?? false; + input.value = ""; + input.onchange = (event) => { + const currentInput = event.currentTarget as HTMLInputElement; + const files = Array.from(currentInput.files ?? []); + currentInput.value = ""; + onFiles(files); + }; + input.click(); + }, + [], + ); +} diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index 374d392818f..4b9d2c6cce7 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -8,6 +8,7 @@ import { import { uploadMediaFile } from "@/shared/api/tauriMedia"; import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots"; +import { useFilePicker } from "./useFilePicker"; import { isVideoFile, videoMimeForFile } from "./videoFileType"; /** @@ -617,21 +618,14 @@ export function useMediaUpload({ [fillSlot, onUploadError, reserveSlots, reserveUploadingPreview], ); + const openFilePicker = useFilePicker(); + const handlePaperclip = React.useCallback(async () => { if (queueUntilSend) { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = true; - input.addEventListener( - "change", - () => { - const files = Array.from(input.files ?? []); - queueFiles(files.filter(shouldQueueFile)); - uploadFiles(files.filter((file) => !shouldQueueFile(file))); - }, - { once: true }, - ); - input.click(); + openFilePicker({ multiple: true }, (files) => { + queueFiles(files.filter(shouldQueueFile)); + uploadFiles(files.filter((file) => !shouldQueueFile(file))); + }); return; } @@ -661,6 +655,7 @@ export function useMediaUpload({ isUploadCanceled, isUploadStale, onUploadError, + openFilePicker, queueFiles, reserveUploadingPreview, shouldQueueFile, diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 054b8778bba..aeb7198b4c5 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -38,6 +38,9 @@ import { insertNewlineInCodeBlock, } from "./codeBlockExtensions"; import { SpoilerMark } from "./spoilerMark"; +import { createComposerLinkPasteHandler } from "./composerMessageLinkNode"; +import type { ComposerMessageLinkChannel } from "./useComposerMessageLinks"; +import { useComposerMessageLinks } from "./useComposerMessageLinks"; function hardBreakLineBounds($from: ResolvedPos) { const parentStart = $from.start(); @@ -83,6 +86,7 @@ export type RichTextEditorOptions = { mentionNames?: string[]; agentMentionNames?: string[]; channelNames?: string[]; + messageLinkChannels?: readonly ComposerMessageLinkChannel[]; /** Known custom-emoji set; used to render `:shortcode:` inline as images. */ customEmoji?: CustomEmoji[]; /** Called on plain Enter (submit). Handled inside Tiptap's extension system @@ -137,11 +141,6 @@ function shouldAppendSpaceAfterPaste(text: string): boolean { return PASTED_LINK_AT_END_RE.test(trimmedEnd); } -function unwrapExactHttpLink(text: string): string | null { - const match = /^(?:<(https?:\/\/[^\s<>]+)>|(https?:\/\/\S+))$/i.exec(text); - return match?.[1] ?? match?.[2] ?? null; -} - const LinkPasteTrailingSpace = Extension.create({ name: "linkPasteTrailingSpace", @@ -206,6 +205,7 @@ export function useRichTextEditor({ mentionNames, agentMentionNames, channelNames, + messageLinkChannels, customEmoji, onSubmit, onEditLastOwnMessage, @@ -238,6 +238,7 @@ export function useRichTextEditor({ // Custom-emoji atom node wiring (config + src re-resolve). Kept in a sibling // hook so this file stays focused on generic editor setup. const customEmojiWiring = useComposerCustomEmoji(customEmoji); + const messageLinkWiring = useComposerMessageLinks(messageLinkChannels); const editor = useEditor( { @@ -462,6 +463,7 @@ export function useRichTextEditor({ SpoilerMark, MentionHighlightExtension, customEmojiWiring.extension, + messageLinkWiring.extension, Placeholder.configure({ placeholder: () => placeholderRef.current ?? "Write a message…", }), @@ -495,34 +497,15 @@ export function useRichTextEditor({ ], editorProps: { handleDOMEvents: { - paste: (view, event) => { - const clipboard = (event as ClipboardEvent).clipboardData; - if ( - parseSnapshotClipboardHtml(clipboard?.getData("text/html") ?? "") + paste: (view, event) => + parseSnapshotClipboardHtml( + (event as ClipboardEvent).clipboardData?.getData("text/html") ?? + "", ) - return false; - const url = unwrapExactHttpLink( - clipboard?.getData("text/plain") ?? "", - ); - if (!url) return false; - const link = view.state.schema.marks.link; - if (!link) return false; - const { from, to } = view.state.selection; - let transaction = view.state.tr.replaceRangeWith( - from, - to, - view.state.schema.text(url, [link.create({ href: url })]), - ); - const end = transaction.mapping.map(to); - transaction = transaction.insertText(" ", end); - transaction = transaction.removeMark(end, end + 1, link); - transaction = transaction.setSelection( - TextSelection.create(transaction.doc, end + 1), - ); - view.dispatch(transaction.setStoredMarks([]).scrollIntoView()); - event.preventDefault(); - return true; - }, + ? false + : createComposerLinkPasteHandler( + messageLinkWiring.resolveChannelName, + )(view, event as ClipboardEvent), }, attributes: { autocapitalize: "none", @@ -732,6 +715,11 @@ export function useRichTextEditor({ customEmojiWiring.syncEmojiSrc(editor); }, [editor, customEmojiWiring.syncEmojiSrc]); + React.useEffect(() => { + if (!editor) return; + messageLinkWiring.syncChannelNames(editor); + }, [editor, messageLinkWiring.syncChannelNames]); + const getMarkdown = React.useCallback((): string => { if (!editor) return ""; return getMarkdownFromEditor(editor); @@ -754,17 +742,26 @@ export function useRichTextEditor({ [editor], ); - const setContentAndFocusEnd = React.useCallback( - (markdown: string) => { + /** + * Replace the editor document with literal plain text and focus its end. + * + * Unlike markdown `setContent`, this preserves trailing whitespace. The + * transaction is marked as programmatic so authored-update observers do not + * reconcile against the intermediate post-send restoration. + */ + const restorePlainTextAndFocusEnd = React.useCallback( + (text: string) => { if (!editor) return; - // The caller already synchronizes composer state. Keep this programmatic - // restoration out of user-edit observers (autocomplete/reconciliation), - // then move selection in the same command chain. - editor - .chain() - .setContent(markdown, { emitUpdate: false }) - .focus("end") - .run(); + const paragraph = editor.schema.nodes.paragraph.create( + null, + text ? editor.schema.text(text) : undefined, + ); + const tr = editor.state.tr + .replaceWith(0, editor.state.doc.content.size, paragraph) + .setMeta("preventUpdate", true); + tr.setSelection(TextSelection.atEnd(tr.doc)); + editor.view.dispatch(tr); + editor.view.focus(); }, [editor], ); @@ -962,7 +959,7 @@ export function useRichTextEditor({ isEmpty, clearContent, setContent, - setContentAndFocusEnd, + restorePlainTextAndFocusEnd, focus, focusEnd, focusPreserve, diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 967e50f5d2f..11163aa8c7a 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -14,6 +14,7 @@ import { Trash2, } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; @@ -36,6 +37,7 @@ 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"; import { DeleteMessageConfirmDialog } from "./DeleteMessageConfirmDialog"; import { DropdownMenu, @@ -61,6 +63,7 @@ function MoreActionsMenu({ onMarkRead, onOpenChange, onRemindLater, + onSendToChannel, onUnfollowThread, open, isFollowingThread, @@ -77,6 +80,7 @@ function MoreActionsMenu({ onMarkRead?: (message: TimelineMessage) => void; onOpenChange: (open: boolean) => void; onRemindLater?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; onUnfollowThread?: (message: TimelineMessage) => void; open: boolean; isFollowingThread?: boolean; @@ -213,6 +217,31 @@ function MoreActionsMenu({ ) : null} + {onSendToChannel ? ( + { + void onSendToChannel(message) + .then(() => toast.success("Sent to channel")) + .catch((error) => { + console.error( + "Failed to send thread message to channel", + error, + ); + toast.error("Couldn't send to channel"); + }); + }} + > + + ) : null} + {hasCopyActions && channelId ? ( Promise; onRemindLater?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; onUnfollowThread?: (message: TimelineMessage) => void; reactionErrorMessage?: string | null; reactions: TimelineReaction[]; @@ -398,6 +429,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ Boolean(onFollowThread) || Boolean(onUnfollowThread) || Boolean(onRemindLater) || + Boolean(onSendToChannel) || !message.pending; const wouldAddReaction = React.useCallback( @@ -545,6 +577,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ onMarkRead={onMarkRead} onOpenChange={setIsDropdownOpen} onRemindLater={onRemindLater} + onSendToChannel={onSendToChannel} onUnfollowThread={onUnfollowThread} open={isDropdownOpen} isFollowingThread={isFollowingThread} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 0dc289eefaf..9d61e9c3657 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -58,6 +58,7 @@ import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; +import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit"; import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ audienceContext = null, @@ -100,11 +101,13 @@ function MessageComposerImpl({ syncContentRefFromEditorRef, } = useComposerContentState(); const [previewContent, setPreviewContent] = React.useState(""); - const deferredPreviewContent = React.useDeferredValue(previewContent); const { previewList: composerLinkPreviews, getReadyTags: getReadyLinkPreviewTags, - } = useComposerLinkPreviews(deferredPreviewContent); + hasPendingSnapshots: hasPendingLinkPreviewSnapshots, + // Ref lets the submit guard block Enter/form/auto-submit until snapshots settle. + hasPendingSnapshotsRef: hasPendingLinkPreviewSnapshotsRef, + } = useComposerLinkPreviews(previewContent, editTarget == null); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< @@ -161,7 +164,6 @@ function MessageComposerImpl({ media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); - // Restore/persist drafts at a key boundary; the hook handles StrictMode. useDraftPersistLifecycle({ effectiveDraftKey, channelId, @@ -198,6 +200,8 @@ function MessageComposerImpl({ const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); + // Sync lock: taken before any async send so rapid Enter can't double-submit. + const isSubmitLockedRef = React.useRef(false); const onSendRef = React.useRef(onSend); const onEditSaveRef = React.useRef(onEditSave); const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); @@ -249,6 +253,7 @@ function MessageComposerImpl({ mentionNames: mentions.knownNames, agentMentionNames: mentions.agentKnownNames, channelNames: channelLinks.knownChannelNames, + messageLinkChannels: channelLinks.channels, customEmoji, onSubmit: () => submitMessageRef.current(), onEditLastOwnMessage: () => { @@ -355,9 +360,9 @@ function MessageComposerImpl({ const editableBody = stripImetaMediaLines(editTarget.body, editableImeta); setComposerContent(editableBody); richText.setContent(editableBody); - // Seed the composer's pending-imeta state with the original event's - // attachments so they show up in `ComposerAttachments` and the user - // can remove existing ones / add new ones before saving. + // Seed pending imeta with removable originals before saving the edit. + // New attachments can then be added through the same row. + mentions.restoreDraftMentionRefs(editTarget.mentionRefs ?? []); media.setPendingImeta(editableImeta); media.clearQueuedAttachments(); setSpoileredAttachmentUrls( @@ -480,7 +485,6 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - // ── @ mention picker (toolbar button) ─────────────────────────────── const openMentionPicker = React.useCallback(() => { if (!richText.editor) return; const { text, cursor } = richText.getPlainTextAndCursor(); @@ -521,6 +525,7 @@ function MessageComposerImpl({ customEmoji, originalContent: editTargetRef.current.body, ownerPubkey: ownerPubkeyRef.current, + editTarget: editTargetRef.current, getMentionRefs: mentions.getDraftMentionRefs, pendingImeta: media.pendingImetaRef.current, queuedAttachments: media.queuedAttachmentsRef.current, @@ -562,7 +567,9 @@ function MessageComposerImpl({ (!trimmed && !hasMedia) || disabledRef.current || isSendingRef.current || + isSubmitLockedRef.current || isUploadingRef.current || + hasPendingLinkPreviewSnapshotsRef.current || mentionSendFlow.isPreparingMentionSend ) { return; @@ -574,6 +581,7 @@ function MessageComposerImpl({ ) { return; } + isSubmitLockedRef.current = true; onPreparingMentionSendChange?.(true); persistentMentionHydration.beginSubmit(); try { @@ -594,6 +602,7 @@ function MessageComposerImpl({ audienceRevision: audienceScope ? persistentAudience.revision : null, }); } finally { + isSubmitLockedRef.current = false; persistentMentionHydration.endSubmit(); onPreparingMentionSendChange?.(false); } @@ -604,6 +613,7 @@ function MessageComposerImpl({ drafts.loadDraft, emojiAutocomplete.clearEmojis, getReadyLinkPreviewTags, + hasPendingLinkPreviewSnapshotsRef, media.clearQueuedAttachments, media.pendingImetaRef, media.queuedAttachmentsRef, @@ -654,15 +664,10 @@ function MessageComposerImpl({ // Clear the trigger BEFORE firing so any navigation from the send cannot // loop back with the param still present. onAutoSubmitCompleteRef.current?.(); - // Defer by one macrotask so the draft-persist lifecycle effect (which runs - // synchronously after mount) has a chance to load the draft content into - // the Tiptap editor before we try to submit. - const timer = window.setTimeout(() => { - submitMessageRef.current(); - }, 0); - return () => { - window.clearTimeout(timer); - }; + return scheduleSettleGatedAutoSubmit({ + isPending: () => hasPendingLinkPreviewSnapshotsRef.current, + submit: () => submitMessageRef.current(), + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // mount-only const handleSubmit = React.useCallback( @@ -802,23 +807,14 @@ function MessageComposerImpl({ }); }, [media.setPendingImeta, richText.editor, scrollComposerToBottom]); // ── Send button state ─────────────────────────────────────────────── - const sendDisabled = React.useMemo( - () => - composerDisabled || - media.isUploading || - mentionSendFlow.isPreparingMentionSend || - (isContentEmpty && - media.pendingImeta.length === 0 && - media.queuedAttachments.length === 0), - [ - composerDisabled, - media.isUploading, - mentionSendFlow.isPreparingMentionSend, - isContentEmpty, - media.pendingImeta.length, - media.queuedAttachments.length, - ], - ); + const sendDisabled = + composerDisabled || + media.isUploading || + hasPendingLinkPreviewSnapshots || + mentionSendFlow.isPreparingMentionSend || + (isContentEmpty && + media.pendingImeta.length === 0 && + media.queuedAttachments.length === 0); const handleCaptureSelection = React.useCallback(() => {}, []); const handlePaperclipClick = React.useCallback(() => { diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index 22e9b75b0a3..ddf987d0f33 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -1,10 +1,27 @@ import type { ReactNode } from "react"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; +export type MessageComposerEditTarget = { + author: string; + body: string; + id: string; + /** + * NIP-92 imeta attachments on the original event, in tag order. Loaded + * into the composer's pending-imeta state on edit-open so the user sees + * them as removable thumbnails (just like the send path) and can add + * more. The submit path emits a fresh full imeta tag set on the edit + * event; the receiver overlays it. + */ + imetaMedia?: ImetaMedia[]; + mentionRefs?: DraftMentionRef[]; + unresolvedMentionPubkeys?: string[]; +}; + export type MessageComposerProps = { audienceContext?: { type: "thread"; @@ -36,19 +53,7 @@ export type MessageComposerProps = { autoSubmitDraftKey?: string | null; /** Called when the auto-submit fires so the parent can clear the trigger. */ onAutoSubmitComplete?: () => void; - editTarget?: { - author: string; - body: string; - id: string; - /** - * NIP-92 imeta attachments on the original event, in tag order. Loaded - * into the composer's pending-imeta state on edit-open so the user sees - * them as removable thumbnails (just like the send path) and can add - * more. The submit path emits a fresh full imeta tag set on the edit - * event; the receiver overlays it. - */ - imetaMedia?: ImetaMedia[]; - } | null; + editTarget?: MessageComposerEditTarget | null; isSending?: boolean; mediaController?: MediaUploadController; onDeferredEditPendingChange?: (isPending: boolean) => void; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 51d9832c128..f6be01d71be 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -7,6 +7,10 @@ import { reactionsEqual, tagsEqual, } from "@/features/messages/lib/messageRowEquality"; +import { + assertCanSendMessageToChannel, + canSendMessageToChannel, +} from "@/features/messages/lib/canSendToChannel"; import type { TimelineMessage } from "@/features/messages/types"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; @@ -50,6 +54,7 @@ import { toast } from "sonner"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -66,6 +71,7 @@ export type ThreadDepthGuideAction = { export const MessageRow = React.memo( function MessageRow({ channelId = null, + currentPubkey, collapseDepthGuideActions, connectDescendants = false, depthGuideDepths, @@ -95,6 +101,7 @@ export const MessageRow = React.memo( onMarkRead, onToggleReaction, onReply, + onSendToChannel, onEntranceComplete, playEntrance = false, onUnfollowThread, @@ -105,6 +112,7 @@ export const MessageRow = React.memo( videoReviewContext, }: { channelId?: string | null; + currentPubkey?: string; collapseDepthGuideActions?: ReadonlyArray; connectDescendants?: boolean; depthGuideDepths?: ReadonlyArray; @@ -144,6 +152,7 @@ export const MessageRow = React.memo( remove: boolean, ) => Promise; onReply?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; onUnfollowThread?: (message: TimelineMessage) => void; onEntranceComplete?: (messageId: string) => void; playEntrance?: boolean; @@ -173,6 +182,7 @@ export const MessageRow = React.memo( tags.filter((tag) => tag[0] === "emoji"), undefined, true, + tags.filter((tag) => tag[0] === "mention"), ); } catch (error) { toast.error( @@ -216,6 +226,18 @@ export const MessageRow = React.memo( }, [channelId, openReminder], ); + const sendToChannelAllowed = canSendMessageToChannel( + message, + currentPubkey, + profiles, + ); + const handleSendToChannel = React.useCallback( + async (target: TimelineMessage) => { + assertCanSendMessageToChannel(target, currentPubkey, profiles); + await onSendToChannel?.(target); + }, + [currentPubkey, onSendToChannel, profiles], + ); const { mentionNames, mentionPubkeysByName } = React.useMemo( () => resolveMentionProps(message.tags, profiles), [profiles, message.tags], @@ -569,6 +591,11 @@ export const MessageRow = React.memo( } onRemindLater={handleRemindLater} onReply={onReply} + onSendToChannel={ + onSendToChannel && sendToChannelAllowed + ? handleSendToChannel + : undefined + } onUnfollowThread={onUnfollowThread} reactionErrorMessage={reactionErrorMessage} reactions={reactions} @@ -646,6 +673,7 @@ export const MessageRow = React.memo( const messageBodyNode = ( <> + {renderBody()} {continuationMetadataNode} void; onCancelReply: () => void; @@ -98,6 +94,11 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { threadHeadId: string | null; } | null, ) => Promise; + onSendToChannel?: ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => Promise; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -219,6 +220,7 @@ export function MessageThreadPanel({ onScrollTargetSettled, onSelectReplyTarget, onSend, + onSendToChannel, onToggleReaction, onUnfollowThread, profiles, @@ -522,7 +524,6 @@ export function MessageThreadPanel({ "padding", settleAtBottomAfterLayout, ); - const knownAgentPubkeys = useKnownAgentPubkeys(); const initialAgentPubkeys = React.useMemo(() => { if ( @@ -546,11 +547,14 @@ export function MessageThreadPanel({ knownAgentPubkeys.has(pubkey) || profiles?.[pubkey]?.isAgent === true, ); }, [currentPubkey, knownAgentPubkeys, profiles, threadHead]); - + const stableSendToChannel = useStableSendToChannel( + channelId, + threadHead, + onSendToChannel, + ); if (!threadHead) { return null; } - const threadScrollRegion = ( : null} { + void goChannel(target.channelId, { + messageId: target.messageId, + threadRootId: target.threadRootId, + }); + }, + [goChannel], + ); + + if (!channelId || !reference) return null; + const link: ParsedMessageLink = { + channelId, + messageId: reference.rootEventId, + threadRootId: reference.rootEventId, + }; + + return ( +
+ Sent from thread: + +
+ ); +} diff --git a/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs b/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs new file mode 100644 index 00000000000..e9a62e42dea --- /dev/null +++ b/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs @@ -0,0 +1,103 @@ +/** + * Unit tests for `scheduleSettleGatedAutoSubmit` — the auto-submit scheduler + * that fires a ?autoSend draft submit exactly once, after link-preview settling + * finishes. + * + * Imports and exercises the ACTUAL source helper. Regression guard for the + * auto-send-drop blocker (PR #5245, Blocker A): a confirmed draft with a + * supported link is normally still settling at mount, so an immediate submit + * bails on the pending guard. The prior one-shot `setTimeout(0)` consumed the + * trigger and silently dropped the draft. The scheduler must instead poll while + * pending and submit exactly once when settling clears — never zero, never + * twice. + * + * A controllable fake timer drives the poll deterministically, so there is no + * real-time flakiness (the E2E form could not reliably send inside the ~350 ms + * window headless). + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit.ts"; + +// Minimal deterministic timer: records scheduled callbacks so the test can +// advance them one "tick" at a time and assert exact call counts. +function makeFakeTimers() { + const pending = new Map(); + let nextId = 1; + return { + set(fn, _ms) { + const id = nextId++; + pending.set(id, fn); + return id; + }, + clear(id) { + pending.delete(id); + }, + // Fire the earliest-scheduled still-pending callback. + tick() { + const [id, fn] = pending.entries().next().value ?? []; + if (id === undefined) return false; + pending.delete(id); + fn(); + return true; + }, + pendingCount() { + return pending.size; + }, + }; +} + +test("submits once immediately when nothing is pending", () => { + const timers = makeFakeTimers(); + let submits = 0; + scheduleSettleGatedAutoSubmit({ + isPending: () => false, + submit: () => submits++, + timers, + }); + timers.tick(); // fire the initial setTimeout(0) + assert.equal(submits, 1); + assert.equal(timers.pendingCount(), 0, "no retry should be scheduled"); +}); + +test("waits while settling then submits exactly once (the drop-guard)", () => { + const timers = makeFakeTimers(); + let submits = 0; + let pending = true; // still settling at mount + scheduleSettleGatedAutoSubmit({ + isPending: () => pending, + submit: () => submits++, + timers, + }); + timers.tick(); // initial attempt: pending → reschedules, does NOT submit + assert.equal(submits, 0, "must not send while a snapshot is still pending"); + assert.equal(timers.pendingCount(), 1, "a retry must be scheduled"); + + timers.tick(); // retry: still pending + assert.equal(submits, 0); + + pending = false; // settling finished + timers.tick(); // retry: fires the send + assert.equal(submits, 1, "must send exactly once after settling clears"); + assert.equal(timers.pendingCount(), 0); +}); + +test("cleanup before settling finishes cancels the submit (no orphan send)", () => { + const timers = makeFakeTimers(); + let submits = 0; + const cleanup = scheduleSettleGatedAutoSubmit({ + isPending: () => true, + submit: () => submits++, + timers, + }); + timers.tick(); // initial attempt reschedules a retry + assert.equal(timers.pendingCount(), 1); + cleanup(); // unmount + assert.equal( + timers.pendingCount(), + 0, + "cleanup must clear the pending retry", + ); + assert.equal(submits, 0); +}); diff --git a/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts new file mode 100644 index 00000000000..f15422b93d1 --- /dev/null +++ b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts @@ -0,0 +1,45 @@ +// Auto-submit scheduler for a confirmed draft that arrived via ?autoSend. A +// draft containing a supported link is normally still settling (350 ms +// debounce + metadata/upload) at mount, so a submit fired immediately bails on +// the pending-snapshot guard. A one-shot `setTimeout(0)` would consume the +// trigger and silently drop the draft; instead poll until settling finishes +// (bounded by the preview hook's own anti-trap cap) then submit exactly once. +// The `didSubmit` guard prevents a double fire, and the initial defer lets the +// draft-persist lifecycle effect load the draft into the editor first. +// +// Extracted from MessageComposer as a pure, timer-injectable helper so the +// retry/one-shot contract is unit-testable without mounting the composer. +export function scheduleSettleGatedAutoSubmit({ + isPending, + submit, + retryDelayMs = 50, + timers = { + set: (fn: () => void, ms: number) => window.setTimeout(fn, ms), + clear: (id: number) => window.clearTimeout(id), + }, +}: { + isPending: () => boolean; + submit: () => void; + retryDelayMs?: number; + timers?: { + set: (fn: () => void, ms: number) => number; + clear: (id: number) => void; + }; +}): () => void { + let didSubmit = false; + let retryTimer = 0; + const attempt = () => { + if (didSubmit) return; + if (isPending()) { + retryTimer = timers.set(attempt, retryDelayMs); + return; + } + didSubmit = true; + submit(); + }; + const initialTimer = timers.set(attempt, 0); + return () => { + timers.clear(initialTimer); + timers.clear(retryTimer); + }; +} diff --git a/desktop/src/features/messages/ui/selectSubmitTags.test.mjs b/desktop/src/features/messages/ui/selectSubmitTags.test.mjs new file mode 100644 index 00000000000..f61f4cbadd9 --- /dev/null +++ b/desktop/src/features/messages/ui/selectSubmitTags.test.mjs @@ -0,0 +1,78 @@ +/** + * Unit tests for `selectSubmitTags` — the pure selector that decides which + * link-preview snapshot tags a composer submit emits. + * + * These import and exercise the ACTUAL source helper (not a mirrored copy), so + * they fail if the submit-tag selection ever regresses. + * + * Regression guard for the "removed-URL tag leak" defect (PR #5245, Blocker B): + * a ready snapshot tag for URL A lingers in the tag map for the 350 ms + * debounce window after A is deleted from the draft. Submit must key off the + * LIVE hrefs in the content being sent — never that debounced set — so deleting + * A and immediately sending replacement text can never attach A's tag (and its + * media refs) to a body that no longer contains A. + * + * The E2E form of this test was flaky: sending inside the 350 ms window from a + * headless browser did not reliably fire a submit, so it could not isolate the + * leak. A pure unit test against the extracted selector is deterministic and + * targets the fix logic directly. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { selectSubmitTags } from "./useComposerLinkPreviews.tsx"; + +const tagA = ["link-preview", "snapshot", "1", "https://a.example/x", "A"]; +const tagB = ["link-preview", "snapshot", "1", "https://b.example/y", "B"]; + +test("emits the tag for a live href that has a ready snapshot", () => { + const tags = selectSubmitTags( + ["https://a.example/x"], + { "https://a.example/x": tagA }, + false, + ); + assert.deepEqual(tags, [tagA]); +}); + +test("LEAK GUARD: a ready tag whose href is no longer live is NOT emitted", () => { + // A resolved (tag still cached), but A was deleted from the draft and the + // live content is now different — the debounced map still holds A's tag. + const tags = selectSubmitTags( + [], // live content no longer contains A + { "https://a.example/x": tagA }, + false, + ); + assert.deepEqual(tags, [], "removed URL A must never leak its snapshot tag"); +}); + +test("LEAK GUARD: replacing A with a live B emits only B's tag, never A's", () => { + const tags = selectSubmitTags( + ["https://b.example/y"], // A deleted, B is what's live now + { "https://a.example/x": tagA, "https://b.example/y": tagB }, + false, + ); + assert.deepEqual(tags, [tagB]); +}); + +test("a live href with no ready tag is omitted (sends as a bare link)", () => { + const tags = selectSubmitTags(["https://a.example/x"], {}, false); + assert.deepEqual(tags, []); +}); + +test("preserves live href order for multiple ready tags", () => { + const tags = selectSubmitTags( + ["https://a.example/x", "https://b.example/y"], + { "https://b.example/y": tagB, "https://a.example/x": tagA }, + false, + ); + assert.deepEqual(tags, [tagA, tagB]); +}); + +test("suppressed emits only the 'none' marker, ignoring any ready tags", () => { + const tags = selectSubmitTags( + ["https://a.example/x"], + { "https://a.example/x": tagA }, + true, + ); + assert.deepEqual(tags, [["link-preview", "none"]]); +}); diff --git a/desktop/src/features/messages/ui/submitMessageEdit.test.mjs b/desktop/src/features/messages/ui/submitMessageEdit.test.mjs new file mode 100644 index 00000000000..126dfd13fcd --- /dev/null +++ b/desktop/src/features/messages/ui/submitMessageEdit.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { submitMessageEdit } from "./submitMessageEdit.ts"; + +const UNRESOLVED_USER = "b".repeat(64); + +function baseOptions( + save, + { + content = "hello @Missing User", + editTarget = { + mentionRefs: [], + unresolvedMentionPubkeys: [UNRESOLVED_USER], + }, + } = {}, +) { + return { + clearComposer: () => {}, + content, + customEmoji: [], + editTarget, + editTargetId: "event-id", + extractMentionPubkeys: () => [], + getMentionRefs: () => [], + originalContent: content, + ownerPubkey: "a".repeat(64), + pendingImeta: [], + queuedAttachments: [], + restoreComposer: () => {}, + restoreMentionRefs: () => {}, + setDeferredUploadPending: () => {}, + setUploadError: () => {}, + shouldRestoreComposer: () => true, + spoileredAttachmentUrls: new Set(), + save, + }; +} + +test("edit save emits unresolved identities as non-notifying mention references", async () => { + let saved; + await submitMessageEdit( + baseOptions(async (content, tags, mentionPubkeys, eventId) => { + saved = { content, tags, mentionPubkeys, eventId }; + }), + ); + + assert.deepEqual(saved, { + content: "hello @Missing User", + tags: [["mention", UNRESOLVED_USER]], + mentionPubkeys: [], + eventId: "event-id", + }); +}); + +test("edit save uses edit-target refs that resolve after edit-open", async () => { + let saved; + const resolvedRef = { + displayName: "Missing User", + isAgent: false, + pubkey: UNRESOLVED_USER, + }; + await submitMessageEdit( + baseOptions( + async (content, tags, mentionPubkeys, eventId) => { + saved = { content, tags, mentionPubkeys, eventId }; + }, + { + editTarget: { + mentionRefs: [resolvedRef], + unresolvedMentionPubkeys: [], + }, + }, + ), + ); + + assert.deepEqual(saved, { + content: "hello @Missing User", + tags: [["mention", UNRESOLVED_USER]], + mentionPubkeys: [], + eventId: "event-id", + }); +}); diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts index 8edeea615e5..06c0de19287 100644 --- a/desktop/src/features/messages/ui/submitMessageEdit.ts +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -1,12 +1,15 @@ import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { hasMention } from "@/features/messages/lib/hasMention"; import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types"; import { buildOutgoingMessage, type ImetaMedia, mergeOutgoingTags, } from "@/features/messages/lib/imetaMediaMarkdown"; import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; +import { mergeOutgoingTagsWithReferenceMentions } from "@/features/messages/ui/useMentionSendFlow.helpers"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; @@ -16,14 +19,22 @@ type EditDraft = { pendingImeta: ImetaMedia[]; queuedAttachments: QueuedMediaAttachment[]; spoileredAttachmentUrls: Set; + unresolvedMentionPubkeys: string[]; }; -type SubmitMessageEditOptions = Omit & { +type SubmitMessageEditOptions = Omit< + EditDraft, + "mentionRefs" | "unresolvedMentionPubkeys" +> & { clearComposer: () => void; customEmoji: ReadonlyArray; extractMentionPubkeys: (content: string) => string[]; getMentionRefs: (content: string) => DraftMentionRef[]; editTargetId: string; + editTarget: Pick< + MessageComposerEditTarget, + "mentionRefs" | "unresolvedMentionPubkeys" + >; originalContent: string; ownerPubkey: string | null; restoreComposer: (draft: EditDraft) => void; @@ -39,12 +50,12 @@ type SubmitMessageEditOptions = Omit & { setUploadError: (message: string) => void; }; -/** Clear an edited message immediately, then upload and save captured state. */ export async function submitMessageEdit({ clearComposer, content, customEmoji, editTargetId, + editTarget, extractMentionPubkeys, getMentionRefs, originalContent, @@ -59,12 +70,19 @@ export async function submitMessageEdit({ setUploadError, spoileredAttachmentUrls, }: SubmitMessageEditOptions): Promise { + const currentMentionRefs = editTarget.mentionRefs ?? []; const draft: EditDraft = { content, - mentionRefs: getMentionRefs(content), + mentionRefs: [ + ...getMentionRefs(content), + ...currentMentionRefs.filter((ref) => + hasMention(content, ref.displayName), + ), + ], pendingImeta: [...pendingImeta], queuedAttachments: [...queuedAttachments], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + unresolvedMentionPubkeys: [...(editTarget.unresolvedMentionPubkeys ?? [])], }; const restoreDraft = () => { if (shouldRestoreComposer()) { @@ -93,11 +111,16 @@ export async function submitMessageEdit({ ), ]), ); - const outgoingTags = + const outgoingTags = mergeOutgoingTagsWithReferenceMentions( mergeOutgoingTags( mediaTags, buildCustomEmojiTags(finalContent, customEmoji), - ) ?? []; + ), + [ + ...draft.mentionRefs.map(({ pubkey }) => pubkey), + ...draft.unresolvedMentionPubkeys, + ], + ); if (signal?.aborted) return; await save(finalContent, outgoingTags, addedMentionPubkeys, editTargetId); }; diff --git a/desktop/src/features/messages/ui/useCommittedEmptyTimeline.test.mjs b/desktop/src/features/messages/ui/useCommittedEmptyTimeline.test.mjs new file mode 100644 index 00000000000..217f6fb5cf0 --- /dev/null +++ b/desktop/src/features/messages/ui/useCommittedEmptyTimeline.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +async function renderTimelineState(initialProps) { + const { renderHook } = await import("@testing-library/react"); + const { useCommittedEmptyTimeline } = await import( + "./useCommittedEmptyTimeline.ts" + ); + return renderHook((props) => useCommittedEmptyTimeline(props), { + initialProps, + }); +} + +const empty = { + channelId: "channel-a", + deferredCount: 0, + hasPersistentIntro: true, + isLoading: false, + liveCount: 0, +}; + +test("only preserves an intro after an empty timeline commits", async () => { + const { result, rerender } = await renderTimelineState(empty); + + assert.equal(result.current, false); + rerender({ ...empty, liveCount: 1 }); + assert.equal(result.current, true); + rerender({ ...empty, deferredCount: 1, liveCount: 1 }); + assert.equal(result.current, false); + rerender({ ...empty, liveCount: 1 }); + assert.equal(result.current, false); +}); + +test("a committed empty proof never carries across channels", async () => { + const { result, rerender } = await renderTimelineState(empty); + + rerender({ ...empty, channelId: "channel-b", liveCount: 1 }); + assert.equal(result.current, false); +}); + +test("loading and deferred-stale commits cannot establish empty proof", async () => { + const { result, rerender } = await renderTimelineState({ + ...empty, + isLoading: true, + }); + + rerender({ ...empty, liveCount: 1 }); + assert.equal(result.current, false); +}); diff --git a/desktop/src/features/messages/ui/useCommittedEmptyTimeline.ts b/desktop/src/features/messages/ui/useCommittedEmptyTimeline.ts new file mode 100644 index 00000000000..cdceedcd26b --- /dev/null +++ b/desktop/src/features/messages/ui/useCommittedEmptyTimeline.ts @@ -0,0 +1,35 @@ +import * as React from "react"; + +/** Track only empty timelines that React actually committed for this channel. */ +export function useCommittedEmptyTimeline({ + channelId, + deferredCount, + hasPersistentIntro, + isLoading, + liveCount, +}: { + channelId: string | null; + deferredCount: number; + hasPersistentIntro: boolean; + isLoading: boolean; + liveCount: number; +}) { + const committedRef = React.useRef({ + channelId: null as string | null, + hasSettledEmpty: false, + }); + const preserveSettledEmptyIntro = + hasPersistentIntro && + committedRef.current.channelId === channelId && + committedRef.current.hasSettledEmpty; + + React.useLayoutEffect(() => { + if (isLoading) return; + committedRef.current = { + channelId, + hasSettledEmpty: liveCount === 0 && deferredCount === 0, + }; + }, [channelId, deferredCount, isLoading, liveCount]); + + return preserveSettledEmptyIntro; +} diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 39d5d9db8ee..3f251a719d1 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { ImageOff, LoaderCircle, X } from "lucide-react"; +import { toast } from "sonner"; import { getRelayHttpUrl, uploadMediaBytes } from "@/shared/api/tauri"; import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; @@ -11,8 +12,12 @@ import { beginRelayOriginFetch, getCachedRelayOrigin, } from "@/shared/lib/mediaUrl"; -import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; -import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; +import { + isBuzzEntityPreview, + type ResolvedLinkPreview, + useResolvedLinkPreviews, + withEntityFallbacks, +} from "@/shared/lib/useResolvedLinkPreviews"; import { Attachment, AttachmentContent, @@ -24,6 +29,17 @@ import { } from "@/shared/ui/attachment"; import { Button } from "@/shared/ui/button"; +// Idle time after the last keystroke before link-preview resolution runs, so +// typing a URL does not flicker a card per character (debounce, not throttle: +// throttle would still fire mid-type). +const LINK_PREVIEW_DEBOUNCE_MS = 350; + +// Upper bound on how long Send stays disabled while a preview is still settling +// (metadata resolving, or snapshot media uploading). Past this the button +// re-enables even if the tag never lands, so a dead or slow link never traps +// the composer — the message then sends as a bare link. +const SNAPSHOT_SETTLE_DISABLE_CAP_MS = 2000; + function previewHostname(href: string): string { try { return new URL(href).hostname.replace(/^www\./, ""); @@ -32,10 +48,32 @@ function previewHostname(href: string): string { } } +// Pure selector for the snapshot tags emitted on submit. Keyed off `liveHrefs` +// (the hrefs in the content being sent RIGHT NOW), never the debounced active +// set — a ready tag for URL A lingers in `tagsByHref` for the 350 ms until the +// debounce drops A, so keying off live hrefs is what stops "delete A, send +// replacement text within the window" from leaking A's tag (and media refs) +// onto a body that no longer contains A. When `suppressed`, emit only the +// "none" marker. Live hrefs without a ready tag (dead/slow link past the +// anti-trap cap) are omitted and the message sends as a bare link. +export function selectSubmitTags( + liveHrefs: readonly string[], + tagsByHref: Record, + suppressed: boolean, +): string[][] { + if (suppressed) return [["link-preview", "none"]]; + return liveHrefs.flatMap((href) => { + const tag = tagsByHref[href]; + return tag ? [tag] : []; + }); +} + function ComposerLinkPreviewCard({ preview, + tagReady, }: { preview: ResolvedLinkPreview; + tagReady: boolean; }) { const imageSrc = preview.imageState === "image" ? preview.imageDataUrl : null; const [failedImageSrc, setFailedImageSrc] = React.useState( @@ -43,6 +81,11 @@ function ComposerLinkPreviewCard({ ); const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc); const hostname = previewHostname(preview.href); + // External cards are send-ready only once their snapshot tag exists. Buzz + // entities never snapshot; recipients resolve them from the relay, so they + // are complete as soon as the recognized entity card exists. + const snapshotTagReady = Boolean(preview.snapshotReady && tagReady); + const done = snapshotTagReady || isBuzzEntityPreview(preview); let path = ""; try { const url = new URL(preview.href); @@ -55,7 +98,8 @@ function ComposerLinkPreviewCard({ data-image-state={preview.imageState} data-link-preview={preview.kind} data-link-preview-composer-card="" - state={preview.snapshotReady ? "done" : "processing"} + data-snapshot-tag-ready={snapshotTagReady ? "true" : "false"} + state={done ? "done" : "processing"} > - {preview.snapshotReady ? preview.title : hostname} + {done ? preview.title : hostname} - {preview.snapshotReady + {done ? preview.provider || hostname : path && path !== "/" ? path @@ -131,21 +175,81 @@ async function uploadDataUrl( return { url: uploaded.url, sha256: uploaded.sha256 }; } -export function useComposerLinkPreviews(content: string) { +// Upload one snapshot media (image or favicon) independently so a single +// failure degrades gracefully instead of dropping the whole preview: on +// failure we return empty url/sha256 (a valid "no media" snapshot field) and +// report which media failed so the caller can toast the user once. +async function uploadSnapshotMedia( + dataUrl: string | null | undefined, + filename: string, + label: "thumbnail" | "favicon", +): Promise<{ url: string; sha256: string; failed: null | typeof label }> { + try { + const { url, sha256 } = await uploadDataUrl(dataUrl, filename); + return { url, sha256, failed: null }; + } catch { + return { url: "", sha256: "", failed: dataUrl ? label : null }; + } +} + +export function useComposerLinkPreviews(content: string, enabled = true) { const [suppressed, setSuppressed] = React.useState(false); + // Debounce the content that drives resolution so typing a URL character by + // character does not churn a new candidate href (and a flickering card) per + // keystroke. `content` is the live editor value; `debounced` is what actually + // resolves. A fast paste-and-Enter before the debounce fires is held by + // `hasUnresolvedLiveCandidates` below, which keeps Send disabled until the + // live candidates resolve — so no synchronous flush is needed at submit. + const [debounced, setDebounced] = React.useState(content); + const debouncedRef = React.useRef(debounced); + debouncedRef.current = debounced; + React.useEffect(() => { + if (content === debouncedRef.current) return; + const timer = window.setTimeout( + () => setDebounced(content), + LINK_PREVIEW_DEBOUNCE_MS, + ); + return () => window.clearTimeout(timer); + }, [content]); + const extractCandidates = React.useCallback( + (source: string) => + enabled + ? extractSupportedLinkPreviews(source).filter((preview) => + preview.href.startsWith("buzz://") + ? true + : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), + ) + : [], + [enabled], + ); const candidates = React.useMemo( - () => - extractSupportedLinkPreviews(content).filter((preview) => - preview.href.startsWith("buzz://") - ? true - : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), - ), - [content], + () => extractCandidates(debounced), + [extractCandidates, debounced], + ); + // Supported candidates in the LIVE content. When these differ from what has + // resolved (debounce not yet fired after a paste/keystroke), Send must still + // treat the preview as pending so a fast Enter cannot ship a bare link ahead + // of resolution. + const liveCandidatesRef = React.useRef([]); + liveCandidatesRef.current = extractCandidates(content).map( + (preview) => preview.href, + ); + const resolvedPreviews = useResolvedLinkPreviews( + suppressed ? [] : candidates, ); - const previews = useResolvedLinkPreviews(suppressed ? [] : candidates); + // Entity links resolve to null metadata when the relay lookup has nothing + // for them; keep their safe fallback cards rather than dropping them. + const previews = React.useMemo( + () => withEntityFallbacks(suppressed ? [] : candidates, resolvedPreviews), + [suppressed, candidates, resolvedPreviews], + ); + // Clear a "hide previews" suppression as soon as the LIVE draft has no + // supported candidates — not the debounced set, whose lag would otherwise let + // a clear-then-retype race keep suppression stuck on after the draft changed. + const liveCandidatesEmpty = liveCandidatesRef.current.length === 0; React.useEffect(() => { - if (candidates.length === 0) setSuppressed(false); - }, [candidates.length]); + if (liveCandidatesEmpty) setSuppressed(false); + }, [liveCandidatesEmpty]); const [readyTags, setReadyTags] = React.useState>( {}, ); @@ -184,12 +288,33 @@ export function useComposerLinkPreviews(content: string) { ) continue; uploadsRef.current.add(preview.href); - void Promise.all([ - uploadDataUrl(preview.imageDataUrl, "link-preview-image.png"), - uploadDataUrl(preview.faviconDataUrl, "link-preview-favicon.png"), + // Upload image and favicon independently so one failure degrades to the + // surviving media instead of dropping the whole preview. A snapshot tag + // with empty media fields is valid (renders as text + favicon, or + // text-only), so a partial or total media failure still ships a real + // inline preview and the card never spins forever. + const uploadPromise = Promise.all([ + uploadSnapshotMedia( + preview.imageDataUrl, + "link-preview-image.png", + "thumbnail", + ), + uploadSnapshotMedia( + preview.faviconDataUrl, + "link-preview-favicon.png", + "favicon", + ), ]) .then(([image, favicon]) => { if (!activeHrefsRef.current.has(preview.href)) return; + const failedMedia = [image.failed, favicon.failed].filter( + (label): label is "thumbnail" | "favicon" => label !== null, + ); + if (failedMedia.length > 0) { + toast.error( + `Something went wrong with the ${failedMedia.join(" and ")}`, + ); + } const tag = buildLinkPreviewSnapshotTag({ canonicalUrl: preview.href, title: preview.title, @@ -201,10 +326,18 @@ export function useComposerLinkPreviews(content: string) { faviconSha256: favicon.sha256, }); if (!tag) return; + // Update the ref alongside state so a submit reading + // `readyTagsByHrefRef` sees the tag before the next render commits. + readyTagsByHrefRef.current = { + ...readyTagsByHrefRef.current, + [preview.href]: tag, + }; setReadyTags((current) => ({ ...current, [preview.href]: tag })); }) - .catch(() => {}) - .finally(() => uploadsRef.current.delete(preview.href)); + .finally(() => { + uploadsRef.current.delete(preview.href); + }); + void uploadPromise; } }, [previews, readyTags]); @@ -213,17 +346,73 @@ export function useComposerLinkPreviews(content: string) { : candidates.flatMap((candidate) => readyTags[candidate.href] ? [readyTags[candidate.href]] : [], ); + // A preview is "settling" from paste until its sendable tag exists: metadata + // is still resolving, or it resolved and the snapshot media is uploading. + // Send stays disabled across the whole window so the button never flickers + // ready -> not-ready -> ready (buzz:// links never snapshot, so they never + // report settling). `imageState === "none"` is terminal (no snapshot), so it + // does not block. See the disable cap below for the dead/slow-link escape. + const hasResolvingSnapshots = + !suppressed && + previews.some( + (preview) => + !preview.href.startsWith("buzz://") && + (preview.imageState === "pending" || + (preview.snapshotReady && !readyTags[preview.href])), + ); + // A supported link in the LIVE content that resolution has not caught up to + // yet (debounce pending, or resolved for an older revision) also counts as + // settling — otherwise a paste-and-immediate-Enter would ship a bare link + // before resolution even starts. buzz:// links never snapshot, so ignore them. + const hasUnresolvedLiveCandidates = + !suppressed && + liveCandidatesRef.current.some( + (href) => + !href.startsWith("buzz://") && + !readyTags[href] && + !candidates.some((candidate) => candidate.href === href), + ); + const hasSettlingSnapshots = + hasResolvingSnapshots || hasUnresolvedLiveCandidates; + // Re-enable Send once the disable cap elapses even if a preview is still + // settling, so a link whose metadata or upload stalls never traps the + // composer. Resets whenever settling ends or the live candidate set changes. + const [settleDisableExpired, setSettleDisableExpired] = React.useState(false); + const liveCandidatesKey = liveCandidatesRef.current.join("\n"); + // biome-ignore lint/correctness/useExhaustiveDependencies: liveCandidatesKey intentionally restarts the anti-trap cap when the link set changes while still settling, so a replaced/added link gets a fresh disable window rather than inheriting the prior link's near-expired timer. + React.useEffect(() => { + if (!hasSettlingSnapshots) { + setSettleDisableExpired(false); + return; + } + setSettleDisableExpired(false); + const timer = window.setTimeout( + () => setSettleDisableExpired(true), + SNAPSHOT_SETTLE_DISABLE_CAP_MS, + ); + return () => window.clearTimeout(timer); + }, [hasSettlingSnapshots, liveCandidatesKey]); + const hasPendingSnapshots = hasSettlingSnapshots && !settleDisableExpired; + // Ref mirror so a synchronous submit guard can read the pending state on any + // entry point (Enter, form, auto-submit), not just the reactive button prop. + const hasPendingSnapshotsRef = React.useRef(hasPendingSnapshots); + hasPendingSnapshotsRef.current = hasPendingSnapshots; const hideAll = React.useCallback(() => setSuppressed(true), []); const previewList = previews.length ? (
{previews.map((preview) => ( - + ))}
) : null; - const getReadyTags = React.useCallback(() => { - if (suppressedRef.current) return [["link-preview", "none"]]; - return [...activeHrefsRef.current].flatMap((href) => { - const tag = readyTagsByHrefRef.current[href]; - return tag ? [tag] : []; - }); - }, []); - return { previewList, getReadyTags }; + // Snapshot tags for a submit, read synchronously at submit start from the + // LIVE candidate set (liveCandidatesRef) via `selectSubmitTags` — so the tags + // always correspond to the content actually being sent, never a debounced set + // that still holds a just-removed URL. No await: Send is disabled until every + // settling preview has its tag (or the anti-trap cap fires), so at submit time + // the tags that will ever exist already exist. + const getReadyTags = React.useCallback( + () => + selectSubmitTags( + liveCandidatesRef.current, + readyTagsByHrefRef.current, + suppressedRef.current, + ), + [], + ); + return { + previewList, + getReadyTags, + hasPendingSnapshots, + hasPendingSnapshotsRef, + }; } diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index cc51c733453..4a721dcfed9 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -73,7 +73,7 @@ type UseMentionSendFlowOptions = { >; richText: Pick< UseRichTextEditorResult, - "clearContent" | "setContent" | "setContentAndFocusEnd" + "clearContent" | "setContent" | "restorePlainTextAndFocusEnd" >; setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; @@ -334,7 +334,7 @@ export function useMentionSendFlow({ setContent(postSendContent); contentRef.current = postSendContent; if (postSendContent) { - richText.setContentAndFocusEnd(postSendContent); + richText.restorePlainTextAndFocusEnd(postSendContent); mentions.cancelMentionAutocomplete(); } else richText.clearContent(); setPendingImeta([]); @@ -352,7 +352,7 @@ export function useMentionSendFlow({ mentions.cancelMentionAutocomplete, mentions.clearMentions, richText.clearContent, - richText.setContentAndFocusEnd, + richText.restorePlainTextAndFocusEnd, setContent, setIsEmojiPickerOpen, setPendingImeta, diff --git a/desktop/src/features/messages/ui/useStableSendToChannel.test.mjs b/desktop/src/features/messages/ui/useStableSendToChannel.test.mjs new file mode 100644 index 00000000000..6de2a03397e --- /dev/null +++ b/desktop/src/features/messages/ui/useStableSendToChannel.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +class EventTargetShim { + addEventListener() {} + removeEventListener() {} +} +class NodeShim extends EventTargetShim { + constructor(tagName) { + super(); + this.nodeType = 1; + this.nodeName = tagName.toUpperCase(); + this.tagName = tagName; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + this.ownerDocument = globalThis.document; + this.parentNode = null; + this.children = []; + this.childNodes = []; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((current) => current !== child); + this.childNodes = this.childNodes.filter((current) => current !== child); + child.parentNode = null; + return child; + } +} +class DocumentShim extends EventTargetShim { + constructor() { + super(); + this.nodeType = 9; + this.defaultView = globalThis; + } + createElement(tagName) { + return new NodeShim(tagName); + } +} +globalThis.document = new DocumentShim(); +globalThis.HTMLIFrameElement = NodeShim; +globalThis.HTMLDivElement = NodeShim; +globalThis.HTMLElement = NodeShim; +globalThis.Node = NodeShim; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, +}); + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { useStableSendToChannel } from "./useStableSendToChannel.ts"; + +function Harness({ channelId, onSendToChannel, threadHead, output }) { + output.current = useStableSendToChannel( + channelId, + threadHead, + onSendToChannel, + ); + return null; +} + +test("send-to-channel callback stays stable while using the latest thread context", async () => { + const output = { current: undefined }; + const calls = []; + const firstRoot = { id: "first" }; + const secondRoot = { id: "second" }; + const firstSend = async (...args) => calls.push(["first", ...args]); + const secondSend = async (...args) => calls.push(["second", ...args]); + const root = createRoot(document.createElement("div")); + + await act(async () => { + root.render( + React.createElement(Harness, { + channelId: "channel-a", + onSendToChannel: firstSend, + threadHead: firstRoot, + output, + }), + ); + }); + const initialCallback = output.current; + + await act(async () => { + root.render( + React.createElement(Harness, { + channelId: "channel-b", + onSendToChannel: secondSend, + threadHead: secondRoot, + output, + }), + ); + }); + + assert.equal(output.current, initialCallback); + const message = { id: "reply" }; + await output.current(message); + assert.deepEqual(calls, [["second", message, secondRoot, "channel-b"]]); + + await act(async () => root.unmount()); +}); diff --git a/desktop/src/features/messages/ui/useStableSendToChannel.ts b/desktop/src/features/messages/ui/useStableSendToChannel.ts new file mode 100644 index 00000000000..ff0dc2bc61e --- /dev/null +++ b/desktop/src/features/messages/ui/useStableSendToChannel.ts @@ -0,0 +1,32 @@ +import * as React from "react"; + +import type { TimelineMessage } from "@/features/messages/types"; + +type SendToChannel = ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, +) => Promise; + +export function useStableSendToChannel( + channelId: string | null, + threadHead: TimelineMessage | null, + onSendToChannel?: SendToChannel, +): ((message: TimelineMessage) => Promise) | undefined { + const contextRef = React.useRef({ channelId, onSendToChannel, threadHead }); + React.useLayoutEffect(() => { + contextRef.current = { channelId, onSendToChannel, threadHead }; + }, [channelId, onSendToChannel, threadHead]); + const sendToChannel = React.useCallback((message: TimelineMessage) => { + const context = contextRef.current; + if (!context.onSendToChannel || !context.threadHead || !context.channelId) { + return Promise.resolve(); + } + return context.onSendToChannel( + message, + context.threadHead, + context.channelId, + ); + }, []); + return onSendToChannel && channelId ? sendToChannel : undefined; +} diff --git a/desktop/src/features/messages/ui/useTimelineRetention.test.mjs b/desktop/src/features/messages/ui/useTimelineRetention.test.mjs new file mode 100644 index 00000000000..d965e190107 --- /dev/null +++ b/desktop/src/features/messages/ui/useTimelineRetention.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { afterEach, it } from "node:test"; + +import { JSDOM } from "jsdom"; +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { useTimelineRetention } from "./useTimelineRetention.ts"; + +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; +const originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT; +const originalRequestAnimationFrame = globalThis.requestAnimationFrame; +const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + +afterEach(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalActEnvironment === undefined) + delete globalThis.IS_REACT_ACT_ENVIRONMENT; + else globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment; + if (originalRequestAnimationFrame === undefined) + delete globalThis.requestAnimationFrame; + else globalThis.requestAnimationFrame = originalRequestAnimationFrame; + if (originalCancelAnimationFrame === undefined) + delete globalThis.cancelAnimationFrame; + else globalThis.cancelAnimationFrame = originalCancelAnimationFrame; +}); + +it("does not keep the full timeline mounted before the viewport is measured", async () => { + const dom = new JSDOM( + "
", + ); + let initialRefresh; + Object.assign(globalThis, { + cancelAnimationFrame() { + initialRefresh = undefined; + }, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + requestAnimationFrame(callback) { + initialRefresh = callback; + return 1; + }, + window: dom.window, + }); + + const keys = Array.from({ length: 10_000 }, (_, index) => `message-${index}`); + const itemHeight = 100; + const list = { + findItemIndex(offset) { + return Math.min(keys.length - 1, Math.floor(offset / itemHeight)); + }, + scrollOffset: 500_000, + scrollSize: keys.length * itemHeight, + viewportSize: 1_000, + }; + let retention; + function Harness() { + retention = useTimelineRetention(keys, { current: list }, false); + return null; + } + + const root = createRoot(document.getElementById("root")); + await act(async () => root.render(React.createElement(Harness))); + + assert.equal(retention.retainedIndices.length, 100); + assert.equal(retention.retainedIndices[0], 9_900); + assert.equal(retention.retainedIndices.at(-1), 9_999); + + await act(async () => initialRefresh()); + assert.ok(retention.retainedIndices.length > 0); + assert.ok(retention.retainedIndices.length < 500); + assert.ok(retention.retainedIndices.includes(5_000)); + assert.ok(retention.retainedIndices.includes(9_999)); + + await act(async () => retention.onScrollEnd()); + assert.ok(retention.retainedIndices.length > 0); + assert.ok(retention.retainedIndices.length < 500); + assert.ok(retention.retainedIndices.includes(5_000)); + assert.ok(retention.retainedIndices.includes(9_999)); + + await act(async () => root.unmount()); + dom.window.close(); +}); diff --git a/desktop/src/features/messages/ui/useTimelineRetention.ts b/desktop/src/features/messages/ui/useTimelineRetention.ts index abe697749e0..05336d4a139 100644 --- a/desktop/src/features/messages/ui/useTimelineRetention.ts +++ b/desktop/src/features/messages/ui/useTimelineRetention.ts @@ -2,18 +2,24 @@ import * as React from "react"; import type { VListHandle } from "virtua"; import { nextRetainedTimelineKeys } from "./timelineRetention"; +const INITIAL_RETAINED_TAIL_SIZE = 100; + export function useTimelineRetention( keys: readonly string[], listRef: React.RefObject, isPrepend: boolean, ) { + // Retain only a bounded visual tail on the first render. The timeline opens + // at newest, so this gives Virtua stable rows for initial bottom positioning + // without turning `keepMounted` into an all-history mount. const [retainedKeys, setRetainedKeys] = React.useState>( - () => new Set(keys), + () => new Set(keys.slice(-INITIAL_RETAINED_TAIL_SIZE)), ); const evictionNotBeforeRef = React.useRef(0); const refreshTimerRef = React.useRef | null>( null, ); + const initialRefreshFrameRef = React.useRef(null); const keysRef = React.useRef(keys); keysRef.current = keys; @@ -40,14 +46,24 @@ export function useTimelineRetention( if (isPrepend) evictionNotBeforeRef.current = performance.now() + 3_000; }, [isPrepend]); - React.useEffect( - () => () => { + React.useEffect(() => { + // `onScrollEnd` is not guaranteed for Virtua's initial programmatic + // positioning. Wait until the first painted frame so the initial render + // still gives Virtua only the bounded tail, then seed from its measured + // viewport instead of retaining all history. + initialRefreshFrameRef.current = requestAnimationFrame(() => { + initialRefreshFrameRef.current = null; + refreshRetainedKeys(); + }); + return () => { + if (initialRefreshFrameRef.current !== null) { + cancelAnimationFrame(initialRefreshFrameRef.current); + } if (refreshTimerRef.current !== null) { clearTimeout(refreshTimerRef.current); } - }, - [], - ); + }; + }, [refreshRetainedKeys]); const retainedIndices = React.useMemo( () => keys.flatMap((key, index) => (retainedKeys.has(key) ? [index] : [])), diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index d70ac60b220..1a2cb4a9a53 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -4,6 +4,7 @@ import { useHomeFeedQuery } from "@/features/home/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { scheduleAfterForegroundReady } from "@/shared/lib/foregroundReady"; import { getDesktopNotificationPermissionState, requestDesktopNotificationAccess, @@ -210,14 +211,23 @@ export function useNotificationSettings(pubkey?: string) { }, [normalizedPubkey]); React.useEffect(() => { + let cancelPendingRefresh: (() => void) | null = null; const refreshWhenVisible = () => { - if (document.visibilityState === "visible") { - void refreshPermission(); + if (document.visibilityState !== "visible") { + cancelPendingRefresh?.(); + cancelPendingRefresh = null; + return; } + if (cancelPendingRefresh) return; + cancelPendingRefresh = scheduleAfterForegroundReady(() => { + cancelPendingRefresh = null; + if (document.visibilityState === "visible") void refreshPermission(); + }); }; document.addEventListener("visibilitychange", refreshWhenVisible); window.addEventListener("focus", refreshWhenVisible); return () => { + cancelPendingRefresh?.(); document.removeEventListener("visibilitychange", refreshWhenVisible); window.removeEventListener("focus", refreshWhenVisible); }; diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index f8c08afc2b2..daf162c0aa1 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -31,6 +31,11 @@ import { updateChannel, } from "@/shared/api/tauri"; +// Adapter: resolves the full channel list without the not-modified short-circuit. +// Onboarding paths run once and always need fresh data. +const getChannelsList = (): Promise => + getChannels(null).then((payload) => payload.channels ?? []); + const STARTER_CHANNEL_SETUP_TOAST_ID = "starter-channel-setup-error"; export type ChannelInitResult = @@ -85,7 +90,7 @@ export async function initializeStarterChannels( try { starterChannels = await ensureStarterChannels({ ensureStarterChannels: ensureStarterChannelsCommand, - getChannels, + getChannels: getChannelsList, }); } catch (error) { // Public starter channels are optional. Owners may have deliberately @@ -99,7 +104,7 @@ export async function initializeStarterChannels( createChannel, deleteChannel, getChannelMembers, - getChannels, + getChannels: getChannelsList, updateChannel, }, { @@ -163,7 +168,7 @@ async function refreshChannelsCache( queryClient: ReturnType, ) { try { - queryClient.setQueryData(channelsQueryKey, await getChannels()); + queryClient.setQueryData(channelsQueryKey, await getChannelsList()); } catch { // The next mounted channels query can still retry; this cache refresh is // only here to avoid a blank Home flash after first-run setup. diff --git a/desktop/src/features/onboarding/ui/AvatarStep.tsx b/desktop/src/features/onboarding/ui/AvatarStep.tsx index 7333bea5c9e..177f8dcaa2f 100644 --- a/desktop/src/features/onboarding/ui/AvatarStep.tsx +++ b/desktop/src/features/onboarding/ui/AvatarStep.tsx @@ -30,6 +30,7 @@ type AvatarStepProps = { direction: OnboardingTransitionDirection; /** When true, a ghost "Skip for now" button is always visible (not just on error). */ showAlwaysSkip?: boolean; + showBack?: boolean; state: Pick< ProfileStepState, "avatar" | "isSaving" | "isUploadingAvatar" | "name" | "saveRecovery" @@ -130,6 +131,7 @@ function AvatarStepActions({ onSkipForNow, onSubmit, saveRecovery, + showBack, showAlwaysSkip, }: { canSubmit: boolean; @@ -141,6 +143,7 @@ function AvatarStepActions({ onSkipForNow: () => void; onSubmit: () => void; saveRecovery: ProfileStepState["saveRecovery"]; + showBack: boolean; showAlwaysSkip: boolean; }) { const areNavigationActionsDisabled = isSaving || isUploadingAvatar; @@ -222,16 +225,18 @@ function AvatarStepActions({ ) : null} - + {showBack ? ( + + ) : null} )} @@ -243,6 +248,7 @@ export function AvatarStep({ actions, direction, showAlwaysSkip = false, + showBack = true, state, }: AvatarStepProps) { const { @@ -407,6 +413,7 @@ export function AvatarStep({ onSkipForNow={skipForNow} onSubmit={submit} saveRecovery={saveRecovery} + showBack={showBack} showAlwaysSkip={showAlwaysSkip} /> diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 2367b9faf98..8793eda7942 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -49,7 +49,6 @@ export function backupNextDisabled(): boolean { type BackupStepProps = { direction: OnboardingTransitionDirection; identityStorage?: IdentityStorage; - onBack: () => void; onNext: () => void; onOpenPasswordBackup: () => void; onShowOptions: () => void; @@ -66,7 +65,6 @@ type BackupStepProps = { export function BackupStep({ direction, identityStorage, - onBack, onNext, onOpenPasswordBackup, onShowOptions, @@ -185,7 +183,6 @@ export function BackupStep({ className="flex min-h-0 w-full flex-col items-center" data-testid="onboarding-page-backup-options" direction={direction} - effect={direction === "forward" ? "mask-reveal-up" : "line-slide"} transitionKey={`backup-options-${direction}`} >
@@ -297,8 +294,7 @@ export function BackupStep({ className="flex min-h-0 w-full flex-col items-center" data-testid="onboarding-page-backup" direction={direction} - effect={returningFromSecurity ? "mask-reveal-down" : "line-slide"} - transitionKey={`backup-${direction}-${returningFromSecurity ? "down" : "line"}`} + transitionKey={`backup-${direction}-${returningFromSecurity ? "security" : "line"}`} >
{/* Plain string concat: cn()'s tailwind-merge misreads the custom @@ -420,16 +416,6 @@ export function BackupStep({ > Next - - ); diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 98210c57cde..d2af0cc3bdc 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -38,6 +38,10 @@ import { OnboardingChrome, } from "./OnboardingChrome"; import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter"; +import { + type OnboardingTransitionDirection, + OnboardingSlideTransition, +} from "./OnboardingSlideTransition"; function isRelayMembershipDeniedError(error: unknown): boolean { if (!(error instanceof Error)) return false; @@ -153,9 +157,22 @@ export function CommunityOnboardingFlow({ const systemColorScheme = useSystemColorScheme(); const [displayName, setDisplayName] = React.useState(""); const [avatarUrl, setAvatarUrl] = React.useState(""); + const [localAvatarPreviewUrl, setLocalAvatarPreviewUrl] = React.useState< + string | null + >(null); + const [avatarSquishKey, setAvatarSquishKey] = React.useState(0); + const [transitionDirection, setTransitionDirection] = + React.useState("forward"); const avatarPresentation = useAvatarPresentation(avatarUrl); const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false); const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false); + const [animatedPreviewEl, setAnimatedPreviewEl] = + React.useState(null); + const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] = + React.useState(false); + const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState< + string | null + >(null); const [starterPersonas, setStarterPersonas] = React.useState( [], ); @@ -173,6 +190,9 @@ export function CommunityOnboardingFlow({ const avatarEditorContentRef = React.useRef(null); const [avatarEditorDialogHeight, setAvatarEditorDialogHeight] = React.useState(null); + const animateEmojiAvatarChange = React.useCallback(() => { + setAvatarSquishKey((key) => key + 1); + }, []); // Also fetch on "entering": the curtain is a fresh mount of this component, // so the team-intro fetch from the pre-curtain instance isn't in this state. @@ -280,6 +300,7 @@ export function CommunityOnboardingFlow({ const backToProfile = React.useCallback(() => { if (isPending) return; setStarterChannelFailureCount(0); + setTransitionDirection("backward"); update({ stage: "profile", error: undefined }); }, [isPending, update]); @@ -292,6 +313,7 @@ export function CommunityOnboardingFlow({ void getProfile() .then((profile) => { if (profile.hasProfileEvent) { + setTransitionDirection("forward"); update({ stage: "team-intro", error: undefined }, transaction.id); } }) @@ -427,6 +449,7 @@ export function CommunityOnboardingFlow({ deferredAvatar?.cancel(); throw error; } + setTransitionDirection("forward"); update({ stage: "team-intro", error: undefined }); } catch (error) { if (isRelayMembershipDeniedError(error)) { @@ -467,250 +490,339 @@ export function CommunityOnboardingFlow({ {isProfileStage || isTeamStage ? ( ) : null} - -
+ - {transaction.stage === "claiming" || - transaction.stage === "connecting" ? ( - <> - -

- Joining {transaction.communityName} -

-

- {transaction.error ?? - (transaction.stage === "claiming" - ? "Accepting your invite…" - : "Connecting securely…")} -

-
- {transaction.error ? ( - - ) : null} - -
- - ) : isProfileStage ? ( - <> -
-
-

Build your profile

-

- Add a name and avatar. They’ll show up on your messages, - reactions, and agent handoffs. -

-
-
- setIsAvatarEditorOpen(true)} - previewName={displayName.trim() || "Your profile"} - triggerRef={avatarTriggerRef} - /> -
- - - - - setIsAvatarEditorOpen(open)} - open={isAvatarEditorOpen} - > - { - event.preventDefault(); - avatarTriggerRef.current?.focus(); - }} - overlayVariant="transparent" - style={ - avatarEditorDialogHeight === null - ? undefined - : { height: avatarEditorDialogHeight } - } + + ) : isProfileStage ? ( + <> +
- - Edit your avatar - -
- +

+ Build your profile +

+

+ Add a name and avatar. They’ll show up on your messages, + reactions, and agent handoffs. +

+
+
+ setIsAvatarEditorOpen(false)} - onUploadingChange={setIsUploadingAvatar} - onUrlChange={setAvatarUrl} - presentation="onboarding-modal" + onClick={() => setIsAvatarEditorOpen(true)} previewName={displayName.trim() || "Your profile"} - testIdPrefix="community-avatar" + triggerRef={avatarTriggerRef} /> +
- -
- - ) : ( - <> -

Meet your starter team

-

- Buzz lets you bring multiple agents into the same workspace. - Your team will help you get started using Buzz. -

-
- {starterPersonas.length > 0 ? ( -
- {starterPersonas.map((persona) => { - const animationUrl = - STARTER_PERSONA_ANIMATIONS[persona.displayName]; - return ( -
- {animationUrl ? ( - {`${persona.displayName} - ) : ( - - )} - - {persona.displayName} - -
- ); - })} -
- ) : null} -
- {transaction.error ? ( -

- {transaction.error} - {starterChannelFailureCount === 1 ? " Try again." : null} -

- ) : null} - -
+ - - - - )} -
+ + + setIsAvatarEditorOpen(open)} + open={isAvatarEditorOpen} + > + { + event.preventDefault(); + avatarTriggerRef.current?.focus(); + }} + overlayVariant="transparent" + style={ + avatarEditorDialogHeight === null + ? undefined + : { height: avatarEditorDialogHeight } + } + > + + Edit your avatar + +
+
+
+
+ {isAnimatedPreviewActive + ? null + : (() => { + if (localAvatarPreviewUrl) { + return ( + + ); + } + const emojiAvatar = + parseEmojiAvatarDataUrl(avatarUrl); + return emojiAvatar ? ( +
+ 0 && + "buzz-avatar-squish", + )} + data-testid="community-avatar-live-preview-emoji" + key={avatarSquishKey} + > + {emojiAvatar.emoji} + +
+ ) : ( + + ); + })()} +
+ {animatedPreviewCaption ? ( +

+ {animatedPreviewCaption} +

+ ) : null} +
+ setIsAvatarEditorOpen(false)} + onAnimatedPreviewActiveChange={ + setIsAnimatedPreviewActive + } + onAnimatedPreviewCaptionChange={ + setAnimatedPreviewCaption + } + onEmojiAvatarChange={animateEmojiAvatarChange} + onLocalPreviewChange={setLocalAvatarPreviewUrl} + onUploadingChange={setIsUploadingAvatar} + onUrlChange={setAvatarUrl} + presentation="onboarding-modal" + previewName={displayName.trim() || "Your profile"} + testIdPrefix="community-avatar" + /> +
+ +
+ + ) : ( + <> +

+ Meet your starter team +

+

+ Buzz lets you bring multiple agents into the same workspace. + Your team will help you get started using Buzz. +

+
+ {starterPersonas.length > 0 ? ( +
+ {starterPersonas.map((persona) => { + const animationUrl = + STARTER_PERSONA_ANIMATIONS[persona.displayName]; + return ( +
+ {animationUrl ? ( + {`${persona.displayName} + ) : ( + + )} + + {persona.displayName} + +
+ ); + })} +
+ ) : null} +
+ {transaction.error ? ( +

+ {transaction.error} + {starterChannelFailureCount === 1 ? " Try again." : null} +

+ ) : null} + + + {starterChannelFailureCount >= 2 ? ( + + ) : null} + + + )} +
+
); diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 78a7a32db8e..1fd2e4bcabd 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -37,6 +37,7 @@ type DefaultConfigStepProps = { actions: DefaultConfigStepActions; direction: OnboardingTransitionDirection; draft: DefaultConfigDraft | null; + onSavingChange?: (isSaving: boolean) => void; readyRuntimeIds: readonly string[]; }; @@ -305,6 +306,7 @@ export function DefaultConfigStep({ actions, direction, draft, + onSavingChange, readyRuntimeIds, }: DefaultConfigStepProps) { const [persistenceState, setPersistenceState] = React.useState<{ @@ -314,6 +316,11 @@ export function DefaultConfigStep({ const [isSaving, setIsSaving] = React.useState(false); const [saveError, setSaveError] = React.useState(null); + React.useEffect(() => { + onSavingChange?.(isSaving); + return () => onSavingChange?.(false); + }, [isSaving, onSavingChange]); + const handleComplete = React.useCallback(async () => { if (isSaving) return; setIsSaving(true); @@ -369,38 +376,24 @@ export function DefaultConfigStep({
- {/* Keep Next centered while the optional action sits beside it. */} -
- - -
- + {saveError ? ( @@ -412,12 +405,6 @@ export function DefaultConfigStep({ Couldn’t save model settings. {saveError} Try again.

) : null} - -

- Configure default models in{" "} - Settings → Agents after - setup. -

); diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx index 3d691500490..d91bc92e61a 100644 --- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -120,19 +120,23 @@ export function DownloadKeyStep({ } ref={setPrimaryActionSlot} /> - + {hasCreated ? ( + + ) : null} ); diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index 6028b36c64d..52b9ebbd07e 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -530,10 +530,7 @@ export function InviteRedeemForm({ {isOnboardingSpotlight ? ( - - {submitButton} - {cancelButton} - + {submitButton} ) : isAddCommunity ? (
{submitButton}
) : ( diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index c0cc2d8f795..27d6b8ce447 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import type { QueryClient } from "@tanstack/react-query"; -import { ArrowUp } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { @@ -39,7 +38,10 @@ import { OnboardingChrome, } from "./OnboardingChrome"; import { OnboardingFooterProvider } from "./OnboardingFooter"; -import { OnboardingSlideTransition } from "./OnboardingSlideTransition"; +import { + type OnboardingTransitionDirection, + OnboardingSlideTransition, +} from "./OnboardingSlideTransition"; import { SetupStep } from "./SetupStep"; import type { DefaultConfigDraft } from "./types"; @@ -84,11 +86,15 @@ export function MachineOnboardingFlow({ const [page, setPage] = React.useState( identityLost ? "key-import" : (initialPage ?? "identity"), ); + const [transitionDirection, setTransitionDirection] = + React.useState("forward"); const [error, setError] = React.useState(null); const [isPending, setIsPending] = React.useState(false); const [identityWasImported, setIdentityWasImported] = React.useState(false); const [keyImportStage, setKeyImportStage] = React.useState("key-entry"); + const [isKeyImporting, setIsKeyImporting] = React.useState(false); + const [keyImportFormKey, setKeyImportFormKey] = React.useState(0); const [keyImportDialog, setKeyImportDialog] = React.useState< "backup" | "phone" | null >(null); @@ -102,6 +108,8 @@ export function MachineOnboardingFlow({ const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); const [defaultConfigDraft, setDefaultConfigDraft] = React.useState(null); + const [isDefaultConfigSaving, setIsDefaultConfigSaving] = + React.useState(false); const [backupSubview, setBackupSubview] = React.useState("created"); const [backupDirection, setBackupDirection] = React.useState< @@ -130,6 +138,7 @@ export function MachineOnboardingFlow({ setSelectedPubkey(identity.pubkey); setIdentityStorage(identity.storage); setBackupDirection("forward"); + setTransitionDirection("forward"); setReturningFromSecurity(false); setBackupSubview("created"); setPage("backup"); @@ -152,6 +161,7 @@ export function MachineOnboardingFlow({ setIdentityWasImported(true); setSelectedPubkey(identity.pubkey); setIdentityStorage(identity.storage); + setTransitionDirection("forward"); setPage("setup"); } catch (cause) { setError( @@ -176,6 +186,7 @@ export function MachineOnboardingFlow({ setSelectedPubkey(identity.pubkey); setIdentityStorage(identity.storage); setBackupDirection("forward"); + setTransitionDirection("forward"); setReturningFromSecurity(false); setBackupSubview("created"); setPage("backup"); @@ -195,11 +206,81 @@ export function MachineOnboardingFlow({ queryClient.setQueryData(["identity"], identity); setIdentityWasImported(true); setSelectedPubkey(identity.pubkey); + setTransitionDirection("forward"); setPage("setup"); }, [continueWithIdentity, queryClient], ); + const backFromKeyImport = React.useCallback(() => { + if (keyImportStage === "backup-password") { + setKeyImportFormKey((current) => current + 1); + setKeyImportStage("key-entry"); + return; + } + setTransitionDirection("backward"); + setPage("identity"); + }, [keyImportStage]); + + const returnToCreatedKey = React.useCallback(() => { + setBackupDirection("backward"); + setReturningFromSecurity(true); + setBackupSubview("created"); + }, []); + + const backFromPasswordBackup = React.useCallback(() => { + resetEncryptedBackupSession(backupSession); + setBackupDirection("backward"); + setReturningFromSecurity(false); + setBackupSubview("options"); + }, [backupSession]); + + const backFromSetup = React.useCallback(() => { + if (identityWasImported) { + setKeyImportFormKey((current) => current + 1); + setKeyImportStage("key-entry"); + setTransitionDirection("backward"); + setPage("key-import"); + return; + } + if (backupSubview === "password") { + backupSessionToPasswordEntry(backupSession); + } + setBackupDirection("backward"); + setTransitionDirection("backward"); + setReturningFromSecurity(false); + setPage("backup"); + }, [backupSession, backupSubview, identityWasImported]); + + const chromeBackAction = + page === "key-import" && + (!identityLost || keyImportStage === "backup-password") + ? { disabled: isKeyImporting, onClick: backFromKeyImport } + : page === "backup" && backupSubview !== "created" + ? { + label: "Return to onboarding", + onClick: returnToCreatedKey, + testId: "backup-return-to-onboarding", + } + : page === "backup" + ? { + onClick: () => { + setTransitionDirection("backward"); + setPage("identity"); + }, + } + : page === "setup" + ? { onClick: backFromSetup } + : page === "config" + ? { + disabled: isDefaultConfigSaving, + onClick: () => { + setTransitionDirection("backward"); + setPage("setup"); + }, + } + : undefined; + return (
{page === "identity" ? : null} - {isSecuritySubview ? ( -
- -
- ) : page !== "identity" ? ( + {page !== "identity" && !isSecuritySubview ? ( ) : null} - +
Buzz { setKeyImportDialog(null); setKeyImportStage("key-entry"); + setTransitionDirection("forward"); setPage("key-import"); }} type="button" @@ -294,9 +358,8 @@ export function MachineOnboardingFlow({ ) : page === "key-import" ? (
{ - setKeyImportStage("key-entry"); - if (identityLost) { - return; - } - setPage("identity"); - }} + key={keyImportFormKey} + onBack={backFromKeyImport} onImport={importExistingIdentity} + onImportingChange={setIsKeyImporting} onStageChange={setKeyImportStage} - showBack={!identityLost} + showBack={false} + showPasswordStageBack={false} variant="spotlight" /> {identityLost && keyImportStage === "key-entry" ? ( ) : null} - {showBack || isPasswordStage ? ( + {showBack || (isPasswordStage && showPasswordStageBack) ? ( +
+ ) : null}
void; onInstallResultsChange: React.Dispatch< React.SetStateAction >; @@ -655,6 +657,26 @@ function RuntimeProvidersSection({ {errorMessage}

) : null} + +

+ + + More harnesses (Cursor, Grok, Amp…){" "} + {navigateToAgentSettings ? ( + + ) : ( + Settings → Agents + )}{" "} + after setup. + +

); @@ -693,60 +715,30 @@ function SetupStepContent({ > - {/* Relative row keeps the primary CTA truly centered while Skip - hangs off its right edge without shifting the center. */} -
- - -
- + - -

- More harnesses (Cursor, Grok, Amp…){" "} - {actions.navigateToAgentSettings ? ( - - ) : ( - Settings → Agents - )}{" "} - after setup. -

); @@ -758,7 +750,6 @@ export function SetupStep({ onReadyRuntimeIdsChange, }: SetupStepProps) { const state = useSetupStepState(); - return ( { + focusManager.setFocused(undefined); +}); + +async function focusRefetchCount({ ageMs, policy }) { + focusManager.setFocused(false); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const queryKey = ["focus-refetch-policy", policy.staleTime, ageMs]; + queryClient.setQueryData(queryKey, "cached", { + updatedAt: Date.now() - ageMs, + }); + let fetchCount = 0; + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => { + fetchCount += 1; + return "refetched"; + }, + refetchOnMount: false, + ...policy, + }); + const unsubscribe = observer.subscribe(() => {}); + + focusManager.setFocused(true); + await new Promise((resolve) => setImmediate(resolve)); + + unsubscribe(); + queryClient.unmount(); + return fetchCount; +} + +test("presence: skips fresh focus refetch", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: presenceFocusRefetchPolicy.staleTime - 1_000, + policy: presenceFocusRefetchPolicy, + }), + 0, + ); +}); + +test("presence: does not refetch stale data on focus", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: presenceFocusRefetchPolicy.staleTime + 1, + policy: presenceFocusRefetchPolicy, + }), + 0, + ); +}); diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts index 04936241df6..b1acacafbc5 100644 --- a/desktop/src/features/presence/hooks.ts +++ b/desktop/src/features/presence/hooks.ts @@ -7,6 +7,7 @@ import { useRelayConnection } from "@/shared/api/useRelayConnection"; import { getOsIdleSeconds } from "@/shared/api/osIdle"; import { getPresence } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { mergePresenceUpdate, parseLivePresenceEvent, @@ -18,6 +19,18 @@ import { import type { PresenceLookup, PresenceStatus } from "@/shared/api/types"; const PRESENCE_STATUS_TICK_INTERVAL_MS = 30_000; +/** Keeps focused polling for presence at the established 60-second backstop cadence. */ +export const PRESENCE_REFETCH_INTERVAL_MS = 60_000; +/** Suppresses the focus refetch until presence data is genuinely stale. + * The live subscription (setQueriesData) and reconnect invalidation are the + * primary freshness paths; the 60s poll is the backstop. */ +export const PRESENCE_FOCUS_STALE_TIME_MS = 5 * 60_000; + +/** Focus-refetch policy for the presence query; consumed by focusRefetchPolicy.test.mjs. */ +export const presenceFocusRefetchPolicy = { + staleTime: PRESENCE_FOCUS_STALE_TIME_MS, + refetchOnWindowFocus: false, +} as const; const PRESENCE_ACTIVITY_THROTTLE_MS = 1_000; const PRESENCE_PREFERENCE_STORAGE_KEY = "buzz-presence-preference"; @@ -83,16 +96,19 @@ export function usePresenceQuery( const enabled = (options?.enabled ?? true) && normalizedPubkeys.length > 0; const connectionState = useRelayConnection(); const connected = connectionState === "connected"; + const refetchInterval = useFocusedRefetchInterval( + connected ? PRESENCE_REFETCH_INTERVAL_MS : false, + ); return useQuery({ enabled, queryKey: presenceQueryKey(normalizedPubkeys), queryFn: () => getPresence(normalizedPubkeys), - staleTime: 30_000, // Backstop poll: catches REST-only writers (ACP agents) and TTL expiry // (crashed clients). WS events handle the fast path. Pause on degraded // connections — HTTP presence calls fail anyway and consume relay quota. - refetchInterval: connected ? 60_000 : false, + refetchInterval, + ...presenceFocusRefetchPolicy, }); } diff --git a/desktop/src/features/profile/avatarPresentationStore.test.mjs b/desktop/src/features/profile/avatarPresentationStore.test.mjs new file mode 100644 index 00000000000..294a01a80c1 --- /dev/null +++ b/desktop/src/features/profile/avatarPresentationStore.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + beginAvatarPresentation, + getAvatarPresentation, + resetAvatarPresentations, +} from "./avatarPresentationStore.ts"; +import { buildAnimatedAvatarUrl } from "../../shared/lib/animatedAvatar.ts"; + +const POSTER_URL = "https://media.example.com/avatar-poster.png"; +const ANIMATION_URL = "https://media.example.com/avatar-animation.png"; + +test("animated avatar presentation waits for both remote assets", async (t) => { + const originalImage = globalThis.Image; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalWindow = globalThis.window; + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + const requestedPaths = []; + + class ProbeImage { + onerror = null; + onload = null; + referrerPolicy = ""; + + set src(value) { + const path = new URL(value).pathname; + requestedPaths.push(path); + queueMicrotask(() => { + if (path === "/avatar-poster.png") this.onload?.(); + else this.onerror?.(); + }); + } + } + + globalThis.Image = ProbeImage; + globalThis.requestAnimationFrame = (callback) => + setTimeout(() => callback(performance.now()), 0); + globalThis.window = globalThis; + URL.createObjectURL = () => "blob:local-poster"; + URL.revokeObjectURL = () => {}; + t.after(() => { + resetAvatarPresentations(); + globalThis.Image = originalImage; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.window = originalWindow; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }); + + const avatarUrl = buildAnimatedAvatarUrl(POSTER_URL, ANIMATION_URL); + beginAvatarPresentation(avatarUrl, new Blob(["poster"])); + + await assert.doesNotReject(async () => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (requestedPaths.length >= 2) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("avatar presentation did not probe both assets"); + }); + + assert.deepEqual(requestedPaths.slice(0, 2).sort(), [ + "/avatar-animation.png", + "/avatar-poster.png", + ]); + assert.deepEqual(getAvatarPresentation(avatarUrl), { + displayUrl: "blob:local-poster", + state: "pending", + }); +}); diff --git a/desktop/src/features/profile/avatarPresentationStore.ts b/desktop/src/features/profile/avatarPresentationStore.ts index cfdad97248f..330e096dbcf 100644 --- a/desktop/src/features/profile/avatarPresentationStore.ts +++ b/desktop/src/features/profile/avatarPresentationStore.ts @@ -1,6 +1,10 @@ import * as React from "react"; import { toast } from "sonner"; +import { + buildAnimatedAvatarUrl, + parseAnimatedAvatarUrl, +} from "@/shared/lib/animatedAvatar"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; export type AvatarPresentationState = "failed" | "pending" | "ready"; @@ -84,6 +88,37 @@ function probeImage( }); } +function presentationAssetUrls(remoteUrl: string): string[] { + const animated = parseAnimatedAvatarUrl(remoteUrl); + return animated ? [animated.posterUrl, animated.animationUrl] : [remoteUrl]; +} + +function verifiedPresentationUrl( + remoteUrl: string, + verifiedUrls: Array, +): string | null { + if (verifiedUrls.some((verifiedUrl) => !verifiedUrl)) return null; + + const animated = parseAnimatedAvatarUrl(remoteUrl); + if (!animated) return verifiedUrls[0] ?? null; + const [posterUrl, animationUrl] = verifiedUrls; + return posterUrl && animationUrl + ? buildAnimatedAvatarUrl(posterUrl, animationUrl) + : null; +} + +async function probePresentation( + remoteUrl: string, + attempt: number, +): Promise { + const verifiedUrls = await Promise.all( + presentationAssetUrls(remoteUrl).map((assetUrl) => + probeImage(assetUrl, attempt), + ), + ); + return verifiedPresentationUrl(remoteUrl, verifiedUrls); +} + async function verifyPresentation( entry: AvatarPresentationEntry, ): Promise { @@ -91,7 +126,7 @@ async function verifyPresentation( await wait(delayMs); if (!isCurrent(entry) || entry.snapshot.state !== "pending") return; - const verifiedUrl = await probeImage(entry.remoteUrl, attempt); + const verifiedUrl = await probePresentation(entry.remoteUrl, attempt); if (!isCurrent(entry) || entry.snapshot.state !== "pending") return; if (!verifiedUrl) continue; diff --git a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs index 6df7a5f9765..1812f63bb3a 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs +++ b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs @@ -2,10 +2,14 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + MAX_SELF_PROFILE_CACHES, + MAX_SELF_PROFILE_CACHES_PER_RELAY, + _resetProfileKeyCountForTest, parseSelfProfileCache, resolveAvatarDataUrl, shouldFetchAvatar, storageKey, + writeSelfProfileCache, } from "./selfProfileStorage.ts"; test("storageKey: includes pubkey in result", () => { @@ -48,6 +52,77 @@ test("storageKey: different pubkeys produce different keys", () => { assert.notEqual(a, b); }); +function installStorage(onGetItem = () => {}) { + const values = new Map(); + globalThis.window = { + dispatchEvent: () => true, + localStorage: { + get length() { + return values.size; + }, + getItem: (key) => { + onGetItem(key); + return values.get(key) ?? null; + }, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, String(value)), + }, + }; + globalThis.CustomEvent ??= class CustomEvent {}; + return values; +} + +test("writeSelfProfileCache caps each relay and the global cache by updatedAt", () => { + _resetProfileKeyCountForTest(); + const values = installStorage(); + const relayA = "wss://relay-a.example"; + for (let index = 0; index < MAX_SELF_PROFILE_CACHES_PER_RELAY + 1; index++) { + assert.equal( + writeSelfProfileCache( + relayA, + `pubkey-${index}`, + makeCache({ updatedAt: index }), + ), + true, + ); + } + assert.equal(values.has(storageKey(relayA, "pubkey-0")), false); + assert.equal(values.has(storageKey(relayA, "pubkey-8")), true); + + for (let index = 0; index < MAX_SELF_PROFILE_CACHES + 1; index++) { + writeSelfProfileCache( + `wss://relay-${index}.example`, + `global-${index}`, + makeCache({ updatedAt: index + 100 }), + ); + } + const profileKeys = [...values.keys()].filter((key) => + key.startsWith("buzz-self-profile.v1:"), + ); + assert.equal(profileKeys.length, MAX_SELF_PROFILE_CACHES); + assert.equal(values.has(storageKey(relayA, "pubkey-1")), false); +}); + +test("writeSelfProfileCache does not read existing payloads below both caps", () => { + _resetProfileKeyCountForTest(); + const readKeys = []; + const values = installStorage((key) => readKeys.push(key)); + const relay = "wss://relay.example"; + const existingKey = storageKey(relay, "existing"); + const writtenKey = storageKey(relay, "written"); + values.set(existingKey, JSON.stringify(makeCache({ updatedAt: 1 }))); + + assert.equal( + writeSelfProfileCache(relay, "written", makeCache({ updatedAt: 2 })), + true, + ); + + assert.deepEqual(readKeys, [writtenKey]); + assert.equal(values.has(existingKey), true); + assert.equal(values.has(writtenKey), true); +}); + test("parseSelfProfileCache: valid v1 payload round-trips", () => { const payload = { version: 1, @@ -343,3 +418,180 @@ test("resolveAvatarDataUrl: fetch failed, URL changed → null", () => { null, ); }); + +// --------------------------------------------------------------------------- +// Memoized key-count tests +// --------------------------------------------------------------------------- + +function installStorageTracked() { + const values = new Map(); + let keyCallCount = 0; + const mock = { + values, + get keyCallCount() { + return keyCallCount; + }, + get length() { + return values.size; + }, + key(index) { + keyCallCount++; + return [...values.keys()][index] ?? null; + }, + getItem: (key) => values.get(key) ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, String(value)), + }; + globalThis.window = { + dispatchEvent: () => true, + localStorage: mock, + }; + globalThis.CustomEvent ??= class CustomEvent {}; + return mock; +} + +test("trim does not call key() when memo is initialized and total is under per-relay cap", () => { + _resetProfileKeyCountForTest(); + const storage = installStorageTracked(); + const relay = "wss://relay.example"; + + // First write: memo uninitialized → ensureProfileKeyCount scans (calls key()). + writeSelfProfileCache(relay, "pk-0", makeCache({ updatedAt: 1 })); + const keyCallsAfterFirst = storage.keyCallCount; + assert.ok( + keyCallsAfterFirst > 0, + "first write triggers key scan to init memo", + ); + + // Second write with total still ≤ MAX_SELF_PROFILE_CACHES_PER_RELAY (8). + // Memo already initialized → trim returns in O(1), no key() calls. + const before = storage.keyCallCount; + writeSelfProfileCache(relay, "pk-1", makeCache({ updatedAt: 2 })); + const after = storage.keyCallCount; + assert.equal( + after - before, + 0, + "no key() calls in trim when memo is hot and count is under cap", + ); +}); + +test("memoized count resyncs when a full scan finds external deletions", () => { + _resetProfileKeyCountForTest(); + const storage = installStorageTracked(); + const relay = "wss://relay.example"; + + // Fill to just above per-relay cap so trim does a key-only scan. + for (let i = 0; i < MAX_SELF_PROFILE_CACHES_PER_RELAY + 1; i++) { + writeSelfProfileCache(relay, `pk-${i}`, makeCache({ updatedAt: i + 1 })); + } + + // Externally delete some keys to make the memo stale. + const keysToDelete = [...storage.values.keys()] + .filter((k) => k.startsWith("buzz-self-profile.v1:")) + .slice(0, 3); + for (const k of keysToDelete) storage.values.delete(k); + + // Next write: trim does key-only scan, detects mismatch, resyncs memo. + // Should not throw and should cap correctly. + assert.doesNotThrow(() => { + writeSelfProfileCache(relay, "pk-new", makeCache({ updatedAt: 999 })); + }); + // After resync the memo reflects actual storage (no stale inflation). + const finalKeys = [...storage.values.keys()].filter((k) => + k.startsWith("buzz-self-profile.v1:"), + ); + assert.ok( + finalKeys.length <= MAX_SELF_PROFILE_CACHES_PER_RELAY, + `expected ≤${MAX_SELF_PROFILE_CACHES_PER_RELAY} keys after trim, got ${finalKeys.length}`, + ); +}); + +test("over-cap write still trims correctly after memo init", () => { + _resetProfileKeyCountForTest(); + const values = installStorage(); + const relay = "wss://relay-a.example"; + + // Fill to cap + 1 (triggers the full parse+evict path). + for (let i = 0; i <= MAX_SELF_PROFILE_CACHES_PER_RELAY; i++) { + writeSelfProfileCache(relay, `pk-${i}`, makeCache({ updatedAt: i })); + } + + // Oldest (updatedAt=0) should have been evicted; newest preserved. + assert.equal(values.has(storageKey(relay, "pk-0")), false, "oldest evicted"); + assert.equal( + values.has(storageKey(relay, `pk-${MAX_SELF_PROFILE_CACHES_PER_RELAY}`)), + true, + "newest preserved", + ); +}); + +test("scan failure in ensureProfileKeyCount: write succeeds, memo stays null, next write rescans and enforces cap", () => { + _resetProfileKeyCountForTest(); + + const values = new Map(); + let throwOnScan = false; + globalThis.window = { + dispatchEvent: () => true, + localStorage: { + get length() { + if (throwOnScan) throw new Error("Storage unavailable"); + return values.size; + }, + getItem: (k) => values.get(k) ?? null, + key: (i) => [...values.keys()][i] ?? null, + removeItem: (k) => values.delete(k), + setItem: (k, v) => values.set(k, String(v)), + }, + }; + globalThis.CustomEvent ??= class CustomEvent {}; + + const relay = "wss://relay.example"; + + // Write with scan disabled — ensureProfileKeyCount should catch the throw, + // leave memo null, and let the write succeed. + throwOnScan = true; + const result = writeSelfProfileCache( + relay, + "pk-0", + makeCache({ updatedAt: 1 }), + ); + throwOnScan = false; + + assert.equal(result, true, "write returns true despite scan failure"); + assert.equal( + values.has(storageKey(relay, "pk-0")), + true, + "entry was written", + ); + + // Pre-seed enough entries (directly into the map) to push total above cap. + for (let i = 1; i <= MAX_SELF_PROFILE_CACHES_PER_RELAY; i++) { + values.set( + storageKey(relay, `pk-${i}`), + JSON.stringify(makeCache({ updatedAt: i + 1 })), + ); + } + + // Replace key() with a tracking version to confirm memo was null (i.e. a + // fresh scan is triggered on the next write). + let keyCalls = 0; + globalThis.window.localStorage.key = (i) => { + keyCalls++; + return [...values.keys()][i] ?? null; + }; + + writeSelfProfileCache(relay, "pk-new", makeCache({ updatedAt: 999 })); + + assert.ok( + keyCalls > 0, + "key() was called, confirming memo was null after scan failure", + ); + + const finalKeys = [...values.keys()].filter((k) => + k.startsWith("buzz-self-profile.v1:"), + ); + assert.ok( + finalKeys.length <= MAX_SELF_PROFILE_CACHES_PER_RELAY, + `cap enforced after rescan: got ${finalKeys.length}, expected ≤${MAX_SELF_PROFILE_CACHES_PER_RELAY}`, + ); +}); diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index 02e083ae1d4..eaff2546c23 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -15,6 +15,8 @@ export { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; +export const MAX_SELF_PROFILE_CACHES_PER_RELAY = 8; +export const MAX_SELF_PROFILE_CACHES = 32; /** * Dispatched on window after a successful writeSelfProfileCache so that any @@ -127,6 +129,141 @@ export function readSelfProfileCache( } } +/** + * Memoized count of localStorage keys matching the self-profile prefix. + * null = uninitialized (first write triggers the scan). + * + * This is NOT community-scoped: localStorage is shared across all communities + * in the same origin, so the count persists across community switches. It must + * not be added to resetCommunityState(). + * + * Multi-tab caveat: writes from other tabs bypass this tab's memo, so the count + * can under-count. The resync-on-divergence check in trimSelfProfileCaches + * corrects this on the next over-cap scan, bounded by MAX_SELF_PROFILE_CACHES. + */ +let _memoizedProfileKeyCount: number | null = null; + +/** + * Returns the memoized key count, scanning once on first call. + * Subsequent calls are O(1). Returns null if the scan throws (storage + * locked/unavailable); callers treat null as "skip trimming." + */ +function ensureProfileKeyCount(): number | null { + if (_memoizedProfileKeyCount !== null) return _memoizedProfileKeyCount; + try { + let count = 0; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key?.startsWith(`${STORAGE_KEY_PREFIX}:`)) count++; + } + _memoizedProfileKeyCount = count; + return count; + } catch { + // Leave memo null — a partial count must never be memoized. The next + // call will rescan when storage recovers. + return null; + } +} + +/** Reset module state between tests. Not for production use. */ +export function _resetProfileKeyCountForTest(): void { + _memoizedProfileKeyCount = null; +} + +/** + * Trims self-profile caches to stay within per-relay and global caps. + * + * Fast path (O(1)): when the memoized total is ≤ MAX_SELF_PROFILE_CACHES_PER_RELAY, + * no relay can exceed the per-relay cap, so both caps are satisfied without + * any localStorage iteration. + * + * Slow path: key-only scan to check per-relay and global counts; if over cap, + * a full parse-scan selects entries for eviction by updatedAt. The memoized + * count is resynced if the scan finds it diverged (e.g. another tab deleted + * keys between writes). + */ +function trimSelfProfileCaches(relayUrl: string, preservedKey: string): void { + const relayPrefix = `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`; + + // O(1) fast path: ≤ per-relay cap total → definitely under both caps. + // null = scan failed (storage locked); skip trimming so the write still lands. + const memoTotal = ensureProfileKeyCount(); + if (memoTotal === null || memoTotal <= MAX_SELF_PROFILE_CACHES_PER_RELAY) + return; + + // Key-only scan to get accurate totals (no getItem, no parsing). + let totalEntryCount = 0; + let relayEntryCount = 0; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key?.startsWith(`${STORAGE_KEY_PREFIX}:`)) continue; + totalEntryCount += 1; + if (key.startsWith(relayPrefix)) relayEntryCount += 1; + } + + // Resync memo if it diverged due to external deletions (another tab, the + // TTL sweep, etc.). + if (totalEntryCount !== memoTotal) { + _memoizedProfileKeyCount = totalEntryCount; + } + + if ( + totalEntryCount <= MAX_SELF_PROFILE_CACHES && + relayEntryCount <= MAX_SELF_PROFILE_CACHES_PER_RELAY + ) { + return; + } + + // Full parse-scan: collect entries for eviction by updatedAt. + const entries: Array<{ key: string; updatedAt: number }> = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key?.startsWith(`${STORAGE_KEY_PREFIX}:`)) continue; + const raw = window.localStorage.getItem(key); + if (!raw) continue; + try { + entries.push({ + key, + updatedAt: parseSelfProfileCache(JSON.parse(raw))?.updatedAt ?? 0, + }); + } catch { + entries.push({ key, updatedAt: 0 }); + } + } + const relayEntries = entries.filter((entry) => + entry.key.startsWith(relayPrefix), + ); + const keysToRemove = new Set(); + for (const candidates of [relayEntries, entries]) { + const maxEntries = + candidates === relayEntries + ? MAX_SELF_PROFILE_CACHES_PER_RELAY + : MAX_SELF_PROFILE_CACHES; + const removable = candidates + .filter((entry) => entry.key !== preservedKey) + .sort((left, right) => left.updatedAt - right.updatedAt); + let removeCount = + candidates.filter((entry) => !keysToRemove.has(entry.key)).length - + maxEntries; + for (const entry of removable) { + if (removeCount <= 0) break; + if (keysToRemove.has(entry.key)) continue; + keysToRemove.add(entry.key); + removeCount -= 1; + } + } + for (const key of keysToRemove) window.localStorage.removeItem(key); + + // Update memo to reflect evictions. + if (_memoizedProfileKeyCount !== null) { + _memoizedProfileKeyCount = Math.max( + 0, + (totalEntryCount !== memoTotal ? totalEntryCount : memoTotal) - + keysToRemove.size, + ); + } +} + /** * Writes the cache to localStorage and fires SELF_PROFILE_CACHE_EVENT so * mounted components can re-read without polling. @@ -144,8 +281,15 @@ export function writeSelfProfileCache( // The 30s profile refetch otherwise re-stringifies ~341KB, rewrites it, // dispatches the cache event, and re-parses on the listener side even // when nothing changed. Skip the write and event entirely when identical. - if (window.localStorage.getItem(key) === serialized) return true; + const existingRaw = window.localStorage.getItem(key); + if (existingRaw === serialized) return true; + const isNew = existingRaw === null; window.localStorage.setItem(key, serialized); + // Increment memo when a genuinely new key is added. + if (isNew && _memoizedProfileKeyCount !== null) { + _memoizedProfileKeyCount++; + } + trimSelfProfileCaches(relayUrl, key); // localStorage is not reactive — dispatch a custom event so any mounted // listeners (e.g. useEffect with addEventListener) can re-read the cache // without a polling interval. @@ -174,6 +318,13 @@ export function removeSelfProfileCachesForRelay(relayUrl: string): void { for (const key of toRemove) { window.localStorage.removeItem(key); } + // Keep memo consistent with the removals performed by this module. + if (_memoizedProfileKeyCount !== null) { + _memoizedProfileKeyCount = Math.max( + 0, + _memoizedProfileKeyCount - toRemove.length, + ); + } } catch { // Storage access failures are non-fatal. } diff --git a/desktop/src/features/profile/lib/userCandidateSearch.test.mjs b/desktop/src/features/profile/lib/userCandidateSearch.test.mjs index 210e92758b0..bc94fc528d6 100644 --- a/desktop/src/features/profile/lib/userCandidateSearch.test.mjs +++ b/desktop/src/features/profile/lib/userCandidateSearch.test.mjs @@ -69,6 +69,39 @@ test("scoreUserCandidate supports agent labels and empty-query defaults", () => ); }); +test("scoreUserCandidate tolerates one name typo as a lower-ranked fallback", () => { + const user = makeUser({ + displayName: "Alice Johnson", + nip05Handle: "alice@example.com", + }); + + assert.equal( + scoreUserCandidate({ label: "Alice Johnson", query: "alcie", user }), + 5, + ); + assert.equal( + scoreUserCandidate({ label: "Alice Johnson", query: "alc", user }), + null, + ); +}); + +test("rankUserCandidatesBySearch keeps exact matches ahead of typo matches", () => { + const candidates = [ + makeUser({ displayName: "Ailce", pubkey: "2000" }), + makeUser({ displayName: "Alice", pubkey: "1000" }), + ]; + + assert.deepEqual( + rankUserCandidatesBySearch({ + candidates, + getLabel: (user) => user.displayName ?? user.pubkey, + limit: 2, + query: "alice", + }).map((user) => user.displayName), + ["Alice", "Ailce"], + ); +}); + test("rankUserCandidatesBySearch applies score, label, and stable order sorting", () => { const candidates = [ makeUser({ displayName: "Charlie", pubkey: "3000" }), diff --git a/desktop/src/features/profile/lib/userCandidateSearch.ts b/desktop/src/features/profile/lib/userCandidateSearch.ts index 7447b01c4d4..570f293c650 100644 --- a/desktop/src/features/profile/lib/userCandidateSearch.ts +++ b/desktop/src/features/profile/lib/userCandidateSearch.ts @@ -1,4 +1,5 @@ import type { UserSearchResult } from "@/shared/api/types"; +import { hasTypoTolerantPrefixMatch } from "@/shared/lib/fuzzyText"; import { normalizePubkey } from "@/shared/lib/pubkey"; type ScoreUserCandidateInput = { @@ -55,6 +56,14 @@ export function scoreUserCandidate({ if (pubkey.startsWith(normalizedQuery)) return 3; if (pubkey.includes(normalizedQuery)) return 4; + if ( + labels.some((candidateLabel) => + hasTypoTolerantPrefixMatch(candidateLabel, normalizedQuery), + ) + ) { + return 5; + } + return null; } diff --git a/desktop/src/features/profile/ui/AnimatedAvatarCameraControls.tsx b/desktop/src/features/profile/ui/AnimatedAvatarCameraControls.tsx index 3b108aef77f..4b91385cc8a 100644 --- a/desktop/src/features/profile/ui/AnimatedAvatarCameraControls.tsx +++ b/desktop/src/features/profile/ui/AnimatedAvatarCameraControls.tsx @@ -58,7 +58,7 @@ export function AnimatedAvatarCameraControls({ {helpText}

) : null} -
+
{onRetry ? ( - - - {isPending ? "Deleting..." : "Delete agent"} - - - - - ); -} diff --git a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx new file mode 100644 index 00000000000..8c6b4138cd7 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx @@ -0,0 +1,283 @@ +import * as React from "react"; +import { + Archive, + ArchiveRestore, + CopyPlus, + Download, + Trash2, + type LucideIcon, +} from "lucide-react"; + +import type { IdentityArchiveActions } from "@/features/identity-archive/hooks"; +import { ArchiveConfirmDialog } from "@/features/profile/ui/ArchiveConfirmDialog"; +import type { ManagedAgent } from "@/shared/api/types"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button, buttonVariants } from "@/shared/ui/button"; +import { PanelSectionGroup } from "@/shared/ui/PanelSectionGroup"; + +export function UserProfileAgentManagementRows({ + archiveActions, + canArchiveAgent, + canDeleteAgent, + isDeletePending, + managedAgent, + onDeleteAgent, + onDuplicateAgent, + onExportAgent, +}: { + archiveActions: IdentityArchiveActions; + canArchiveAgent: boolean; + canDeleteAgent: boolean; + isDeletePending: boolean; + managedAgent?: ManagedAgent; + onDeleteAgent: () => void; + onDuplicateAgent?: () => void; + onExportAgent?: () => void; +}) { + if ( + !onDuplicateAgent && + !onExportAgent && + !canArchiveAgent && + !canDeleteAgent + ) { + return null; + } + + return ( + + {onDuplicateAgent ? ( + + ) : null} + {onExportAgent ? ( + + ) : null} + {canArchiveAgent ? ( + + ) : null} + {canDeleteAgent ? ( + + ) : null} + + ); +} + +function ProfileAgentActionRow({ + destructive = false, + disabled = false, + icon: Icon, + label, + onClick, + testId, +}: { + destructive?: boolean; + disabled?: boolean; + icon: LucideIcon; + label: string; + onClick: () => void; + testId: string; +}) { + return ( + + ); +} + +function ProfileArchiveAgentRow({ + archiveActions, +}: { + archiveActions: IdentityArchiveActions; +}) { + const [confirmOpen, setConfirmOpen] = React.useState(false); + const isArchived = archiveActions.isArchived === true; + const Icon = isArchived ? ArchiveRestore : Archive; + const label = archiveActions.isPending + ? isArchived + ? "Unarchiving…" + : "Archiving…" + : isArchived + ? "Unarchive agent" + : "Archive agent"; + + return ( + <> + { + if (isArchived) { + archiveActions.unarchive(); + return; + } + setConfirmOpen(true); + }} + testId={ + isArchived + ? "user-profile-unarchive-agent-row" + : "user-profile-archive-agent-row" + } + /> + { + archiveActions.archive(); + setConfirmOpen(false); + }} + onOpenChange={setConfirmOpen} + open={confirmOpen} + /> + + ); +} + +function ProfileDeleteAgentRow({ + isPending, + managedAgent, + onDelete, +}: { + isPending: boolean; + managedAgent?: ManagedAgent; + onDelete: () => void; +}) { + const [confirmOpen, setConfirmOpen] = React.useState(false); + + return ( + <> + { + if (managedAgent) { + setConfirmOpen(true); + return; + } + onDelete(); + }} + testId="user-profile-delete-agent-row" + /> + {managedAgent ? ( + { + setConfirmOpen(false); + onDelete(); + }} + onOpenChange={setConfirmOpen} + open={confirmOpen} + /> + ) : null} + + ); +} + +function AgentDeleteConfirmDialog({ + agent, + isPending, + onConfirm, + onOpenChange, + open, +}: { + agent: ManagedAgent; + isPending: boolean; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const isProviderAgent = agent.backend.type === "provider"; + + return ( + + + + Delete this agent? + + Deleting this agent stops and removes the agent from this community. + + +
    +
  • Removes the local management record and saved agent key
  • +
  • Removes the agent from every channel it belongs to
  • +
  • + Archives the agent's identity on the relay so it no longer + appears in member lists or mention suggestions +
  • +
  • + {isProviderAgent + ? "Requests remote deletion; if it is online, Buzz first sends a shutdown command when possible. If the deployment cannot be reached through a channel, the remote process may keep running without local management." + : "Stops any local agent process before deleting the record"} +
  • +
+

+ Archive this agent if you want to hide it instead of removing it. +

+ + + + + + {isPending ? "Deleting…" : "Delete agent"} + + +
+
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfileEditAgentDialog.tsx b/desktop/src/features/profile/ui/UserProfileEditAgentDialog.tsx new file mode 100644 index 00000000000..b1f4f54eedf --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileEditAgentDialog.tsx @@ -0,0 +1,34 @@ +import { AgentDialog } from "@/features/agents/ui/AgentDialog"; +import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; +import type { ManagedAgent } from "@/shared/api/types"; + +export function UserProfileEditAgentDialog({ + agent, + canEdit, + initialFocus, + onEditLinkedPersona, + onOpenChange, + open, +}: { + agent: ManagedAgent | undefined; + canEdit: boolean; + initialFocus: EditAgentFocusTarget | undefined; + onEditLinkedPersona: (() => void) | undefined; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + if (!canEdit || !agent) { + return null; + } + + return ( + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index cb188dd0089..91040a28e24 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -2,10 +2,7 @@ import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { - useAgentMemoryQuery, - useIsManagedAgent, -} from "@/features/agent-memory/hooks"; +import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { type AttachManagedAgentToChannelResult, useAcpRuntimesQuery, @@ -33,13 +30,7 @@ import { resolveStartRuntimeForDefinition, } from "@/features/agents/lib/instanceInputForDefinition"; import { describeLogFile } from "@/features/agents/ui/agentUi"; -import { AgentDialog } from "@/features/agents/ui/AgentDialog"; import { useAgentLifecycleActions } from "@/features/profile/ui/useAgentLifecycleActions"; -import { - consumePendingOpenEditAgent, - type EditAgentFocusTarget, - subscribeOpenEditAgent, -} from "@/features/agents/openEditAgentEvent"; import { duplicatePersonaDialogState, editPersonaDialogState, @@ -59,13 +50,15 @@ import { import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { resolveProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; import { - AgentInfoFocusedView, AgentInstructionsFocusedView, + ProfileSummaryView, +} from "@/features/profile/ui/UserProfilePanelSections"; +import { + AgentInfoFocusedView, ChannelsFocusedView, DiagnosticsFocusedView, MemoryFocusedView, - ProfileSummaryView, -} from "@/features/profile/ui/UserProfilePanelSections"; +} from "@/features/profile/ui/UserProfilePanelFocusedViews"; import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProfileAgentActions"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; @@ -86,7 +79,7 @@ import { type UserProfilePanelProps, useRetainedPersona, } from "@/features/profile/ui/UserProfilePanelUtils"; -import { useProfileDmAction } from "@/features/profile/ui/useProfileDmAction"; +import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; @@ -101,6 +94,8 @@ import type { } from "@/shared/api/types"; import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame"; import { getUserProfilePanelHeaderContent } from "@/features/profile/ui/UserProfilePanelHeaderContent"; +import { UserProfileEditAgentDialog } from "@/features/profile/ui/UserProfileEditAgentDialog"; +import { useProfileEditAgentRequest } from "@/features/profile/ui/useProfileEditAgentRequest"; export type { ProfilePanelTab, ProfilePanelView }; export function UserProfilePanel({ @@ -154,28 +149,27 @@ export function UserProfilePanel({ }, [onTabChange], ); - const [editAgentOpen, setEditAgentOpen] = React.useState(false); - const [editAgentFocus, setEditAgentFocus] = React.useState< - EditAgentFocusTarget | undefined - >(undefined); - - // Open the Edit Agent dialog when `requestOpenEditAgent(pubkey)` fires from - // a card or other non-panel surface (e.g. `ConfigNudgeCard`). Mirrors the - // `subscribeOpenCreateAgent` pattern in AgentsView. - React.useEffect(() => { - if (!pubkey) return; - // Consume any pending request that arrived before this panel mounted. - const pending = consumePendingOpenEditAgent(pubkey); - if (pending !== false) { - setEditAgentFocus(pending === true ? undefined : pending); - setEditAgentOpen(true); - } - // Subscribe for events that arrive while the panel is mounted. - return subscribeOpenEditAgent(pubkey, (focus) => { - setEditAgentFocus(focus); - setEditAgentOpen(true); - }); - }, [pubkey]); + const [stickyChrome, setStickyChrome] = React.useState({ + active: false, + height: 0, + }); + const handleStickyChromeChange = React.useCallback( + (nextState: { active: boolean; height: number }) => { + setStickyChrome((currentState) => + currentState.active === nextState.active && + currentState.height === nextState.height + ? currentState + : nextState, + ); + }, + [], + ); + const { + focus: editAgentFocus, + open: editAgentOpen, + setFocus: setEditAgentFocus, + setOpen: setEditAgentOpen, + } = useProfileEditAgentRequest(pubkey); const [addToChannelOpen, setAddToChannelOpen] = React.useState(false); const [personaDialogState, setPersonaDialogState] = React.useState(null); @@ -325,15 +319,8 @@ export function UserProfilePanel({ }), [effectivePubkey, isBot, managedAgent, profile, relayAgent, viewerIsOwner], ); - // Observer ingestion (frame decryption + derived active-turn liveness) is - // owner-global — mounted once in AppShell via useAgentObserverIngestion — - // covering both locally managed agents and declared-owned relay agents. - const canEditAgent = - isOwner === true && - (managedAgent !== undefined || resolvedPersona !== undefined); - const memoryQuery = useAgentMemoryQuery(effectivePubkey, { - enabled: viewerIsOwner && Boolean(effectivePubkey), - }); + // Observer ingestion is owner-global across local and declared-owned agents. + const canEditAgent = Boolean(isOwner && (managedAgent ?? resolvedPersona)); const isSelf = currentPubkey !== undefined && pubkeyLower.length > 0 && @@ -395,10 +382,22 @@ export function UserProfilePanel({ setView("summary", { replace: true }); setTab("info", { replace: true }); }, [setTab, setView, targetKey]); - const { handleMessage, isOpeningDm } = useProfileDmAction({ + const { + canHuddle, + canMessage, + canWave, + handleHuddle, + handleMessage, + handleWave, + isStartingHuddle, + pendingAction, + } = useProfileInteractionActions({ effectivePubkey, + enabled: onOpenDm !== undefined, + isBot, + isSelf, onClose, - onOpenDm, + viewerIsOwner, }); const handleEditAgent = React.useCallback(() => { @@ -407,7 +406,7 @@ export function UserProfilePanel({ return; } setEditAgentOpen(true); - }, [resolvedPersona]); + }, [resolvedPersona, setEditAgentOpen]); const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = useProfileAgentDeletion({ @@ -707,31 +706,27 @@ export function UserProfilePanel({ : null; const ownerProfilePubkey = ownerPubkey ?? (isOwner === true ? (currentPubkey ?? null) : null); - const ownerAvatarProfile = ownerPubkey - ? ownerProfileQuery.data - : currentProfileQuery.data; - const memoryCount = - memoryQuery.data && - (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length; const agentInstruction = resolveAgentInstruction( managedAgent, resolvedPersona, ); const canManagePersona = isOwner === true && resolvedPersona !== undefined; - const canEditPersona = canManagePersona; const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam; + const canDeleteProfileAgent = + isBot && + ((viewerIsOwner && managedAgent !== undefined) || + (canInstantiateAgent && canDeletePersona)); + const handleDeleteProfileAgent = + viewerIsOwner && managedAgent ? handleDeleteAgent : handleDeletePersona; const archiveActions = useIdentityArchive(effectivePubkey); - const agentSettingsMenu = ( + const agentSettingsMenu = isBot ? null : ( setView("summary"), + onEditAgent: canEditAgent ? handleEditAgent : undefined, view, viewerIsOwner, }, @@ -783,10 +778,12 @@ export function UserProfilePanel({ ? "flex flex-col overflow-hidden" : "overflow-y-auto", )} + data-testid="user-profile-scroll-body" > {view === "summary" ? ( setAddToChannelOpen(true)} + onDeleteAgent={handleDeleteProfileAgent} + onDuplicateAgent={ + isBot && canManagePersona ? handleDuplicatePersona : undefined + } + onExportAgent={ + isBot && canManagePersona ? handleExportPersona : undefined + } onOpenInstance={(instancePubkey) => onOpenProfile?.(instancePubkey)} onOpenActivity={handleOpenActivity} onOpenChannel={handleOpenChannel} onOpenDiagnostics={() => setView("diagnostics")} - onOpenInstructions={() => setView("instructions")} + onStickyChromeChange={handleStickyChromeChange} onTabChange={setTab} - onOpenDm={onOpenDm} - onCreateCard={ - canManagePersona && resolvedPersona - ? () => - setCardMintTarget({ - // Prefer the live instance pubkey; fall back to the - // persona/definition id (same resolution as export). - id: managedAgent?.pubkey ?? resolvedPersona.id, - name: resolvedPersona.displayName, - // Locking needs an instance keypair to encrypt to. - canLock: Boolean(managedAgent?.pubkey), - }) - : undefined - } presenceStatus={presenceStatus} profile={profile} pubkey={effectivePubkey} @@ -905,28 +899,27 @@ export function UserProfilePanel({ ) : null} ); - const editAgentDialog = - canEditAgent && managedAgent ? ( - { - setEditAgentOpen(false); - setEditAgentFocus(undefined); - setPersonaDialogState(editPersonaDialogState(resolvedPersona)); - } - : undefined - } - onOpenChange={(next) => { - setEditAgentOpen(next); - if (!next) setEditAgentFocus(undefined); - }} - open={editAgentOpen} - /> - ) : null; + const editAgentDialog = ( + { + setEditAgentOpen(false); + setEditAgentFocus(undefined); + setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + } + : undefined + } + onOpenChange={(next) => { + setEditAgentOpen(next); + if (!next) setEditAgentFocus(undefined); + }} + open={editAgentOpen} + /> + ); const addAgentToChannelDialog = managedAgent ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx b/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx index 844de528e23..1da2e597dd8 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx @@ -116,11 +116,11 @@ export function AgentInstructionRow({ trimmedInstruction.length > 0 && onOpenInstructions !== undefined; const rowContent = ( <> - - - +
-
Instructions
+
+ Agent instructions +
{trimmedInstruction ? ( canOpenInstructions ? ( void; testId?: string; @@ -82,7 +83,6 @@ export function useProfileFieldBuckets({ isOwner, managedAgent, onOpenProfile, - ownerAvatarUrl, ownerDisplayName, ownerHandle, ownerProfilePubkey, @@ -98,7 +98,6 @@ export function useProfileFieldBuckets({ isOwner: boolean | undefined; managedAgent: ManagedAgent | undefined; onOpenProfile?: (pubkey: string) => void; - ownerAvatarUrl: string | null; ownerDisplayName: string | null; ownerHandle: string | null; ownerProfilePubkey: string | null; @@ -118,7 +117,6 @@ export function useProfileFieldBuckets({ includeOperationalFields: isOwner === true, managedAgent, onOpenProfile, - ownerAvatarUrl, ownerDisplayName, ownerHandle, ownerProfilePubkey, @@ -136,7 +134,6 @@ export function useProfileFieldBuckets({ isOwner, managedAgent, onOpenProfile, - ownerAvatarUrl, ownerDisplayName, ownerHandle, ownerProfilePubkey, @@ -167,10 +164,17 @@ export function buildPublicFields({ if (pubkey) { fields.push({ + copyValue: pubkey, displayValue: truncatePubkey(pubkey), - displayNode: , - icon: Fingerprint, + displayNode: ( + + ), label: "Public key", + testId: "user-profile-public-key", }); } @@ -220,7 +224,6 @@ export function buildOwnerFields({ includeOperationalFields, managedAgent, onOpenProfile, - ownerAvatarUrl, ownerDisplayName, ownerHandle, ownerProfilePubkey, @@ -233,7 +236,6 @@ export function buildOwnerFields({ includeOperationalFields: boolean; managedAgent: ManagedAgent | undefined; onOpenProfile?: (pubkey: string) => void; - ownerAvatarUrl: string | null; ownerDisplayName: string | null; ownerHandle: string | null; ownerProfilePubkey: string | null; @@ -256,18 +258,6 @@ export function buildOwnerFields({ : null; const ownerClickable = Boolean(onOpenProfile && ownerProfilePubkey); - const ownerContent = ( - <> - - {ownerDisplayName} - - ); if (ownerDisplayName) { fields.push({ @@ -275,12 +265,7 @@ export function buildOwnerFields({ ? undefined : (ownerProfilePubkey ?? ownerPubkey ?? ownerHandle ?? undefined), displayValue: ownerDisplayName, - displayNode: ( - - {ownerContent} - - ), - icon: UserRound, + displayNode: {ownerDisplayName}, label: "Managed by", onClick: ownerClickable && ownerProfilePubkey @@ -335,8 +320,10 @@ export function buildOwnerFields({ .replace(/\b\w/g, (char: string) => char.toUpperCase()), displayNode: ( ), @@ -439,52 +426,114 @@ function orderProfileFields(fields: ProfileField[]) { ]; } -export function ProfileFieldRows({ fields }: { fields: ProfileField[] }) { +export function ProfileFieldRows({ + fields, + variant = "default", +}: { + fields: ProfileField[]; + variant?: "default" | "runtime"; +}) { return ( <> {orderProfileFields(fields).map((field) => ( - + ))} ); } -export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { +export function ProfileSectionGroup({ + children, + headerAction, + testId, + title, +}: { + children: React.ReactNode; + headerAction?: React.ReactNode; + testId?: string; + title?: string; +}) { + return ( + +
{children}
+
+ ); +} + +export function ProfileFieldGroup({ + fields, + title, +}: { + fields: ProfileField[]; + title?: string; +}) { return ( -
-
- -
-
+ + + ); } -function ProfileFieldRow({ field }: { field: ProfileField }) { +function ProfileFieldRow({ + field, + variant, +}: { + field: ProfileField; + variant: "default" | "runtime"; +}) { const Icon = field.icon; const isCopyable = Boolean(field.copyValue); const isActionable = Boolean(field.onClick); + const isTrailingDisplay = + variant === "runtime" && field.label === "Status" && field.displayNode; + const { copied, copy } = useCopyFeedback({ + label: field.label, + value: field.copyValue ?? "", + }); const content = ( <> - - - + {variant === "default" && Icon ? ( + + ) : null} - + {field.label} - - {field.displayNode ?? field.displayValue} - + {!isTrailingDisplay ? ( + + {field.displayNode ?? field.displayValue} + + ) : null} + {isTrailingDisplay ? field.displayNode : null} {field.trailingNode} {isActionable ? ( - + ) : isCopyable ? ( - + ) : null} ); @@ -493,7 +542,7 @@ function ProfileFieldRow({ field }: { field: ProfileField }) { return ( + + ))} + + )} + +
+ ); +} + +export function AgentInfoFocusedView({ + metadataFields, +}: { + metadataFields: ProfileField[]; +}) { + if (metadataFields.length === 0) { + return null; + } + + return ( +
+ +
+ ); +} + +export function DiagnosticsFocusedView({ + canOpenAgentLogs, + fields, + logContent, + logError, + logLoading, + managedAgent, +}: { + canOpenAgentLogs: boolean; + fields: ProfileField[]; + logContent: string | null; + logError: Error | null; + logLoading: boolean; + managedAgent: ManagedAgent | undefined; +}) { + const hasLog = canOpenAgentLogs && managedAgent !== undefined; + const lastErrorField = fields.find((field) => field.label === "Last error"); + const detailFields = fields.filter( + (field) => field.label !== "Last error" && field.label !== "Status", + ); + + if (!lastErrorField && detailFields.length === 0 && !hasLog) { + return null; + } + + return ( +
+ {lastErrorField ? ( + + +
+ Last error + + {lastErrorField.displayValue} + +
+
+ ) : null} + {detailFields.length > 0 ? ( + + ) : null} + {hasLog ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx b/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx index 81d1860448c..11a744ee8a4 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx @@ -1,7 +1,11 @@ import type * as React from "react"; -import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel"; -import { AuxiliaryPanelHeader } from "@/shared/layout/AuxiliaryPanel"; +import { + AUXILIARY_PANEL_DEFAULT_SURFACE_CLASS, + AuxiliaryPanel, + AuxiliaryPanelHeader, +} from "@/shared/layout/AuxiliaryPanel"; +import { cn } from "@/shared/lib/cn"; type UserProfilePanelFrameProps = { addAgentToChannelDialog: React.ReactNode; @@ -18,6 +22,9 @@ type UserProfilePanelFrameProps = { personaDialogs: React.ReactNode; profileBody: React.ReactNode; splitPaneClamp: boolean; + stickyChromeActive: boolean; + stickyChromeEnabled: boolean; + stickyChromeHeight: number; widthPx: number; transparentChrome?: boolean; }; @@ -37,12 +44,16 @@ export function UserProfilePanelFrame({ personaDialogs, profileBody, splitPaneClamp, + stickyChromeActive, + stickyChromeEnabled, + stickyChromeHeight, widthPx, transparentChrome = false, }: UserProfilePanelFrameProps) { return ( - {headerLeftContent} - {headerActions} - + <> +