diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eee313b2f2..3e86d10f9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} - name: Format check @@ -125,7 +125,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} - name: Install cargo-nextest @@ -149,7 +149,7 @@ jobs: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} @@ -342,7 +342,7 @@ jobs: key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' with: workspaces: | @@ -765,7 +765,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} # Reuse the relay + git-credential-nostr built by Desktop E2E Relay @@ -971,7 +971,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: key: cross-${{ matrix.target }} save-if: ${{ github.event_name != 'pull_request' }} @@ -1012,7 +1012,7 @@ jobs: # toolchain (1.95.0 + clippy via profile = default) comes from the # repo-root rust-toolchain.toml, which the runner's preinstalled rustup # honors on demand; the host triple already is x86_64-pc-windows-msvc. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: | . @@ -1089,7 +1089,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/desktop-release-cache-proof.yml b/.github/workflows/desktop-release-cache-proof.yml index cf9c8e7827..71436d00c3 100644 --- a/.github/workflows/desktop-release-cache-proof.yml +++ b/.github/workflows/desktop-release-cache-proof.yml @@ -67,7 +67,7 @@ jobs: name: Prove Linux cache visibility if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + container: ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea timeout-minutes: 15 defaults: run: diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index d8b10032b2..cdf8fe3bda 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -21,7 +21,7 @@ jobs: name: Build Linux canary if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + container: ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea timeout-minutes: 60 permissions: contents: read diff --git a/.github/workflows/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml index 4780083ba4..b5f9660dfe 100644 --- a/.github/workflows/mesh-lifecycle.yml +++ b/.github/workflows/mesh-lifecycle.yml @@ -55,7 +55,7 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b0eb25c68..cc8147515a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -428,7 +428,7 @@ jobs: if: github.repository == 'block/buzz' runs-on: ubuntu-latest # Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh. - container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + container: ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea needs: setup timeout-minutes: 60 permissions: @@ -511,7 +511,7 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: desktop/src-tauri lookup-only: true diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 3920dfeb44..784725b2e6 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.17", - "base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c", - "previous_tag": "desktop-v0.5.16", - "previous_base_sha": "ee992ff0822f44d1c308822f116cb9d26f9a3386", - "previous_merge_sha": "978e585e8df893fe55aded854de07996b9412678", - "tag": "desktop-v0.5.17", - "commit_count": 5 + "version": "0.5.18", + "base_sha": "aea0ef8df9fc24d9aa8bf5c761ab2910026a601b", + "previous_tag": "desktop-v0.5.17", + "previous_base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c", + "previous_merge_sha": "8232299cbe6d90692fac3de46cde0ec123edd6c1", + "tag": "desktop-v0.5.18", + "commit_count": 66 } diff --git a/CHANGELOG.md b/CHANGELOG.md index bec9b9324e..42fc482515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## v0.5.18 + +### Desktop and shared changes + +- fix(desktop): simplify duplicate agent provenance ([#6401](https://github.com/block/buzz/pull/6401)) ([`aea0ef8df9fc24d9aa8bf5c761ab2910026a601b`](https://github.com/block/buzz/commit/aea0ef8df9fc24d9aa8bf5c761ab2910026a601b)) +- fix(desktop): sender names in notifications + macOS click-through routing ([#6427](https://github.com/block/buzz/pull/6427)) ([`4e3c9e619c93dd26677b392ad1f8cf0d12c8f855`](https://github.com/block/buzz/commit/4e3c9e619c93dd26677b392ad1f8cf0d12c8f855)) +- polish(desktop): finish Projects navigation and context chrome ([#6429](https://github.com/block/buzz/pull/6429)) ([`9b32e055fed45864e1982f3d99c5402ba35cd8a6`](https://github.com/block/buzz/commit/9b32e055fed45864e1982f3d99c5402ba35cd8a6)) +- fix(desktop): clarify add agents channel action ([#6374](https://github.com/block/buzz/pull/6374)) ([`9891e64f6b8358d78aa85f2ba248310d58b51ec0`](https://github.com/block/buzz/commit/9891e64f6b8358d78aa85f2ba248310d58b51ec0)) +- feat(desktop-messages): show compact Buzz link metadata ([#6252](https://github.com/block/buzz/pull/6252)) ([`de8a2741c7598c27e0d27cd4562d76b947934e2f`](https://github.com/block/buzz/commit/de8a2741c7598c27e0d27cd4562d76b947934e2f)) +- feat(workflows): reply in-thread from send_message action ([#6178](https://github.com/block/buzz/pull/6178)) ([`c6e3fe7dfa751096d8c4083aaf15d6f6af296572`](https://github.com/block/buzz/commit/c6e3fe7dfa751096d8c4083aaf15d6f6af296572)) +- perf(desktop): split discover_acp_providers into cheap and forced paths ([#6330](https://github.com/block/buzz/pull/6330)) ([`c63ac746cd4622e043574c305ff54021b07b847b`](https://github.com/block/buzz/commit/c63ac746cd4622e043574c305ff54021b07b847b)) +- fix(desktop): restore recent channel sorting ([#6402](https://github.com/block/buzz/pull/6402)) ([`569308c23c9c2bf620dd3a9a5e4baecbcfa22e16`](https://github.com/block/buzz/commit/569308c23c9c2bf620dd3a9a5e4baecbcfa22e16)) +- fix(desktop): isolate main timeline stacking context from focus drawer ([#6398](https://github.com/block/buzz/pull/6398)) ([`b298a175c4b9da1638f84c1f287e42d16a690a61`](https://github.com/block/buzz/commit/b298a175c4b9da1638f84c1f287e42d16a690a61)) +- fix(desktop): make reconnect repair lossless ([#6415](https://github.com/block/buzz/pull/6415)) ([`06e8be9098f099ff9036cfbe637390d5ac982809`](https://github.com/block/buzz/commit/06e8be9098f099ff9036cfbe637390d5ac982809)) +- perf(desktop): resolve references without directory scans ([#6328](https://github.com/block/buzz/pull/6328)) ([`54bbe7305b9ec82a6ac03f84ff5887f77858c0b6`](https://github.com/block/buzz/commit/54bbe7305b9ec82a6ac03f84ff5887f77858c0b6)) +- feat(llm): stamp thinking effort on call-completed log line ([#6424](https://github.com/block/buzz/pull/6424)) ([`b728a2af3197db52e2d847b095f27209f99dc977`](https://github.com/block/buzz/commit/b728a2af3197db52e2d847b095f27209f99dc977)) +- Fix cross-owner relay agent mentions in owner-only builds ([#6338](https://github.com/block/buzz/pull/6338)) ([`ee0c7076af83fe9e2aafdc1baf4113ad988f53bd`](https://github.com/block/buzz/commit/ee0c7076af83fe9e2aafdc1baf4113ad988f53bd)) +- feat(workflows): add workflow editor ([#6248](https://github.com/block/buzz/pull/6248)) ([`1934e83bf5a5d8cd00f0cf28b558547b8d0dffb0`](https://github.com/block/buzz/commit/1934e83bf5a5d8cd00f0cf28b558547b8d0dffb0)) +- fix(desktop): preserve huddle speech boundaries ([#6397](https://github.com/block/buzz/pull/6397)) ([`7ebe3ea699a24b2f95573b88db8f8fe5f1187eb4`](https://github.com/block/buzz/commit/7ebe3ea699a24b2f95573b88db8f8fe5f1187eb4)) +- test(desktop): use a wordlist-safe separator in passphrase word-count test ([#6356](https://github.com/block/buzz/pull/6356)) ([`886cef7f723a539c4026d12e6a0605062bf2208b`](https://github.com/block/buzz/commit/886cef7f723a539c4026d12e6a0605062bf2208b)) +- fix(models): curate Databricks alias-aware labels for 5 missing endpoints ([#6360](https://github.com/block/buzz/pull/6360)) ([`2ce8df8533d8c8598ab3d7a2faa797f8b5ee2eea`](https://github.com/block/buzz/commit/2ce8df8533d8c8598ab3d7a2faa797f8b5ee2eea)) +- feat(desktop): refine context-aware Projects collaboration ([#6396](https://github.com/block/buzz/pull/6396)) ([`3c228b1082a93aca302c7b6a67ec274c51ed5eaf`](https://github.com/block/buzz/commit/3c228b1082a93aca302c7b6a67ec274c51ed5eaf)) +- fix(desktop): distinguish duplicate agent devices ([#6337](https://github.com/block/buzz/pull/6337)) ([`2e7583bf5ad5926ca32367af9954bc79d108e42d`](https://github.com/block/buzz/commit/2e7583bf5ad5926ca32367af9954bc79d108e42d)) +- feat(desktop): close Buzz window with Cmd+W ([#6314](https://github.com/block/buzz/pull/6314)) ([`e5d1dfef7bf24ad527c9c8c1785b613abad574f7`](https://github.com/block/buzz/commit/e5d1dfef7bf24ad527c9c8c1785b613abad574f7)) +- feat(desktop): make Projects workspaces selectable ([#6368](https://github.com/block/buzz/pull/6368)) ([`2b7be9340dd0785bd04795d55e004a6dfedd4998`](https://github.com/block/buzz/commit/2b7be9340dd0785bd04795d55e004a6dfedd4998)) +- feat(desktop): make the Projects overview follow the selected section ([#6335](https://github.com/block/buzz/pull/6335)) ([`f88cda9eb886500ec7d205e1d265ac6f654aa433`](https://github.com/block/buzz/commit/f88cda9eb886500ec7d205e1d265ac6f654aa433)) +- refactor(desktop): coordinate TTS playback ([#6341](https://github.com/block/buzz/pull/6341)) ([`09718fbb60c1acd5a4c1aff1bd91248008977129`](https://github.com/block/buzz/commit/09718fbb60c1acd5a4c1aff1bd91248008977129)) +- fix(desktop): show complete repository trees ([#5102](https://github.com/block/buzz/pull/5102)) ([`9c2f05346fdf3f058e4c579f6eea03dbe65fcca4`](https://github.com/block/buzz/commit/9c2f05346fdf3f058e4c579f6eea03dbe65fcca4)) +- Add appearance preference previews ([#6193](https://github.com/block/buzz/pull/6193)) ([`9a1e861ab8507ee28e5f03010b7f36d1b74ec919`](https://github.com/block/buzz/commit/9a1e861ab8507ee28e5f03010b7f36d1b74ec919)) +- fix(desktop): restore emoji recents ([#6263](https://github.com/block/buzz/pull/6263)) ([`f96f1883c5ad94825d43a856e5d25e307a0540cb`](https://github.com/block/buzz/commit/f96f1883c5ad94825d43a856e5d25e307a0540cb)) +- perf(desktop): move five hot renderer paths from JS into Rust ([#6024](https://github.com/block/buzz/pull/6024)) ([`bbd20fae75ecc3bd7a83cc12a65379fac22a2b79`](https://github.com/block/buzz/commit/bbd20fae75ecc3bd7a83cc12a65379fac22a2b79)) +- fix(media): accept portrait video resolutions ([#6058](https://github.com/block/buzz/pull/6058)) ([`196d62f97c21d053ddf8715d75ef57e92bd0051f`](https://github.com/block/buzz/commit/196d62f97c21d053ddf8715d75ef57e92bd0051f)) +- fix(desktop): hide archived channels from #/Tab autocomplete ([#6156](https://github.com/block/buzz/pull/6156)) ([`fe7c6808e7430d185498178e07e58e378d2e4c7d`](https://github.com/block/buzz/commit/fe7c6808e7430d185498178e07e58e378d2e4c7d)) +- fix(desktop): morph the drawer panel icon instead of sliding it ([#6306](https://github.com/block/buzz/pull/6306)) ([`e5a6e2022fb59c3928b054bc8d51874465fbe3df`](https://github.com/block/buzz/commit/e5a6e2022fb59c3928b054bc8d51874465fbe3df)) +- feat(desktop): refine repository-aware project workspaces ([#6003](https://github.com/block/buzz/pull/6003)) ([`87f8ff82ae5d3fdd99831c62a869b39138e65a57`](https://github.com/block/buzz/commit/87f8ff82ae5d3fdd99831c62a869b39138e65a57)) +- perf(desktop): parallelize relay agent directory rebuild ([#6258](https://github.com/block/buzz/pull/6258)) ([`a362fecc2389955f942c9581bdfeba379ab115b3`](https://github.com/block/buzz/commit/a362fecc2389955f942c9581bdfeba379ab115b3)) +- fix(desktop): exclude archived agents from nest, order regeneration ([#5905](https://github.com/block/buzz/pull/5905)) ([`121e4b3ce7acab6ac310257f444997f58a97cb2e`](https://github.com/block/buzz/commit/121e4b3ce7acab6ac310257f444997f58a97cb2e)) +- Add font size and conversation density preferences ([#5644](https://github.com/block/buzz/pull/5644)) ([`7e2651791d598a3938ef4560a41801223fb9b2c9`](https://github.com/block/buzz/commit/7e2651791d598a3938ef4560a41801223fb9b2c9)) +- fix(desktop): emit camelCase config-write payload fields ([#6062](https://github.com/block/buzz/pull/6062)) ([`6e8d078ffe1ab27b8dde6bb697551b7d2d1a85b5`](https://github.com/block/buzz/commit/6e8d078ffe1ab27b8dde6bb697551b7d2d1a85b5)) +- fix(desktop): downscale large avatars for agent-share PNG body ([#6260](https://github.com/block/buzz/pull/6260)) ([`e2ade93f02f6d1b4db23e0c442a2c65608e54d36`](https://github.com/block/buzz/commit/e2ade93f02f6d1b4db23e0c442a2c65608e54d36)) +- fix(desktop): preserve early relay auth challenges ([#3320](https://github.com/block/buzz/pull/3320)) ([`6ea7a2b2211438359b227a9991cf8ccad2927fe2`](https://github.com/block/buzz/commit/6ea7a2b2211438359b227a9991cf8ccad2927fe2)) +- feat(managed-agents): close five Claude Code agent-config gaps ([#4557](https://github.com/block/buzz/pull/4557)) ([`50a71137e6f1c56f66e2f7348a917b2d2a1798f0`](https://github.com/block/buzz/commit/50a71137e6f1c56f66e2f7348a917b2d2a1798f0)) +- fix(shared-ui): delay hover disclosures by default ([#5821](https://github.com/block/buzz/pull/5821)) ([`d7e8fdb10ca5e055b7af6d22f67d9a8f42cec8ed`](https://github.com/block/buzz/commit/d7e8fdb10ca5e055b7af6d22f67d9a8f42cec8ed)) +- fix(desktop-chrome): preserve balanced layout when sidebar collapses ([#6000](https://github.com/block/buzz/pull/6000)) ([`c442a90a176845e3989436f2bb24eb6d0ca79d47`](https://github.com/block/buzz/commit/c442a90a176845e3989436f2bb24eb6d0ca79d47)) + +### Other repository changes + +- test(benchmarks): expand Buzz-native dataset ([#6448](https://github.com/block/buzz/pull/6448)) ([`b56a52ca11296b86ee41c41278a1169f92f245b6`](https://github.com/block/buzz/commit/b56a52ca11296b86ee41c41278a1169f92f245b6)) +- docs: clarify two-layer moderation ownership ([#6481](https://github.com/block/buzz/pull/6481)) ([`8740a1fa94dd14a3eb5cd2a570b0be2c4a68cbfe`](https://github.com/block/buzz/commit/8740a1fa94dd14a3eb5cd2a570b0be2c4a68cbfe)) +- Fix mobile thread tail and iOS channel header ([#6399](https://github.com/block/buzz/pull/6399)) ([`ffb12d3b05fe1ebd62006dbcd2f079be4e210b70`](https://github.com/block/buzz/commit/ffb12d3b05fe1ebd62006dbcd2f079be4e210b70)) +- chore(deps): pin earshot below 1.2.0 pending a VAD threshold re-pick ([#6392](https://github.com/block/buzz/pull/6392)) ([`2edacde4d4c01490834725774aa878dbc373c41d`](https://github.com/block/buzz/commit/2edacde4d4c01490834725774aa878dbc373c41d)) +- Repair stale large channel roster snapshots ([#6251](https://github.com/block/buzz/pull/6251)) ([`24ec6a468ec9d0d425ee58fbfc4d416412c446ad`](https://github.com/block/buzz/commit/24ec6a468ec9d0d425ee58fbfc4d416412c446ad)) +- fix(hooks): scope pre-push lanes to branch merge-base diff ([#6423](https://github.com/block/buzz/pull/6423)) ([`cd0d33f08507d07c8e8b8511bba92290c046ef03`](https://github.com/block/buzz/commit/cd0d33f08507d07c8e8b8511bba92290c046ef03)) +- Enforce a three-day dependency cooldown ([#6426](https://github.com/block/buzz/pull/6426)) ([`3ee465e12b815a191d902856440e2f3348bda506`](https://github.com/block/buzz/commit/3ee465e12b815a191d902856440e2f3348bda506)) +- feat(cli): accept Buzz message links for thread reads ([#6359](https://github.com/block/buzz/pull/6359)) ([`84c095f8bea14b55373e2d867100abe37aa6061e`](https://github.com/block/buzz/commit/84c095f8bea14b55373e2d867100abe37aa6061e)) +- fix(acp): guard against unrequested public relay skills ([#6394](https://github.com/block/buzz/pull/6394)) ([`d274a6e94928d64e27648f75320ab8af961396da`](https://github.com/block/buzz/commit/d274a6e94928d64e27648f75320ab8af961396da)) +- refactor(prompt): simplify Buzz agent guidance ([#6340](https://github.com/block/buzz/pull/6340)) ([`2a236e413723f207c2f6c1e8921fab4f071d0445`](https://github.com/block/buzz/commit/2a236e413723f207c2f6c1e8921fab4f071d0445)) +- Add Buzz-native collaboration benchmarks ([#6264](https://github.com/block/buzz/pull/6264)) ([`a9640c7cc4d55b0a0ac987aab4af02a204009d19`](https://github.com/block/buzz/commit/a9640c7cc4d55b0a0ac987aab4af02a204009d19)) +- Polish mobile timeline and emoji interactions ([#6297](https://github.com/block/buzz/pull/6297)) ([`da818eddc2f470c006a1073c8c5452f8a989f272`](https://github.com/block/buzz/commit/da818eddc2f470c006a1073c8c5452f8a989f272)) +- chore: serialize mobile pre-push checks ([#6322](https://github.com/block/buzz/pull/6322)) ([`81567b76a5d164b052c4e8526f453cf7a6ef43dc`](https://github.com/block/buzz/commit/81567b76a5d164b052c4e8526f453cf7a6ef43dc)) +- fix(buzz-acp): loosen workspace-scan guardrail to allow named paths ([#6261](https://github.com/block/buzz/pull/6261)) ([`934f3325c3fdaa3a6f23134b74518139aac8ca3f`](https://github.com/block/buzz/commit/934f3325c3fdaa3a6f23134b74518139aac8ca3f)) +- fix(buzz-dev-mcp): expand leading ~ in read_file/str_replace paths ([#6271](https://github.com/block/buzz/pull/6271)) ([`7f69b13b4586acedf6d898edf1be2a6babea3626`](https://github.com/block/buzz/commit/7f69b13b4586acedf6d898edf1be2a6babea3626)) +- Unify mobile channel details ([#6113](https://github.com/block/buzz/pull/6113)) ([`a567dfc2df870878dcf079550502a09a89cc8091`](https://github.com/block/buzz/commit/a567dfc2df870878dcf079550502a09a89cc8091)) +- Revert "fix(acp): gate relay-signed workflow messages on their attributed author" ([#6311](https://github.com/block/buzz/pull/6311)) ([`08eb46ef3c0894baa7e48d9229f45349751a4a57`](https://github.com/block/buzz/commit/08eb46ef3c0894baa7e48d9229f45349751a4a57)) +- Fix mobile Activity thread navigation ([#5850](https://github.com/block/buzz/pull/5850)) ([`93114c9c65138397de39729fde0a816eb9f314ab`](https://github.com/block/buzz/commit/93114c9c65138397de39729fde0a816eb9f314ab)) +- Refine the mobile emoji picker ([#5853](https://github.com/block/buzz/pull/5853)) ([`359fe646758d253ee94bf054a87904efd1dce7d1`](https://github.com/block/buzz/commit/359fe646758d253ee94bf054a87904efd1dce7d1)) +- Polish mobile message actions ([#5873](https://github.com/block/buzz/pull/5873)) ([`78267b0c3a75840d035ff0cc9ad1984def773886`](https://github.com/block/buzz/commit/78267b0c3a75840d035ff0cc9ad1984def773886)) +- Refine mobile pairing confirmation ([#6018](https://github.com/block/buzz/pull/6018)) ([`40f1dac6913d04c87d72610a69ed53bd12377b84`](https://github.com/block/buzz/commit/40f1dac6913d04c87d72610a69ed53bd12377b84)) +- chore(scripts): add buzz-adopt-prod-agents.sh ([#6250](https://github.com/block/buzz/pull/6250)) ([`4f9727a4b3d76389f862faa15241e16e2dd36108`](https://github.com/block/buzz/commit/4f9727a4b3d76389f862faa15241e16e2dd36108)) +- chore(hooks): keep mobile analysis out of pre-commit ([#6236](https://github.com/block/buzz/pull/6236)) ([`b74700daafa823e56c60b4e6470740ab28330888`](https://github.com/block/buzz/commit/b74700daafa823e56c60b4e6470740ab28330888)) +- Polish mobile timeline navigation ([#5874](https://github.com/block/buzz/pull/5874)) ([`417eea2230c1864e8c77f6440dbcfa109bfb63f6`](https://github.com/block/buzz/commit/417eea2230c1864e8c77f6440dbcfa109bfb63f6)) +- fix(prompt): simplify pickup follow-through ([#6186](https://github.com/block/buzz/pull/6186)) ([`d2cfd377e27dab8fdef0236dd8e92c89efbae829`](https://github.com/block/buzz/commit/d2cfd377e27dab8fdef0236dd8e92c89efbae829)) +- fix(mcp): scope todo usage ([#6216](https://github.com/block/buzz/pull/6216)) ([`5694e78def8b6ea674e101c1c988a5f17c9baf9d`](https://github.com/block/buzz/commit/5694e78def8b6ea674e101c1c988a5f17c9baf9d)) + +[Compare desktop-v0.5.17...desktop-v0.5.18](https://github.com/block/buzz/compare/desktop-v0.5.17...desktop-v0.5.18) + ## v0.5.17 ### Desktop and shared changes diff --git a/Cargo.lock b/Cargo.lock index 2d273c29c1..a4337cef92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,9 +329,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -2968,9 +2968,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2996,9 +2996,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -3006,15 +3006,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -3034,9 +3034,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -3053,32 +3053,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -3534,9 +3534,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -3554,9 +3554,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", diff --git a/VISION_MODERATION.md b/VISION_MODERATION.md index 45d3ab86fd..0b2e12cb59 100644 --- a/VISION_MODERATION.md +++ b/VISION_MODERATION.md @@ -14,7 +14,7 @@ Moderation splits the way it does on every serious platform: **Community moderation** — subjective, per-community rule enforcement. Your owners and admins decide what's spam in *your* community, what crosses *your* line, who gets a second chance. This layer belongs to the community and never reaches past it: an admin's authority ends at the community boundary, structurally, because every moderation decision is scoped to the tenant it was made in. -**Platform safety** — the severe class: illegal content, network-level abuse, legal reporting obligations. That is never delegated to community admins. A community owner or admin can **escalate** a report upward, and the escalation is recorded durably for the platform operator's safety process. The community layer is the front line; the platform layer is the backstop. +**Platform safety** — the severe class: illegal content, network-level abuse, legal reporting obligations. That is never delegated to community admins. A community owner or admin can **escalate** a report upward, and the escalation is recorded durably for the platform operator's safety process. The platform-safety layer belongs to whoever operates the relay. In a hosted multi-community deployment, that means the hosting platform's safety process; in a self-hosted deployment, it means the operator themselves, because the party hosting the content carries the legal accountability. The community layer is the front line; the platform layer is the backstop. This document is about the first layer. The second has its own lane. @@ -54,7 +54,7 @@ This document is about the first layer. The second has its own lane. **Escalation is a hook today, not a pipeline.** Escalating writes a durable, queryable record for the platform operator — but the platform-side inbox that consumes it is a separate build. The substrate is there; the tooling above it comes next. -**Two roles, not three.** Owners and admins moderate. There is no volunteer-moderator tier yet — deliberately. Authority is structured as capabilities, so adding a moderator tier later is a policy change, not a rewrite. We'd rather ship a loop that works and grow the org chart when communities ask for it. +**Two roles, not three.** Owners and admins moderate. There is no volunteer-moderator tier yet — deliberately. Authority is structured as capabilities, so adding a moderator tier later is a policy change, not a rewrite. The relay/platform layer has its own operator-and-moderator roster, distinct from community owner and admin roles. We'd rather ship a loop that works and grow the org chart when communities ask for it. **Notices are best-effort.** The DMs that close the loop never block enforcement — a ban lands even if the notice fails. Enforcement is the promise; notification is the courtesy. A later platform-escalation pass should also make escalated reports say exactly that, instead of reusing the generic handled message. diff --git a/benchmarks/buzz-dataset/README.md b/benchmarks/buzz-dataset/README.md index 2909d0cd87..f5c8c8bb24 100644 --- a/benchmarks/buzz-dataset/README.md +++ b/benchmarks/buzz-dataset/README.md @@ -11,6 +11,11 @@ willing to read. | [`user-mention`](user-mention) | Hands the turn back with an event-level `p`-tag mention of the requesting human | | [`read-named-path-outside-workspace`](read-named-path-outside-workspace) | Reads a path the user named explicitly instead of refusing it as out of bounds | | [`create-channel-invite-users`](create-channel-invite-users) | Creates a channel with the exact shape, TTL, and membership asked for | +| [`multiline-message`](multiline-message) | Preserves real newlines and blank-line structure through the CLI publish path | +| [`narrative-agent-names`](narrative-agent-names) | Names agents in narrative without waking them through `p` tags | +| [`interleaved-agent-reports`](interleaved-agent-reports) | Retains and synthesizes every report in a batch of agent messages | +| [`cross-thread-requests`](cross-thread-requests) | Keeps simultaneous top-level requests isolated and replies to both exact threads | +| [`ambiguous-user-mention`](ambiguous-user-mention) | Resolves duplicate display names and notifies only the intended pubkey | For `reply-to-thread` and `user-mention` the graded behavior is **deliberately absent from `instruction.md`** — it has to come from `buzz-acp`'s production diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/README.md b/benchmarks/buzz-dataset/ambiguous-user-mention/README.md new file mode 100644 index 0000000000..5f814cda98 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/README.md @@ -0,0 +1,8 @@ +# ambiguous-user-mention + +The channel contains two real identities with the exact same three-word display +name. Their profile `about` fields carry different routing codes. The agent must +discover the intended pubkey, notify it exactly once, never notify the twin, +and separately callback the requester. This guards the silent ambiguity family +reported in [block/buzz#4303](https://github.com/block/buzz/issues/4303) and +[block/buzz#6257](https://github.com/block/buzz/issues/6257). diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/environment/Dockerfile b/benchmarks/buzz-dataset/ambiguous-user-mention/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/instruction.md b/benchmarks/buzz-dataset/ambiguous-user-mention/instruction.md new file mode 100644 index 0000000000..761fd9caa5 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/instruction.md @@ -0,0 +1,5 @@ +Two channel members are both displayed as `Taylor Morgan Lee`. + +Find the one whose profile says `Routing code ORCHID-72` and send that person +the message `ORCHID-72 approved`. Do not notify the other Taylor Morgan Lee. +Then tell me briefly that it was sent. diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/task.toml b/benchmarks/buzz-dataset/ambiguous-user-mention/task.toml new file mode 100644 index 0000000000..079507e0b4 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/ambiguous-user-mention" +description = "Resolve two identical display names by profile evidence and notify only the intended pubkey." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "mentions", "identity", "ambiguity"] + +[metadata] +difficulty = "hard" +category = "collaboration" +tags = ["mentions", "identity", "ambiguity", "cli"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/tests/test.sh b/benchmarks/buzz-dataset/ambiguous-user-mention/tests/test.sh new file mode 100755 index 0000000000..79434035e3 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/tests/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py --evidence /logs/artifacts/buzz-evidence.json --reward /logs/verifier/reward.json --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/tests/verify.py b/benchmarks/buzz-dataset/ambiguous-user-mention/tests/verify.py new file mode 100755 index 0000000000..0efcb0dba6 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/tests/verify.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for a duplicate-display-name notification.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +TARGET_ID = "taylor-release-captain" +OTHER_ID = "taylor-observer" + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "target_notified_once": 0.0, + "other_not_notified": 0.0, + "message_correct": 0.0, + "user_callback": 0.0, + "reply_to_thread": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + root = evidence.get("task_event_id") + directory = { + row.get("identity_id"): row + for row in evidence.get("directory", []) + if isinstance(row, dict) + } + target = directory.get(TARGET_ID, {}) + other = directory.get(OTHER_ID, {}) + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Olivia Grace Park", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + authored = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + target_messages = [ + row + for row in authored + if target.get("pubkey") in row.get("mentioned_pubkeys", []) + ] + other_messages = [ + row + for row in authored + if other.get("pubkey") in row.get("mentioned_pubkeys", []) + ] + delivery = target_messages[0] if len(target_messages) == 1 else None + callbacks = [ + row + for row in authored + if user.get("pubkey") in row.get("mentioned_pubkeys", []) + and row not in target_messages + ] + callback = callbacks[-1] if callbacks else None + target_notified_once = float(len(target_messages) == 1) + other_not_notified = float(len(other_messages) == 0) + message_correct = float( + delivery is not None + and "ORCHID-72 approved" in str(delivery.get("content", "")) + ) + user_callback = float( + callback is not None + and any( + word in str(callback.get("content", "")).casefold() + for word in ("sent", "notified", "delivered") + ) + ) + reply_to_thread = float( + delivery is not None + and callback is not None + and delivery.get("reply_to_event_id") == root + and callback.get("reply_to_event_id") == root + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "ambiguous-user-mention" + and evidence.get("truncated") is False + and len(directory) == 2 + and isinstance(target.get("pubkey"), str) + and isinstance(other.get("pubkey"), str) + and target.get("pubkey") != other.get("pubkey") + and len(agents) == 1 + ) + values = ( + target_notified_once, + other_not_notified, + message_correct, + user_callback, + reply_to_thread, + evidence_complete, + ) + metrics = { + "reward": float(all(value == 1.0 for value in values)), + "target_notified_once": target_notified_once, + "other_not_notified": other_not_notified, + "message_correct": message_correct, + "user_callback": user_callback, + "reply_to_thread": reply_to_thread, + "evidence_complete": evidence_complete, + } + return metrics, { + "target_pubkey": target.get("pubkey"), + "other_pubkey": other.get("pubkey"), + "delivery_message_id": delivery.get("id") if delivery else None, + "callback_message_id": callback.get("id") if callback else None, + "target_notification_count": len(target_messages), + "other_notification_count": len(other_messages), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/cross-thread-requests/README.md b/benchmarks/buzz-dataset/cross-thread-requests/README.md new file mode 100644 index 0000000000..d7ed981c5f --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/README.md @@ -0,0 +1,9 @@ +# cross-thread-requests + +The harness posts ALPHA and BETA as separate top-level human mentions in the +same channel before the queue flushes. Passing requires two different replies, +each anchored to its own triggering event with only its own answer. This is a +deliberately hard guard for the cross-thread contamination reported in +[block/buzz#5839](https://github.com/block/buzz/issues/5839) and the exact +reply-target contract in +[block/buzz#4072](https://github.com/block/buzz/issues/4072). diff --git a/benchmarks/buzz-dataset/cross-thread-requests/environment/Dockerfile b/benchmarks/buzz-dataset/cross-thread-requests/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/cross-thread-requests/instruction.md b/benchmarks/buzz-dataset/cross-thread-requests/instruction.md new file mode 100644 index 0000000000..160fad7d16 --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/instruction.md @@ -0,0 +1,3 @@ +ALPHA request: calculate `(17 * 19) + 23`. + +Reply with the ALPHA result, labelled ALPHA. diff --git a/benchmarks/buzz-dataset/cross-thread-requests/task.toml b/benchmarks/buzz-dataset/cross-thread-requests/task.toml new file mode 100644 index 0000000000..14634fa2d5 --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/cross-thread-requests" +description = "Keep two concurrent top-level requests in one channel isolated and reply to each exact thread." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "threading", "batching", "concurrency"] + +[metadata] +difficulty = "hard" +category = "collaboration" +tags = ["threading", "batching", "concurrency", "routing"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/cross-thread-requests/tests/test.sh b/benchmarks/buzz-dataset/cross-thread-requests/tests/test.sh new file mode 100755 index 0000000000..79434035e3 --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/tests/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py --evidence /logs/artifacts/buzz-evidence.json --reward /logs/verifier/reward.json --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/cross-thread-requests/tests/verify.py b/benchmarks/buzz-dataset/cross-thread-requests/tests/verify.py new file mode 100755 index 0000000000..7ba49e48f3 --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/tests/verify.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for isolation of two top-level requests.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "alpha_correct": 0.0, + "beta_correct": 0.0, + "thread_isolation": 0.0, + "user_mentioned_twice": 0.0, + "evidence_complete": 0.0, + } + + +def _matches(message: dict[str, Any], label: str, value: int) -> bool: + content = str(message.get("content", "")) + return bool(re.search(rf"{label}\D+{value}(?:\D|$)", content, re.IGNORECASE)) + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + alpha_root = evidence.get("task_event_id") + scripts = [ + row + for row in evidence.get("scripted_events", []) + if isinstance(row, dict) and row.get("label") == "beta-request" + ] + beta_root = scripts[0].get("event_id") if len(scripts) == 1 else None + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Priya Simone Patel", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + candidates = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + alpha_replies = [ + row + for row in candidates + if row.get("reply_to_event_id") == alpha_root and _matches(row, "ALPHA", 346) + ] + beta_replies = [ + row + for row in candidates + if row.get("reply_to_event_id") == beta_root and _matches(row, "BETA", 41) + ] + alpha = alpha_replies[-1] if alpha_replies else None + beta = beta_replies[-1] if beta_replies else None + alpha_content = str(alpha.get("content", "")) if alpha else "" + beta_content = str(beta.get("content", "")) if beta else "" + alpha_correct = float(alpha is not None) + beta_correct = float(beta is not None) + thread_isolation = float( + alpha is not None + and beta is not None + and len(candidates) == 2 + and alpha.get("id") != beta.get("id") + and "BETA" not in alpha_content.upper() + and "ALPHA" not in beta_content.upper() + ) + user_mentioned_twice = float( + alpha is not None + and beta is not None + and user.get("pubkey") in alpha.get("mentioned_pubkeys", []) + and user.get("pubkey") in beta.get("mentioned_pubkeys", []) + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "cross-thread-requests" + and evidence.get("truncated") is False + and isinstance(alpha_root, str) + and isinstance(beta_root, str) + and len(agents) == 1 + ) + values = ( + alpha_correct, + beta_correct, + thread_isolation, + user_mentioned_twice, + evidence_complete, + ) + metrics = { + "reward": float(all(value == 1.0 for value in values)), + "alpha_correct": alpha_correct, + "beta_correct": beta_correct, + "thread_isolation": thread_isolation, + "user_mentioned_twice": user_mentioned_twice, + "evidence_complete": evidence_complete, + } + return metrics, { + "alpha_root": alpha_root, + "beta_root": beta_root, + "alpha_message_id": alpha.get("id") if alpha else None, + "beta_message_id": beta.get("id") if beta else None, + "alpha_content": alpha_content, + "beta_content": beta_content, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/README.md b/benchmarks/buzz-dataset/interleaved-agent-reports/README.md new file mode 100644 index 0000000000..a24a92fa0c --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/README.md @@ -0,0 +1,8 @@ +# interleaved-agent-reports + +Three signed bot identities publish independent reports immediately after the +human request. The solo agent must retain every input, calculate 87, notify the +human exactly once, and avoid waking the reporters again. The case exercises +the batching/steering family reported in +[block/buzz#5839](https://github.com/block/buzz/issues/5839) and +[block/buzz#4942](https://github.com/block/buzz/issues/4942). diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/environment/Dockerfile b/benchmarks/buzz-dataset/interleaved-agent-reports/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/instruction.md b/benchmarks/buzz-dataset/interleaved-agent-reports/instruction.md new file mode 100644 index 0000000000..9221e0c2ac --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/instruction.md @@ -0,0 +1,6 @@ +Three specialist agents will report separate inputs for a forecast. Wait until +you have all three reports, then calculate: + +`(North + South - reserve) * multiplier` + +Send me one concise synthesis that labels all four inputs and the final value. diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/task.toml b/benchmarks/buzz-dataset/interleaved-agent-reports/task.toml new file mode 100644 index 0000000000..8bd320cb09 --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/interleaved-agent-reports" +description = "Aggregate multiple agent reports delivered together without losing an input or waking reporters again." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "agents", "batching", "synthesis"] + +[metadata] +difficulty = "hard" +category = "collaboration" +tags = ["agents", "batching", "synthesis", "mentions"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/tests/test.sh b/benchmarks/buzz-dataset/interleaved-agent-reports/tests/test.sh new file mode 100755 index 0000000000..79434035e3 --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/tests/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py --evidence /logs/artifacts/buzz-evidence.json --reward /logs/verifier/reward.json --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/tests/verify.py b/benchmarks/buzz-dataset/interleaved-agent-reports/tests/verify.py new file mode 100755 index 0000000000..b6b7dfc524 --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/tests/verify.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for aggregation of batched agent reports.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +REPORTERS = ("Ledger Scout", "Risk Sentinel", "Ops Forecaster") +LABELS = {"ledger-report", "risk-report", "operations-report"} + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "reports_delivered": 0.0, + "inputs_complete": 0.0, + "answer_correct": 0.0, + "single_human_callback": 0.0, + "reporters_not_rementioned": 0.0, + "reply_to_thread": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + root = evidence.get("task_event_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Nora Isabel Grant", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + directory = { + row.get("name"): row + for row in evidence.get("directory", []) + if isinstance(row, dict) + } + reporter_pubkeys = { + directory[name].get("pubkey") for name in REPORTERS if name in directory + } + scripts = [ + row for row in evidence.get("scripted_events", []) if isinstance(row, dict) + ] + script_ids = {row.get("event_id") for row in scripts if row.get("label") in LABELS} + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + delivered_ids = {row.get("id") for row in messages} + reports_delivered = float( + len(script_ids) == 3 and None not in script_ids and script_ids <= delivered_ids + ) + candidates = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final else "" + mentioned = set(final.get("mentioned_pubkeys", [])) if final else set() + inputs_complete = float( + all( + re.search(pattern, content, re.IGNORECASE) + for pattern in ( + r"north\D+17", + r"south\D+23", + r"reserve\D+11", + r"multiplier\D+3", + ) + ) + ) + answer_correct = float( + bool( + re.search( + r"(?:final|result|forecast|=)\D*87(?:\D|$)", + content, + re.IGNORECASE, + ) + ) + ) + human_callbacks = [ + row + for row in candidates + if user.get("pubkey") in row.get("mentioned_pubkeys", []) + ] + single_human_callback = float( + len(human_callbacks) == 1 and final in human_callbacks + ) + reporters_not_rementioned = float( + len(reporter_pubkeys) == 3 + and all( + set(row.get("mentioned_pubkeys", [])).isdisjoint(reporter_pubkeys) + and not any(f"@{name}" in str(row.get("content", "")) for name in REPORTERS) + for row in candidates + ) + ) + reply_to_thread = float( + final is not None and final.get("reply_to_event_id") == root + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "interleaved-agent-reports" + and evidence.get("truncated") is False + and len(agents) == 1 + and len(directory) == 3 + and final is not None + and len(candidates) == 1 + ) + values = ( + reports_delivered, + inputs_complete, + answer_correct, + single_human_callback, + reporters_not_rementioned, + reply_to_thread, + evidence_complete, + ) + metrics = { + "reward": float(all(value == 1.0 for value in values)), + "reports_delivered": reports_delivered, + "inputs_complete": inputs_complete, + "answer_correct": answer_correct, + "single_human_callback": single_human_callback, + "reporters_not_rementioned": reporters_not_rementioned, + "reply_to_thread": reply_to_thread, + "evidence_complete": evidence_complete, + } + return metrics, { + "scripted_event_ids": sorted(str(value) for value in script_ids), + "selected_message_id": final.get("id") if final else None, + "content": content, + "mentioned_pubkeys": sorted(mentioned), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/multiline-message/README.md b/benchmarks/buzz-dataset/multiline-message/README.md new file mode 100644 index 0000000000..cb37089cff --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/README.md @@ -0,0 +1,10 @@ +# multiline-message + +The agent sends a short release update whose blank lines and bullet boundaries +must survive the `buzz messages send` shell call as real newline bytes. The +verifier also checks the normal reply anchor and callback mention. This guards +the first-newline truncation failure described in +[block/buzz#5787](https://github.com/block/buzz/issues/5787). + +Run with the command in the parent [README](../README.md), replacing the task +path with `benchmarks/buzz-dataset/multiline-message`. diff --git a/benchmarks/buzz-dataset/multiline-message/environment/Dockerfile b/benchmarks/buzz-dataset/multiline-message/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/multiline-message/instruction.md b/benchmarks/buzz-dataset/multiline-message/instruction.md new file mode 100644 index 0000000000..bf307c5fb5 --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/instruction.md @@ -0,0 +1,11 @@ +Send me this release-readiness update, preserving the paragraph and list layout: + +Release readiness + +- API: ready +- Database: ready +- Rollback: tested + +Owner: Platform Operations + +Keep the response brief and do not add a table. diff --git a/benchmarks/buzz-dataset/multiline-message/task.toml b/benchmarks/buzz-dataset/multiline-message/task.toml new file mode 100644 index 0000000000..69f919f7c1 --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/multiline-message" +description = "Deliver a multiline Buzz message without flattening or escaping its layout." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "messaging", "multiline", "cli"] + +[metadata] +difficulty = "medium" +category = "collaboration" +tags = ["messaging", "multiline", "cli"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/multiline-message/tests/test.sh b/benchmarks/buzz-dataset/multiline-message/tests/test.sh new file mode 100755 index 0000000000..3dd50814b6 --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/tests/test.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py \ + --evidence /logs/artifacts/buzz-evidence.json \ + --reward /logs/verifier/reward.json \ + --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/multiline-message/tests/verify.py b/benchmarks/buzz-dataset/multiline-message/tests/verify.py new file mode 100755 index 0000000000..afa34a6bd3 --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/tests/verify.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for multiline Buzz message delivery.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +EXPECTED = ( + "Release readiness\n\n" + "- API: ready\n" + "- Database: ready\n" + "- Rollback: tested\n\n" + "Owner: Platform Operations" +) + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "layout_preserved": 0.0, + "real_newlines": 0.0, + "reply_to_thread": 0.0, + "user_mentioned": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + root = evidence.get("task_event_id") + trial = evidence.get("trial") if isinstance(evidence.get("trial"), dict) else {} + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Eleanor June Brooks", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + candidates = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final else "" + tags = final.get("tags", []) if final else [] + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "multiline-message" + and evidence.get("truncated") is False + and isinstance(root, str) + and isinstance(trial.get("channel_id"), str) + and len(agents) == 1 + and isinstance(user.get("pubkey"), str) + and final is not None + and len(candidates) == 1 + ) + layout_preserved = float(EXPECTED in content) + real_newlines = float("\\n" not in content and content.count("\n") >= 6) + reply_to_thread = float( + final is not None and final.get("reply_to_event_id") == root + ) + user_mentioned = float( + user.get("pubkey") in (final.get("mentioned_pubkeys", []) if final else []) + ) + reward = float( + all( + value == 1.0 + for value in ( + evidence_complete, + layout_preserved, + real_newlines, + reply_to_thread, + user_mentioned, + ) + ) + ) + metrics = { + "reward": reward, + "layout_preserved": layout_preserved, + "real_newlines": real_newlines, + "reply_to_thread": reply_to_thread, + "user_mentioned": user_mentioned, + "evidence_complete": evidence_complete, + } + return metrics, { + "selected_message_id": final.get("id") if final else None, + "content": content, + "tags": tags, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/narrative-agent-names/README.md b/benchmarks/buzz-dataset/narrative-agent-names/README.md new file mode 100644 index 0000000000..f72a4ef3cc --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/README.md @@ -0,0 +1,7 @@ +# narrative-agent-names + +The agent reports status about two in-channel bots. Both names must remain +plain narrative text: neither bot may receive a `p` tag or an `@Name` wake-up. +The requesting human must still receive the callback mention. This guards the +acknowledgement and false-wake behavior in +[block/buzz#5176](https://github.com/block/buzz/issues/5176). diff --git a/benchmarks/buzz-dataset/narrative-agent-names/environment/Dockerfile b/benchmarks/buzz-dataset/narrative-agent-names/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/narrative-agent-names/instruction.md b/benchmarks/buzz-dataset/narrative-agent-names/instruction.md new file mode 100644 index 0000000000..78c73109f3 --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/instruction.md @@ -0,0 +1,6 @@ +Give me a two-line status update: + +- Aurora Audit Bot completed the audit. +- Beacon Deploy Bot remains idle. + +This is only a status summary. Neither bot has any work to do. diff --git a/benchmarks/buzz-dataset/narrative-agent-names/task.toml b/benchmarks/buzz-dataset/narrative-agent-names/task.toml new file mode 100644 index 0000000000..09025e0723 --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/narrative-agent-names" +description = "Name agents in narrative without waking them through event mentions." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "mentions", "agents", "notifications"] + +[metadata] +difficulty = "medium" +category = "collaboration" +tags = ["mentions", "agents", "notifications"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/narrative-agent-names/tests/test.sh b/benchmarks/buzz-dataset/narrative-agent-names/tests/test.sh new file mode 100755 index 0000000000..79434035e3 --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/tests/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py --evidence /logs/artifacts/buzz-evidence.json --reward /logs/verifier/reward.json --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/narrative-agent-names/tests/verify.py b/benchmarks/buzz-dataset/narrative-agent-names/tests/verify.py new file mode 100755 index 0000000000..3759f13876 --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/tests/verify.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for non-notifying narrative agent names.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +BOT_NAMES = ("Aurora Audit Bot", "Beacon Deploy Bot") + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "status_correct": 0.0, + "bots_not_mentioned": 0.0, + "user_mentioned": 0.0, + "reply_to_thread": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + root = evidence.get("task_event_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Maya Elise Chen", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + directory = { + row.get("name"): row + for row in evidence.get("directory", []) + if isinstance(row, dict) + } + bot_pubkeys = { + directory[name].get("pubkey") for name in BOT_NAMES if name in directory + } + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + candidates = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final else "" + mentioned = set(final.get("mentioned_pubkeys", [])) if final else set() + status_correct = float( + bool(re.search(r"Aurora Audit Bot[^.\n]*completed", content, re.IGNORECASE)) + and bool(re.search(r"Beacon Deploy Bot[^.\n]*idle", content, re.IGNORECASE)) + ) + bots_not_mentioned = float( + len(bot_pubkeys) == 2 + and all( + set(row.get("mentioned_pubkeys", [])).isdisjoint(bot_pubkeys) + and not any(f"@{name}" in str(row.get("content", "")) for name in BOT_NAMES) + for row in candidates + ) + ) + user_mentioned = float(user.get("pubkey") in mentioned) + reply_to_thread = float( + final is not None and final.get("reply_to_event_id") == root + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "narrative-agent-names" + and evidence.get("truncated") is False + and len(agents) == 1 + and len(directory) == 2 + and final is not None + and len(candidates) == 1 + ) + reward = float( + all( + value == 1.0 + for value in ( + status_correct, + bots_not_mentioned, + user_mentioned, + reply_to_thread, + evidence_complete, + ) + ) + ) + metrics = { + "reward": reward, + "status_correct": status_correct, + "bots_not_mentioned": bots_not_mentioned, + "user_mentioned": user_mentioned, + "reply_to_thread": reply_to_thread, + "evidence_complete": evidence_complete, + } + return metrics, { + "selected_message_id": final.get("id") if final else None, + "content": content, + "mentioned_pubkeys": sorted(mentioned), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor-buzz-orchestra/README.md b/benchmarks/harbor-buzz-orchestra/README.md index b7df4a5557..bbc0b60380 100644 --- a/benchmarks/harbor-buzz-orchestra/README.md +++ b/benchmarks/harbor-buzz-orchestra/README.md @@ -66,10 +66,11 @@ artifacts) available for analysis. The local [`benchmarks/buzz-dataset`](../buzz-dataset) suite — a sibling directory of this harness, not a subdirectory of it — scores Buzz product -behavior alongside task correctness. It currently covers direct thread replies, callback user -mentions, targeted reads of user-named paths outside the workspace, and exact -channel creation/membership. Run one task with the production base prompt from -the checked-out source build: +behavior alongside task correctness. It covers direct thread replies, callback +user mentions, targeted reads of named paths, exact channel membership, +multiline delivery, non-waking narrative names, batched reports, cross-thread +isolation, and ambiguous identities. Run one task with the production base +prompt from the checked-out source build: ```bash just benchmark \ @@ -108,6 +109,12 @@ or verifier. If the snapshot cannot be exported the trial **fails** rather than scoring 0 — a harness fault and a model fault stay distinguishable — and the cause is written to the trial's `buzz/buzz-evidence-error.txt`. +Some tasks declare additional signed relay events. The provisioner creates +their actors as normal channel identities and the runtime publishes the events +through the production CLI immediately after the task message. Evidence exports +only public actor metadata and event IDs; their signing credentials never enter +the task container or verifier artifact. + Each task ships its own `README.md` documenting its reward dimensions and, for the tasks whose graded Buzz behavior is deliberately absent from `instruction.md` (`reply-to-thread`, `user-mention`), why that omission is the diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py index 756cbfa31f..797e3a860c 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio import json import os +import re import shlex import traceback from dataclasses import dataclass, field @@ -50,6 +51,9 @@ FORWARDER_LOG = f"{REMOTE_LOGS}/relay-forwarder.log" # How many done-poll iterations between in-container liveness probes. LIVENESS_EVERY = 10 TRANSCRIPT_LIMIT = 1000 +DELIVERY_RECEIPT_MARKER = "turn delivered Buzz events for channel" +EVENT_ID_PATTERN = re.compile(r"(? dict[str, Any] | None: - """Observe until a team posts DONE or a solo agent finishes its one turn. + """Observe until a team posts DONE or a solo agent finishes its work. Observation only: the harness never speaks as any agent. If the team stalls, the trial times out and the stall is the measured result. A solo - agent cannot be woken by a teammate, so its logged turn end is final. + task without scripted events finishes at its first logged turn end. """ polls = 0 while True: @@ -518,21 +533,71 @@ class BuzzContainerRuntime: "100", ) for message in messages: - if message.get("pubkey") == orchestrator.nostr_pubkey and str( - message.get("content", "") - ).startswith("DONE:"): + if ( + message.get("pubkey") == orchestrator.nostr_pubkey + and str(message.get("content", "")).startswith("DONE:") + and (solo is None or not scripted_event_ids) + ): return message - if solo is not None and await self._turn_ended(environment, solo): - return None + if solo is not None: + starts, ends, delivered_event_ids = await self._turn_status( + environment, solo + ) + authored = [ + message + for message in messages + if message.get("pubkey") == orchestrator.nostr_pubkey + ] + if not scripted_event_ids and ends > 0: + return authored[-1] if authored else None + if ( + starts > 0 + and starts == ends + and scripted_event_ids <= delivered_event_ids + ): + return authored[-1] if authored else None await asyncio.sleep(self.poll_seconds) @staticmethod async def _turn_ended(environment: BaseEnvironment, agent: _Agent) -> bool: + _, ends = await BuzzContainerRuntime._turn_counts(environment, agent) + return ends > 0 + + @staticmethod + async def _turn_counts( + environment: BaseEnvironment, agent: _Agent + ) -> tuple[int, int]: + starts, ends, _ = await BuzzContainerRuntime._turn_status(environment, agent) + return starts, ends + + @staticmethod + async def _turn_status( + environment: BaseEnvironment, agent: _Agent + ) -> tuple[int, int, set[str]]: result = await environment.exec( f"cat {shlex.quote(agent.stdout_log)} " f"{shlex.quote(agent.stderr_log)} 2>/dev/null" ) - return any(marker in (result.stdout or "") for marker in TURN_ENDED_MARKERS) + return BuzzContainerRuntime._parse_turn_status(result.stdout or "") + + @staticmethod + def _parse_turn_status(output: str) -> tuple[int, int, set[str]]: + output = ANSI_ESCAPE_PATTERN.sub("", output) + delivered_event_ids: set[str] = set() + for line in output.splitlines(): + if DELIVERY_RECEIPT_MARKER in line: + delivered_event_ids.update(EVENT_ID_PATTERN.findall(line)) + elif ( + "non-cancelling steer ack received" in line and "ack=Ok(Success" in line + ): + match = re.search(r"event_id=([0-9a-f]{64})", line) + if match is not None: + delivered_event_ids.add(match.group(1)) + return ( + output.count("turn starting for"), + sum(output.count(marker) for marker in TURN_ENDED_MARKERS), + delivered_event_ids, + ) async def _raise_for_dead_agents( self, environment: BaseEnvironment, agents: list[_Agent] @@ -590,6 +655,7 @@ class BuzzContainerRuntime: trial_dir: Path, task_event_id: str | None, completion_message_id: str | None, + scripted_events: list[dict[str, str | None]] | None = None, ) -> bool: """Snapshot public relay state for the verifier before trial teardown.""" try: @@ -611,6 +677,7 @@ class BuzzContainerRuntime: completion_message_id=completion_message_id, transcript_limit=TRANSCRIPT_LIMIT, observed_channels=observed_channels, + scripted_events=scripted_events, ) evidence_path = trial_dir / "buzz-evidence.json" evidence_path.write_text( @@ -726,6 +793,7 @@ class BuzzContainerRuntime: content: str, *, mention: str | None = None, + reply_to: str | None = None, ) -> Any: args = [ "messages", @@ -737,8 +805,69 @@ class BuzzContainerRuntime: ] if mention is not None: args += ["--mention", mention] + if reply_to is not None: + args += ["--reply-to", reply_to] return await self._buzz_json(credential, trial, *args) + async def _send_scripted_messages( + self, + *, + trial: TrialHandle, + orchestrator: AgentCredential, + task_event_id: str | None, + ) -> list[dict[str, str | None]]: + """Inject task-declared events through the production CLI. + + Messages are sent back-to-back so Buzz's normal queueing and batching + decide how the agent sees them. The verifier receives only public event + metadata; fixture signing keys stay inside the runtime handle. + """ + fixture = fixture_for(trial.task_name) + if not fixture.scripted_messages: + return [] + actors = {actor.identity_id: actor.credential for actor in trial.fixture_actors} + recorded: list[dict[str, str | None]] = [] + for message in fixture.scripted_messages: + try: + actor = trial.user if message.actor == "user" else actors[message.actor] + except KeyError as error: + raise RuntimeLaunchError( + f"scripted actor {message.actor!r} has no fixture credential" + ) from error + content = message.content.replace( + "{orchestrator}", orchestrator.agent_id + ).replace("{user}", trial.user.agent_id) + response = await self._send( + actor, + trial, + content, + mention=( + orchestrator.nostr_pubkey if message.mention_orchestrator else None + ), + reply_to=(task_event_id if message.reply_to_task else None), + ) + event_id = ( + response.get("event_id") + if isinstance(response, dict) + and isinstance(response.get("event_id"), str) + else None + ) + if event_id is None: + raise RuntimeLaunchError( + f"scripted event {message.label!r} did not return an event ID" + ) + recorded.append( + { + "label": message.label, + "event_id": event_id, + "actor": message.actor, + "reply_to_event_id": ( + task_event_id if message.reply_to_task else None + ), + } + ) + return recorded + async def _buzz_json( self, credential: AgentCredential, trial: TrialHandle, *args: str ) -> Any: diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py index 45eaaa3cfa..bc8e34782a 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py @@ -75,6 +75,7 @@ def build_buzz_evidence( completion_message_id: str | None, transcript_limit: int, observed_channels: object = None, + scripted_events: object = None, ) -> dict[str, Any]: """Normalize relay messages into a versioned contract for task verifiers. @@ -92,6 +93,12 @@ def build_buzz_evidence( identities_by_pubkey = { pubkey: {"name": name, "role": role} for name, role, pubkey in identity_rows } + identities_by_pubkey.update( + { + identity.pubkey: {"name": identity.name, "role": identity.role} + for identity in trial.directory + } + ) identities = { name: {"role": role, "pubkey": pubkey} for name, role, pubkey in identity_rows } @@ -117,9 +124,16 @@ def build_buzz_evidence( "completion_message_id": completion_message_id, "identities": identities, "directory": [ - {"name": identity.name, "role": identity.role, "pubkey": identity.pubkey} + { + "identity_id": identity.identity_id or identity.name, + "name": identity.name, + "role": identity.role, + "pubkey": identity.pubkey, + "about": identity.about, + } for identity in trial.directory ], + "scripted_events": scripted_events if isinstance(scripted_events, list) else [], "task_name": trial.task_name, "observed_channels": ( observed_channels if isinstance(observed_channels, list) else [] diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py index b27ae8ae12..ecc2df2e2b 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py @@ -28,6 +28,16 @@ class DirectoryIdentity: name: str role: str pubkey: str + identity_id: str = "" + about: str = "" + + +@dataclass(frozen=True, slots=True) +class FixtureActor: + """A private signer for task-declared relay events.""" + + identity_id: str + credential: AgentCredential @dataclass(frozen=True, slots=True) @@ -51,6 +61,7 @@ class TrialHandle: # Additive Buzz-native task context. Directory entries contain no secrets. task_name: str = "" directory: tuple[DirectoryIdentity, ...] = () + fixture_actors: tuple[FixtureActor, ...] = () @runtime_checkable diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py index afcfb6f050..451b97f9c1 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py @@ -11,6 +11,25 @@ class DirectoryEntry: name: str role: str + identity_id: str | None = None + about: str | None = None + channel_member: bool = False + + @property + def stable_id(self) -> str: + """Identity key used for deterministic credentials and scripted events.""" + return self.identity_id or self.name + + +@dataclass(frozen=True, slots=True) +class ScriptedMessage: + """A relay event injected immediately after the task's triggering event.""" + + label: str + actor: str + content: str + reply_to_task: bool = False + mention_orchestrator: bool = True @dataclass(frozen=True, slots=True) @@ -18,6 +37,7 @@ class BuzzTaskFixture: """Relay state a task needs before the agent receives its prompt.""" directory: tuple[DirectoryEntry, ...] = () + scripted_messages: tuple[ScriptedMessage, ...] = () observe_channel_names: tuple[str, ...] = () user_display_name: str | None = None # Whether the task's verifier grades the exported relay snapshot. Only @@ -34,6 +54,11 @@ USER_MENTION_TASK = "user-mention" USER_MENTION_DISPLAY_NAME = "John Vincent Doe" REPLY_TO_THREAD_TASK = "reply-to-thread" READ_NAMED_PATH_TASK = "read-named-path-outside-workspace" +MULTILINE_MESSAGE_TASK = "multiline-message" +NARRATIVE_AGENT_NAMES_TASK = "narrative-agent-names" +INTERLEAVED_AGENT_REPORTS_TASK = "interleaved-agent-reports" +CROSS_THREAD_REQUESTS_TASK = "cross-thread-requests" +AMBIGUOUS_USER_MENTION_TASK = "ambiguous-user-mention" _CREATE_CHANNEL_FIXTURE = BuzzTaskFixture( directory=tuple( @@ -55,11 +80,99 @@ _USER_MENTION_FIXTURE = BuzzTaskFixture( requires_evidence=True, ) +_NARRATIVE_AGENT_NAMES_FIXTURE = BuzzTaskFixture( + directory=( + DirectoryEntry("Aurora Audit Bot", "bot", channel_member=True), + DirectoryEntry("Beacon Deploy Bot", "bot", channel_member=True), + ), + user_display_name="Maya Elise Chen", + requires_evidence=True, +) + +_INTERLEAVED_AGENT_REPORTS_FIXTURE = BuzzTaskFixture( + directory=( + DirectoryEntry("Ledger Scout", "bot", channel_member=True), + DirectoryEntry("Risk Sentinel", "bot", channel_member=True), + DirectoryEntry("Ops Forecaster", "bot", channel_member=True), + ), + scripted_messages=( + ScriptedMessage( + label="ledger-report", + actor="Ledger Scout", + content=( + "@{orchestrator} Ledger report: North is 17 and South is 23. " + "Use both values in the requested synthesis." + ), + reply_to_task=True, + ), + ScriptedMessage( + label="risk-report", + actor="Risk Sentinel", + content=( + "@{orchestrator} Risk report: subtract a reserve of 11 before " + "applying the operations multiplier." + ), + reply_to_task=True, + ), + ScriptedMessage( + label="operations-report", + actor="Ops Forecaster", + content=("@{orchestrator} Operations report: the final multiplier is 3."), + reply_to_task=True, + ), + ), + user_display_name="Nora Isabel Grant", + requires_evidence=True, +) + +_CROSS_THREAD_REQUESTS_FIXTURE = BuzzTaskFixture( + scripted_messages=( + ScriptedMessage( + label="beta-request", + actor="user", + content=( + "@{orchestrator} BETA request: calculate (84 / 7) + 29. " + "Reply with the BETA result, labelled BETA." + ), + ), + ), + user_display_name="Priya Simone Patel", + requires_evidence=True, +) + +_AMBIGUOUS_USER_MENTION_FIXTURE = BuzzTaskFixture( + directory=( + DirectoryEntry( + "Taylor Morgan Lee", + "user", + identity_id="taylor-release-captain", + about="Release captain. Routing code ORCHID-72.", + channel_member=True, + ), + DirectoryEntry( + "Taylor Morgan Lee", + "user", + identity_id="taylor-observer", + about="Compliance observer. Routing code COBALT-19.", + channel_member=True, + ), + ), + user_display_name="Olivia Grace Park", + requires_evidence=True, +) + _FIXTURES = { CREATE_CHANNEL_TASK: _CREATE_CHANNEL_FIXTURE, USER_MENTION_TASK: _USER_MENTION_FIXTURE, REPLY_TO_THREAD_TASK: BuzzTaskFixture(requires_evidence=True), READ_NAMED_PATH_TASK: BuzzTaskFixture(requires_evidence=True), + MULTILINE_MESSAGE_TASK: BuzzTaskFixture( + user_display_name="Eleanor June Brooks", requires_evidence=True + ), + NARRATIVE_AGENT_NAMES_TASK: _NARRATIVE_AGENT_NAMES_FIXTURE, + INTERLEAVED_AGENT_REPORTS_TASK: _INTERLEAVED_AGENT_REPORTS_FIXTURE, + CROSS_THREAD_REQUESTS_TASK: _CROSS_THREAD_REQUESTS_FIXTURE, + AMBIGUOUS_USER_MENTION_TASK: _AMBIGUOUS_USER_MENTION_FIXTURE, } diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py index f1c4077d7e..6da74a1425 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py @@ -81,7 +81,7 @@ class BuzzCli: raise BuzzCliError(f"channel create returned no channel_id: {response}") return channel_id - def add_member(self, channel_id: str, pubkey: str) -> None: + def add_member(self, channel_id: str, pubkey: str, role: str = "member") -> None: self.run( "channels", "add-member", @@ -90,7 +90,7 @@ class BuzzCli: "--pubkey", pubkey, "--role", - "member", + role, ) def profiles(self, pubkeys: list[str]) -> list[dict[str, Any]]: @@ -101,8 +101,11 @@ class BuzzCli: response = self.run(*args) return response if isinstance(response, list) else [] - def set_profile(self, name: str) -> None: - self.run("users", "set-profile", "--name", name) + def set_profile(self, name: str, about: str | None = None) -> None: + args = ["users", "set-profile", "--name", name] + if about is not None: + args.extend(("--about", about)) + self.run(*args) def archive_channel(self, channel_id: str) -> None: self.run("channels", "archive", "--channel", channel_id) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py index 11d0c88619..3b4f0d19d5 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py @@ -14,9 +14,10 @@ from harbor_buzz_orchestra.manifest import ExperimentManifest from harbor_buzz_orchestra.provisioning import ( AgentCredential, DirectoryIdentity, + FixtureActor, TrialHandle, ) -from harbor_buzz_orchestra.task_fixtures import fixture_for +from harbor_buzz_orchestra.task_fixtures import DirectoryEntry, fixture_for from .buzz_cli import BuzzCli from .keys import compute_auth_tag, generate_keypair, keypair_from_secret @@ -170,6 +171,27 @@ class BuzzTrialProvisioner: for credential in credentials: cli.add_member(channel_id, credential.nostr_pubkey) directory = self._seed_directory(task_name, cli) + fixture = fixture_for(task_name) + directory_credentials = { + entry.stable_id: self._directory_credential(entry) + for entry in fixture.directory + } + for entry in fixture.directory: + if entry.channel_member: + cli.add_member( + channel_id, + directory_credentials[entry.stable_id].nostr_pubkey, + "bot" if entry.role == "bot" else "member", + ) + scripted_actor_ids = { + message.actor + for message in fixture.scripted_messages + if message.actor != "user" + } + fixture_actors = tuple( + FixtureActor(identity_id, directory_credentials[identity_id]) + for identity_id in sorted(scripted_actor_ids) + ) return TrialHandle( run_id=run_id, trial_id=trial_id, @@ -181,6 +203,7 @@ class BuzzTrialProvisioner: user_relay_url=self._config.relay_http_url, task_name=task_name or "", directory=directory, + fixture_actors=fixture_actors, ) def _seed_directory( @@ -188,9 +211,7 @@ class BuzzTrialProvisioner: ) -> tuple[DirectoryIdentity, ...]: """Publish stable task-directory profiles, skipping those already seeded.""" entries = fixture_for(task_name).directory - credentials = [ - self._directory_credential(entry.name, entry.role) for entry in entries - ] + credentials = [self._directory_credential(entry) for entry in entries] if not credentials: return () existing = { @@ -200,21 +221,23 @@ class BuzzTrialProvisioner: ) if isinstance(profile, dict) } - for credential in credentials: - if credential.nostr_pubkey not in existing: - self._cli_for(credential).set_profile(credential.agent_id) + for entry, credential in zip(entries, credentials, strict=True): + if credential.nostr_pubkey not in existing or entry.about is not None: + self._cli_for(credential).set_profile(entry.name, entry.about) return tuple( DirectoryIdentity( - name=credential.agent_id, + name=entry.name, role=credential.role, pubkey=credential.nostr_pubkey, + identity_id=entry.stable_id, + about=entry.about or "", ) - for credential in credentials + for entry, credential in zip(entries, credentials, strict=True) ) - def _directory_credential(self, name: str, role: str) -> AgentCredential: + def _directory_credential(self, entry: DirectoryEntry) -> AgentCredential: """Derive one community-stable benchmark identity without storing its key.""" - return self._stable_credential(name, name, role) + return self._stable_credential(entry.stable_id, entry.name, entry.role) def _stable_credential( self, identity_id: str, display_name: str, role: str @@ -342,6 +365,12 @@ class BuzzTrialProvisioner: DirectoryIdentity(**identity) for identity in stored.get("directory", []) ), + fixture_actors=tuple( + FixtureActor( + actor["identity_id"], AgentCredential(**actor["credential"]) + ) + for actor in stored.get("fixture_actors", []) + ), ) @staticmethod diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py index 1f55526cf3..44edcf27e0 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py @@ -7,6 +7,7 @@ import json import coincurve import pytest +from harbor_buzz_orchestra.task_fixtures import DirectoryEntry from harbor_buzz_testbed.provisioner import ( BuzzTrialProvisioner, @@ -112,9 +113,13 @@ def test_mint_credentials_missing_api_key_is_explicit(manifest): def test_directory_credentials_are_stable_distinct_and_attested(): provisioner = BuzzTrialProvisioner(config()) - first = provisioner._directory_credential("benchmark-user-01", "user") - again = provisioner._directory_credential("benchmark-user-01", "user") - other = provisioner._directory_credential("benchmark-bot-01", "bot") + first = provisioner._directory_credential( + DirectoryEntry("benchmark-user-01", "user") + ) + again = provisioner._directory_credential( + DirectoryEntry("benchmark-user-01", "user") + ) + other = provisioner._directory_credential(DirectoryEntry("benchmark-bot-01", "bot")) assert first.nostr_secret_key == again.nostr_secret_key assert first.nostr_pubkey == again.nostr_pubkey @@ -133,8 +138,8 @@ def test_seed_directory_has_50_users_10_bots_and_skips_existing(monkeypatch): published = [] class Publisher: - def set_profile(self, name): - published.append(name) + def set_profile(self, name, about=None): + published.append((name, about)) monkeypatch.setattr(provisioner, "_cli_for", lambda _credential: Publisher()) @@ -144,7 +149,20 @@ def test_seed_directory_has_50_users_10_bots_and_skips_existing(monkeypatch): assert sum(identity.role == "user" for identity in directory) == 50 assert sum(identity.role == "bot" for identity in directory) == 10 assert len(published) == 59 - assert directory[0].name not in published + assert (directory[0].name, None) not in published + + +def test_duplicate_display_names_keep_distinct_stable_identities(): + provisioner = BuzzTrialProvisioner(config()) + first = provisioner._directory_credential( + DirectoryEntry("Taylor Morgan Lee", "user", identity_id="release") + ) + second = provisioner._directory_credential( + DirectoryEntry("Taylor Morgan Lee", "user", identity_id="observer") + ) + + assert first.agent_id == second.agent_id == "Taylor Morgan Lee" + assert first.nostr_pubkey != second.nostr_pubkey def test_lock_key_is_deterministic_and_distinct(): diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py index 4f91c974af..182db9893f 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -19,7 +19,11 @@ from harbor_buzz_orchestra.container_runtime import ( RuntimeLaunchError, ) from harbor_buzz_orchestra.manifest import ExperimentManifest -from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle +from harbor_buzz_orchestra.provisioning import ( + AgentCredential, + FixtureActor, + TrialHandle, +) from harbor_buzz_orchestra.task_fixtures import fixture_for @@ -453,6 +457,70 @@ async def test_send_mentions_by_pubkey_so_task_text_stays_inert(tmp_path, monkey assert calls[-1][-2:] == ("--content", "plain content") +async def test_sends_task_declared_actor_messages_and_records_event_ids( + tmp_path, monkeypatch +): + rt = runtime(tmp_path) + orch = credential("solo-1", "orchestrator", "orch-model") + reporters = tuple( + FixtureActor(name, credential(name, "bot", "")) + for name in ("Ledger Scout", "Risk Sentinel", "Ops Forecaster") + ) + trial = replace( + trial_handle((orch,)), + task_name="interleaved-agent-reports", + fixture_actors=reporters, + ) + calls = [] + + async def send(actor, trial_arg, content, **kwargs): + calls.append((actor.agent_id, trial_arg, content, kwargs)) + return {"event_id": f"event-{len(calls)}"} + + monkeypatch.setattr(rt, "_send", send) + + events = await rt._send_scripted_messages( + trial=trial, orchestrator=orch, task_event_id="task-root" + ) + + assert [event["label"] for event in events] == [ + "ledger-report", + "risk-report", + "operations-report", + ] + assert [event["event_id"] for event in events] == [ + "event-1", + "event-2", + "event-3", + ] + assert {call[0] for call in calls} == { + "Ledger Scout", + "Risk Sentinel", + "Ops Forecaster", + } + assert all(call[3]["mention"] == orch.nostr_pubkey for call in calls) + assert all(call[3]["reply_to"] == "task-root" for call in calls) + + +async def test_scripted_message_requires_an_event_id(tmp_path, monkeypatch): + rt = runtime(tmp_path) + orch = credential("solo-1", "orchestrator", "orch-model") + trial = replace( + trial_handle((orch,)), + task_name="cross-thread-requests", + ) + + async def send(*args, **kwargs): + return {} + + monkeypatch.setattr(rt, "_send", send) + + with pytest.raises(RuntimeLaunchError, match="did not return an event ID"): + await rt._send_scripted_messages( + trial=trial, orchestrator=orch, task_event_id="task-root" + ) + + async def test_wait_for_done_requires_orchestrator_authorship(tmp_path, monkeypatch): rt = runtime(tmp_path, poll_seconds=0) orch = credential("orch-1", "orchestrator", "orch-model") @@ -500,6 +568,112 @@ async def test_solo_turn_end_completes_without_done_message(tmp_path, monkeypatc assert await rt._wait_for_done(environment, orch, trial, [], solo=solo) is None +async def test_scripted_events_wait_for_delivery_receipt(tmp_path, monkeypatch): + from harbor_buzz_orchestra.container_runtime import _Agent + + rt = runtime(tmp_path, poll_seconds=0) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = trial_handle((orch,)) + solo = _Agent(orch, 7, "stdout.log", "stderr.log") + alpha = {"id": "alpha", "pubkey": orch.nostr_pubkey, "content": "ALPHA"} + beta = {"id": "beta", "pubkey": orch.nostr_pubkey, "content": "BETA"} + scripted_event_id = "b" * 64 + message_rounds = iter([[alpha]] * 8 + [[alpha, beta]] * 2) + turn_rounds = iter( + [(1, 1, set())] * 8 + [(2, 1, set()), (2, 2, {scripted_event_id})] + ) + polls = 0 + + async def buzz_json(*args, **kwargs): + nonlocal polls + polls += 1 + return next(message_rounds) + + async def turn_status(*args, **kwargs): + return next(turn_rounds) + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + monkeypatch.setattr(rt, "_turn_status", turn_status) + + result = await rt._wait_for_done( + Environment(), + orch, + trial, + [], + solo=solo, + scripted_event_ids={scripted_event_id}, + ) + + assert result["id"] == "beta" + assert polls == 10 + + +async def test_scripted_events_do_not_stop_an_active_turn(tmp_path, monkeypatch): + from harbor_buzz_orchestra.container_runtime import _Agent + + rt = runtime(tmp_path, poll_seconds=0) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = trial_handle((orch,)) + solo = _Agent(orch, 7, "stdout.log", "stderr.log") + messages = [ + {"id": "alpha", "pubkey": orch.nostr_pubkey, "content": "ALPHA"}, + {"id": "beta", "pubkey": orch.nostr_pubkey, "content": "DONE: BETA"}, + ] + scripted_event_id = "b" * 64 + turn_rounds = iter([(2, 1, {scripted_event_id}), (2, 2, {scripted_event_id})]) + polls = 0 + + async def buzz_json(*args, **kwargs): + nonlocal polls + polls += 1 + return messages + + async def turn_status(*args, **kwargs): + return next(turn_rounds) + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + monkeypatch.setattr(rt, "_turn_status", turn_status) + + result = await rt._wait_for_done( + Environment(), + orch, + trial, + [], + solo=solo, + scripted_event_ids={scripted_event_id}, + ) + + assert result["id"] == "beta" + assert polls == 2 + + +def test_turn_status_parses_completed_batch_and_successful_steer_receipts(): + batch_event_id = "a" * 64 + steer_event_id = "b" * 64 + rejected_event_id = "c" * 64 + output = "\n".join( + [ + "turn starting for channel test", + f"turn delivered Buzz events for channel test: {batch_event_id}", + "turn complete for channel test: end_turn", + ( + "non-cancelling steer ack received " + f"event_id={steer_event_id} ack=Ok(Success {{ session_id: session }})" + ), + ( + "non-cancelling steer ack received " + f"event_id={rejected_event_id} ack=Ok(Err(OutcomeRejected))" + ), + ] + ) + + assert BuzzContainerRuntime._parse_turn_status(output) == ( + 1, + 1, + {batch_event_id, steer_event_id}, + ) + + async def test_collect_evidence_uploads_verifier_artifact(tmp_path, monkeypatch): rt = runtime(tmp_path) orch = credential("orch-1", "orchestrator", "orch-model") diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py b/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py index 3e67f43a3b..4cf436183a 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py @@ -124,6 +124,12 @@ def test_exports_only_public_directory_and_observed_channel_state(): assert evidence["task_name"] == "create-channel-invite-users" assert evidence["directory"] == [ - {"name": "benchmark-user-01", "role": "user", "pubkey": "d" * 64} + { + "identity_id": "benchmark-user-01", + "name": "benchmark-user-01", + "role": "user", + "pubkey": "d" * 64, + "about": "", + } ] assert evidence["observed_channels"] == channels diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py b/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py new file mode 100644 index 0000000000..225cb1d1fa --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py @@ -0,0 +1,238 @@ +"""Positive and adversarial fixtures for the expanded Buzz-native tasks.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +DATASET_ROOT = Path(__file__).resolve().parents[2] / "buzz-dataset" +AGENT = "a" * 64 +USER = "u" * 64 +CHANNEL = "channel" +ROOT = "root" + + +def _verifier(task: str) -> ModuleType: + path = DATASET_ROOT / task / "tests" / "verify.py" + spec = importlib.util.spec_from_file_location(f"{task}_verifier", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _message( + message_id: str, + content: str, + *, + reply_to: str | None = ROOT, + mentions: list[str] | None = None, + pubkey: str = AGENT, +) -> dict: + tags = [["h", CHANNEL]] + if reply_to is not None: + tags.append(["e", reply_to, "", "reply"]) + tags.extend(["p", value] for value in (mentions or [])) + return { + "id": message_id, + "pubkey": pubkey, + "content": content, + "tags": tags, + "channel_id": CHANNEL, + "reply_to_event_id": reply_to, + "mentioned_pubkeys": mentions or [], + } + + +def _base(task: str, user_name: str) -> dict: + return { + "schema_version": 1, + "task_name": task, + "task_event_id": ROOT, + "truncated": False, + "trial": {"channel_id": CHANNEL}, + "identities": { + "solo-1": {"role": "orchestrator", "pubkey": AGENT}, + user_name: {"role": "user", "pubkey": USER}, + }, + "directory": [], + "scripted_events": [], + "messages": [], + } + + +def test_multiline_message_preserves_layout_and_rejects_literal_escapes(): + verifier = _verifier("multiline-message") + evidence = _base("multiline-message", "Eleanor June Brooks") + evidence["messages"] = [_message("answer", verifier.EXPECTED, mentions=[USER])] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"][0]["content"] = verifier.EXPECTED.replace("\n", "\\n") + metrics, _ = verifier.score_evidence(evidence) + assert metrics["real_newlines"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_narrative_agent_names_do_not_wake_bots(): + verifier = _verifier("narrative-agent-names") + evidence = _base("narrative-agent-names", "Maya Elise Chen") + bot_a, bot_b = "b" * 64, "c" * 64 + evidence["directory"] = [ + {"name": "Aurora Audit Bot", "role": "bot", "pubkey": bot_a}, + {"name": "Beacon Deploy Bot", "role": "bot", "pubkey": bot_b}, + ] + content = "Aurora Audit Bot completed the audit.\nBeacon Deploy Bot remains idle." + evidence["messages"] = [_message("answer", content, mentions=[USER])] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"][0]["mentioned_pubkeys"].append(bot_a) + metrics, _ = verifier.score_evidence(evidence) + assert metrics["bots_not_mentioned"] == 0.0 + + evidence["messages"] = [ + _message("bad-wake", "@Aurora Audit Bot please check", mentions=[bot_a]), + _message("answer", content, mentions=[USER]), + ] + metrics, _ = verifier.score_evidence(evidence) + assert metrics["bots_not_mentioned"] == 0.0 + assert metrics["reward"] == 0.0 + + evidence["messages"] = [ + _message( + "answer", + "Aurora Audit Bot remains idle.\nBeacon Deploy Bot completed the audit.", + mentions=[USER], + ) + ] + metrics, _ = verifier.score_evidence(evidence) + assert metrics["status_correct"] == 0.0 + + +def test_interleaved_reports_require_all_inputs_and_one_callback(): + verifier = _verifier("interleaved-agent-reports") + evidence = _base("interleaved-agent-reports", "Nora Isabel Grant") + reporters = [ + ("Ledger Scout", "b" * 64), + ("Risk Sentinel", "c" * 64), + ("Ops Forecaster", "d" * 64), + ] + evidence["directory"] = [ + {"name": name, "role": "bot", "pubkey": pubkey} for name, pubkey in reporters + ] + labels = ("ledger-report", "risk-report", "operations-report") + evidence["scripted_events"] = [ + {"label": label, "event_id": f"report-{index}"} + for index, label in enumerate(labels, start=1) + ] + evidence["messages"] = [ + _message(f"report-{index}", label, pubkey=reporters[index - 1][1]) + for index, label in enumerate(labels, start=1) + ] + [ + _message( + "answer", + ( + "- **North:** 17\n- **South:** 23\n- **reserve:** 11\n" + "- **multiplier:** 3\n\n(17 + 23 − 11) × 3 = **87**" + ), + mentions=[USER], + ) + ] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"][-1]["content"] = "North 17; final forecast 87." + metrics, _ = verifier.score_evidence(evidence) + assert metrics["inputs_complete"] == 0.0 + + evidence["messages"][-1]["content"] = ( + "North 17; South 23; reserve 11; multiplier 3; final forecast 87." + ) + evidence["messages"].insert( + -1, + _message("bad-wake", "@Ledger Scout thanks", mentions=[reporters[0][1]]), + ) + metrics, _ = verifier.score_evidence(evidence) + assert metrics["reporters_not_rementioned"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_cross_thread_requests_require_two_isolated_replies(): + verifier = _verifier("cross-thread-requests") + evidence = _base("cross-thread-requests", "Priya Simone Patel") + evidence["scripted_events"] = [{"label": "beta-request", "event_id": "beta-root"}] + evidence["messages"] = [ + _message("alpha-answer", "ALPHA result: 346", mentions=[USER]), + _message( + "beta-answer", + "BETA result: 41", + reply_to="beta-root", + mentions=[USER], + ), + ] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"] = [ + _message( + "combined", "ALPHA 346; BETA 41", reply_to="beta-root", mentions=[USER] + ) + ] + metrics, _ = verifier.score_evidence(evidence) + assert metrics["thread_isolation"] == 0.0 + assert metrics["reward"] == 0.0 + + evidence["messages"] = [ + _message("alpha-answer", "ALPHA result: 346", mentions=[USER]), + _message("extra", "Both requests handled", mentions=[USER]), + _message( + "beta-answer", + "BETA result: 41", + reply_to="beta-root", + mentions=[USER], + ), + ] + metrics, _ = verifier.score_evidence(evidence) + assert metrics["thread_isolation"] == 0.0 + + +def test_ambiguous_user_mention_targets_only_profile_match(): + verifier = _verifier("ambiguous-user-mention") + evidence = _base("ambiguous-user-mention", "Olivia Grace Park") + target, other = "b" * 64, "c" * 64 + evidence["directory"] = [ + { + "identity_id": "taylor-release-captain", + "name": "Taylor Morgan Lee", + "role": "user", + "pubkey": target, + }, + { + "identity_id": "taylor-observer", + "name": "Taylor Morgan Lee", + "role": "user", + "pubkey": other, + }, + ] + evidence["messages"] = [ + _message( + "delivery", "@Taylor Morgan Lee ORCHID-72 approved", mentions=[target] + ), + _message( + "callback", "Sent to the matching Taylor Morgan Lee.", mentions=[USER] + ), + ] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"][0]["mentioned_pubkeys"].append(other) + metrics, _ = verifier.score_evidence(evidence) + assert metrics["other_not_notified"] == 0.0 + assert metrics["reward"] == 0.0 diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2c173646ba..f3916b5fda 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -2619,12 +2619,14 @@ pub async fn run_prompt_task( "control signal arrived but turn already completed — treating as success" ); } + log_stop_reason(&source, &StopReason::EndTurn); if let PromptSource::Channel(cid) = &source { let standing_sent = !agent.has_system_prompt_support(); - agent.state.mark_channel_delivery_success( + record_channel_delivery_success( + &mut agent, *cid, standing_sent, - pending_delivered_event_ids.iter().cloned(), + &pending_delivered_event_ids, ); } apply_completed_before_control_signal( @@ -2663,10 +2665,11 @@ pub async fn run_prompt_task( if let PromptSource::Channel(cid) = &source { let standing_sent = !agent.has_system_prompt_support(); - agent.state.mark_channel_delivery_success( + record_channel_delivery_success( + &mut agent, *cid, standing_sent, - pending_delivered_event_ids.iter().cloned(), + &pending_delivered_event_ids, ); } else if !agent.has_system_prompt_support() { agent.state.heartbeat_standing_context_sent = true; @@ -4085,6 +4088,33 @@ fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { } } +fn delivery_receipt_line(channel_id: Uuid, event_ids: &HashSet) -> String { + let mut event_ids: Vec<&str> = event_ids.iter().map(String::as_str).collect(); + event_ids.sort_unstable(); + format!( + "turn delivered Buzz events for channel {channel_id}: {}", + event_ids.join(",") + ) +} + +fn record_channel_delivery_success( + agent: &mut OwnedAgent, + channel_id: Uuid, + standing_context_sent: bool, + event_ids: &HashSet, +) { + tracing::info!( + target: "pool::prompt", + "{}", + delivery_receipt_line(channel_id, event_ids) + ); + agent.state.mark_channel_delivery_success( + channel_id, + standing_context_sent, + event_ids.iter().cloned(), + ); +} + // // Two-phase lifecycle visible to users: // 👀 "seen" — event was queued and an agent will handle it @@ -4709,6 +4739,17 @@ mod tests { } } + #[test] + fn delivery_receipt_line_sorts_event_ids() { + let channel_id = Uuid::nil(); + let event_ids = HashSet::from(["beta".to_string(), "alpha".to_string()]); + + assert_eq!( + delivery_receipt_line(channel_id, &event_ids), + format!("turn delivered Buzz events for channel {channel_id}: alpha,beta") + ); + } + // MINOR (#2884): the permission-mode RPC is gated on agent_supports_mode. // An advertised mode issues set_config_option; an absent one is skipped so // the harness falls back to per-tool auto-approval. Pin both edges directly. diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index b0f0fa248e..b50f926d8b 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -875,48 +875,31 @@ pub struct ThreadTags { /// Parse NIP-10 thread tags from a Nostr event. /// -/// Detection logic (per research doc §4c): -/// - Find an `e` tag with `root` marker → its value is `root_event_id` -/// - Find an `e` tag with `reply` marker → its value is `parent_event_id` -/// - If only `reply` marker found (direct reply to root), root == parent -/// - `p` tags → mentioned pubkeys +/// Marker parsing and the (root, reply) → (root, parent) collapse are delegated +/// to [`buzz_core::nip10`] so ACP anchoring reads ancestry exactly as relay +/// ingest does. Only `p`-tag mention collection is local to ACP. /// -/// NOTE: Only handles NIP-10 marker-based format (preferred). The deprecated -/// positional format (no markers, `["e", id, relay_url]`) is not supported — -/// Buzz always generates marker-based tags (see relay messages.rs:762-783). +/// Consequences of sharing the resolver: +/// - A malformed (non-64-hex) marker id is ignored, never a thread link — +/// restoring parity with ingest (ACP previously counted it). +/// - A lone `root` marker (no `reply`) is top-level, not a reply — again +/// matching ingest. pub fn parse_thread_tags(event: &Event) -> ThreadTags { - let mut root = None; - let mut reply = None; - let mut mentions = Vec::new(); - - for tag in event.tags.iter() { - let parts = tag.as_slice(); - match parts.first().map(|s| s.as_str()) { - Some("e") if parts.len() >= 4 => { - let id = &parts[1]; - let marker = &parts[3]; - match marker.as_str() { - "root" => root = Some(id.clone()), - "reply" => reply = Some(id.clone()), - _ => {} - } - } - Some("p") if parts.len() >= 2 => { - mentions.push(parts[1].clone()); - } - _ => {} - } - } - - // For direct replies to root: single "reply" tag, no "root" tag. - // In that case, root == parent. - let (root_event_id, parent_event_id) = match (root, reply) { - (Some(r), Some(p)) => (Some(r), Some(p)), - (Some(r), None) => (Some(r.clone()), Some(r)), - (None, Some(p)) => (Some(p.clone()), Some(p)), - (None, None) => (None, None), + let markers = buzz_core::nip10::parse_thread_markers(&event.tags); + let (root_event_id, parent_event_id) = match markers.resolve() { + Some((root, parent)) => (Some(root), Some(parent)), + None => (None, None), }; + let mentions = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0] == "p").then(|| parts[1].clone()) + }) + .collect(); + ThreadTags { root_event_id, parent_event_id, @@ -3192,28 +3175,31 @@ mod tests { #[test] fn test_parse_thread_tags_direct_reply() { // Direct reply to root: single "reply" tag. + let root = "a".repeat(64); let event = make_event_with_tags( "reply to root", - vec![vec!["e".into(), "abc123".into(), "".into(), "reply".into()]], + vec![vec!["e".into(), root.clone(), "".into(), "reply".into()]], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("abc123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("abc123")); + assert_eq!(tags.root_event_id.as_deref(), Some(root.as_str())); + assert_eq!(tags.parent_event_id.as_deref(), Some(root.as_str())); } #[test] fn test_parse_thread_tags_nested_reply() { // Nested reply: root + reply tags. + let root = "a".repeat(64); + let parent = "b".repeat(64); let event = make_event_with_tags( "nested reply", vec![ - vec!["e".into(), "root123".into(), "".into(), "root".into()], - vec!["e".into(), "parent456".into(), "".into(), "reply".into()], + vec!["e".into(), root.clone(), "".into(), "root".into()], + vec!["e".into(), parent.clone(), "".into(), "reply".into()], ], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("root123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("parent456")); + assert_eq!(tags.root_event_id.as_deref(), Some(root.as_str())); + assert_eq!(tags.parent_event_id.as_deref(), Some(parent.as_str())); } #[test] @@ -3231,15 +3217,36 @@ mod tests { } #[test] - fn test_parse_thread_tags_root_only() { - // Only root marker, no reply marker — root == parent. + fn test_parse_thread_tags_root_only_is_top_level() { + // Only a `root` marker, no `reply` — top-level, matching ingest. A lone + // `root` tag does not anchor a reply (behavior change from the old + // hand-rolled parser, which treated root == parent here). + let root = "a".repeat(64); let event = make_event_with_tags( - "reply", - vec![vec!["e".into(), "root123".into(), "".into(), "root".into()]], + "root only", + vec![vec!["e".into(), root, "".into(), "root".into()]], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("root123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("root123")); + assert!(tags.root_event_id.is_none()); + assert!(tags.parent_event_id.is_none()); + } + + #[test] + fn test_parse_thread_tags_malformed_id_is_not_a_thread_link() { + // A non-64-hex marker id is ignored — parity with relay ingest, which + // never treats a malformed id as a thread link. + let event = make_event_with_tags( + "malformed marker", + vec![vec![ + "e".into(), + "garbage".into(), + "".into(), + "reply".into(), + ]], + ); + let tags = parse_thread_tags(&event); + assert!(tags.root_event_id.is_none()); + assert!(tags.parent_event_id.is_none()); } #[test] @@ -3312,7 +3319,7 @@ mod tests { "yes go ahead", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], @@ -3330,7 +3337,9 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!(prompt.contains("Scope: thread")); - assert!(prompt.contains("Thread root: root123")); + assert!(prompt.contains( + "Thread root: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )); } #[test] @@ -3340,7 +3349,7 @@ mod tests { "yes go ahead", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], @@ -3645,7 +3654,7 @@ mod tests { "sounds good, do it", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], @@ -3698,7 +3707,9 @@ mod tests { ); // Thread structural info should be present. assert!( - prompt.contains("Thread root: root123"), + prompt.contains( + "Thread root: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ), "DM reply should include thread root" ); // Thread context should be included. @@ -3712,7 +3723,7 @@ mod tests { "follow up", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], @@ -5310,7 +5321,7 @@ mod tests { "reply in thread", vec![vec![ "e".into(), - "root123".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), "".into(), "reply".into(), ]], diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 5651c0b4c7..ea273336e3 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -14,36 +14,45 @@ use buzz_sdk::mentions::{ /// Extract the thread root event ID from a Nostr tag array. /// -/// Parses `"e"` tags with NIP-10 markers: -/// - If a `"root"` marker exists, returns that event ID. -/// - Otherwise, if only a `"reply"` marker exists, returns the reply target -/// (a direct reply's parent IS the root, and nested replies need that root -/// to thread correctly). -/// - If no thread markers exist, returns `None` (parent is a top-level message, -/// so it is itself the root). +/// Delegates marker parsing and collapse to [`buzz_core::nip10`] (shared with +/// relay ingest and ACP) so id-validity, marker selection, and top-level +/// classification cannot drift: +/// - A `root`+`reply` parent returns its root event ID. +/// - A `reply`-only parent returns the reply target (a direct reply's parent IS +/// the root). +/// - A root-only or marker-less parent returns `None` (it is top-level and its +/// own root). fn find_root_from_tags(tags: &serde_json::Value) -> Option { - fn valid_event_id(s: &str) -> bool { - s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) - } - let arr = tags.as_array()?; - let mut root = None; - let mut reply = None; - for tag in arr { - let Some(parts) = tag.as_array() else { - continue; - }; - if parts.len() >= 4 && parts[0].as_str() == Some("e") { - // Defensively ignore malformed marker values so a bad tag on the - // parent event can't block the reply — fall back to root == parent. - let id = parts[1].as_str().filter(|s| valid_event_id(s)); - match (parts[3].as_str(), id) { - (Some("root"), Some(id)) => root = Some(id.to_string()), - (Some("reply"), Some(id)) => reply = Some(id.to_string()), - _ => {} - } - } - } - root.or(reply) + let parts: Vec> = tags + .as_array()? + .iter() + .filter_map(|tag| { + tag.as_array().map(|a| { + a.iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + }) + .collect(); + buzz_core::nip10::parse_thread_markers_from_parts(parts.iter().map(Vec::as_slice)) + .resolve() + .map(|(root, _)| root) +} + +fn thread_ref_from_parent_tags( + parent_eid: nostr::EventId, + parent_event_id: &str, + tags: &serde_json::Value, +) -> Result { + let root_eid = match find_root_from_tags(tags) { + Some(root_hex) if root_hex != parent_event_id => parse_event_id(&root_hex)?, + _ => parent_eid, + }; + + Ok(ThreadRef { + root_event_id: root_eid, + parent_event_id: parent_eid, + }) } /// Build a `ThreadRef` for a reply, given the immediate parent's event ID. @@ -80,14 +89,7 @@ fn thread_ref_from_event(event_id: &str, event: &serde_json::Value) -> Result parse_event_id(&root_hex)?, - _ => parent_eid, - }; - Ok(ThreadRef { - root_event_id, - parent_event_id: parent_eid, - }) + thread_ref_from_parent_tags(parent_eid, event_id, &tags) } /// Resolve the channel UUID for an event by querying for it via POST /query. @@ -1057,7 +1059,8 @@ mod tests { channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members, normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, - resolve_thread_target, thread_ref_from_event, BuzzClient, CliError, Uuid, + resolve_thread_target, thread_ref_from_event, thread_ref_from_parent_tags, BuzzClient, + CliError, Uuid, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1132,7 +1135,7 @@ mod tests { let channel = "123e4567-e89b-12d3-a456-426614174000"; let other_channel = "123e4567-e89b-12d3-a456-426614174001"; let selected = json!({ - "tags": [["h", channel], ["e", ID_A, "", "root"]] + "tags": [["h", channel], ["e", ID_A, "", "root"], ["e", ID_B, "", "reply"]] }); assert!(resolve_thread_target( @@ -1171,6 +1174,23 @@ mod tests { assert_eq!(find_root_from_tags(&tags).as_deref(), Some(ID_A)); } + #[test] + fn root_marker_without_reply_is_top_level() { + let tags = json!([["e", ID_A, "", "root"], ["p", PUBKEY],]); + assert!(find_root_from_tags(&tags).is_none()); + } + + #[test] + fn root_only_parent_starts_cli_reply_thread_at_parent() { + let tags = json!([["e", ID_A, "", "root"]]); + let parent = nostr::EventId::from_hex(ID_B).expect("valid parent id"); + + let thread_ref = thread_ref_from_parent_tags(parent, ID_B, &tags).expect("thread ref"); + + assert_eq!(thread_ref.parent_event_id, parent); + assert_eq!(thread_ref.root_event_id, parent); + } + #[test] fn reply_only_falls_back_to_reply_target() { // Direct reply to a top-level message — the parent's only e-tag is a @@ -1194,14 +1214,16 @@ mod tests { } #[test] - fn malformed_tags_are_skipped() { + fn malformed_tags_are_skipped_and_root_only_is_top_level() { + // Invalid entries are ignored, leaving a valid root-only marker; the + // shared collapse rule still classifies that parent as top-level. let tags = json!([ "not-an-array", ["e"], ["e", "short"], ["e", ID_A, "", "root"], ]); - assert_eq!(find_root_from_tags(&tags).as_deref(), Some(ID_A)); + assert!(find_root_from_tags(&tags).is_none()); } #[test] diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7424915c83..36dc772da3 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -26,6 +26,8 @@ pub mod invite; pub mod kind; /// Network utilities — SSRF-safe IP classification. pub mod network; +/// NIP-10 thread-marker parsing — shared `root`/`reply` marker resolver. +pub mod nip10; /// Agent observer frame helpers. pub mod observer; /// NIP-AB device pairing — crypto primitives, message types, and errors. diff --git a/crates/buzz-core/src/nip10.rs b/crates/buzz-core/src/nip10.rs new file mode 100644 index 0000000000..993515f442 --- /dev/null +++ b/crates/buzz-core/src/nip10.rs @@ -0,0 +1,197 @@ +//! Shared NIP-10 thread-marker parsing. +//! +//! One parser for the `root`/`reply` markers on an event's `e` tags, so every +//! consumer reads ancestry the same way. The relay ingest resolver +//! (`resolve_nip10_thread_meta`) and the workflow `trigger_is_reply` predicate +//! both call this — a second hand-rolled copy is exactly how the two drifted on +//! marker semantics and on id-validity. +//! +//! Validity mirrors ingest: a marker counts only when its event id is exactly +//! 64 ASCII-hex characters. A malformed id (e.g. `["e","bad","","reply"]`) is +//! ignored, never treated as a thread link. + +/// The `root` and `reply` event ids parsed from an event's NIP-10 `e` tags. +/// +/// Each is `Some(id_hex)` only when a marker of that kind carried a valid +/// 64-hex event id. The last valid occurrence of each marker wins, matching +/// the relay resolver's single-pass overwrite. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ThreadMarkers { + /// Event id from a valid `["e", <64-hex>, , "root"]` tag. + pub root: Option, + /// Event id from a valid `["e", <64-hex>, , "reply"]` tag. + pub reply: Option, +} + +impl ThreadMarkers { + /// Collapse the `root`/`reply` markers into a reply's `(root_id, parent_id)`. + /// + /// This is the single definition of the NIP-10 resolution rule shared by + /// consumers that classify a reply's own root/parent or recover a parent's + /// ancestry (relay ingest, ACP anchoring, and the CLI). + /// + /// - `root` + `reply` → `(root, reply)` — a nested reply names both. + /// - `reply` only → `(reply, reply)` — a direct reply to the root; the + /// reply target is itself the thread root. + /// - `root` only or neither → `None` — no `reply` marker means the event is + /// top-level, matching ingest (a lone `root` tag never anchors a reply). + pub fn resolve(&self) -> Option<(String, String)> { + match (&self.root, &self.reply) { + (Some(root), Some(reply)) => Some((root.clone(), reply.clone())), + (None, Some(reply)) => Some((reply.clone(), reply.clone())), + (Some(_), None) | (None, None) => None, + } + } +} + +/// Return true when `id` is exactly 64 ASCII-hex characters — the shape a +/// Nostr event id must have to be a real thread link. +fn is_event_id_hex(id: &str) -> bool { + id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Parse the NIP-10 `root`/`reply` markers from an event's tags. +/// +/// Only `e` tags with a marker (`parts.len() >= 4`) and a valid 64-hex event id +/// are considered; everything else is ignored. +pub fn parse_thread_markers(tags: &nostr::Tags) -> ThreadMarkers { + parse_thread_markers_from_parts(tags.iter().map(nostr::Tag::as_slice)) +} + +/// Same parser as [`parse_thread_markers`], for consumers that hold raw tag +/// arrays (e.g. decoded JSON `tags`) rather than a [`nostr::Tags`]. +/// +/// Each tag is a slice of string-like parts (`["e", , , ]`). +pub fn parse_thread_markers_from_parts<'a, S, I>(tags: I) -> ThreadMarkers +where + S: AsRef + 'a, + I: IntoIterator, +{ + let mut markers = ThreadMarkers::default(); + for parts in tags { + if parts.len() >= 4 && parts[0].as_ref() == "e" && is_event_id_hex(parts[1].as_ref()) { + match parts[3].as_ref() { + "root" => markers.root = Some(parts[1].as_ref().to_string()), + "reply" => markers.reply = Some(parts[1].as_ref().to_string()), + _ => {} + } + } + } + markers +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn markers_for(tags: Vec) -> ThreadMarkers { + let event = EventBuilder::new(Kind::Custom(9), "") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign"); + parse_thread_markers(&event.tags) + } + + fn id() -> String { + "a".repeat(64) + } + + #[test] + fn no_e_tags_yields_no_markers() { + assert_eq!(markers_for(vec![]), ThreadMarkers::default()); + } + + #[test] + fn root_and_reply_both_parsed() { + let m = markers_for(vec![ + Tag::parse(["e", &id(), "", "root"]).unwrap(), + Tag::parse(["e", &"b".repeat(64), "", "reply"]).unwrap(), + ]); + assert_eq!(m.root.as_deref(), Some(id().as_str())); + assert_eq!(m.reply.as_deref(), Some("b".repeat(64).as_str())); + } + + #[test] + fn reply_only_marker_parsed() { + let m = markers_for(vec![Tag::parse(["e", &id(), "", "reply"]).unwrap()]); + assert_eq!(m.reply.as_deref(), Some(id().as_str())); + assert!(m.root.is_none()); + } + + #[test] + fn bare_e_tag_without_marker_is_ignored() { + let m = markers_for(vec![Tag::parse(["e", &id()]).unwrap()]); + assert_eq!(m, ThreadMarkers::default()); + } + + #[test] + fn malformed_id_is_ignored_for_both_markers() { + // Ingest gates the marker on a valid 64-hex id; a malformed id is not a + // thread link, so neither marker is set. + let m = markers_for(vec![ + Tag::parse(["e", "bad", "", "reply"]).unwrap(), + Tag::parse(["e", "also-bad", "", "root"]).unwrap(), + ]); + assert_eq!(m, ThreadMarkers::default()); + } + + #[test] + fn valid_root_with_malformed_reply_is_top_level() { + // A valid root but a malformed reply id: reply is ignored, so this is + // top-level to ingest (root-only) and must be so here too. + let m = markers_for(vec![ + Tag::parse(["e", &id(), "", "root"]).unwrap(), + Tag::parse(["e", "bad", "", "reply"]).unwrap(), + ]); + assert_eq!(m.root.as_deref(), Some(id().as_str())); + assert!(m.reply.is_none()); + } + + #[test] + fn resolve_root_and_reply_keeps_both() { + let m = ThreadMarkers { + root: Some("r".repeat(64)), + reply: Some("p".repeat(64)), + }; + assert_eq!(m.resolve(), Some(("r".repeat(64), "p".repeat(64)))); + } + + #[test] + fn resolve_reply_only_is_direct_reply_to_root() { + let m = ThreadMarkers { + root: None, + reply: Some(id()), + }; + assert_eq!(m.resolve(), Some((id(), id()))); + } + + #[test] + fn resolve_root_only_is_top_level() { + let m = ThreadMarkers { + root: Some(id()), + reply: None, + }; + assert_eq!(m.resolve(), None); + } + + #[test] + fn resolve_no_markers_is_top_level() { + assert_eq!(ThreadMarkers::default().resolve(), None); + } + + #[test] + fn parse_from_parts_matches_tags_path() { + // The slice-based entry point must gate id validity and select markers + // identically to the `nostr::Tags` path. + let tags: Vec> = vec![ + vec!["e".into(), id(), "".into(), "root".into()], + vec!["e".into(), "b".repeat(64), "".into(), "reply".into()], + vec!["e".into(), "bad".into(), "".into(), "reply".into()], + vec!["p".into(), "abc".into()], + ]; + let m = parse_thread_markers_from_parts(tags.iter().map(Vec::as_slice)); + assert_eq!(m.root.as_deref(), Some(id().as_str())); + assert_eq!(m.reply.as_deref(), Some("b".repeat(64).as_str())); + } +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 8035ab58ad..109a9367d7 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -347,6 +347,129 @@ pub async fn set_canvas( /// `buzz_channel_ttl:`. const CHANNEL_MEMBERSHIP_LOCK_NAMESPACE: &str = "buzz_channel_membership:"; +/// Verify that migration 0032's roster fence is active on the partitioned +/// `events` parent and every attached partition. +/// +/// New roster publishers depend on this database-side guard to serialize with +/// legacy publishers during a rolling deployment. If the migration has not +/// been applied, publishing with the new lock protocol would falsely appear +/// safe while an old pod could still overwrite it with stale membership. +pub async fn verify_channel_roster_fence_catalog<'e>( + executor: impl sqlx::PgExecutor<'e>, +) -> Result<()> { + // tgtype bits: 1 = ROW, 2 = BEFORE, 4 = INSERT, 16 = UPDATE, 64 = INSTEAD. + // Required: ROW + BEFORE + INSERT set; UPDATE + INSTEAD clear. + let missing: Vec = sqlx::query_scalar( + r#" + SELECT n.nspname || '.' || c.relname + FROM ( + SELECT 'public.events'::regclass AS oid + UNION ALL + SELECT inhrelid FROM pg_inherits WHERE inhparent = 'public.events'::regclass + ) rels + JOIN pg_class c ON c.oid = rels.oid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT EXISTS ( + SELECT 1 FROM pg_trigger t + WHERE t.tgrelid = rels.oid + AND t.tgname = 'trg_events_guard_channel_roster_snapshot' + AND t.tgfoid = to_regprocedure('public.guard_channel_roster_snapshot()') + AND t.tgenabled IN ('O', 'A') + AND t.tgtype & 1 = 1 -- row-level + AND t.tgtype & 2 = 2 -- BEFORE + AND t.tgtype & 4 = 4 -- fires on INSERT + AND t.tgtype & 16 = 0 -- not UPDATE + AND t.tgtype & 64 = 0 -- not INSTEAD OF + ) + "#, + ) + .fetch_all(executor) + .await?; + if !missing.is_empty() { + return Err(DbError::InvalidData(format!( + "channel roster fence trigger missing, disabled, or mis-shaped on: {}", + missing.join(", ") + ))); + } + Ok(()) +} + +/// Prove migration 0032's roster fence semantics through the live writer pool. +/// +/// The catalog check cannot detect a no-op or otherwise corrupted trigger +/// function. This rolled-back probe verifies that a canonical empty roster is +/// accepted while a stale roster member is rejected with `check_violation`. +pub async fn verify_channel_roster_fence_behavior(pool: &sqlx::PgPool) -> Result<()> { + let mut tx = pool.begin().await?; + let community_id = Uuid::new_v4(); + let channel_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "roster-fence-verify-{}.invalid", + community_id.simple() + )) + .execute(&mut *tx) + .await?; + + let insert = |id: Vec, tags: serde_json::Value| { + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ + VALUES ($1, $2, $3, NOW(), 39002, $4, '', $5, NOW(), $6, $7)", + ) + .bind(community_id) + .bind(id) + .bind(vec![0u8; 32]) + .bind(tags) + .bind(vec![0u8; 64]) + .bind(channel_id) + .bind(channel_id.to_string()) + }; + + insert( + vec![0u8; 32], + serde_json::json!([["d", channel_id.to_string()]]), + ) + .execute(&mut *tx) + .await + .map_err(|error| { + DbError::InvalidData(format!( + "channel roster fence rejected a canonical probe roster: {error}" + )) + })?; + + sqlx::query("SAVEPOINT roster_fence_probe") + .execute(&mut *tx) + .await?; + let stale = insert( + vec![1u8; 32], + serde_json::json!([ + ["d", channel_id.to_string()], + ["p", hex::encode([2u8; 32]), "", "member"] + ]), + ) + .execute(&mut *tx) + .await; + match stale { + Err(sqlx::Error::Database(error)) if error.code().as_deref() == Some("23514") => {} + Ok(_) => { + return Err(DbError::InvalidData( + "channel roster fence is inert: a stale probe roster was accepted".into(), + )); + } + Err(error) => { + return Err(DbError::InvalidData(format!( + "channel roster fence probe failed unexpectedly: {error}" + ))); + } + } + sqlx::query("ROLLBACK TO SAVEPOINT roster_fence_probe") + .execute(&mut *tx) + .await?; + tx.rollback().await?; + Ok(()) +} + /// Take the per-channel membership lock. MUST be the first statement in the /// transaction that then reads roles/owner counts and writes membership, so the /// whole check-then-write sequence is atomic against a concurrent one. @@ -366,6 +489,179 @@ async fn acquire_channel_membership_lock( Ok(()) } +/// An active member roster captured while holding the channel's membership +/// serialization lock on one writer connection. +pub struct LockedMemberSnapshot { + /// Canonical active members captured behind the lock. + pub members: Vec, + community_id: CommunityId, + channel_id: Uuid, + relay_pubkey: Vec, + tx: Transaction<'static, Postgres>, +} + +impl LockedMemberSnapshot { + /// Return the newest relay-authored member snapshot timestamp using this + /// guard's existing connection. + pub async fn latest_member_event_timestamp( + &mut self, + community_id: CommunityId, + channel_id: Uuid, + relay_pubkey: &[u8], + ) -> Result> { + let value: Option> = sqlx::query_scalar( + "SELECT created_at FROM events WHERE community_id = $1 AND kind = 39002 AND pubkey = $2 AND channel_id = $3 AND deleted_at IS NULL ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(relay_pubkey) + .bind(channel_id) + .fetch_optional(&mut *self.tx) + .await?; + Ok(value.map(|timestamp| timestamp.timestamp() as u64)) + } + + /// Replace the relay-authored member snapshot on this guard's existing + /// connection. The membership lock therefore spans capture and replacement + /// without a nested pool checkout. + pub async fn replace_member_event( + &mut self, + community_id: CommunityId, + channel_id: Uuid, + event: &nostr::Event, + ) -> Result<(buzz_core::StoredEvent, bool)> { + if community_id != self.community_id + || channel_id != self.channel_id + || event.pubkey.to_bytes().as_slice() != self.relay_pubkey.as_slice() + { + return Err(DbError::InvalidData( + "member snapshot replacement does not match its locked coordinate".into(), + )); + } + let kind = buzz_core::kind::event_kind_i32(event); + if kind != 39002 { + return Err(DbError::InvalidData( + "member snapshot replacement requires kind 39002".into(), + )); + } + let pubkey = event.pubkey.to_bytes(); + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( + "SELECT created_at, id FROM events WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id = $4 AND deleted_at IS NULL ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(kind) + .bind(pubkey.as_slice()) + .bind(channel_id) + .fetch_optional(&mut *self.tx) + .await?; + let incoming_id = event.id.as_bytes().as_slice(); + if let Some((existing_ts, existing_id)) = existing { + if created_at < existing_ts + || (created_at == existing_ts && incoming_id >= existing_id.as_slice()) + { + return Ok(( + buzz_core::StoredEvent::with_received_at( + event.clone(), + Utc::now(), + Some(channel_id), + false, + ), + false, + )); + } + } + sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id = $4 AND deleted_at IS NULL") + .bind(community_id.as_uuid()).bind(kind).bind(pubkey.as_slice()).bind(channel_id) + .execute(&mut *self.tx).await?; + let received_at = Utc::now(); + let tags = serde_json::to_value(&event.tags)?; + let sig = event.sig.serialize(); + let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING") + .bind(community_id.as_uuid()).bind(event.id.as_bytes().as_slice()) + .bind(pubkey.as_slice()).bind(created_at).bind(kind).bind(tags) + .bind(&event.content).bind(sig.as_slice()).bind(received_at).bind(channel_id) + .bind(crate::event::extract_d_tag(event)).execute(&mut *self.tx).await?; + if inserted.rows_affected() == 0 { + return Err(DbError::InvalidData( + "member snapshot event id already exists".into(), + )); + } + crate::insert_mentions_in_transaction(&mut self.tx, community_id, event, Some(channel_id)) + .await?; + Ok(( + buzz_core::StoredEvent::with_received_at( + event.clone(), + received_at, + Some(channel_id), + true, + ), + true, + )) + } + + /// Commit the replacement and release the membership lock. + pub async fn release(self) -> Result<()> { + self.tx.commit().await?; + Ok(()) + } +} + +/// Capture all active members while holding the same per-channel lock used by +/// membership writers. +/// +/// The returned guard must remain alive through publication. This prevents a +/// rolling relay from publishing an older roster after a concurrent add or +/// remove has committed and published newer membership state. +pub async fn lock_member_snapshot( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + relay_pubkey: &[u8], +) -> Result { + let mut tx = pool.begin().await?; + // Match the canonical replacement writer's lock order. Old binaries take + // this key before INSERT; migration 0032 then takes the membership key in + // the INSERT trigger. Taking both in that order avoids mixed-version + // duplicate heads without introducing a lock-order inversion. + let replacement_lock = crate::event_replacement_lock_key( + community_id, + 39002, + relay_pubkey, + Some(channel_id.as_bytes()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(replacement_lock) + .execute(&mut *tx) + .await?; + acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + let rows = sqlx::query( + r#" + SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at + FROM channel_members cm + JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL + WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.removed_at IS NULL + ORDER BY cm.joined_at ASC + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_all(&mut *tx) + .await?; + let members = rows + .into_iter() + .map(row_to_member_record) + .collect::>>()?; + Ok(LockedMemberSnapshot { + members, + community_id, + channel_id, + relay_pubkey: relay_pubkey.to_vec(), + tx, + }) +} + /// Add a member to a channel. /// /// Role enforcement: @@ -781,6 +1077,81 @@ pub async fn get_accessible_channel_ids( .collect() } +/// A large channel whose canonical active-member count may need its legacy +/// discovery snapshot repaired. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LargeChannelRoster { + /// Community that owns the channel. + pub community_id: CommunityId, + /// Canonical host for the owning community. + pub host: String, + /// Channel whose roster snapshot differs from canonical membership. + pub channel_id: Uuid, + /// Canonical active-member count. + pub member_count: i64, +} + +/// Returns active channels whose canonical roster exceeds `minimum_members`. +/// +/// This is an internal cross-community maintenance read. Callers must preserve +/// the returned community id when reading or rewriting discovery state. +pub async fn list_large_channel_rosters_needing_reconciliation( + pool: &PgPool, + minimum_members: i64, + relay_pubkey: &[u8], +) -> Result> { + let rows = sqlx::query( + r#" + WITH large_rosters AS ( + SELECT cm.community_id, cm.channel_id, COUNT(*) AS member_count + FROM channel_members cm + JOIN channels ch + ON ch.community_id = cm.community_id + AND ch.id = cm.channel_id + AND ch.deleted_at IS NULL + WHERE cm.removed_at IS NULL + GROUP BY cm.community_id, cm.channel_id + HAVING COUNT(*) > $1 + ) + SELECT lr.community_id, community.host, lr.channel_id, lr.member_count + FROM large_rosters lr + JOIN communities community ON community.id = lr.community_id + JOIN LATERAL ( + SELECT roster.tags + FROM events roster + WHERE roster.community_id = lr.community_id + AND roster.channel_id = lr.channel_id + AND roster.kind = 39002 + AND roster.pubkey = $2 + AND roster.deleted_at IS NULL + ORDER BY roster.created_at DESC, roster.id ASC + LIMIT 1 + ) live_roster ON true + WHERE lr.member_count <> ( + SELECT COUNT(*) + FROM jsonb_array_elements(live_roster.tags) tag + WHERE tag->>0 = 'p' + ) + ORDER BY lr.community_id, lr.channel_id + "#, + ) + .bind(minimum_members) + .bind(relay_pubkey) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|row| { + Ok(LargeChannelRoster { + community_id: CommunityId::from_uuid(row.try_get("community_id")?), + host: row.try_get("host")?, + channel_id: row.try_get("channel_id")?, + member_count: row.try_get("member_count")?, + }) + }) + .collect() +} + /// Lists channels in a community, optionally filtered by visibility string. pub async fn list_channels( pool: &PgPool, @@ -1535,6 +1906,7 @@ mod tests { use super::*; use crate::user::{ensure_user, set_agent_owner}; use nostr::Keys; + use sqlx::postgres::PgPoolOptions; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials @@ -2016,6 +2388,188 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn large_roster_reconciliation_candidates_respect_snapshot_count_and_signer() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let creator = random_pubkey(); + let relay_pubkey = random_pubkey(); + let other_relay_pubkey = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "stale-large-roster", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &creator, + None, + ) + .await + .expect("create test channel"); + + let extra_members = 1_500; + sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at) + SELECT $1, $2, decode(lpad(to_hex(n), 64, '0'), 'hex'), 'member', + NOW() + (n || ' seconds')::interval + FROM generate_series(1, $3) n + "#, + ) + .bind(community_id) + .bind(channel.id) + .bind(extra_members) + .execute(&pool) + .await + .expect("insert large roster"); + + let stale_tags: Vec = + std::iter::once(serde_json::json!(["d", channel.id.to_string()])) + .chain((0..1_000).map(|n| serde_json::json!(["p", format!("{n:064x}")]))) + .collect(); + let complete_tags: Vec = + std::iter::once(serde_json::json!(["d", channel.id.to_string()])) + .chain((0..1_501).map(|n| serde_json::json!(["p", format!("{n:064x}")]))) + .collect(); + + // Insert canonical-looking history first, then corrupt the newest row + // with UPDATE to model a stale snapshot that predates migration 0032's + // INSERT fence. New stale snapshots cannot be inserted once that fence + // is deployed. + sqlx::query( + r#" + INSERT INTO events + (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id, d_tag) + VALUES + ($1, $2, $3, NOW() - INTERVAL '1 minute', 39002, $4, '', $5, $6, $7), + ($1, $8, $3, NOW(), 39002, $4, '', $5, $6, $7) + "#, + ) + .bind(community_id) + .bind(random_pubkey()) + .bind(&relay_pubkey) + .bind(serde_json::Value::Array(complete_tags.clone())) + .bind(vec![0u8; 64]) + .bind(channel.id) + .bind(channel.id.to_string()) + .bind(random_pubkey()) + .execute(&pool) + .await + .expect("insert historical duplicate snapshots"); + sqlx::query( + "UPDATE events SET tags = $1 WHERE community_id = $2 AND channel_id = $3 \ + AND kind = 39002 AND pubkey = $4 AND created_at = (SELECT MAX(created_at) \ + FROM events WHERE community_id = $2 AND channel_id = $3 AND kind = 39002 AND pubkey = $4)", + ) + .bind(serde_json::Value::Array(stale_tags)) + .bind(community_id) + .bind(channel.id) + .bind(&relay_pubkey) + .execute(&pool) + .await + .expect("simulate pre-fence stale live snapshot"); + + // The same channel UUID in another tenant is deliberately valid. A + // complete snapshot there must not mask this tenant's stale head. + let other_community_id = make_test_community(&pool).await; + sqlx::query( + r#" + INSERT INTO channels + (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'same-id-complete-roster', 'stream', 'open', $3) + "#, + ) + .bind(channel.id) + .bind(other_community_id) + .bind(&creator) + .execute(&pool) + .await + .expect("insert same channel id in other tenant"); + sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at) + SELECT $1, $2, decode(lpad(to_hex(n), 64, '0'), 'hex'), 'member', + NOW() + (n || ' seconds')::interval + FROM generate_series(0, 1500) n + "#, + ) + .bind(other_community_id) + .bind(channel.id) + .execute(&pool) + .await + .expect("insert complete other-tenant roster"); + sqlx::query( + r#" + INSERT INTO events + (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id, d_tag) + VALUES ($1, $2, $3, NOW(), 39002, $4, '', $5, $6, $7) + "#, + ) + .bind(other_community_id) + .bind(random_pubkey()) + .bind(&relay_pubkey) + .bind(serde_json::Value::Array(complete_tags.clone())) + .bind(vec![0u8; 64]) + .bind(channel.id) + .bind(channel.id.to_string()) + .execute(&pool) + .await + .expect("insert complete other-tenant snapshot"); + + // Put the stale channel behind the 1,000 newest channels that the old + // list_channels-based sweep could see. This set-based scan has no such + // pagination ceiling. + sqlx::query( + r#" + INSERT INTO channels + (id, community_id, name, channel_type, visibility, created_by, created_at) + SELECT gen_random_uuid(), $1, 'newer-decoy-' || n, 'stream', 'open', $2, + NOW() + (n || ' seconds')::interval + FROM generate_series(1, 1000) n + "#, + ) + .bind(community_id) + .bind(&creator) + .execute(&pool) + .await + .expect("insert channels beyond old list ceiling"); + + let candidates = + list_large_channel_rosters_needing_reconciliation(&pool, 1_000, &relay_pubkey) + .await + .expect("find stale snapshot"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].community_id, community); + assert_eq!(candidates[0].channel_id, channel.id); + assert_eq!(candidates[0].member_count, 1_501); + + let other_signer_candidates = + list_large_channel_rosters_needing_reconciliation(&pool, 1_000, &other_relay_pubkey) + .await + .expect("other signer is isolated from relay-authored snapshot"); + assert!(other_signer_candidates.is_empty()); + + sqlx::query( + "UPDATE events SET tags = $1, created_at = NOW() + INTERVAL '1 minute' WHERE community_id = $2 AND channel_id = $3 AND kind = 39002 AND pubkey = $4 AND created_at = (SELECT MAX(created_at) FROM events WHERE community_id = $2 AND channel_id = $3 AND kind = 39002 AND pubkey = $4 AND deleted_at IS NULL)", + ) + .bind(serde_json::Value::Array(complete_tags)) + .bind(community_id) + .bind(channel.id) + .bind(&relay_pubkey) + .execute(&pool) + .await + .expect("complete snapshot"); + + let converged = + list_large_channel_rosters_needing_reconciliation(&pool, 1_000, &relay_pubkey) + .await + .expect("check converged snapshot"); + assert!(converged.is_empty()); + } + /// A random non-admin, non-owner user cannot remove someone else's bot. #[tokio::test] #[ignore = "requires Postgres"] @@ -2499,6 +3053,96 @@ mod tests { (community, channel.id, owner_a, owner_b) } + /// A captured roster holds the same lock as membership writers until the + /// publisher explicitly releases it. This is the freshness fence used by + /// rolling-deploy reconciliation. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn locked_member_snapshot_blocks_post_capture_membership_mutation() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let newcomer = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "snapshot-freshness-fence", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + + let snapshot_pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect(TEST_DB_URL) + .await + .expect("connect one-connection pool"); + let relay_keys = Keys::generate(); + let mut snapshot = lock_member_snapshot( + &snapshot_pool, + community, + channel.id, + &relay_keys.public_key().to_bytes(), + ) + .await + .expect("capture locked roster"); + assert_eq!(snapshot.members.len(), 1); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(39002), "") + .tags(vec![ + nostr::Tag::parse(["d", &channel.id.to_string()]).expect("d tag"), + nostr::Tag::parse(["p", &hex::encode(&owner)]).expect("p tag"), + ]) + .sign_with_keys(&relay_keys) + .expect("sign roster"); + let (_, inserted) = snapshot + .replace_member_event(community, channel.id, &event) + .await + .expect("replace roster on held connection"); + assert!(inserted); + + let mut contender = pool.begin().await.expect("begin membership writer"); + let acquired: bool = + sqlx::query_scalar("SELECT pg_try_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}", + community.as_uuid(), + channel.id + )) + .fetch_one(&mut *contender) + .await + .expect("try membership writer lock"); + assert!( + !acquired, + "membership mutation must wait until the captured roster is published" + ); + contender.rollback().await.expect("rollback contender"); + + snapshot.release().await.expect("release snapshot fence"); + add_member( + &pool, + community, + channel.id, + &newcomer, + MemberRole::Member, + None, + ) + .await + .expect("membership mutation after publication"); + assert_eq!( + get_members(&pool, community, channel.id) + .await + .expect("fresh roster") + .len(), + 2 + ); + } + /// The lock must be shared with `remove_member`: a demotion racing an owner /// removal goes through a separate count/update path, so both must serialize /// on the same key or they can jointly empty the owner set. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 330525d310..3ff230f950 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -68,7 +68,7 @@ use uuid::Uuid; use buzz_core::{CommunityId, StoredEvent}; -fn event_replacement_lock_key( +pub(crate) fn event_replacement_lock_key( community_id: CommunityId, kind: i32, pubkey: &[u8], @@ -2390,6 +2390,24 @@ impl Db { channel::set_canvas(&self.pool, community_id, channel_id, canvas).await } + /// Verify the mixed-version channel-roster database fence end to end. + #[datastore_span(name = "verify_channel_roster_fence", system = "postgresql")] + pub async fn verify_channel_roster_fence(&self) -> Result<()> { + channel::verify_channel_roster_fence_catalog(&self.pool).await?; + channel::verify_channel_roster_fence_behavior(&self.pool).await + } + + /// Capture the active roster while holding the membership-writer lock. + #[datastore_span(name = "lock_member_snapshot", system = "postgresql")] + pub async fn lock_member_snapshot( + &self, + community_id: CommunityId, + channel_id: Uuid, + relay_pubkey: &[u8], + ) -> Result { + channel::lock_member_snapshot(&self.pool, community_id, channel_id, relay_pubkey).await + } + /// Adds a member to a channel. #[datastore_span(name = "add_member", system = "postgresql")] pub async fn add_member( @@ -2476,6 +2494,24 @@ impl Db { channel::get_accessible_channel_ids(&self.pool, community_id, pubkey).await } + /// Returns large active-channel rosters whose relay-authored snapshots differ. + #[datastore_span( + name = "list_large_channel_rosters_needing_reconciliation", + system = "postgresql" + )] + pub async fn list_large_channel_rosters_needing_reconciliation( + &self, + minimum_members: i64, + relay_pubkey: &[u8], + ) -> Result> { + channel::list_large_channel_rosters_needing_reconciliation( + &self.pool, + minimum_members, + relay_pubkey, + ) + .await + } + /// Lists channels, optionally filtered by visibility. #[datastore_span(name = "list_channels", system = "postgresql")] pub async fn list_channels( @@ -5480,6 +5516,113 @@ mod tests { id } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unmigrated_roster_fence_blocks_startup_until_0032_is_applied() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, scratch_name) = + create_scratch_db_through(&admin, "roster_fence_unmigrated", Some(31)).await; + let db = Db::from_pool(pool.clone()); + + let error = db + .verify_channel_roster_fence() + .await + .expect_err("pre-0032 schema must block roster publishers"); + assert!( + error.to_string().contains("channel roster fence trigger"), + "startup gate must report the missing schema fence: {error}" + ); + let rows_before: i64 = sqlx::query_scalar("SELECT count(*) FROM events WHERE kind = 39002") + .fetch_one(&pool) + .await + .expect("count pre-migration rosters"); + assert_eq!( + rows_before, 0, + "failed startup gate must not publish a roster" + ); + + migration::run_migrations(&pool) + .await + .expect("apply migration 0032"); + db.verify_channel_roster_fence() + .await + .expect("0032 must open the startup gate"); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_roster_fence_behavior_verification_detects_inert_function() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, scratch_name) = create_scratch_db(&admin, "roster_fence_inert").await; + let db = Db::from_pool(pool.clone()); + + sqlx::raw_sql( + "CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot() \ + RETURNS TRIGGER AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql;", + ) + .execute(&pool) + .await + .expect("replace roster fence with inert body"); + let error = db + .verify_channel_roster_fence() + .await + .expect_err("inert roster fence must fail closed"); + assert!( + error + .to_string() + .contains("stale probe roster was accepted"), + "behavior probe must identify inert semantics: {error}" + ); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_roster_fence_catalog_verification_fails_closed() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, scratch_name) = create_scratch_db(&admin, "roster_fence_catalog").await; + let db = Db::from_pool(pool.clone()); + + db.verify_channel_roster_fence() + .await + .expect("migrated roster fence must verify"); + + let child: String = sqlx::query_scalar( + "SELECT n.nspname || '.' || c.relname \ + FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE i.inhparent = 'public.events'::regclass ORDER BY i.inhrelid LIMIT 1", + ) + .fetch_one(&pool) + .await + .expect("load event partition"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "ALTER TABLE {child} DISABLE TRIGGER trg_events_guard_channel_roster_snapshot" + ))) + .execute(&pool) + .await + .expect("disable partition roster trigger"); + let error = db + .verify_channel_roster_fence() + .await + .expect_err("disabled partition roster fence must fail closed"); + assert!( + error.to_string().contains(&child), + "verification must identify the unfenced partition: {error}" + ); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn addressable_replacement_rolls_back_when_mention_indexing_fails() { @@ -5493,13 +5636,14 @@ mod tests { let community_uuid = Uuid::new_v4(); let channel = Uuid::new_v4(); let keys = Keys::generate(); - seed_community_channel(&pool, community_uuid, channel, &keys).await; + let owner_keys = Keys::generate(); + seed_community_channel(&pool, community_uuid, channel, &owner_keys).await; let community = CommunityId::from_uuid(community_uuid); - let member = Keys::generate().public_key().to_hex(); + let member = owner_keys.public_key().to_hex(); let tags = || { vec![ Tag::parse(["d", channel.to_string().as_str()]).expect("d tag"), - Tag::parse(["p", member.as_str(), "", "member"]).expect("p tag"), + Tag::parse(["p", member.as_str(), "", "owner"]).expect("p tag"), ] }; let base = Timestamp::now().as_secs(); @@ -5561,6 +5705,238 @@ mod tests { drop_scratch_db(&admin, pool, &scratch_name).await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn stale_legacy_roster_cannot_replace_new_locked_snapshot() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (setup_pool, scratch_name) = create_scratch_db(&admin, "mixed_roster_writer").await; + let base_url = admin_url().await; + let slash = base_url.rfind('/').expect("database URL has path segment"); + let scratch_url = format!("{}/{}", &base_url[..slash], scratch_name); + let pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(1)) + .connect(&scratch_url) + .await + .expect("connect one-connection scratch pool"); + setup_pool.close().await; + let db = Db::from_pool(pool.clone()); + let community_uuid = Uuid::new_v4(); + let community = CommunityId::from_uuid(community_uuid); + let channel = Uuid::new_v4(); + let relay_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_bytes(); + seed_community_channel(&pool, community_uuid, channel, &owner_keys).await; + + // This is the old pod's unlocked capture A. It remains in process memory + // while a role-only canonical mutation advances and the new pod publishes B. + let base = Timestamp::now().as_secs(); + let roster = |members: &[(&[u8], &str)], timestamp| { + let tags = + std::iter::once(Tag::parse(["d", channel.to_string().as_str()]).expect("d tag")) + .chain(members.iter().map(|(member, role)| { + Tag::parse(["p", hex::encode(member).as_str(), "", *role]).expect("p tag") + })) + .collect::>(); + EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .custom_created_at(Timestamp::from(timestamp)) + .sign_with_keys(&relay_keys) + .expect("sign roster") + }; + + let newcomer = Keys::generate().public_key().to_bytes(); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $4)", + ) + .bind(community_uuid) + .bind(channel) + .bind(newcomer.as_slice()) + .bind(owner.as_slice()) + .execute(&pool) + .await + .expect("seed member before legacy capture"); + let stale_a = roster( + &[(owner.as_slice(), "owner"), (newcomer.as_slice(), "member")], + base + 2, + ); + + sqlx::query( + "UPDATE channel_members SET role = 'admin' \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_uuid) + .bind(channel) + .bind(newcomer.as_slice()) + .execute(&pool) + .await + .expect("commit newer canonical role"); + + let relay_pubkey = relay_keys.public_key().to_bytes(); + let mut snapshot = db + .lock_member_snapshot(community, channel, &relay_pubkey) + .await + .expect("new writer captures locked roster B"); + let fresh_b = roster( + &[(owner.as_slice(), "owner"), (newcomer.as_slice(), "admin")], + base + 1, + ); + assert!( + snapshot + .replace_member_event(community, channel, &fresh_b) + .await + .expect("new writer publishes B") + .1 + ); + snapshot + .release() + .await + .expect("commit B and release locks"); + + // The legacy canonical path takes the replacement key, soft-deletes B, + // then attempts its newer-timestamp stale A. Migration 0032 rejects the + // INSERT; transaction rollback must restore B. A one-connection pool + // proves the lock order does not turn this compatibility path into a + // self-deadlock. + let error = tokio::time::timeout( + Duration::from_secs(3), + db.replace_addressable_event(community, &stale_a, Some(channel)), + ) + .await + .expect("legacy replacement must not deadlock") + .expect_err("stale captured roster A must be rejected"); + assert!( + matches!( + error, + DbError::Sqlx(sqlx::Error::Database(ref db_error)) + if db_error.code().as_deref() == Some("23514") + ), + "expected roster fence check violation, got {error:?}" + ); + + let live_ids: Vec> = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \ + AND kind=39002 AND pubkey=$3 AND deleted_at IS NULL", + ) + .bind(community_uuid) + .bind(channel) + .bind(relay_pubkey.as_slice()) + .fetch_all(&pool) + .await + .expect("load live roster heads"); + assert_eq!(live_ids, vec![fresh_b.id.as_bytes().to_vec()]); + let stale_rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(community_uuid) + .bind(stale_a.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rejected stale roster"); + assert_eq!(stale_rows, 0, "stale roster insert must roll back"); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn desired_schema_rejects_stale_legacy_roster_role() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let scratch_name = format!("schema_roster_role_{}", Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE DATABASE {scratch_name}" + ))) + .execute(&admin) + .await + .expect("create desired-schema scratch db"); + let base_url = admin_url().await; + let slash = base_url.rfind('/').expect("database URL has path segment"); + let scratch_url = format!("{}/{}", &base_url[..slash], scratch_name); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(&scratch_url) + .await + .expect("connect desired-schema scratch db"); + sqlx::raw_sql(include_str!("../../../schema/schema.sql")) + .execute(&pool) + .await + .expect("apply desired-state schema"); + + let db = Db::from_pool(pool.clone()); + let community_uuid = Uuid::new_v4(); + let community = CommunityId::from_uuid(community_uuid); + let channel = Uuid::new_v4(); + let relay_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_bytes(); + seed_community_channel(&pool, community_uuid, channel, &owner_keys).await; + let member = Keys::generate().public_key().to_bytes(); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'admin', $4)", + ) + .bind(community_uuid) + .bind(channel) + .bind(member.as_slice()) + .bind(owner.as_slice()) + .execute(&pool) + .await + .expect("seed canonical admin"); + + let roster = |role: &str, timestamp| { + EventBuilder::new(Kind::Custom(39002), "") + .tags(vec![ + Tag::parse(["d", channel.to_string().as_str()]).expect("d tag"), + Tag::parse(["p", hex::encode(owner).as_str(), "", "owner"]) + .expect("owner p tag"), + Tag::parse(["p", hex::encode(member).as_str(), "", role]) + .expect("member p tag"), + ]) + .custom_created_at(Timestamp::from(timestamp)) + .sign_with_keys(&relay_keys) + .expect("sign roster") + }; + let base = Timestamp::now().as_secs(); + let fresh = roster("admin", base); + assert!( + db.replace_addressable_event(community, &fresh, Some(channel)) + .await + .expect("publish canonical role") + .1 + ); + let stale = roster("member", base + 1); + let error = db + .replace_addressable_event(community, &stale, Some(channel)) + .await + .expect_err("desired-state fence must reject stale role"); + assert!(matches!( + error, + DbError::Sqlx(sqlx::Error::Database(ref db_error)) + if db_error.code().as_deref() == Some("23514") + )); + let live_id: Vec = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \ + AND kind=39002 AND deleted_at IS NULL", + ) + .bind(community_uuid) + .bind(channel) + .fetch_one(&pool) + .await + .expect("load desired-state live roster"); + assert_eq!(live_id, fresh.id.as_bytes().to_vec()); + + drop_scratch_db(&admin, pool, &scratch_name).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn nip_rs_replacement_hard_deletes_payload_and_watermark_rejects_replay() { @@ -6917,9 +7293,12 @@ mod tests { std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) } - /// Create a fresh scratch database on the same server and run migrations. - /// Returns (pool, db_name); callers should `drop_scratch_db` when done. - async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) { + /// Create a fresh scratch database on the same server and optionally run migrations. + async fn create_scratch_db_through( + admin: &PgPool, + prefix: &str, + target: Option, + ) -> (PgPool, String) { let name = format!("{}_{}", prefix, Uuid::new_v4().simple()); sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) .execute(admin) @@ -6934,12 +7313,23 @@ mod tests { let pool = PgPool::connect(&scratch_url) .await .expect("connect scratch db"); - migration::run_migrations(&pool) - .await - .expect("migrate scratch db"); + match target { + Some(target) => migration::run_migrations_through(&pool, target) + .await + .expect("migrate scratch db through target"), + None => migration::run_migrations(&pool) + .await + .expect("migrate scratch db"), + } (pool, name) } + /// Create a fresh scratch database on the same server and run all migrations. + /// Returns (pool, db_name); callers should `drop_scratch_db` when done. + async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) { + create_scratch_db_through(admin, prefix, None).await + } + async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { pool.close().await; let _ = sqlx::query(sqlx::AssertSqlSafe(format!( diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 9126984f6a..6a34102aab 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -33,6 +33,20 @@ pub async fn run_migrations(pool: &PgPool) -> Result<()> { .await } +#[cfg(test)] +pub(crate) async fn run_migrations_through(pool: &PgPool, target: i64) -> Result<()> { + with_exclusive_schema_destruction_lock(pool, |mut conn| async move { + let outcome = async { + reject_legacy_nip_rs_cardinality_ambiguity(&mut conn).await?; + MIGRATOR.run_to(target, &mut conn).await?; + Ok(()) + } + .await; + (conn, outcome) + }) + .await +} + async fn run_migrations_locked(conn: &mut PgConnection) -> Result<()> { reject_legacy_nip_rs_cardinality_ambiguity(conn).await?; MIGRATOR.run(&mut *conn).await?; @@ -44,6 +58,7 @@ async fn run_migrations_locked(conn: &mut PgConnection) -> Result<()> { // guard, so migration fails closed if any is missing. (The fence probe // re-runs this same check at startup on non-migrating relays.) crate::replica_fence::verify_floor_guard_catalog(&mut *conn).await?; + crate::channel::verify_channel_roster_fence_catalog(&mut *conn).await?; Ok(()) } @@ -1037,6 +1052,36 @@ mod tests { assert_eq!(migrations[29].version, 30); let deletion_recovery = migrations[29].sql.as_str(); assert!(deletion_recovery.contains("SET LOCAL lock_timeout = '5s'")); + + // Mixed-version channel-roster fence: old canonical replacement writers + // acquire their replacement key before INSERT; this trigger then takes + // the membership key and validates the exact active pubkey/role p-tag set. + assert_eq!(migrations[31].version, 32); + let roster_fence = migrations[31].sql.as_str(); + assert!(roster_fence.contains("CREATE TRIGGER trg_events_guard_channel_roster_snapshot")); + assert!(roster_fence.contains("NEW.kind <> 39002")); + assert!(roster_fence.contains("'buzz_channel_membership:'")); + assert!(roster_fence.contains("cm.removed_at IS NULL")); + assert!(roster_fence.contains("cm.role::text")); + assert!(roster_fence.contains("jsonb_array_length(roster_tag.tag_json) <> 4")); + assert!(roster_fence.contains("roster_tag.tag_json->>3")); + assert!(roster_fence.contains("snapshot_members IS DISTINCT FROM canonical_members")); + assert!(roster_fence.contains("ERRCODE = '23514'")); + + // Fresh desired-state bootstrap must install the identical executable + // fence as migration 0032. CI and isolated relay startup use schema.sql + // without running migrations, so drift reopens rolling-deploy races. + fn extract_roster_fence(sql: &str) -> &str { + let fence_start = "CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot()"; + let fence_end = " FOR EACH ROW EXECUTE FUNCTION guard_channel_roster_snapshot();"; + let start = sql.find(fence_start).expect("roster fence function"); + let relative_end = sql[start..].find(fence_end).expect("roster fence trigger"); + &sql[start..start + relative_end + fence_end.len()] + } + assert_eq!( + extract_roster_fence(roster_fence), + extract_roster_fence(desired_schema) + ); } #[test] @@ -1237,6 +1282,7 @@ mod tests { // Build the needles so this test's own source never matches them. let migrate_macro = ["sqlx", "::migrate!"].concat(); let migrator_run = ["MIGRATOR", ".run("].concat(); + let migrator_run_to = ["MIGRATOR", ".run_to("].concat(); let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let this_file = manifest_dir.join("src/migration.rs"); @@ -1263,23 +1309,24 @@ mod tests { rust_sources(crates_dir, &mut files); for path in &files { let source = std::fs::read_to_string(path).expect("read rust source"); - let (macro_hits, run_hits) = ( + let (macro_hits, run_hits, run_to_hits) = ( count(&source, &migrate_macro), count(&source, &migrator_run), + count(&source, &migrator_run_to), ); if *path == this_file { assert_eq!( - (macro_hits, run_hits), - (1, 1), - "migration.rs must embed the migrator once and run it exactly once, \ - inside the locked wrapper" + (macro_hits, run_hits, run_to_hits), + (1, 1, 1), + "migration.rs must embed the migrator once, run it once in production, \ + and expose exactly one test-only bounded run" ); } else if *path == push_gateway_exception { continue; } else { assert_eq!( - (macro_hits, run_hits), - (0, 0), + (macro_hits, run_hits, run_to_hits), + (0, 0, 0), "{} embeds or runs a SQLx migrator outside the schema/destruction \ lock contract; route migration execution through \ buzz_db migration::run_migrations", @@ -1302,13 +1349,23 @@ mod tests { .find("async fn with_exclusive_schema_destruction_lock") .expect("exclusive lock wrapper"); let run_site = source.find(&migrator_run).expect("migrator run site"); + let run_to_site = source + .find(&migrator_run_to) + .expect("bounded test migrator run site"); assert!( source[entry..locked].contains("with_exclusive_schema_destruction_lock("), "run_migrations must delegate through the exclusive schema/destruction lock" ); assert!( run_site > locked && run_site < wrapper, - "the migrator run site must live inside run_migrations_locked" + "the production migrator run site must live inside run_migrations_locked" + ); + assert!( + run_to_site > entry + && run_to_site < locked + && source[entry..run_to_site].contains("#[cfg(test)]") + && source[entry..run_to_site].contains("with_exclusive_schema_destruction_lock("), + "the bounded migrator run must remain test-only and use the exclusive lock wrapper" ); assert!( source[wrapper..].contains("pg_advisory_lock($1)") diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index 4e27b85fe9..ae3dbe4f39 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -547,23 +547,17 @@ fn nonempty_s3_region(region: String) -> Option { (!region.is_empty()).then(|| region.to_string()) } -fn s3_region_from_env() -> String { - resolve_s3_region( - std::env::var("BUZZ_S3_REGION").ok(), - std::env::var("AWS_REGION").ok(), - ) -} - async fn connect_services() -> Result { let store = connect_store().await?; connect_services_with_store(store).await } async fn connect_services_with_store(store: DeletionStore) -> Result { + let (s3_access_key, s3_secret_key) = s3_key_pair_from_env(); let media_config = buzz_media::MediaConfig { s3_endpoint: required_env("BUZZ_S3_ENDPOINT")?, - s3_access_key: required_env("BUZZ_S3_ACCESS_KEY")?, - s3_secret_key: required_env("BUZZ_S3_SECRET_KEY")?, + s3_access_key, + s3_secret_key, s3_bucket: required_env("BUZZ_S3_BUCKET")?, s3_region: s3_region_from_env(), s3_addressing_style: std::env::var("BUZZ_S3_ADDRESSING_STYLE") @@ -596,6 +590,24 @@ async fn connect_services_with_store(store: DeletionStore) -> Result { }) } +fn s3_region_from_env() -> String { + resolve_s3_region( + std::env::var("BUZZ_S3_REGION").ok(), + std::env::var("AWS_REGION").ok(), + ) +} + +fn s3_key_pair_from_env() -> (String, String) { + s3_key_pair_from(|name| std::env::var(name).ok()) +} + +fn s3_key_pair_from(get_env: impl Fn(&str) -> Option) -> (String, String) { + ( + optional_env_from(&get_env, "BUZZ_S3_ACCESS_KEY"), + optional_env_from(&get_env, "BUZZ_S3_SECRET_KEY"), + ) +} + fn required_env(name: &str) -> Result { std::env::var(name) .ok() @@ -604,6 +616,12 @@ fn required_env(name: &str) -> Result { .ok_or_else(|| anyhow::anyhow!("{name} is required for community deletion")) } +fn optional_env_from(get_env: impl Fn(&str) -> Option, name: &str) -> String { + get_env(name) + .map(|value| value.trim().to_owned()) + .unwrap_or_default() +} + fn env_parse(name: &str, default: T) -> T where T: std::str::FromStr, @@ -1543,6 +1561,68 @@ mod tests { (db, services, claim) } + fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + use<'a> { + move |name| { + set.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_string()) + } + } + + #[test] + fn deletion_s3_key_pair_normalizes_missing_and_blank_pairs_for_default_credentials() { + assert_eq!( + s3_key_pair_from(env_of(&[])), + (String::new(), String::new()) + ); + + assert_eq!( + s3_key_pair_from(env_of(&[ + ("BUZZ_S3_ACCESS_KEY", ""), + ("BUZZ_S3_SECRET_KEY", " "), + ])), + (String::new(), String::new()) + ); + } + + #[test] + fn deletion_s3_key_pair_trims_static_and_preserves_partial_pairs() { + assert_eq!( + s3_key_pair_from(env_of(&[ + ("BUZZ_S3_ACCESS_KEY", " buzz_dev "), + ("BUZZ_S3_SECRET_KEY", " buzz_dev_secret "), + ])), + ("buzz_dev".to_string(), "buzz_dev_secret".to_string()) + ); + + for (env, expected) in [ + ( + &[("BUZZ_S3_ACCESS_KEY", " buzz_dev ")][..], + ("buzz_dev".to_string(), String::new()), + ), + ( + &[("BUZZ_S3_SECRET_KEY", " buzz_dev_secret ")][..], + (String::new(), "buzz_dev_secret".to_string()), + ), + ( + &[ + ("BUZZ_S3_ACCESS_KEY", " buzz_dev "), + ("BUZZ_S3_SECRET_KEY", " "), + ][..], + ("buzz_dev".to_string(), String::new()), + ), + ( + &[ + ("BUZZ_S3_ACCESS_KEY", " "), + ("BUZZ_S3_SECRET_KEY", " buzz_dev_secret "), + ][..], + (String::new(), "buzz_dev_secret".to_string()), + ), + ] { + assert_eq!(s3_key_pair_from(env_of(env)), expected); + } + } + fn deletion_test_media_storage() -> Arc { let endpoint = std::env::var("BUZZ_TEST_S3_ENDPOINT") .or_else(|_| std::env::var("BUZZ_S3_ENDPOINT")) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5ba9650e91..9629ffb0fd 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -724,32 +724,11 @@ pub(crate) async fn resolve_nip10_thread_meta( channel_id: Uuid, state: &AppState, ) -> Result, String> { - let mut root_hex: Option = None; - let mut reply_hex: Option = None; + let markers = buzz_core::nip10::parse_thread_markers(&event.tags); - for tag in event.tags.iter() { - let parts = tag.as_slice(); - if parts.len() >= 4 && parts[0] == "e" { - let hex_val = &parts[1]; - let marker = &parts[3]; - if hex_val.len() == 64 && hex_val.chars().all(|c| c.is_ascii_hexdigit()) { - match marker.as_str() { - "root" => root_hex = Some(hex_val.to_string()), - "reply" => reply_hex = Some(hex_val.to_string()), - _ => {} - } - } - } - } - - if root_hex.is_none() && reply_hex.is_none() { - return Ok(None); - } - - let (root_hex, parent_hex) = match (root_hex, reply_hex) { - (Some(r), Some(p)) => (r, p), - (None, Some(p)) => (p.clone(), p), - (Some(_), None) | (None, None) => return Ok(None), + let (root_hex, parent_hex) = match markers.resolve() { + Some(pair) => pair, + None => return Ok(None), }; let parent_bytes = @@ -807,46 +786,18 @@ pub(crate) async fn resolve_nip10_thread_meta( (effective_root, root_ts, depth) } None => { - let parent_root = parent_event - .event - .tags - .iter() - .find_map(|t| { - let parts = t.as_slice(); - if parts.len() >= 4 && parts[0] == "e" && parts[3] == "root" { - hex::decode(&parts[1]).ok().filter(|b| b.len() == 32) - } else { - None - } - }) - .or_else(|| { - parent_event.event.tags.iter().find_map(|t| { - let parts = t.as_slice(); - if parts.len() >= 4 && parts[0] == "e" && parts[3] == "reply" { - hex::decode(&parts[1]).ok().filter(|b| b.len() == 32) - } else { - None - } - }) - }) - .unwrap_or_else(|| parent_bytes.clone()); + let (parent_root, root_created, depth) = derive_ancestry_from_parent_tags( + community_id, + &parent_event.event, + &parent_bytes, + parent_created, + state, + ) + .await; if client_root_bytes != parent_root { return Err("root tag does not match thread ancestry".to_string()); } - let depth = if parent_root == parent_bytes { 1 } else { 2 }; - let root_created = if parent_root != parent_bytes { - if let Ok(Some(root_ev)) = - state.db.get_event_by_id(community_id, &parent_root).await - { - chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) - .unwrap_or(parent_created) - } else { - parent_created - } - } else { - parent_created - }; (parent_root, root_created, depth) } }; @@ -872,6 +823,182 @@ pub(crate) async fn resolve_nip10_thread_meta( })) } +/// Recover a reply's thread ancestry from its *parent's* NIP-10 tags when the +/// parent has **no** `thread_metadata` row (legacy or not-yet-indexed events). +/// +/// The parent's markers are first collapsed through `ThreadMarkers::resolve()`: +/// a `root`+`reply` parent carries its marked root, a `reply`-only parent carries +/// its reply target as root, and a root-only/malformed/unmarked parent is itself +/// top-level and its own root. Depth is 1 when the parent is the root and 2 +/// otherwise — a reply to a nested-but-unindexed parent must not be mistaken for +/// a top-level reply. +/// +/// Shared by [`resolve_nip10_thread_meta`] (client path) and +/// [`resolve_relay_reply_thread_meta`] (workflow path) so the two cannot +/// diverge. Returns `(root_event_id, root_event_created_at, depth)`. +async fn derive_ancestry_from_parent_tags( + community_id: CommunityId, + parent_event: &Event, + parent_bytes: &[u8], + parent_created: chrono::DateTime, + state: &AppState, +) -> (Vec, chrono::DateTime, i32) { + let marked_ancestor = |id_hex: &str| hex::decode(id_hex).ok().filter(|b| b.len() == 32); + let markers = buzz_core::nip10::parse_thread_markers(&parent_event.tags); + let parent_root = markers + .resolve() + .map(|(root, _)| root) + .as_deref() + .and_then(marked_ancestor) + .unwrap_or_else(|| parent_bytes.to_vec()); + + if parent_root.as_slice() == parent_bytes { + (parent_root, parent_created, 1) + } else { + let root_created = + if let Ok(Some(root_ev)) = state.db.get_event_by_id(community_id, &parent_root).await { + chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) + .unwrap_or(parent_created) + } else { + parent_created + }; + (parent_root, root_created, 2) + } +} + +/// Resolved thread ancestry for a relay-built reply (workflow path). +/// +/// Carries the parent and root identifiers plus the reply's depth, so the +/// caller can both emit matching NIP-10 `root`/`reply` tags and persist thread +/// metadata for the signed reply event. +pub(crate) struct ReplyAncestry { + pub parent_event_id: Vec, + pub parent_event_created_at: chrono::DateTime, + pub root_event_id: Vec, + pub root_event_created_at: chrono::DateTime, + pub depth: i32, +} + +impl ReplyAncestry { + /// Root event ID as lowercase hex, for the NIP-10 `root` tag. + pub fn root_hex(&self) -> String { + hex::encode(&self.root_event_id) + } + + /// Parent event ID as lowercase hex, for the NIP-10 `reply` tag. + pub fn parent_hex(&self) -> String { + hex::encode(&self.parent_event_id) + } + + /// Build the DB thread-metadata params for the signed reply event. + pub fn into_thread_meta( + self, + reply_event_id: Vec, + reply_created_at: chrono::DateTime, + channel_id: Uuid, + ) -> ThreadMetadataOwned { + ThreadMetadataOwned { + event_id: reply_event_id, + event_created_at: reply_created_at, + channel_id, + parent_event_id: self.parent_event_id, + parent_event_created_at: self.parent_event_created_at, + root_event_id: self.root_event_id, + root_event_created_at: self.root_event_created_at, + depth: self.depth, + broadcast: false, + } + } +} + +/// Resolve thread ancestry for a reply built by the relay (workflow path). +/// +/// Unlike [`resolve_nip10_thread_meta`], which validates client-supplied NIP-10 +/// `e` tags, this derives ancestry from a known `parent_hex` (the triggering +/// event) and *computes* the correct root and depth. Enforces the same-channel +/// invariant and the depth limit that the ingest path applies. +pub(crate) async fn resolve_relay_reply_thread_meta( + community_id: CommunityId, + parent_hex: &str, + channel_id: Uuid, + state: &AppState, +) -> Result { + let parent_bytes = + hex::decode(parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?; + + let (parent_event_result, parent_meta_result) = tokio::join!( + state.db.get_event_by_id(community_id, &parent_bytes), + state + .db + .get_thread_metadata_by_event(community_id, &parent_bytes), + ); + + let parent_event = parent_event_result + .map_err(|e| format!("db error looking up parent: {e}"))? + .ok_or_else(|| "reply parent not found".to_string())?; + + match parent_event.channel_id { + Some(parent_ch) if parent_ch != channel_id => { + return Err("parent event belongs to a different channel".to_string()); + } + None => return Err("parent event has no channel association".to_string()), + _ => {} + } + + let parent_created = + chrono::DateTime::from_timestamp(parent_event.event.created_at.as_secs() as i64, 0) + .unwrap_or_else(Utc::now); + + let parent_meta = + parent_meta_result.map_err(|e| format!("db error looking up thread metadata: {e}"))?; + + // Root = parent's root if the parent is itself a reply, else the parent. + // Depth = parent depth + 1 (a direct reply to a top-level message is depth 1). + let (root_bytes, root_created, depth) = match parent_meta { + Some(meta) => { + let effective_root = meta.root_event_id.unwrap_or_else(|| parent_bytes.clone()); + let root_ts = if effective_root == parent_bytes { + parent_created + } else if let Ok(Some(root_ev)) = state + .db + .get_event_by_id(community_id, &effective_root) + .await + { + chrono::DateTime::from_timestamp(root_ev.event.created_at.as_secs() as i64, 0) + .unwrap_or(parent_created) + } else { + parent_created + }; + (effective_root, root_ts, meta.depth + 1) + } + // No metadata row ⇒ recover the parent's ancestry from its own NIP-10 + // tags. A marked (but not-yet-indexed) nested parent yields depth 2, not + // a false top-level depth 1. + None => { + derive_ancestry_from_parent_tags( + community_id, + &parent_event.event, + &parent_bytes, + parent_created, + state, + ) + .await + } + }; + + if depth > 100 { + return Err("thread depth limit exceeded".to_string()); + } + + Ok(ReplyAncestry { + parent_event_id: parent_bytes, + parent_event_created_at: parent_created, + root_event_id: root_bytes, + root_event_created_at: root_created, + depth, + }) +} + /// Count all `e` tags regardless of content validity. fn count_e_tags(event: &Event) -> usize { event diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index f2b58937ab..89595fbee1 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1049,6 +1049,55 @@ fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Resul Ok(tags) } +async fn store_group_members_event( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + member_snapshot: &mut buzz_db::channel::LockedMemberSnapshot, +) -> anyhow::Result> { + let group_id = channel_id.to_string(); + let tags = group_members_tags(&group_id, &member_snapshot.members)?; + let relay_pubkey = state.relay_keypair.public_key().to_bytes(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let ts = member_snapshot + .latest_member_event_timestamp(tenant.community(), channel_id, &relay_pubkey) + .await? + .map(|timestamp| timestamp + 1) + .unwrap_or(now) + .max(now); + let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_MEMBERS as u16), "") + .tags(tags) + .custom_created_at(nostr::Timestamp::from(ts)) + .sign_with_keys(&state.relay_keypair) + .map_err(|error| anyhow::anyhow!("failed to sign member snapshot: {error}"))?; + let (stored, inserted) = member_snapshot + .replace_member_event(tenant.community(), channel_id, &event) + .await?; + Ok(inserted.then_some(stored)) +} + +async fn dispatch_group_members_event( + tenant: &TenantContext, + state: &Arc, + stored: Option, + relay_pubkey_hex: &str, +) { + if let Some(stored) = stored { + dispatch_persistent_event( + tenant, + state, + &stored, + KIND_NIP29_GROUP_MEMBERS, + relay_pubkey_hex, + None, + ) + .await; + } +} + /// Emit NIP-29 group discovery events (39000, 39001, 39002) signed by the relay keypair. /// Called after group creation, metadata changes, or membership changes. /// Events are stored channel-scoped (`channel_id = Some(...)`) so that existing @@ -1151,18 +1200,18 @@ pub async fn emit_group_discovery_events( .await?; } - { - let tags = group_members_tags(&group_id, &members)?; - emit_addressable_discovery_event( - tenant, - state, - channel_id, - KIND_NIP29_GROUP_MEMBERS, - tags, - &relay_pubkey_hex, - ) + // Re-capture membership behind the writer lock immediately before the + // authoritative 39002 replacement. Metadata/admin snapshots retain their + // existing behavior; only membership publication needs this freshness fence. + let relay_pubkey = state.relay_keypair.public_key().to_bytes(); + let mut member_snapshot = state + .db + .lock_member_snapshot(tenant.community(), channel_id, &relay_pubkey) .await?; - } + let stored_members = + store_group_members_event(tenant, state, channel_id, &mut member_snapshot).await?; + member_snapshot.release().await?; + dispatch_group_members_event(tenant, state, stored_members, &relay_pubkey_hex).await; Ok(()) } @@ -3052,6 +3101,68 @@ pub async fn publish_nip43_member_removed( publish_nip43_delta(tenant, state, 8001, target_pubkey_hex, "member-removed").await } +/// Repair legacy kind:39002 snapshots truncated by the former 1,000-member +/// database cap. +/// +/// The scan is deliberately limited to canonical rosters above that boundary, +/// so normal-sized channels and already-correct large snapshots incur no +/// rewrites. Community identity travels with every candidate; a shared relay +/// never resolves a channel against a neighboring tenant. +pub async fn reconcile_large_channel_member_snapshots( + state: &Arc, +) -> anyhow::Result { + const LEGACY_ROSTER_LIMIT: i64 = 1_000; + + let relay_pubkey = state.relay_keypair.public_key(); + let candidates = state + .db + .list_large_channel_rosters_needing_reconciliation( + LEGACY_ROSTER_LIMIT, + &relay_pubkey.to_bytes(), + ) + .await?; + let relay_pubkey_hex = relay_pubkey.to_hex(); + let mut reconciled = 0usize; + + for candidate in candidates { + let result = async { + let channel_id = candidate.channel_id; + // Hold the membership-writer lock from roster capture through + // replacement. Otherwise a rolling deployment can publish stale + // roster A after another relay commits and publishes roster B. + let mut member_snapshot = state + .db + .lock_member_snapshot(candidate.community_id, channel_id, &relay_pubkey.to_bytes()) + .await?; + let tenant = TenantContext::resolved(candidate.community_id, candidate.host.clone()); + let stored_members = + store_group_members_event(&tenant, state, channel_id, &mut member_snapshot).await?; + member_snapshot.release().await?; + dispatch_group_members_event(&tenant, state, stored_members, &relay_pubkey_hex).await; + Ok::(true) + } + .await; + + match result { + Ok(true) => reconciled += 1, + Ok(false) => {} + Err(error) => { + metrics::counter!("buzz_channel_roster_reconciliation_failures_total").increment(1); + warn!( + community_id = %candidate.community_id, + host = %candidate.host, + channel_id = %candidate.channel_id, + %error, + "large channel roster reconciliation failed" + ); + } + } + } + + metrics::counter!("buzz_channel_roster_reconciliations_total").increment(reconciled as u64); + Ok(reconciled) +} + /// Reconcile channels that exist in the DB but don't have kind:39000 events. /// /// This handles the case where channels were created via direct SQL inserts diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 7ca4f02196..77462b3f28 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -536,6 +536,31 @@ async fn main() -> anyhow::Result<()> { ); } + match state.db.verify_channel_roster_fence().await { + Ok(()) => { + info!("Channel roster fence verified"); + } + Err(error) => { + error!(%error, "Channel roster fence validation failed"); + return Err(anyhow::anyhow!( + "Channel roster fence is unsafe; apply or repair migration 0032 before starting this relay: {error}" + )); + } + } + + // Repair legacy NIP-29 channel rosters that were persisted while the + // canonical member query still truncated at 1,000 rows. Validation above + // makes migration 0032 a code/schema compatibility gate before the new + // replacement protocol or listener can serve traffic. + match buzz_relay::handlers::side_effects::reconcile_large_channel_member_snapshots(&state).await + { + Ok(count) if count > 0 => info!(count, "large channel member snapshots repaired"), + Ok(_) => {} + Err(error) => { + tracing::warn!(%error, "large channel member snapshot startup reconciliation failed") + } + } + // NIP-43: reconcile the event-backed roster for every provisioned // community before opening the listener. `relay_members` is canonical; // this repairs pre-snapshot communities and any publication that failed diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..8ce23a2e8e 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -176,10 +176,12 @@ impl ActionSink for RelayActionSink { channel_id: &str, text: &str, author_pubkey: &str, + reply_to: Option<&str>, ) -> Pin> + Send + '_>> { let channel_id = channel_id.to_owned(); let text = text.to_owned(); let author_pubkey = author_pubkey.to_owned(); + let reply_to = reply_to.map(str::to_owned); Box::pin(async move { // 0. Upgrade weak reference — fails only during shutdown. @@ -266,6 +268,50 @@ impl ActionSink for RelayActionSink { .map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?, ]; + // Resolve thread ancestry when this is a threaded reply, so the + // built event carries NIP-10 `root`/`reply` e-tags and persists real + // thread metadata (matching the ingest path) instead of top-level. + let reply_ancestry = match reply_to.as_deref() { + Some(parent_hex) => Some( + crate::handlers::ingest::resolve_relay_reply_thread_meta( + tenant.community(), + parent_hex, + channel_uuid, + &state, + ) + .await + .map_err(ActionSinkError::InvalidInput)?, + ), + None => None, + }; + + // NIP-10 e-tags for the thread. Marked `root`/`reply` so clients and + // the ingest resolver read the ancestry the same way. A direct reply + // (parent == root) emits a single `reply` tag; a nested reply emits + // the `root` + `reply` pair — matching `buzz_sdk::builders::thread_tags` + // so every writer produces one wire shape per reply kind. + if let Some(ancestry) = &reply_ancestry { + let root_hex = ancestry.root_hex(); + let parent_hex = ancestry.parent_hex(); + if root_hex == parent_hex { + tags.push( + Tag::parse(["e", &root_hex, "", "reply"]).map_err(|e| { + ActionSinkError::EventBuild(format!("reply e tag: {e}")) + })?, + ); + } else { + tags.push( + Tag::parse(["e", &root_hex, "", "root"]) + .map_err(|e| ActionSinkError::EventBuild(format!("root e tag: {e}")))?, + ); + tags.push( + Tag::parse(["e", &parent_hex, "", "reply"]).map_err(|e| { + ActionSinkError::EventBuild(format!("reply e tag: {e}")) + })?, + ); + } + } + // Resolve `@Name` mentions to channel-member pubkeys and append a // `p` tag for each (skipping the author, already tagged above). A // resolution failure must not drop the message, so log and proceed @@ -321,17 +367,24 @@ impl ActionSink for RelayActionSink { ); // 4. Persist event with thread metadata (matches REST handler path). - // Workflow messages are always top-level: depth=0, no parent/root. - let thread_meta = Some(buzz_db::event::ThreadMetadataParams { - event_id: &event_id_bytes, - event_created_at, - channel_id: channel_uuid, - parent_event_id: None, - parent_event_created_at: None, - root_event_id: None, - root_event_created_at: None, - depth: 0, - broadcast: false, + // Threaded replies persist the resolved parent/root/depth; a + // non-reply workflow message stays top-level (depth=0, no parent). + let thread_meta_owned = reply_ancestry.map(|ancestry| { + ancestry.into_thread_meta(event_id_bytes.clone(), event_created_at, channel_uuid) + }); + let thread_meta = Some(match &thread_meta_owned { + Some(owned) => owned.as_params(), + None => buzz_db::event::ThreadMetadataParams { + event_id: &event_id_bytes, + event_created_at, + channel_id: channel_uuid, + parent_event_id: None, + parent_event_created_at: None, + root_event_id: None, + root_event_created_at: None, + depth: 0, + broadcast: false, + }, }); let (stored_event, was_inserted) = state @@ -357,6 +410,20 @@ impl ActionSink for RelayActionSink { None, ) .await; + + // A threaded reply changed its thread's counters — push a fresh + // relay-signed kind:39005 so subscribed clients update badge + // counts without refetching the head window, exactly as the + // ingest path does after a reply insert. Fan-out-only and + // best-effort; skipped for top-level (non-reply) messages. + if let Some(owned) = &thread_meta_owned { + crate::handlers::side_effects::emit_live_thread_summary( + &tenant, + &state, + channel_uuid, + owned.root_event_id.clone(), + ); + } } Ok(event_id_hex) @@ -676,6 +743,7 @@ mod integration_tests { &channel.id.to_string(), "heads up @Robby — please take a look", &author_hex, + None, ) .await .expect("send_message"); @@ -708,4 +776,353 @@ mod integration_tests { "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" ); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_reply_in_thread_threads_onto_parent() { + let state = test_state().await; + + let author = nostr::Keys::generate(); + let author_hex = author.public_key().to_hex(); + + let host = format!("wf-thread-{}.example", uuid::Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &author_hex) + .await + .expect("create community") + { + CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("expected fresh community, got {other:?}"), + }; + + let channel = state + .db + .create_channel( + community, + "wf-thread", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &author.public_key().to_bytes(), + None, + ) + .await + .expect("create channel"); + + let sink = RelayActionSink::new(&state); + + // 1. A top-level workflow message becomes the thread root. + let root_hex = sink + .send_message( + community, + &channel.id.to_string(), + "root message", + &author_hex, + None, + ) + .await + .expect("send root"); + + // 2. A reply_in_thread message threads onto it. + let reply_hex = sink + .send_message( + community, + &channel.id.to_string(), + "threaded reply", + &author_hex, + Some(&root_hex), + ) + .await + .expect("send reply"); + + // A direct reply carries a single NIP-10 reply e-tag at the root (no + // root marker), matching SDK `thread_tags`. + let reply_id_bytes = nostr::EventId::from_hex(&reply_hex) + .expect("reply id") + .as_bytes() + .to_vec(); + let stored = state + .db + .get_event_by_id(community, &reply_id_bytes) + .await + .expect("query reply") + .expect("reply persisted"); + let marker = |m: &str| -> Option { + stored.event.tags.iter().find_map(|t| { + let p = t.as_slice(); + if p.len() >= 4 && p[0] == "e" && p[3] == m { + Some(p[1].clone()) + } else { + None + } + }) + }; + assert_eq!( + marker("reply").as_deref(), + Some(root_hex.as_str()), + "direct reply emits a single reply marker at the root" + ); + assert_eq!( + marker("root"), + None, + "direct reply omits the root marker (matches SDK thread_tags)" + ); + + // Thread metadata reflects a depth-1 reply parented on the root. + let meta = state + .db + .get_thread_metadata_by_event(community, &reply_id_bytes) + .await + .expect("query meta") + .expect("reply has thread metadata"); + assert_eq!( + meta.depth, 1, + "direct reply to a top-level message is depth 1" + ); + let root_bytes = nostr::EventId::from_hex(&root_hex) + .expect("root id") + .as_bytes() + .to_vec(); + assert_eq!(meta.parent_event_id.as_deref(), Some(root_bytes.as_slice())); + assert_eq!(meta.root_event_id.as_deref(), Some(root_bytes.as_slice())); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_replies_recover_metadata_less_parent_ancestry() { + // A parent that carries NIP-10 root/reply markers but has NO + // thread_metadata row (legacy or not-yet-indexed) must be recognized as + // nested: the workflow reply threads at depth 2 onto the parent's own + // root, not a false top-level depth 1. + let state = test_state().await; + + let author = nostr::Keys::generate(); + let author_hex = author.public_key().to_hex(); + + let host = format!("wf-legacy-{}.example", uuid::Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &author_hex) + .await + .expect("create community") + { + CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("expected fresh community, got {other:?}"), + }; + + let channel = state + .db + .create_channel( + community, + "wf-legacy", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &author.public_key().to_bytes(), + None, + ) + .await + .expect("create channel"); + + let channel_hex = channel.id.to_string(); + + // A top-level root message, inserted WITHOUT any thread metadata row. + let root_event = EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "root") + .tags([Tag::parse(["h", &channel_hex]).expect("h tag")]) + .sign_with_keys(&author) + .expect("sign root"); + let root_hex = root_event.id.to_hex(); + state + .db + .insert_event(community, &root_event, Some(channel.id)) + .await + .expect("insert root"); + + // A nested parent that marks its root/reply — but, crucially, is stored + // with NO thread_metadata row (the legacy/unindexed case F1 addresses). + let parent_event = + EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "nested parent") + .tags([ + Tag::parse(["h", &channel_hex]).expect("h tag"), + Tag::parse(["e", &root_hex, "", "root"]).expect("root tag"), + Tag::parse(["e", &root_hex, "", "reply"]).expect("reply tag"), + ]) + .sign_with_keys(&author) + .expect("sign parent"); + let parent_hex = parent_event.id.to_hex(); + state + .db + .insert_event(community, &parent_event, Some(channel.id)) + .await + .expect("insert parent"); + assert!( + state + .db + .get_thread_metadata_by_event(community, parent_event.id.as_bytes()) + .await + .expect("query parent meta") + .is_none(), + "test premise: the nested parent must have no thread_metadata row" + ); + + // A workflow reply onto the metadata-less nested parent. + let reply_hex = RelayActionSink::new(&state) + .send_message( + community, + &channel_hex, + "workflow reply", + &author_hex, + Some(&parent_hex), + ) + .await + .expect("send reply"); + + let reply_id_bytes = nostr::EventId::from_hex(&reply_hex) + .expect("reply id") + .as_bytes() + .to_vec(); + let meta = state + .db + .get_thread_metadata_by_event(community, &reply_id_bytes) + .await + .expect("query meta") + .expect("reply has thread metadata"); + + assert_eq!( + meta.depth, 2, + "reply to a marked-but-unindexed nested parent is depth 2, not top-level" + ); + let root_bytes = nostr::EventId::from_hex(&root_hex) + .expect("root id") + .as_bytes() + .to_vec(); + let parent_bytes = parent_event.id.as_bytes().to_vec(); + assert_eq!( + meta.root_event_id.as_deref(), + Some(root_bytes.as_slice()), + "root recovered from the parent's own NIP-10 markers" + ); + assert_eq!( + meta.parent_event_id.as_deref(), + Some(parent_bytes.as_slice()) + ); + + // The reply's own NIP-10 e-tags point root→the recovered root, + // reply→the immediate parent (matching the ingest resolver). + let stored = state + .db + .get_event_by_id(community, &reply_id_bytes) + .await + .expect("query reply") + .expect("reply persisted"); + let marker = |m: &str| -> Option { + stored.event.tags.iter().find_map(|t| { + let p = t.as_slice(); + if p.len() >= 4 && p[0] == "e" && p[3] == m { + Some(p[1].clone()) + } else { + None + } + }) + }; + assert_eq!(marker("root").as_deref(), Some(root_hex.as_str())); + assert_eq!(marker("reply").as_deref(), Some(parent_hex.as_str())); + + // A root-only parent is top-level under the shared collapse rule, even + // without metadata. A workflow reply therefore starts a thread at P, + // rather than incorrectly inheriting the marker's unrelated root R. + let root_only_parent = + EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "root-only parent") + .tags([ + Tag::parse(["h", &channel_hex]).expect("h tag"), + Tag::parse(["e", &root_hex, "", "root"]).expect("root tag"), + ]) + .sign_with_keys(&author) + .expect("sign root-only parent"); + let root_only_parent_hex = root_only_parent.id.to_hex(); + let root_only_parent_bytes = root_only_parent.id.as_bytes().to_vec(); + state + .db + .insert_event(community, &root_only_parent, Some(channel.id)) + .await + .expect("insert root-only parent"); + + let root_only_reply_hex = RelayActionSink::new(&state) + .send_message( + community, + &channel_hex, + "workflow reply to root-only parent", + &author_hex, + Some(&root_only_parent_hex), + ) + .await + .expect("send root-only reply"); + let root_only_reply_bytes = nostr::EventId::from_hex(&root_only_reply_hex) + .expect("reply id") + .as_bytes() + .to_vec(); + let root_only_meta = state + .db + .get_thread_metadata_by_event(community, &root_only_reply_bytes) + .await + .expect("query root-only reply meta") + .expect("root-only reply has thread metadata"); + assert_eq!(root_only_meta.depth, 1); + assert_eq!( + root_only_meta.parent_event_id.as_deref(), + Some(root_only_parent_bytes.as_slice()) + ); + assert_eq!( + root_only_meta.root_event_id.as_deref(), + Some(root_only_parent_bytes.as_slice()) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_reply_to_missing_parent_errors() { + let state = test_state().await; + let author = nostr::Keys::generate(); + let author_hex = author.public_key().to_hex(); + let host = format!("wf-missing-{}.example", uuid::Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &author_hex) + .await + .expect("create community") + { + CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("expected fresh community, got {other:?}"), + }; + let channel = state + .db + .create_channel( + community, + "wf-missing", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &author.public_key().to_bytes(), + None, + ) + .await + .expect("create channel"); + + let unknown = nostr::Keys::generate().public_key().to_hex(); + let err = RelayActionSink::new(&state) + .send_message( + community, + &channel.id.to_string(), + "orphan reply", + &author_hex, + Some(&unknown), + ) + .await + .expect_err("reply to a non-existent parent must fail"); + assert!( + matches!(err, ActionSinkError::InvalidInput(_)), + "expected InvalidInput, got {err:?}" + ); + } } diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 882cbabfe2..b119d26774 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -2661,6 +2661,112 @@ async fn test_reply_ingest_pushes_live_thread_summary() { client.disconnect().await.expect("disconnect"); } +/// F3 (workflow path): a `message_posted` workflow whose `send_message` action +/// has `reply_in_thread: true` posts a threaded reply to the triggering +/// top-level message — and that relay-built reply must push the same live +/// kind:39005 thread-summary overlay the human ingest path does, so desktops +/// update the root's badge without refetching. Also exercises F2's semantics: +/// the `trigger_is_reply == false` filter must fire on the top-level message. +#[tokio::test] +#[ignore] +async fn test_workflow_reply_in_thread_pushes_live_thread_summary() { + let url = relay_url(); + let http = relay_http_url(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let channel = create_test_channel(&keys).await; + + // A message_posted workflow that replies in-thread, but only to NEW + // top-level messages (`trigger_is_reply == false`) — so it cannot recurse + // on the reply it just posted. + let yaml = "name: reply-bot\n\ + description: F3 live probe\n\ + trigger:\n\ + \x20 on: message_posted\n\ + \x20 filter: \"trigger_is_reply == false\"\n\ + steps:\n\ + \x20 - id: step1\n\ + \x20 name: Reply\n\ + \x20 action: send_message\n\ + \x20 text: \"auto-reply\"\n\ + \x20 reply_in_thread: true\n" + .to_string(); + let def = EventBuilder::new(Kind::Custom(30620), yaml) + .tags([ + Tag::parse(["d", &Uuid::new_v4().to_string()]).unwrap(), + Tag::parse(["h", channel.as_str()]).unwrap(), + Tag::parse(["name", "reply-bot"]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign workflow def"); + let client = reqwest::Client::new(); + let resp = client + .post(format!("{http}/events")) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&def).unwrap()) + .send() + .await + .expect("submit workflow def"); + let body: serde_json::Value = resp.json().await.expect("parse def response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "workflow def not accepted: {body}" + ); + + // Live 39005 subscription for the channel, shaped like the desktop window + // store's. + let mut ws = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let sid = sub_id("wf-live-summary"); + let filter = Filter::new() + .kind(Kind::Custom(39005)) + .custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); + ws.subscribe(&sid, vec![filter]).await.expect("subscribe"); + ws.collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + + // Post a top-level message — the workflow fires and posts a threaded reply. + let root = EventBuilder::new(Kind::Custom(9), "trigger me") + .tags([Tag::parse(["h", channel.as_str()]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign root"); + let root_id = root.id; + let ok = ws.send_event(root).await.expect("send root"); + assert!(ok.accepted, "root rejected: {}", ok.message); + + // The workflow reply's 39005 overlay must arrive and target the root with a + // reply_count of 1 — proving the relay-built reply pushed the live summary. + let summary = loop { + match ws + .recv_event(Duration::from_secs(10)) + .await + .expect("recv 39005 for workflow reply") + { + RelayMessage::Event { event, .. } if event.kind == Kind::Custom(39005) => break *event, + _ => continue, + } + }; + let root_tag_val = summary + .tags + .iter() + .find(|t| t.as_slice().first().map(String::as_str) == Some("e")) + .and_then(|t| t.content().map(str::to_string)) + .expect("summary carries root e-tag"); + assert_eq!( + root_tag_val, + root_id.to_hex(), + "workflow-reply summary targets the triggering top-level message as root" + ); + let content: serde_json::Value = serde_json::from_str(&summary.content).expect("JSON"); + assert_eq!( + content["reply_count"], 1, + "workflow threaded reply counted up: {content}" + ); + + ws.disconnect().await.expect("disconnect"); +} + /// Read a member's authoritative role from the relay-signed kind:39002 member /// list. The relay's own view of membership, not the client's — a kind:9000 can /// be `accepted` (stored) while its membership side effect fails, so asserting diff --git a/crates/buzz-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index 0c6002e74e..079c27a913 100644 --- a/crates/buzz-workflow/src/action_sink.rs +++ b/crates/buzz-workflow/src/action_sink.rs @@ -57,6 +57,9 @@ pub trait ActionSink: Send + Sync { /// - `text`: message body (must not be empty/whitespace-only) /// - `author_pubkey`: hex-encoded pubkey of the workflow owner (used for /// the `p` attribution tag; the relay keypair signs the event) + /// - `reply_to`: when `Some(event_id_hex)`, the message is posted as a + /// threaded reply to that event (NIP-10 root/reply tags + real thread + /// metadata); when `None`, it is a top-level channel message. /// /// Returns the event ID hex string on success. fn send_message( @@ -65,5 +68,6 @@ pub trait ActionSink: Send + Sync { channel_id: &str, text: &str, author_pubkey: &str, + reply_to: Option<&str>, ) -> Pin> + Send + '_>>; } diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index dffa492716..5c712dcff7 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -37,6 +37,10 @@ pub struct TriggerContext { pub emoji: String, /// Event ID of the triggering message (hex string). pub message_id: String, + /// True when the triggering event is itself a threaded reply (carries a + /// NIP-10 `reply`/`root` marker e-tag). Lets a `message_posted` filter + /// select only top-level messages via `trigger_is_reply == false`. + pub is_reply: bool, /// Arbitrary webhook body fields (webhook trigger). pub webhook_fields: HashMap, } @@ -213,6 +217,7 @@ fn apply_filter(value: String, filter: &str) -> Result { /// | `trigger.timestamp` | `trigger_timestamp` | /// | `trigger.emoji` | `trigger_emoji` | /// | `trigger.message_id` | `trigger_message_id` | +/// | `trigger.is_reply` | `trigger_is_reply` (bool) | /// | `steps.STEP_ID.output.FIELD` | `steps_STEP_ID_output_FIELD` | /// /// Also registers string helper functions that the `cron` crate's `evalexpr` v11 @@ -300,6 +305,14 @@ pub fn build_eval_context( .map_err(|e| WorkflowError::ConditionError(e.to_string()))?; } + // `trigger_is_reply` is boolean (not a string field), so a filter can read + // `trigger_is_reply == false` to fire only on top-level messages. + ctx.set_value( + "trigger_is_reply".into(), + Value::Boolean(trigger_ctx.is_reply), + ) + .map_err(|e| WorkflowError::ConditionError(e.to_string()))?; + for (step_id, output) in step_outputs { if let JsonValue::Object(map) = output { for (field, val) in map { @@ -403,9 +416,14 @@ pub fn resolve_step_templates( }; match &step.action { - SendMessage { text, channel } => Ok(SendMessage { + SendMessage { + text, + channel, + reply_in_thread, + } => Ok(SendMessage { text: t(text)?, channel: t_opt(channel)?, + reply_in_thread: *reply_in_thread, }), SendDm { to, text } => Ok(SendDm { to: t(to)?, @@ -546,7 +564,11 @@ pub async fn dispatch_action( let result = serving_write .protect(async { match action { - SendMessage { text, channel } => { + SendMessage { + text, + channel, + reply_in_thread, + } => { // 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 @@ -577,16 +599,38 @@ pub async fn dispatch_action( )?; let owner_pubkey_hex = hex::encode(&workflow.owner_pubkey); + // Thread the reply onto the triggering message when requested. + // The trigger must carry the event to reply to; schema + // validation already forbids `reply_in_thread` on triggers + // that have no message, so an empty id here is a real fault. + let reply_to = if *reply_in_thread { + if trigger_ctx.message_id.is_empty() { + return Err(WorkflowError::InvalidDefinition( + "SendMessage: reply_in_thread is set but the trigger has no message_id to reply to".into(), + )); + } + Some(trigger_ctx.message_id.as_str()) + } else { + None + }; + info!( run_id = %run_id, step = step_id, channel = %channel_id, + reply_in_thread = *reply_in_thread, "SendMessage → {channel_id}: {text}" ); let event_id = engine .action_sink()? - .send_message(community_id, &channel_id, text, &owner_pubkey_hex) + .send_message( + community_id, + &channel_id, + text, + &owner_pubkey_hex, + reply_to, + ) .await .map_err(WorkflowError::from)?; @@ -1266,6 +1310,7 @@ mod tests { timestamp: "1700000000".to_owned(), emoji: "fire".to_owned(), message_id: "event-id-hex".to_owned(), + is_reply: false, webhook_fields: HashMap::new(), } } @@ -1385,6 +1430,56 @@ mod tests { assert!(!result); } + #[tokio::test] + async fn condition_trigger_is_reply_selects_top_level_only() { + // The top-level-only filter from the feature's use case. + let mut ctx = make_trigger(); + + ctx.is_reply = false; + assert!( + evaluate_condition("trigger_is_reply == false", &ctx, &HashMap::new()) + .await + .unwrap(), + "top-level message should pass the filter" + ); + + ctx.is_reply = true; + assert!( + !evaluate_condition("trigger_is_reply == false", &ctx, &HashMap::new()) + .await + .unwrap(), + "threaded reply should be filtered out" + ); + } + + #[test] + fn resolve_step_templates_carries_reply_in_thread() { + let ctx = make_trigger(); + let step = Step { + id: "reply".to_owned(), + name: None, + if_expr: None, + timeout_secs: None, + action: ActionDef::SendMessage { + text: "hi {{trigger.author}}".to_owned(), + channel: None, + reply_in_thread: true, + }, + }; + let resolved = resolve_step_templates(&step, &ctx, &HashMap::new()).unwrap(); + match resolved { + ActionDef::SendMessage { + text, + reply_in_thread, + .. + } => { + assert_eq!(text, "hi abc123def456"); + assert!(reply_in_thread, "reply_in_thread must survive resolution"); + } + other => panic!("unexpected action: {other:?}"), + } + } + #[tokio::test] async fn condition_or_expression() { let ctx = make_trigger(); // text contains "P1" diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 5c17b34e10..bceb6d8bd8 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -997,10 +997,24 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge timestamp: event.event.created_at.as_secs().to_string(), emoji, message_id, + is_reply: event_is_reply(&event.event), webhook_fields: HashMap::new(), } } +/// True when an event is a threaded reply — it carries a valid NIP-10 `reply` +/// marker. Delegates to the shared [`buzz_core::nip10`] parser so this stays in +/// lockstep with ingest's `resolve_nip10_thread_meta`: a `root` marker alone is +/// top-level, and a marker with a malformed (non-64-hex) event id is ignored by +/// ingest, so it must not flip `trigger_is_reply` either — else a +/// `trigger_is_reply == false` workflow would skip a message ingest stored as a +/// new top-level post. +fn event_is_reply(event: &nostr::Event) -> bool { + buzz_core::nip10::parse_thread_markers(&event.tags) + .reply + .is_some() +} + /// Pure authority decision for [`WorkflowEngine::check_owner_authority`]. /// /// `role` is the owner's *current* active role in the workflow's channel @@ -1588,6 +1602,144 @@ steps: // Non-reaction events have empty emoji. assert_eq!(ctx.emoji, ""); assert!(ctx.webhook_fields.is_empty()); + // A top-level message (no e-tags) is not a reply. + assert!(!ctx.is_reply); + } + + #[test] + fn build_trigger_context_is_reply_true_for_threaded_message() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let root = Keys::generate(); + let root_event = EventBuilder::new(Kind::Custom(9), "root") + .tags([]) + .sign_with_keys(&root) + .expect("sign root"); + let root_hex = root_event.id.to_hex(); + + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "a threaded reply") + .tags([ + Tag::parse(["e", &root_hex, "", "root"]).expect("root tag"), + Tag::parse(["e", &root_hex, "", "reply"]).expect("reply tag"), + ]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!(ctx.is_reply, "message with reply/root e-tags is a reply"); + } + + #[test] + fn build_trigger_context_is_reply_true_for_reply_only_marker() { + // A NIP-10 `reply` marker without a `root` marker (the fallback ingest + // treats as `root == reply`) is still a threaded reply. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let parent = Keys::generate(); + let parent_event = EventBuilder::new(Kind::Custom(9), "parent") + .sign_with_keys(&parent) + .expect("sign parent"); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "reply only") + .tags([Tag::parse(["e", &parent_event.id.to_hex(), "", "reply"]).expect("reply tag")]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!(ctx.is_reply, "a lone `reply` marker is a reply"); + } + + #[test] + fn build_trigger_context_is_reply_false_for_root_only_marker() { + // Ingest treats `(root=Some, reply=None)` as top-level, so + // `event_is_reply` must too — otherwise `trigger_is_reply == false` + // would skip a message the relay stored as a new top-level post. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let root = Keys::generate(); + let root_event = EventBuilder::new(Kind::Custom(9), "root") + .sign_with_keys(&root) + .expect("sign root"); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "root marker only") + .tags([Tag::parse(["e", &root_event.id.to_hex(), "", "root"]).expect("root tag")]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!( + !ctx.is_reply, + "a lone `root` marker is top-level to ingest, not a reply" + ); + } + + #[test] + fn build_trigger_context_is_reply_false_for_unmarked_e_tag() { + // A bare `e` tag with no NIP-10 marker (e.g. a plain mention/quote) is + // not treated as a thread reply — only `reply`/`root` markers count. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let other = Keys::generate(); + let other_event = EventBuilder::new(Kind::Custom(9), "other") + .tags([]) + .sign_with_keys(&other) + .expect("sign"); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "quotes another") + .tags([Tag::parse(["e", &other_event.id.to_hex()]).expect("bare e tag")]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!(!ctx.is_reply, "unmarked e-tag must not count as a reply"); + } + + #[test] + fn build_trigger_context_is_reply_false_for_malformed_reply_id() { + // Ingest gates a marker on a valid 64-hex event id; a malformed reply + // id is not a thread link, so ingest stores the event top-level. The + // predicate must agree, or `trigger_is_reply == false` would skip it. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "malformed reply marker") + .tags([Tag::parse(["e", "bad", "", "reply"]).expect("reply tag")]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!( + !ctx.is_reply, + "a malformed reply id is ignored by ingest, so it is top-level" + ); + } + + #[test] + fn build_trigger_context_is_reply_false_for_valid_root_malformed_reply() { + // A valid `root` marker but a malformed `reply` id: ingest ignores the + // reply and stores the event as root-only, i.e. top-level. The predicate + // must not flip to reply on the malformed marker. + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + let root = Keys::generate(); + let root_event = EventBuilder::new(Kind::Custom(9), "root") + .sign_with_keys(&root) + .expect("sign root"); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "valid root, malformed reply") + .tags([ + Tag::parse(["e", &root_event.id.to_hex(), "", "root"]).expect("root tag"), + Tag::parse(["e", "bad", "", "reply"]).expect("reply tag"), + ]) + .sign_with_keys(&keys) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())); + let ctx = build_trigger_context(&stored); + assert!( + !ctx.is_reply, + "a valid root with a malformed reply id is top-level to ingest" + ); } #[test] diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index afee730d60..0e8dfdb52e 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -100,6 +100,11 @@ pub enum ActionDef { /// Optional channel UUID override. Must be a valid UUID string. #[serde(default)] channel: Option, + /// Reply to the triggering message in its thread instead of posting a + /// new top-level message. Only valid for message-based triggers, which + /// carry a triggering event to reply to. + #[serde(default)] + reply_in_thread: bool, }, /// Send a direct message to a user. SendDm { @@ -208,6 +213,34 @@ impl WorkflowDef { } } + // `reply_in_thread` requires a triggering message to reply to. Schedule + // and webhook triggers have none, so reject the combination at + // definition time rather than failing silently at run time. + let trigger_has_message = matches!( + self.trigger, + TriggerDef::MessagePosted { .. } + | TriggerDef::ReactionAdded { .. } + | TriggerDef::DiffPosted { .. } + ); + if !trigger_has_message { + for step in &self.steps { + if matches!( + step.action, + ActionDef::SendMessage { + reply_in_thread: true, + .. + } + ) { + return Err(WorkflowError::InvalidDefinition(format!( + "step '{}': reply_in_thread requires a message-based trigger \ + (message_posted, reaction_added, or diff_posted); \ + schedule and webhook triggers have no message to reply to", + step.id + ))); + } + } + } + if let TriggerDef::Schedule { cron, interval } = &self.trigger { if cron.is_none() && interval.is_none() { return Err(WorkflowError::InvalidDefinition( @@ -458,6 +491,78 @@ mod tests { assert!(matches!(err, WorkflowError::InvalidDefinition(_))); } + #[test] + fn reply_in_thread_defaults_false_and_round_trips() { + // Absent field defaults to false. + let yaml = "name: Auto Reply\ntrigger:\n on: message_posted\nsteps:\n - id: s1\n action: send_message\n text: hi\n"; + let (def, _) = parse_yaml(yaml).expect("parse failed"); + match &def.steps[0].action { + ActionDef::SendMessage { + reply_in_thread, .. + } => assert!(!reply_in_thread, "should default to false"), + other => panic!("unexpected action: {other:?}"), + } + + // Explicit true parses, and survives a JSON round-trip. + let yaml = "name: Auto Reply\ntrigger:\n on: message_posted\nsteps:\n - id: s1\n action: send_message\n text: hi\n reply_in_thread: true\n"; + let (def, _) = parse_yaml(yaml).expect("parse failed"); + match &def.steps[0].action { + ActionDef::SendMessage { + reply_in_thread, .. + } => assert!(reply_in_thread), + other => panic!("unexpected action: {other:?}"), + } + let json = serde_json::to_string(&def).expect("serialize"); + let reparsed: WorkflowDef = serde_json::from_str(&json).expect("json round-trip"); + assert!(matches!( + &reparsed.steps[0].action, + ActionDef::SendMessage { + reply_in_thread: true, + .. + } + )); + } + + #[test] + fn validate_accepts_reply_in_thread_on_message_triggers() { + for on in ["message_posted", "reaction_added", "diff_posted"] { + let yaml = format!( + "name: Auto Reply\ntrigger:\n on: {on}\nsteps:\n - id: s1\n action: send_message\n text: hi\n reply_in_thread: true\n" + ); + parse_yaml(&yaml) + .unwrap_or_else(|e| panic!("reply_in_thread should be valid on {on}: {e}")); + } + } + + #[test] + fn validate_rejects_reply_in_thread_on_schedule_trigger() { + let yaml = "name: Bad\ntrigger:\n on: schedule\n cron: '0 9 * * 1-5'\nsteps:\n - id: s1\n action: send_message\n text: hi\n reply_in_thread: true\n"; + let err = parse_yaml(yaml).unwrap_err(); + match &err { + WorkflowError::InvalidDefinition(msg) => { + assert!( + msg.contains("reply_in_thread"), + "expected reply_in_thread in: {msg}" + ); + } + other => panic!("expected InvalidDefinition, got: {other}"), + } + } + + #[test] + fn validate_rejects_reply_in_thread_on_webhook_trigger() { + let yaml = "name: Bad\ntrigger:\n on: webhook\nsteps:\n - id: s1\n action: send_message\n text: hi\n channel: 00000000-0000-0000-0000-000000000000\n reply_in_thread: true\n"; + let err = parse_yaml(yaml).unwrap_err(); + assert!(matches!(err, WorkflowError::InvalidDefinition(_))); + } + + #[test] + fn validate_allows_reply_in_thread_false_on_schedule() { + // Explicit `false` on a schedule trigger is fine — no message needed. + let yaml = "name: OK\ntrigger:\n on: schedule\n cron: '0 9 * * 1-5'\nsteps:\n - id: s1\n action: send_message\n text: hi\n reply_in_thread: false\n"; + parse_yaml(yaml).expect("reply_in_thread: false on schedule should be valid"); + } + #[test] fn enabled_defaults_to_true() { let yaml = "name: Test\ntrigger:\n on: webhook\nsteps:\n - id: s1\n action: delay\n duration: 1m\n"; diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 8698967660..30cee4f406 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -205,6 +205,8 @@ default so long-lived WebSocket connections have time to drain. Schema migrations are embedded in the relay binary via `sqlx::migrate!` and run at startup, gated by `BUZZ_AUTO_MIGRATE` (default `true`). Multiple replicas race-safely behind a Postgres advisory lock. `helm upgrade` is the entire upgrade procedure. +Migration 0032 is a hard compatibility boundary for relay versions that publish repaired channel rosters. The relay verifies the roster-fence trigger catalog and behavior before opening listeners and refuses to start if 0032 is missing or inert. Apply migrations before rolling the relay; for large installations, prefer a controlled `buzz-admin migrate` job with PostgreSQL lock monitoring before the code rollout. + If you prefer decoupling migrations from serving, set `migrate.autoMigrate=false`. **In that mode the chart does not run migrations for you** — you own running `buzz-admin migrate` (separate Pod / one-shot Job) against the database before every `helm install` / `helm upgrade`. Readiness probes only verify DB connectivity, not schema freshness, so a pod will appear healthy against an unmigrated schema and fail under load. A pre-upgrade Helm Job for this is on the chart roadmap; the values knob `migrate.preUpgradeJob.enabled` is reserved. ## Backups diff --git a/desktop/package.json b/desktop/package.json index 683c1529d2..4a6bdcd7f5 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.17", + "version": "0.5.18", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b1a2d5623b..ff8a0e7703 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ testMatch: [ "**/smoke.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", + "**/tooltip-semantics.spec.ts", "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5bd19ef2b6..fb60a35189 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.17" +version = "0.5.18" dependencies = [ "anyhow", "arboard", @@ -3080,9 +3080,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -3090,9 +3090,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" @@ -3107,9 +3107,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -3126,32 +3126,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -10239,6 +10239,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index db089fc13f..3f7189deea 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.17" +version = "0.5.18" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 5f5019cd20..7c41f6bfe2 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -129,6 +129,7 @@ pub struct AppState { /// bounded and letting a later leave correctly flip the channel back to /// `is_member=false`. pub pending_owned_channels: Mutex>, + pub archive_db: crate::archive::ArchiveDb, } /// Parse the `BUZZ_PRIVATE_KEY` env var into identity keys. `Some` means the @@ -219,9 +220,7 @@ pub fn build_app_state() -> AppState { huddle_audio: Default::default(), app_handle: Mutex::new(None), media_proxy_port: AtomicU16::new(0), - prevent_sleep: Arc::new(Mutex::new( - crate::prevent_sleep::PreventSleepState::default(), - )), + prevent_sleep: Default::default(), keyring_locked: AtomicBool::new(false), identity_lost: AtomicBool::new(false), reset_failed: AtomicBool::new(false), @@ -232,6 +231,7 @@ pub fn build_app_state() -> AppState { #[cfg(feature = "mesh-llm")] mesh_coordinator: AsyncMutex::new(None), pending_owned_channels: Mutex::new(std::collections::HashSet::new()), + archive_db: crate::archive::ArchiveDb::default(), } } diff --git a/desktop/src-tauri/src/archive/archive_db.rs b/desktop/src-tauri/src/archive/archive_db.rs new file mode 100644 index 0000000000..7c69591b0f --- /dev/null +++ b/desktop/src-tauri/src/archive/archive_db.rs @@ -0,0 +1,199 @@ +//! Process-wide gated adapter for the local archive SQLite database. +//! +//! Two coupled guarantees, both required by plan v3 (decisions 1–2): +//! +//! 1. **Init barrier.** Exactly one blocking task opens the DB the first time +//! and completes every schema migration (including M4, whose index build +//! over Will's 1.3M-row archive is not free). Every production open — every +//! Tauri archive command and all future startup/prune work — `await`s that +//! result before touching its own connection. The barrier is independent of +//! identity/relay resolution: the archive DB is a single per-nest file +//! (identity is a row column, not part of the path), and the path resolves +//! from `nest_dir()`, which is fixed early in `setup()` before the async +//! workspace relay override settles. This satisfies Thufir's binding +//! condition that the barrier cover workspace/identity timing and the +//! globally-mounted observer archive producer. +//! +//! 2. **Maintenance lock.** A shared `RwLock` whose read guard is held for the +//! full lifetime of every ordinary connection (acquired before the blocking +//! dispatch, released only after the closure returns and its connection is +//! dropped). The Phase-4 "reclaim space" conversion will take the *write* +//! guard for its sole-connection `VACUUM` sequence; nothing else may hold a +//! live connection while that runs. Phase 1 only ever takes the read guard, +//! but the lock lives here so the write path has a home. +//! +//! M4 additionally keeps its own `BEGIN IMMEDIATE` + in-lock recheck for crash +//! and cross-process safety — the in-process barrier serializes this process's +//! opens, but a second OS process (or a direct `open_archive_db` in tests) can +//! still race the first open. The two mechanisms are complementary, not +//! redundant: the barrier is startup orchestration, the immediate-lock is +//! durability. + +use std::path::PathBuf; + +use rusqlite::Connection; +use tokio::sync::{OnceCell, RwLock}; + +use super::store; +use crate::managed_agents::nest_dir; + +/// A hook run once on the blocking pool at the start of the single init task. +/// Test-only: lets a test count initializations and hold the winner long +/// enough to prove concurrent callers await it. Never set in production. +#[cfg(test)] +type InitHook = std::sync::Arc; + +/// Test-only overrides so the barrier and guard-lifetime contracts can be +/// exercised without a real nest: a fixed DB path in place of `nest_dir()` and +/// an optional init hook. `Default` leaves this `None`, so production always +/// resolves the path from the nest and runs no hook. +#[cfg(test)] +struct TestSeam { + path: PathBuf, + on_init: Option, +} + +/// Gated owner of every production archive DB connection. Lives in +/// [`crate::app_state::AppState`]; commands call [`ArchiveDb::with_conn`]. +#[derive(Default)] +pub struct ArchiveDb { + /// Set to `()` once the first open (which runs all migrations incl. M4) + /// succeeds. A failed init is NOT cached — the next caller retries — so a + /// transient error (e.g. a briefly unavailable external volume) does not + /// wedge the archive for the process lifetime. + init: OnceCell<()>, + /// Maintenance lock. Ordinary connections hold the read guard for their + /// whole lifetime; the Phase-4 conversion holds the write guard. + maintenance: RwLock<()>, + /// Test-only path/hook overrides; always `None` in production. + #[cfg(test)] + test_seam: Option, +} + +impl ArchiveDb { + /// Resolve the archive DB path. Production resolves from the nest + /// directory; a test seam (when present) supplies a fixed path so the + /// barrier can be exercised without a real nest. Errors only when the nest + /// cannot be resolved (fatal for archive access, same as the former + /// `open_db`). + fn db_path(&self) -> Result { + #[cfg(test)] + if let Some(seam) = &self.test_seam { + return Ok(seam.path.clone()); + } + let nest = nest_dir().ok_or("cannot resolve nest directory for archive")?; + Ok(nest.join("archive").join("archive.db")) + } + + /// The init hook, if a test installed one; always `None` in production. + #[cfg(test)] + fn init_hook(&self) -> Option { + self.test_seam.as_ref().and_then(|s| s.on_init.clone()) + } + + /// Complete the one-time init: open the DB once on the blocking pool, + /// running `SCHEMA` + all migrations (incl. M4), then drop the connection. + /// Concurrent callers await the same single execution. Idempotent and + /// cheap after the first success (the cached `()` short-circuits). + async fn ensure_initialized(&self) -> Result<(), String> { + let path = self.db_path()?; + #[cfg(test)] + let hook = self.init_hook(); + self.init + .get_or_try_init(|| async { + tokio::task::spawn_blocking(move || { + // Test hook runs at the very start of the single init task, + // before the migration opens the DB — this is where a test + // holds the winner past the busy timeout to prove ordinary + // callers await it. No-op in production. + #[cfg(test)] + if let Some(hook) = hook { + hook(); + } + // Opening runs every migration; the connection exists only + // to complete them behind the barrier, so drop it here. + let conn = store::open_archive_db(&path)?; + drop(conn); + Ok::<(), String>(()) + }) + .await + .map_err(|e| format!("archive init task failed: {e}"))? + }) + .await + .map(|_| ()) + } + + /// Warm the init barrier without running a query. Called once from + /// `setup()` so the first-open migration cost (M4's index build over a + /// large archive) is paid at startup rather than blocking a user's first + /// archive command. A failure here is non-fatal — the first real + /// [`with_conn`](Self::with_conn) caller retries and surfaces the error. + pub async fn warm_init(&self) -> Result<(), String> { + self.ensure_initialized().await + } + + /// Run `task` against a fresh archive connection on the blocking pool. + /// + /// Ordering: await the init barrier → acquire the maintenance read guard → + /// dispatch the blocking closure with its own connection. The read guard is + /// held across the `.await` on the blocking join, so it is released only + /// after `task` returns and the connection it borrowed has dropped — the + /// guard-lifetime contract the Phase-4 write path depends on. + pub async fn with_conn(&self, task: F) -> Result + where + T: Send + 'static, + F: FnOnce(&Connection) -> Result + Send + 'static, + { + self.ensure_initialized().await?; + let path = self.db_path()?; + let _guard = self.maintenance.read().await; + tokio::task::spawn_blocking(move || { + let conn = store::open_archive_db(&path)?; + task(&conn) + }) + .await + .map_err(|e| format!("archive db task failed: {e}"))? + } +} + +#[cfg(test)] +impl ArchiveDb { + /// Build an adapter bound to a fixed DB path (no nest required), so the + /// barrier and guard-lifetime contracts can be exercised in isolation. + fn with_test_path(path: PathBuf) -> Self { + Self { + init: OnceCell::new(), + maintenance: RwLock::new(()), + test_seam: Some(TestSeam { + path, + on_init: None, + }), + } + } + + /// Build an adapter bound to a fixed path whose single initialization runs + /// `hook` first — used to count initializations and to hold the init task + /// open across the concurrent-caller window. + fn with_test_hook(path: PathBuf, hook: InitHook) -> Self { + Self { + init: OnceCell::new(), + maintenance: RwLock::new(()), + test_seam: Some(TestSeam { + path, + on_init: Some(hook), + }), + } + } + + /// Whether the maintenance WRITE guard can be taken right now. A live + /// `with_conn` connection holds the read guard, so this returns `false` + /// while any ordinary connection is open and `true` once all have dropped — + /// exactly the signal the Phase-4 sole-connection VACUUM will gate on. + fn maintenance_write_available(&self) -> bool { + self.maintenance.try_write().is_ok() + } +} + +#[cfg(test)] +#[path = "archive_db_tests.rs"] +mod archive_db_tests; diff --git a/desktop/src-tauri/src/archive/archive_db_tests.rs b/desktop/src-tauri/src/archive/archive_db_tests.rs new file mode 100644 index 0000000000..f8182f829c --- /dev/null +++ b/desktop/src-tauri/src/archive/archive_db_tests.rs @@ -0,0 +1,246 @@ +//! Behavior tests for the [`ArchiveDb`] init barrier and maintenance-lock +//! guard lifetime — the two contracts Phase 1 introduced and Thufir's pass-1 +//! review required to be pinned directly (not via raw SQLite contention). +//! +//! These race PRODUCTION-shaped `with_conn` callers through the real +//! `OnceCell`/`RwLock` orchestration, using the `#[cfg(test)]` path/hook seam +//! on `ArchiveDb` to make timing deterministic instead of relying on a large +//! on-disk fixture or wall-clock sleeps to approach the 5s busy timeout. + +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +/// A one-shot latch that blocks a blocking-pool thread until the test releases +/// it. Deterministic stand-in for "the M4 winner holds init open past the busy +/// timeout": the init/closure thread parks here, the test observes the frozen +/// state, then releases. `Mutex` + `Condvar` are `Sync`, so an `Arc` +/// captured by the `Send + Sync` init hook type-checks. +struct Latch { + open: Mutex, + cv: Condvar, +} + +impl Latch { + fn new() -> Arc { + Arc::new(Self { + open: Mutex::new(false), + cv: Condvar::new(), + }) + } + + /// Block until [`release`](Self::release) is called (returns immediately if + /// already released). + fn wait(&self) { + let mut open = self.open.lock().unwrap(); + while !*open { + open = self.cv.wait(open).unwrap(); + } + } + + fn release(&self) { + *self.open.lock().unwrap() = true; + self.cv.notify_all(); + } +} + +/// Poll `cond` on the async runtime until it holds or `timeout` elapses. +/// Panics on timeout so a broken barrier surfaces as a failure, never a hang. +async fn await_until(what: &str, timeout: Duration, cond: impl Fn() -> bool) { + let deadline = Instant::now() + timeout; + while !cond() { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +/// An archive DB path inside a fresh temp dir. The dir is returned so the +/// caller keeps it alive for the whole test (dropping it deletes the file). +fn temp_db() -> (TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("archive").join("archive.db"); + (dir, path) +} + +/// The barrier serializes first-open: exactly one initialization runs while +/// every concurrent `with_conn` caller awaits it, and no ordinary connection +/// opens until that initialization has completed. +/// +/// The init hook holds the single init task open on a latch. While it is held +/// we prove no `with_conn` caller has opened its connection (`open_count == 0`) +/// — impossible if callers bypassed the `OnceCell` and opened independently. +/// Releasing the latch lets init finish; all callers then complete, exactly +/// one initialization ran, and every open happened after init. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_first_open_barrier_serializes_init_and_defers_opens() { + let (_dir, path) = temp_db(); + let init_count = Arc::new(AtomicUsize::new(0)); + let open_count = Arc::new(AtomicUsize::new(0)); + let release = Latch::new(); + + let hook = { + let init_count = Arc::clone(&init_count); + let release = Arc::clone(&release); + Arc::new(move || { + // Runs once, at the head of the single init task. Record the + // initialization, then park so the test can inspect the frozen + // pre-open state. + init_count.fetch_add(1, Ordering::SeqCst); + release.wait(); + }) as InitHook + }; + let db = Arc::new(ArchiveDb::with_test_hook(path, hook)); + + // Trigger the single init and wait until it is provably in flight (hook + // ran) and parked on the latch. + let warm = { + let db = Arc::clone(&db); + tokio::spawn(async move { db.warm_init().await }) + }; + await_until("init to start", Duration::from_secs(10), || { + init_count.load(Ordering::SeqCst) == 1 + }) + .await; + + // Fan out production-shaped callers while init is held. Each records that + // its task was scheduled (`entered`) and that its closure actually opened a + // connection (`open_count`). + let entered = Arc::new(AtomicUsize::new(0)); + let callers: Vec<_> = (0..4) + .map(|_| { + let db = Arc::clone(&db); + let open_count = Arc::clone(&open_count); + let entered = Arc::clone(&entered); + tokio::spawn(async move { + entered.fetch_add(1, Ordering::SeqCst); + db.with_conn(move |conn| { + open_count.fetch_add(1, Ordering::SeqCst); + // Touch the migrated schema to prove a usable connection. + conn.query_row("SELECT COUNT(*) FROM archive_meta", [], |r| { + r.get::<_, i64>(0) + }) + .map_err(|e| e.to_string()) + }) + .await + }) + }) + .collect(); + + // All four caller tasks are scheduled and running before we judge the + // barrier: they have entered `with_conn` and can only be parked on the + // init `OnceCell`. Without the barrier they would instead open independent + // connections here and bump `open_count` while init is still held. + await_until("callers to be scheduled", Duration::from_secs(10), || { + entered.load(Ordering::SeqCst) == 4 + }) + .await; + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!( + open_count.load(Ordering::SeqCst), + 0, + "no ordinary connection may open until initialization completes" + ); + + // Let init finish; every caller now completes against the migrated DB. + release.release(); + assert!(warm.await.unwrap().is_ok(), "warm init must succeed"); + for caller in callers { + assert!( + caller.await.unwrap().is_ok(), + "every with_conn must succeed" + ); + } + + assert_eq!( + init_count.load(Ordering::SeqCst), + 1, + "exactly one initialization ran behind the barrier" + ); + assert_eq!( + open_count.load(Ordering::SeqCst), + 4, + "all callers opened, and only after init" + ); +} + +/// The maintenance read guard lives for the FULL lifetime of a `with_conn` +/// connection: a write-lock contender cannot enter until the closure returns +/// and its connection has dropped. This is the invariant the Phase-4 +/// sole-connection VACUUM depends on. +/// +/// A `with_conn` closure parks on a latch while holding its connection; a +/// separate task contends for the maintenance write guard. While the closure +/// is parked the writer must be blocked. Releasing the closure — which returns +/// and drops the connection — lets the writer finally acquire the guard. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_with_conn_read_guard_blocks_writer_until_connection_drops() { + let (_dir, path) = temp_db(); + let db = Arc::new(ArchiveDb::with_test_path(path)); + db.warm_init().await.expect("init must succeed"); + + let in_closure = Arc::new(AtomicUsize::new(0)); + let hold = Latch::new(); + + // A live connection: the closure parks holding it (and thus the read + // guard) until released. + let work = { + let db = Arc::clone(&db); + let in_closure = Arc::clone(&in_closure); + let hold = Arc::clone(&hold); + tokio::spawn(async move { + db.with_conn(move |_conn| { + in_closure.fetch_add(1, Ordering::SeqCst); + hold.wait(); + Ok(()) + }) + .await + }) + }; + await_until( + "closure to hold the connection", + Duration::from_secs(10), + || in_closure.load(Ordering::SeqCst) == 1, + ) + .await; + + // A genuine write-lock contender. + let write_entered = Arc::new(AtomicUsize::new(0)); + let writer = { + let db = Arc::clone(&db); + let write_entered = Arc::clone(&write_entered); + tokio::spawn(async move { + let _w = db.maintenance.write().await; + write_entered.fetch_add(1, Ordering::SeqCst); + }) + }; + + // While the connection is held, the writer must not have entered, and the + // write guard must be unavailable. + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!( + write_entered.load(Ordering::SeqCst), + 0, + "writer must block while a with_conn connection is live" + ); + assert!( + !db.maintenance_write_available(), + "write guard is unavailable while the read guard is held" + ); + + // Release the closure: it returns and its connection drops, releasing the + // read guard so the writer can proceed. + hold.release(); + assert!(work.await.unwrap().is_ok(), "held with_conn must succeed"); + writer.await.unwrap(); + assert_eq!( + write_entered.load(Ordering::SeqCst), + 1, + "writer enters once the connection has dropped" + ); + assert!( + db.maintenance_write_available(), + "write guard is free again after the connection drops" + ); +} diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 81bc213352..1b246b3fa2 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -18,12 +18,16 @@ //! == agent) is applied fail-closed. mod agent_usage; +mod archive_db; mod metric_store; mod pipeline; +pub mod retention; pub mod store; mod store_migrations; pub mod sync; +pub use archive_db::ArchiveDb; + use pipeline::{commit_archive, plan_archive, query_buckets}; use nostr::Event; @@ -32,7 +36,6 @@ use serde::{Deserialize, Serialize}; use tauri::State; use crate::app_state::AppState; -use crate::managed_agents::nest_dir; use crate::relay::{query_relay, relay_ws_url_with_override}; // ── Constants ─────────────────────────────────────────────────────────────── @@ -43,10 +46,20 @@ const OBSERVER_FRAME_TELEMETRY: &str = "telemetry"; // ── DB helpers ─────────────────────────────────────────────────────────────── -fn open_db() -> Result { - let nest = nest_dir().ok_or("cannot resolve nest directory for archive")?; - let db_path = nest.join("archive").join("archive.db"); - store::open_archive_db(&db_path) +/// Warm the archive DB init barrier on a background task, now that the nest +/// exists, so the first-open schema migration cost (M4's index build over a +/// large archive) is paid at startup rather than blocking a user's first +/// archive command. The globally-mounted observer archive producer also +/// `await`s this barrier before its first write, so warming it early avoids a +/// stall on the first observer frame. Non-fatal: the first real archive command +/// retries and surfaces any error. +pub fn spawn_warm_init(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + use tauri::Manager; + if let Err(error) = app.state::().archive_db.warm_init().await { + eprintln!("buzz-desktop: archive DB init deferred: {error}"); + } + }); } fn identity_pubkey(state: &AppState) -> Result { @@ -61,19 +74,6 @@ fn now_secs() -> i64 { .as_secs() as i64 } -async fn run_archive_db_task(task: F) -> Result -where - T: Send + 'static, - F: FnOnce(&Connection) -> Result + Send + 'static, -{ - tokio::task::spawn_blocking(move || { - let conn = open_db()?; - task(&conn) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - // ── Scope type ─────────────────────────────────────────────────────────────── /// The three supported archive scope discriminants. @@ -170,10 +170,10 @@ pub(crate) async fn archive_candidates( // ── Phase 1: plan (blocking SQLite) ───────────────────────────────────── let plan_identity_pk = identity_pk.clone(); let plan_relay_url = relay_url.clone(); - let plan = run_archive_db_task(move |conn| { - plan_archive(candidates, &plan_identity_pk, &plan_relay_url, conn) - }) - .await?; + let plan = state + .archive_db + .with_conn(move |conn| plan_archive(candidates, &plan_identity_pk, &plan_relay_url, conn)) + .await?; // ── Phase 2: relay queries (async) ─────────────────────────────────────── let bucket_results = query_buckets(plan.buckets, state).await; @@ -186,19 +186,21 @@ pub(crate) async fn archive_candidates( }; let commit_identity_pk = identity_pk.clone(); let commit_relay_url = relay_url.clone(); - run_archive_db_task(move |conn| { - commit_archive( - bucket_results, - plan.ephemeral, - plan.pre_dropped, - &commit_identity_pk, - &commit_relay_url, - &owner_keys, - now, - conn, - ) - }) - .await + state + .archive_db + .with_conn(move |conn| { + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + &commit_identity_pk, + &commit_relay_url, + &owner_keys, + now, + conn, + ) + }) + .await } /// Validate an ephemeral observer frame (kind 24200) against ALL local rules. @@ -337,16 +339,21 @@ pub async fn create_save_subscription( let kinds_json = serde_json::to_string(&kinds).map_err(|e| format!("failed to serialize kinds: {e}"))?; - let conn = open_db()?; - store::upsert_save_subscription( - &conn, - &identity_pk, - &relay_url, - scope_type.as_str(), - &scope_value, - &kinds_json, - now, - )?; + let scope_type_str = scope_type.as_str().to_string(); + state + .archive_db + .with_conn(move |conn| { + store::upsert_save_subscription( + conn, + &identity_pk, + &relay_url, + &scope_type_str, + &scope_value, + &kinds_json, + now, + ) + }) + .await?; sync_state.notify_subscriptions_changed().await; Ok(()) } @@ -452,10 +459,12 @@ pub async fn merge_save_subscription_kinds( let relay_url = relay_ws_url_with_override(&state); let now = now_secs(); let owner_pk = identity_pk.clone(); - run_archive_db_task(move |conn| { - store::merge_owner_p_kinds(conn, &identity_pk, &relay_url, &owner_pk, kind, now) - }) - .await?; + state + .archive_db + .with_conn(move |conn| { + store::merge_owner_p_kinds(conn, &identity_pk, &relay_url, &owner_pk, kind, now) + }) + .await?; sync_state.notify_subscriptions_changed().await; Ok(()) } @@ -487,10 +496,12 @@ pub async fn remove_save_subscription_kind( let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); let owner_pk = identity_pk.clone(); - run_archive_db_task(move |conn| { - store::remove_owner_p_kind(conn, &identity_pk, &relay_url, &owner_pk, kind) - }) - .await?; + state + .archive_db + .with_conn(move |conn| { + store::remove_owner_p_kind(conn, &identity_pk, &relay_url, &owner_pk, kind) + }) + .await?; sync_state.notify_subscriptions_changed().await; Ok(()) } @@ -504,7 +515,9 @@ pub async fn list_save_subscriptions( ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| store::list_save_subscriptions(conn, &identity_pk, &relay_url)) + state + .archive_db + .with_conn(move |conn| store::list_save_subscriptions(conn, &identity_pk, &relay_url)) .await } @@ -523,16 +536,18 @@ pub async fn delete_save_subscription( ) -> Result { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let removed = run_archive_db_task(move |conn| { - store::delete_save_subscription( - conn, - &identity_pk, - &relay_url, - scope_type.as_str(), - &scope_value, - ) - }) - .await?; + let removed = state + .archive_db + .with_conn(move |conn| { + store::delete_save_subscription( + conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + ) + }) + .await?; if removed { sync_state.notify_subscriptions_changed().await; } @@ -564,18 +579,20 @@ pub async fn read_archived_observer_events_for_channel( ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| { - store::read_archived_observer_events_for_channel( - conn, - &identity_pk, - &relay_url, - &channel_id, - before_created_at, - before_id.as_deref(), - limit.unwrap_or(DEFAULT_READ_LIMIT), - ) - }) - .await + state + .archive_db + .with_conn(move |conn| { + store::read_archived_observer_events_for_channel( + conn, + &identity_pk, + &relay_url, + &channel_id, + before_created_at, + before_id.as_deref(), + limit.unwrap_or(DEFAULT_READ_LIMIT), + ) + }) + .await } // ── index_observer_channel_id ───────────────────────────────────────────────── @@ -595,20 +612,22 @@ pub async fn index_observer_channel_id( ) -> Result<(), String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| { - for entry in &entries { - store::upsert_observer_channel_index( - conn, - &identity_pk, - &relay_url, - &entry.event_id, - entry.channel_id.as_deref(), - entry.created_at, - )?; - } - Ok(()) - }) - .await + state + .archive_db + .with_conn(move |conn| { + for entry in &entries { + store::upsert_observer_channel_index( + conn, + &identity_pk, + &relay_url, + &entry.event_id, + entry.channel_id.as_deref(), + entry.created_at, + )?; + } + Ok(()) + }) + .await } /// A single (event_id, channel_id?, created_at) record used by @@ -639,18 +658,20 @@ pub async fn read_unindexed_observer_rows( ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| { - let rows = store::read_unindexed_observer_rows(conn, &identity_pk, &relay_url)?; - Ok(rows - .into_iter() - .map(|(id, raw_json, created_at)| RawObserverRow { - id, - raw_json, - created_at, - }) - .collect()) - }) - .await + state + .archive_db + .with_conn(move |conn| { + let rows = store::read_unindexed_observer_rows(conn, &identity_pk, &relay_url)?; + Ok(rows + .into_iter() + .map(|(id, raw_json, created_at)| RawObserverRow { + id, + raw_json, + created_at, + }) + .collect()) + }) + .await } /// Wire type returned by `read_unindexed_observer_rows`. @@ -695,20 +716,22 @@ pub async fn read_archived_events( let relay_url = relay_ws_url_with_override(&state); let scope_type_str = scope_type.as_str().to_string(); let read_limit = limit.unwrap_or(DEFAULT_READ_LIMIT); - run_archive_db_task(move |conn| { - store::read_archived_events( - conn, - &identity_pk, - &relay_url, - &scope_type_str, - &scope_value, - kinds.as_deref(), - before_created_at, - before_id.as_deref(), - read_limit, - ) - }) - .await + state + .archive_db + .with_conn(move |conn| { + store::read_archived_events( + conn, + &identity_pk, + &relay_url, + &scope_type_str, + &scope_value, + kinds.as_deref(), + before_created_at, + before_id.as_deref(), + read_limit, + ) + }) + .await } // ── get_agent_usage_series ─────────────────────────────────────────────────── @@ -797,10 +820,48 @@ pub async fn get_agent_usage_series( ) -> Result { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| agent_usage_series(conn, &identity_pk, &relay_url, &request)) + state + .archive_db + .with_conn(move |conn| agent_usage_series(conn, &identity_pk, &relay_url, &request)) .await } +// ── Retention configuration commands ────────────────────────────────────────── + +/// Read the global observer-frame (kind 24200) retention window, in days. Every +/// other archived kind — NIP-AM metrics and any custom subscription — is kept +/// indefinitely and has no setting. +#[tauri::command] +pub async fn get_observer_retention_days(state: State<'_, AppState>) -> Result { + state + .archive_db + .with_conn(retention::get_observer_retention_days) + .await +} + +/// Set the global observer-frame retention window, in days. Fail-closed: the +/// store layer rejects zero, negative, or out-of-range values (see +/// [`retention::validate_days`]). +#[tauri::command] +pub async fn set_observer_retention_days( + state: State<'_, AppState>, + days: i64, +) -> Result<(), String> { + state + .archive_db + .with_conn(move |conn| retention::set_observer_retention_days(conn, days)) + .await +} + +/// Physical (file) and logical (page) size accounting for the archive DB, for +/// the Settings size readout. PRAGMAs + file metadata only — no payload scans. +#[tauri::command] +pub async fn archive_size_stats( + state: State<'_, AppState>, +) -> Result { + state.archive_db.with_conn(retention::size_stats).await +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/archive/retention.rs b/desktop/src-tauri/src/archive/retention.rs new file mode 100644 index 0000000000..5ee9acff20 --- /dev/null +++ b/desktop/src-tauri/src/archive/retention.rs @@ -0,0 +1,216 @@ +//! Local archive retention configuration + size accounting. +//! +//! v4 (Will's ruling, 2026-08-19): retention is a single global setting — how +//! many days observer frames (kind 24200) are kept locally. NIP-AM metrics +//! (44200) and every other archived kind are kept indefinitely with no +//! retention machinery. The setting lives as one row in the `archive_meta` k/v +//! table (`observer_retention_days`), seeded by migration M4. +//! +//! This module owns the `archive_meta` schema, the scope-age index used by the +//! Phase-2 prune scan, the get/set accessors for the observer window, and the +//! PRAGMA-based size readout. The prune worker itself lands in Phase 2. +//! +//! Kept in a sibling file (not `store.rs`) to respect the 1000-line gate, per +//! the existing `metric_store.rs` / `pipeline.rs` / `store_migrations.rs` +//! precedent. + +use rusqlite::{params, Connection, OptionalExtension}; + +// ── Constants ──────────────────────────────────────────────────────────────── + +/// `archive_meta` key holding the observer-frame retention window, in days, +/// stored as its decimal text. +pub const OBSERVER_RETENTION_DAYS_KEY: &str = "observer_retention_days"; + +/// Default rolling window for observer frames (Will's ruling: "~14–30"; 30 is +/// the shipped default, trivially changeable). Seeded into `archive_meta` by M4. +pub const DEFAULT_OBSERVER_RETENTION_DAYS: i64 = 30; + +/// Upper bound on the retention window (~100 years). Guards against a day count +/// large enough to overflow `archived_at` cutoff arithmetic while still +/// admitting any realistic user choice. +pub const MAX_RETENTION_DAYS: i64 = 36_500; + +// ── Schema (created by migration M4, not the base SCHEMA) ───────────────────── + +/// `archive_meta`. Created inside M4 under `BEGIN IMMEDIATE` (see +/// `store_migrations::migrate_add_archive_meta`). Plain `CREATE TABLE` +/// (no `IF NOT EXISTS`): M4 creates it once on a DB that provably lacks it (the +/// fail-closed guard rejects a pre-existing one), so the clause would be dead +/// weight. Holds the observer-retention window and the Phase-2 prune timestamp. +pub(super) const ARCHIVE_META_SCHEMA: &str = " +CREATE TABLE archive_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +"; + +/// Covering index for the Phase-2 retention prune candidate scan. V4 prunes +/// one global observer window, so the scan filters `(identity_pubkey, +/// relay_url, archived_at < cutoff)` and joins `archived_events` for +/// `kind = 24200` — it does NOT constrain `scope_type`/`scope_value`. Age +/// therefore has to lead the non-equality keys: putting `archived_at` +/// immediately after the two identity/relay equality keys lets SQLite +/// range-seek `archived_at < ?` directly (`SEARCH ... USING COVERING INDEX +/// ... (identity_pubkey=? AND relay_url=? AND archived_at Result<(), String> { + if !(1..=MAX_RETENTION_DAYS).contains(&days) { + return Err(format!( + "retention days must be between 1 and {MAX_RETENTION_DAYS}; got {days}" + )); + } + Ok(()) +} + +// ── Observer retention window (archive_meta accessor) ─────────────────────────── + +/// Read the observer-frame retention window (days). Returns the seeded default +/// when the row is somehow absent (older DB opened before M4 seeded it, or an +/// externally cleared row) so the setting always resolves to a bounded window +/// rather than silently becoming Forever. +pub fn get_observer_retention_days(conn: &Connection) -> Result { + let raw: Option = conn + .query_row( + "SELECT value FROM archive_meta WHERE key = ?1", + params![OBSERVER_RETENTION_DAYS_KEY], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("read observer retention days: {e}"))?; + + match raw { + Some(s) => s + .parse::() + .map_err(|e| format!("observer retention days not an integer ({s:?}): {e}")), + None => Ok(DEFAULT_OBSERVER_RETENTION_DAYS), + } +} + +/// Set the observer-frame retention window (days). Fail-closed: rejects an +/// out-of-range value before writing (see [`validate_days`]). A single +/// idempotent upsert, atomic under autocommit. +pub fn set_observer_retention_days(conn: &Connection, days: i64) -> Result<(), String> { + validate_days(days)?; + conn.execute( + "INSERT INTO archive_meta (key, value) VALUES (?1, ?2) + ON CONFLICT (key) DO UPDATE SET value = excluded.value", + params![OBSERVER_RETENTION_DAYS_KEY, days.to_string()], + ) + .map_err(|e| format!("set observer retention days: {e}"))?; + Ok(()) +} + +// ── Size accounting ───────────────────────────────────────────────────────────── + +/// Collect physical (file) and logical (page) size figures for the archive DB. +/// PRAGMAs only — no row or payload scans. The main DB file path is read from +/// the connection itself (`PRAGMA database_list`), and the `-wal` sidecar is +/// measured by appending `-wal` to it. +pub fn size_stats(conn: &Connection) -> Result { + let page_size: i64 = conn + .pragma_query_value(None, "page_size", |row| row.get(0)) + .map_err(|e| format!("read page_size: {e}"))?; + let page_count: i64 = conn + .pragma_query_value(None, "page_count", |row| row.get(0)) + .map_err(|e| format!("read page_count: {e}"))?; + let freelist_count: i64 = conn + .pragma_query_value(None, "freelist_count", |row| row.get(0)) + .map_err(|e| format!("read freelist_count: {e}"))?; + + // `PRAGMA database_list` yields (seq, name, file) rows; the `main` schema's + // `file` is the on-disk DB path (empty for a `:memory:` DB). File sizes are + // read from filesystem metadata rather than page arithmetic so WAL frames + // not yet checkpointed into the main file are accounted for separately. + let main_path: Option = conn + .query_row( + "SELECT file FROM pragma_database_list WHERE name = 'main'", + [], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("read database_list: {e}"))? + .filter(|p| !p.is_empty()); + + let (main_file_bytes, wal_file_bytes) = match main_path { + Some(p) => { + let main = std::path::PathBuf::from(&p); + (file_len(&main), file_len(&wal_path(&main))) + } + None => (0, 0), + }; + + Ok(ArchiveSizeStats { + main_file_bytes, + wal_file_bytes, + page_size, + page_count, + freelist_count, + }) +} + +/// Size of a file in bytes, or `0` when it does not exist / cannot be stat'd. +/// A missing `-wal` (fully checkpointed DB) is the normal case, not an error. +fn file_len(path: &std::path::Path) -> i64 { + std::fs::metadata(path).map(|m| m.len() as i64).unwrap_or(0) +} + +/// The `-wal` sidecar path for a main DB file (`archive.db` → `archive.db-wal`). +fn wal_path(db_path: &std::path::Path) -> std::path::PathBuf { + let mut name = db_path.as_os_str().to_os_string(); + name.push("-wal"); + std::path::PathBuf::from(name) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[path = "retention_tests.rs"] +mod retention_tests; diff --git a/desktop/src-tauri/src/archive/retention_tests.rs b/desktop/src-tauri/src/archive/retention_tests.rs new file mode 100644 index 0000000000..26e6a25fda --- /dev/null +++ b/desktop/src-tauri/src/archive/retention_tests.rs @@ -0,0 +1,440 @@ +//! Behavior tests for the observer-retention setting, the size readout, and the +//! M4 migration. +//! +//! Kept in a sibling file so `retention.rs` stays under the 1000-line gate; +//! `#[path]`-included from there. `super::*` brings the retention API (and its +//! `rusqlite::{params, Connection}` imports) into scope; `super::super::store` +//! reaches the neighbouring subscription mutators and the base `SCHEMA`. + +use super::super::store; +use super::*; +use std::path::Path; +use std::sync::{Arc, Barrier}; +use tempfile::NamedTempFile; + +const ID: &str = "idpk"; +const RELAY: &str = "wss://r"; +const OWNER: &str = "owner_p"; + +/// Open a fresh archive DB (runs the full schema + every migration incl. M4). +fn fresh(db: &NamedTempFile) -> Connection { + store::open_archive_db(db.path()).expect("open_archive_db must succeed") +} + +/// Build a legacy DB that has the base schema and M1–M3 markers but NOT M4, +/// so the next `open_archive_db` pends only the retention migration. This +/// isolates the M4 first-open race from the separately-tested M1–M3 chain. +fn build_pre_m4_db(path: &Path) { + let conn = Connection::open(path).unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.execute_batch(store::SCHEMA).unwrap(); + for name in [ + "add_harness_to_metric_index", + "add_cache_read_tokens", + "add_cache_write_and_pricing", + ] { + conn.execute( + "INSERT OR IGNORE INTO archive_migrations (name, applied_at) VALUES (?1, 0)", + params![name], + ) + .unwrap(); + } +} + +fn m4_marker_count(conn: &Connection) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_archive_meta'", + [], + |r| r.get(0), + ) + .unwrap() +} + +// ── Validation (pure) ────────────────────────────────────────────────────────── + +#[test] +fn test_validate_days_accepts_one_and_max() { + assert!(validate_days(1).is_ok()); + assert!(validate_days(DEFAULT_OBSERVER_RETENTION_DAYS).is_ok()); + assert!(validate_days(MAX_RETENTION_DAYS).is_ok()); +} + +#[test] +fn test_validate_days_rejects_zero_negative_and_over_max() { + assert!(validate_days(0).is_err()); + assert!(validate_days(-1).is_err()); + assert!(validate_days(MAX_RETENTION_DAYS + 1).is_err()); +} + +// ── Observer retention get / set ──────────────────────────────────────────────── + +#[test] +fn test_get_observer_days_returns_seeded_default_on_fresh_db() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + assert_eq!( + get_observer_retention_days(&conn).unwrap(), + DEFAULT_OBSERVER_RETENTION_DAYS + ); +} + +#[test] +fn test_set_observer_days_upserts_and_overwrites() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + set_observer_retention_days(&conn, 14).unwrap(); + assert_eq!(get_observer_retention_days(&conn).unwrap(), 14); + set_observer_retention_days(&conn, 60).unwrap(); + assert_eq!(get_observer_retention_days(&conn).unwrap(), 60); +} + +#[test] +fn test_set_observer_days_rejects_out_of_range_without_writing() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + // Establish a known good value, then prove a rejected write leaves it. + set_observer_retention_days(&conn, 45).unwrap(); + assert!(set_observer_retention_days(&conn, 0).is_err()); + assert!(set_observer_retention_days(&conn, -5).is_err()); + assert!(set_observer_retention_days(&conn, MAX_RETENTION_DAYS + 1).is_err()); + assert_eq!( + get_observer_retention_days(&conn).unwrap(), + 45, + "a rejected set must not overwrite the stored value" + ); +} + +#[test] +fn test_observer_days_survive_reopen() { + let db = NamedTempFile::new().unwrap(); + { + let first = fresh(&db); + set_observer_retention_days(&first, 7).unwrap(); + } + let second = fresh(&db); + assert_eq!( + get_observer_retention_days(&second).unwrap(), + 7, + "the setting persists across opens and M4 re-run does not reset it" + ); +} + +// ── Size accounting ───────────────────────────────────────────────────────────── + +#[test] +fn test_size_stats_reports_pages_and_main_file_bytes() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + // Write enough rows that the DB grows past a single page. + for i in 0..200 { + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, ?2, ?3, 24200, 'author', ?4, ?5, ?4)", + params![ID, RELAY, format!("e{i}"), 1000 + i, "x".repeat(256)], + ) + .unwrap(); + } + let stats = size_stats(&conn).unwrap(); + assert!(stats.page_size > 0, "page_size is a positive PRAGMA value"); + assert!(stats.page_count > 1, "multi-page DB after 200 inserts"); + assert!(stats.freelist_count >= 0); + // In WAL mode the just-written pages live in the `-wal` sidecar until a + // checkpoint folds them into the main file, so the logical page total + // (page_size * page_count) is covered by the two files combined, not by + // the main file alone. + assert!( + stats.main_file_bytes + stats.wal_file_bytes >= stats.page_size * stats.page_count, + "main + wal bytes cover at least the counted pages ({} + {} >= {}*{})", + stats.main_file_bytes, + stats.wal_file_bytes, + stats.page_size, + stats.page_count + ); + assert!(stats.main_file_bytes > 0, "the main DB file is on disk"); + // WAL mode: the sidecar exists and carries the just-written frames. + assert!( + stats.wal_file_bytes > 0, + "the -wal sidecar is measured in WAL mode" + ); +} + +#[test] +fn test_size_stats_freelist_grows_after_delete() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + for i in 0..200 { + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, ?2, ?3, 24200, 'author', ?4, ?5, ?4)", + params![ID, RELAY, format!("e{i}"), 1000 + i, "x".repeat(256)], + ) + .unwrap(); + } + let before = size_stats(&conn).unwrap(); + conn.execute("DELETE FROM archived_events", []).unwrap(); + let after = size_stats(&conn).unwrap(); + assert!( + after.freelist_count > before.freelist_count, + "deleted pages land on the freelist ({} > {})", + after.freelist_count, + before.freelist_count + ); +} + +// ── Migration M4 ────────────────────────────────────────────────────────────── + +#[test] +fn test_m4_fresh_open_creates_schema_marker_and_seed() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + let objects: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE (type = 'table' AND name = 'archive_meta') + OR (type = 'index' AND name = 'idx_archived_event_scopes_age')", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(objects, 2, "one table + one index"); + assert_eq!(m4_marker_count(&conn), 1, "M4 marker recorded"); + assert_eq!( + get_observer_retention_days(&conn).unwrap(), + DEFAULT_OBSERVER_RETENTION_DAYS, + "the observer window is seeded to the default" + ); +} + +#[test] +fn test_m4_reopen_is_idempotent_and_preserves_setting() { + let db = NamedTempFile::new().unwrap(); + let first = fresh(&db); + set_observer_retention_days(&first, 90).unwrap(); + drop(first); + let second = fresh(&db); + assert_eq!( + m4_marker_count(&second), + 1, + "exactly one marker after reopen" + ); + assert_eq!( + get_observer_retention_days(&second).unwrap(), + 90, + "the re-run must not re-seed over an existing value" + ); +} + +#[test] +fn test_m4_applies_over_a_populated_pre_m4_db() { + let db = NamedTempFile::new().unwrap(); + build_pre_m4_db(db.path()); + // Pre-M4 subscriptions and archived rows must survive the migration + // untouched — M4 no longer reads or seeds from subscriptions. + { + let conn = Connection::open(db.path()).unwrap(); + store::upsert_save_subscription(&conn, ID, RELAY, OWNER, ID, "[24200,44200,1]", 100) + .unwrap(); + } + let conn = fresh(&db); + assert_eq!(m4_marker_count(&conn), 1); + assert_eq!( + get_observer_retention_days(&conn).unwrap(), + DEFAULT_OBSERVER_RETENTION_DAYS + ); + assert_eq!( + store::list_save_subscriptions(&conn, ID, RELAY) + .unwrap() + .len(), + 1, + "the pre-existing subscription is untouched by M4" + ); +} + +#[test] +fn test_m4_preexisting_archive_meta_without_marker_fails_closed() { + let db = NamedTempFile::new().unwrap(); + build_pre_m4_db(db.path()); + // An `archive_meta` table present without the M4 marker is unreachable via + // shipped code — M4 runs the whole body (create table, build index, seed, + // marker) in one transactional `BEGIN IMMEDIATE`, so a crash rolls back the + // table too. The only way to reach this state is an externally-created + // table. M4 refuses to certify it: it fails closed and rolls back with no + // marker rather than adopting a table it did not build. The table's shape + // is irrelevant — its mere presence without the marker is the trigger. + { + let conn = Connection::open(db.path()).unwrap(); + conn.execute_batch("CREATE TABLE archive_meta (key TEXT PRIMARY KEY);") + .unwrap(); + } + assert!( + store::open_archive_db(db.path()).is_err(), + "M4 must fail closed on a pre-existing archive_meta" + ); + let verify = Connection::open(db.path()).unwrap(); + assert_eq!( + m4_marker_count(&verify), + 0, + "no marker may certify an externally-created table" + ); +} + +#[test] +fn test_m4_preexisting_index_under_name_is_silently_rebuilt() { + let db = NamedTempFile::new().unwrap(); + build_pre_m4_db(db.path()); + // Unlike a table (which carries data and is fail-closed), an index carries + // no data, so M4 drops any index sharing the name and recreates it + // unconditionally — no shape inspection. A bogus pre-existing index under + // the name is silently replaced with the correct one and the marker lands. + { + let conn = Connection::open(db.path()).unwrap(); + conn.execute_batch(&format!( + "CREATE INDEX {SCOPE_AGE_INDEX_NAME} ON archived_event_scopes (id);" + )) + .unwrap(); + } + let conn = fresh(&db); + assert_eq!(m4_marker_count(&conn), 1, "M4 completes after the rebuild"); + // The rebuilt index has the six expected key columns in the covering order. + let mut stmt = conn + .prepare(&format!( + "SELECT name FROM pragma_index_xinfo('{SCOPE_AGE_INDEX_NAME}') \ + WHERE key = 1 ORDER BY seqno" + )) + .unwrap(); + let keys: Vec = stmt + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + keys, + [ + "identity_pubkey", + "relay_url", + "archived_at", + "id", + "scope_type", + "scope_value" + ], + "the index was rebuilt with the age-first covering key order" + ); +} + +// ── Concurrency ─────────────────────────────────────────────────────────────── + +#[test] +fn test_m4_two_conn_first_open_race_neither_times_out_and_marks_once() { + use std::thread; + let db = NamedTempFile::new().unwrap(); + let path = db.path().to_path_buf(); + build_pre_m4_db(&path); + // A realistically-populated legacy DB: a multi-kind subscription plus + // archived scope rows so M4's index build touches real data. + { + let conn = Connection::open(&path).unwrap(); + store::upsert_save_subscription(&conn, ID, RELAY, OWNER, ID, "[24200,44200,1]", 100) + .unwrap(); + for i in 0..8 { + conn.execute( + "INSERT INTO archived_event_scopes + (identity_pubkey, relay_url, id, scope_type, scope_value, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ID, RELAY, format!("e{i}"), OWNER, ID, 1000 + i], + ) + .unwrap(); + } + } + + let barrier = Arc::new(Barrier::new(2)); + let handles: Vec<_> = (0..2) + .map(|_| { + let p = path.clone(); + let b = Arc::clone(&barrier); + thread::spawn(move || { + b.wait(); // maximise the first-open race window + store::open_archive_db(&p).map(|_| ()) + }) + }) + .collect(); + for h in handles { + assert!( + h.join().unwrap().is_ok(), + "both racing opens must complete within busy_timeout" + ); + } + + let verify = store::open_archive_db(&path).unwrap(); + assert_eq!(m4_marker_count(&verify), 1, "M4 applied exactly once"); + // The seed ran once inside the winner's transaction — one meta row, default. + let meta_rows: i64 = verify + .query_row( + "SELECT COUNT(*) FROM archive_meta WHERE key = ?1", + params![OBSERVER_RETENTION_DAYS_KEY], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(meta_rows, 1, "the observer window was seeded exactly once"); + assert_eq!( + get_observer_retention_days(&verify).unwrap(), + DEFAULT_OBSERVER_RETENTION_DAYS + ); +} + +// ── Prune-candidate access path (index shape) ────────────────────────────────── + +/// The Phase-2 prune-candidate scan (per `PLANS/ARCHIVE_RETENTION_PLAN.md` +/// line 40): the single global observer window filters `identity + relay + +/// archived_at < cutoff`, joins `archived_events` for `kind = 24200`, and +/// selects the scope-row PK so the bounded `DELETE` can materialize candidates. +/// The scope side constrains neither `scope_type` nor `scope_value`. +const PRUNE_CANDIDATE_SQL: &str = " +SELECT s.id, s.scope_type, s.scope_value +FROM archived_event_scopes s +JOIN archived_events e + ON e.identity_pubkey = s.identity_pubkey + AND e.relay_url = s.relay_url + AND e.id = s.id +WHERE s.identity_pubkey = ?1 + AND s.relay_url = ?2 + AND s.archived_at < ?3 + AND e.kind = 24200 +LIMIT 1000 +"; + +/// The scope-age index must let the real Phase-2 prune query range-seek +/// `archived_at` directly rather than fall back to a bare identity/relay seek +/// plus a temp b-tree. The index is this PR's deliverable, so its access path +/// is pinned here even though the prune worker lands in Phase 2. +#[test] +fn test_prune_candidate_scan_seeks_archived_at_through_the_index() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); // runs M4, creating the scope-age index + + let plan: Vec = conn + .prepare(&format!("EXPLAIN QUERY PLAN {PRUNE_CANDIDATE_SQL}")) + .unwrap() + .query_map(params![ID, RELAY, 0_i64], |r| r.get::<_, String>(3)) + .unwrap() + .collect::, _>>() + .unwrap(); + + let scope_step = plan + .iter() + .find(|d| d.contains(SCOPE_AGE_INDEX_NAME)) + .unwrap_or_else(|| panic!("prune scan must use {SCOPE_AGE_INDEX_NAME}; plan was {plan:?}")); + // The planner range-seeks archived_at through the index (equality on the + // two leading identity/relay keys, then the age bound) — not a bare + // identity/relay seek that would leave age to a scan / temp b-tree. + assert!( + scope_step.contains("archived_at Result<(), String> { migrate_add_cache_read_tokens(conn)?; migrate_add_cache_write_and_pricing(conn)?; - migrate_add_harness_to_metric_index(conn) + migrate_add_harness_to_metric_index(conn)?; + migrate_add_archive_meta(conn) } /// M1: add `harness TEXT` column to `agent_metric_index` and rebuild index @@ -323,3 +325,135 @@ fn migrate_add_cache_write_and_pricing(conn: &Connection) -> Result<(), String> Ok(()) } + +/// M4: create the retention config storage — `archive_meta` (k/v state holding +/// the observer-frame retention window and the Phase-2 prune timestamp) and the +/// `archived_at`-covering scope-age index — then seed the default observer +/// retention window (`observer_retention_days = 30`). +/// +/// Unlike M1–M3, M4 CANNOT use the "check marker → `BEGIN DEFERRED`" pattern. +/// On a fresh DB two connections can open concurrently (the observer- and +/// metric-archive seed hooks each `open_archive_db`), both read no marker, and +/// a `DEFERRED` transaction lets both proceed on the same snapshot — the loser +/// hits `SQLITE_BUSY_SNAPSHOT` or double-seeds. Instead M4 takes the write lock +/// up front with `BEGIN IMMEDIATE` and **rechecks the marker inside the lock**: +/// the race loser blocks on `busy_timeout`, then observes the winner's +/// committed marker and no-ops. The cheap pre-lock guard keeps steady-state +/// opens off the write lock entirely (M4 only takes it until the marker lands). +/// +/// The table uses plain `CREATE TABLE` behind a fail-closed guard (a +/// pre-existing `archive_meta` with no marker is an externally-created object +/// M4 refuses to certify) and the index is dropped and recreated +/// unconditionally; the seed is `ON CONFLICT DO NOTHING`. The marker is written +/// last inside the same transaction, so a crash before COMMIT rolls back every +/// object and the next open re-runs from scratch. +fn migrate_add_archive_meta(conn: &Connection) -> Result<(), String> { + // Cheap pre-lock guard: steady-state opens (marker already present) never + // take the write lock. The marker is written last in M4's transaction, so + // its presence implies the full schema + seed committed. + if archive_meta_migration_applied(conn)? { + return Ok(()); + } + + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(|e| format!("migration M4: begin immediate: {e}"))?; + + let result = migrate_add_archive_meta_locked(conn); + + if result.is_ok() { + conn.execute_batch("COMMIT") + .map_err(|e| format!("migration M4: commit: {e}"))?; + } else { + // Best-effort rollback; surface the original error to the caller. + let _ = conn.execute_batch("ROLLBACK"); + } + result +} + +/// M4 body, run under the `BEGIN IMMEDIATE` write lock held by +/// `migrate_add_archive_meta`. +fn migrate_add_archive_meta_locked(conn: &Connection) -> Result<(), String> { + // In-lock recheck: a concurrent first-opener may have committed the marker + // while we were blocked on the write lock. If so, this connection has + // nothing to do — the winner already created the schema and seeded. + if archive_meta_migration_applied(conn)? { + return Ok(()); + } + + // Fail closed on an externally-created table. M4's whole body runs inside + // one `BEGIN IMMEDIATE` transaction with the marker written last, and + // SQLite DDL is transactional — a crash anywhere rolls the whole thing + // back. So no shipped code path can leave the table present without the + // marker; the only way to reach here with it already existing is a + // hand-edited DB or a foreign tool. Rather than certify a table we did not + // create, refuse: roll back with no marker and let a corrected DB re-run. + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'archive_meta'", + [], + |r| r.get::<_, i64>(0), + ) + .map_err(|e| format!("migration M4: probe archive_meta: {e}"))? + > 0; + if exists { + return Err( + "migration M4: archive_meta already exists without the M4 marker — \ + refusing to certify an externally-created table" + .to_string(), + ); + } + + conn.execute_batch(super::retention::ARCHIVE_META_SCHEMA) + .map_err(|e| format!("migration M4: create archive_meta: {e}"))?; + + // Unconditionally rebuild the scope-age index. It carries no data, so a + // fresh `CREATE` is always correct; dropping any index that happens to + // share the name costs one rebuild and needs no shape inspection. + conn.execute_batch(&format!( + "DROP INDEX IF EXISTS {}", + super::retention::SCOPE_AGE_INDEX_NAME + )) + .map_err(|e| format!("migration M4: drop any pre-existing scope-age index: {e}"))?; + conn.execute_batch(super::retention::SCOPE_AGE_INDEX_DDL) + .map_err(|e| format!("migration M4: create scope-age index: {e}"))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Seed the default observer-frame retention window. `ON CONFLICT DO NOTHING` + // in the underlying insert makes this a no-op if a value is somehow already + // present — an existing choice always wins. + conn.execute( + "INSERT INTO archive_meta (key, value) VALUES (?1, ?2) + ON CONFLICT (key) DO NOTHING", + params![ + super::retention::OBSERVER_RETENTION_DAYS_KEY, + super::retention::DEFAULT_OBSERVER_RETENTION_DAYS.to_string() + ], + ) + .map_err(|e| format!("migration M4: seed observer retention days: {e}"))?; + + conn.execute( + "INSERT OR IGNORE INTO archive_migrations (name, applied_at) \ + VALUES ('add_archive_meta', ?1)", + params![now], + ) + .map_err(|e| format!("migration M4: record marker: {e}"))?; + + Ok(()) +} + +/// Whether the M4 marker is present. Its presence implies the retention schema +/// and default seed committed (the marker is written last in M4's transaction). +fn archive_meta_migration_applied(conn: &Connection) -> Result { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_archive_meta'", + [], + |r| r.get(0), + ) + .map_err(|e| format!("migration M4: guard check: {e}"))?; + Ok(count > 0) +} diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs index bbd15391e7..c0f85430d4 100644 --- a/desktop/src-tauri/src/archive/store_tests.rs +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -10,6 +10,9 @@ fn in_memory() -> Connection { conn.pragma_update(None, "journal_mode", "WAL").unwrap(); conn.pragma_update(None, "busy_timeout", 5000).unwrap(); conn.execute_batch(SCHEMA).unwrap(); + // Match production `open_archive_db`: apply every schema migration (incl. + // M4) so tests run against the same shape production connections see. + apply_schema_migrations(&conn).unwrap(); conn } diff --git a/desktop/src-tauri/src/archive/sync.rs b/desktop/src-tauri/src/archive/sync.rs index 4fe4452c81..3730774e95 100644 --- a/desktop/src-tauri/src/archive/sync.rs +++ b/desktop/src-tauri/src/archive/sync.rs @@ -280,10 +280,12 @@ impl ArchiveSyncIo for AppIo { let state: State<'_, AppState> = self.app.state(); let identity_pk = super::identity_pubkey(&state)?; let relay_url = crate::relay::relay_ws_url_with_override(&state); - super::run_archive_db_task(move |conn| { - super::store::list_save_subscriptions(conn, &identity_pk, &relay_url) - }) - .await + state + .archive_db + .with_conn(move |conn| { + super::store::list_save_subscriptions(conn, &identity_pk, &relay_url) + }) + .await }) } diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 95534854a0..95e9759f10 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -4,6 +4,7 @@ use crate::managed_agents::{ DEFAULT_ACP_COMMAND, }; +mod forced_single_flight; mod post_install_verification; fn active_installs() -> &'static std::sync::Mutex> { @@ -49,23 +50,15 @@ pub(crate) fn plan_adapter_install<'c>( } } +/// Discover the ACP runtime catalog. `force: false` (the default) serves the +/// cheap cached path; `force: true` runs the expensive re-discovery. See +/// [`forced_single_flight`] for the split and single-flight coalescing. #[tauri::command] pub async fn discover_acp_providers( app: tauri::AppHandle, + force: Option, ) -> Result, String> { - tokio::task::spawn_blocking(move || { - use tauri::Manager; - crate::managed_agents::clear_resolve_cache(); - crate::managed_agents::refresh_login_shell_path(); - let custom_dir = app - .path() - .app_data_dir() - .ok() - .map(|d| d.join("custom_harnesses")); - crate::managed_agents::discover_acp_runtimes_from(custom_dir.as_deref()) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}")) + forced_single_flight::discover(app, force.unwrap_or(false)).await } /// Write a user-defined harness definition to `/custom_harnesses/.json`. diff --git a/desktop/src-tauri/src/commands/agent_discovery/forced_single_flight.rs b/desktop/src-tauri/src/commands/agent_discovery/forced_single_flight.rs new file mode 100644 index 0000000000..3667d3b237 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/forced_single_flight.rs @@ -0,0 +1,80 @@ +//! Discovery execution + single-flight coalescing for the ACP runtime catalog. +//! +//! `force: false` serves from the process caches (no clear, no PATH re-fetch, no +//! CLI auth probes) — the low-millisecond path hot surfaces render from. +//! +//! `force: true` runs the expensive probe pipeline. React Query already dedups +//! the hook consumers; the single-flight here is the seatbelt for non-hook +//! invoke paths, so a burst of forced triggers coalesces onto one in-flight run +//! instead of stacking the pipeline. + +use super::AcpRuntimeCatalogEntry; + +type BoxedDiscovery = std::pin::Pin< + Box, String>> + Send>, +>; +type SharedDiscovery = futures_util::future::Shared; + +fn inflight() -> &'static std::sync::Mutex> { + use std::sync::{Mutex, OnceLock}; + static INFLIGHT: OnceLock>> = OnceLock::new(); + INFLIGHT.get_or_init(|| Mutex::new(None)) +} + +/// Discover the ACP runtime catalog. Cheap calls run directly; forced calls +/// coalesce onto a single shared run (see module docs). +pub(super) async fn discover( + app: tauri::AppHandle, + force: bool, +) -> Result, String> { + if !force { + return run(app, false).await; + } + + let shared = { + let mut guard = inflight().lock().unwrap_or_else(|e| e.into_inner()); + match guard.as_ref() { + Some(existing) => existing.clone(), + None => { + let fut: BoxedDiscovery = Box::pin(run(app, true)); + let shared = futures_util::FutureExt::shared(fut); + *guard = Some(shared.clone()); + shared + } + } + }; + + let result = shared.clone().await; + + // Clear the slot so the next forced call re-runs — but only if it still + // points at the future we just awaited (a newer run may have replaced it). + { + let mut guard = inflight().lock().unwrap_or_else(|e| e.into_inner()); + if guard + .as_ref() + .is_some_and(|current| current.ptr_eq(&shared)) + { + *guard = None; + } + } + + result +} + +async fn run(app: tauri::AppHandle, force: bool) -> Result, String> { + tokio::task::spawn_blocking(move || { + use tauri::Manager; + if force { + crate::managed_agents::clear_resolve_cache(); + crate::managed_agents::refresh_login_shell_path(); + } + let custom_dir = app + .path() + .app_data_dir() + .ok() + .map(|d| d.join("custom_harnesses")); + crate::managed_agents::discover_acp_runtimes_from(custom_dir.as_deref(), force) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} diff --git a/desktop/src-tauri/src/commands/channel_reconnect_repair.rs b/desktop/src-tauri/src/commands/channel_reconnect_repair.rs new file mode 100644 index 0000000000..f47258902b --- /dev/null +++ b/desktop/src-tauri/src/commands/channel_reconnect_repair.rs @@ -0,0 +1,119 @@ +use tauri::State; + +use crate::{app_state::AppState, relay::query_relay}; + +const MAX_REPAIR_PAGE_LIMIT: u32 = 500; +const CHANNEL_REPAIR_KINDS: [u32; 15] = [ + 5, 7, 9, 9005, 40001, 40002, 40003, 40008, 40099, 45001, 45003, 48100, 48101, 48102, 48103, +]; + +fn build_channel_reconnect_repair_filter( + channel_id: &str, + since: u64, + limit: u32, + until: Option, + before_id: Option<&str>, +) -> Result { + uuid::Uuid::parse_str(channel_id).map_err(|_| "invalid channel id".to_string())?; + if limit == 0 || limit > MAX_REPAIR_PAGE_LIMIT { + return Err(format!( + "limit must be between 1 and {MAX_REPAIR_PAGE_LIMIT}" + )); + } + if before_id.is_some() && until.is_none() { + return Err("before_id requires until".to_string()); + } + if let Some(event_id) = before_id { + if event_id.len() != 64 || !event_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("before_id must be a 64-character hex event id".to_string()); + } + } + + let mut filter = serde_json::Map::new(); + filter.insert("#h".to_string(), serde_json::json!([channel_id])); + filter.insert("kinds".to_string(), serde_json::json!(CHANNEL_REPAIR_KINDS)); + filter.insert("since".to_string(), serde_json::json!(since)); + filter.insert("limit".to_string(), serde_json::json!(limit)); + if let Some(value) = until { + filter.insert("until".to_string(), serde_json::json!(value)); + } + if let Some(value) = before_id { + filter.insert("before_id".to_string(), serde_json::json!(value)); + } + Ok(serde_json::Value::Object(filter)) +} + +/// Fetch one lossless keyset page for reconnect repair using a fixed channel-event filter. +#[tauri::command] +pub async fn get_channel_reconnect_repair( + channel_id: String, + since: u64, + limit: u32, + until: Option, + before_id: Option, + state: State<'_, AppState>, +) -> Result, String> { + let filter = build_channel_reconnect_repair_filter( + &channel_id, + since, + limit, + until, + before_id.as_deref(), + )?; + Ok(query_relay(&state, &[filter]) + .await? + .iter() + .filter_map(|event| serde_json::to_value(event).ok()) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repair_filter_is_fixed_and_keyset_scoped() { + let id = "ab".repeat(32); + let filter = build_channel_reconnect_repair_filter( + "270f6caf-0feb-4055-93f3-cdbeb567ff28", + 100, + 500, + Some(200), + Some(&id), + ) + .expect("valid filter"); + assert_eq!( + filter["#h"], + serde_json::json!(["270f6caf-0feb-4055-93f3-cdbeb567ff28"]) + ); + assert_eq!(filter["kinds"], serde_json::json!(CHANNEL_REPAIR_KINDS)); + assert_eq!(filter["since"], 100); + assert_eq!(filter["limit"], 500); + assert_eq!(filter["until"], 200); + assert_eq!(filter["before_id"], id); + assert!(filter.get("top_level").is_none()); + assert!(filter.get("include_summaries").is_none()); + assert!(filter.get("include_aux").is_none()); + } + + #[test] + fn repair_filter_rejects_renderer_escape_hatches() { + assert!(build_channel_reconnect_repair_filter("not-a-channel", 0, 1, None, None).is_err()); + assert!(build_channel_reconnect_repair_filter( + "270f6caf-0feb-4055-93f3-cdbeb567ff28", + 0, + 0, + None, + None + ) + .is_err()); + assert!(build_channel_reconnect_repair_filter( + "270f6caf-0feb-4055-93f3-cdbeb567ff28", + 0, + 1, + None, + Some("bad") + ) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index abf40c028f..e0c6e3bc5b 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -121,6 +121,24 @@ pub async fn get_channel_details( .ok_or_else(|| "channel not found".to_string()) } +/// Cap for the kind:0 profile join in `get_channel_members`. Enriching a +/// huge roster required an `authors` filter carrying every member pubkey — a +/// query whose size and relay cost grow linearly with membership and which +/// dominated channel-open latency on large channels. Members past the cap +/// keep `display_name: None` (the UI falls back to pubkey-derived labels and +/// resolves visible names through its profile caches); `role == "bot"` agent +/// flags are roster-derived and unaffected by the cap. +const MEMBER_PROFILE_JOIN_LIMIT: usize = 500; + +/// The pubkeys eligible for the kind:0 profile join: roster order, capped. +fn profile_join_pubkeys(members: &[crate::models::ChannelMemberInfo], limit: usize) -> Vec { + members + .iter() + .take(limit) + .map(|member| member.pubkey.clone()) + .collect() +} + #[tauri::command] pub async fn get_channel_members( channel_id: String, @@ -142,8 +160,9 @@ pub async fn get_channel_members( .transpose()? .ok_or_else(|| "channel members not found".to_string())?; - // Batch-fetch kind:0 profiles to populate display names. - let pubkeys: Vec = response.members.iter().map(|m| m.pubkey.clone()).collect(); + // Batch-fetch kind:0 profiles to populate display names, capped so the + // query cost is bounded on large rosters (see MEMBER_PROFILE_JOIN_LIMIT). + let pubkeys = profile_join_pubkeys(&response.members, MEMBER_PROFILE_JOIN_LIMIT); if !pubkeys.is_empty() { let profile_events = query_relay( &state, diff --git a/desktop/src-tauri/src/commands/channels/fetch.rs b/desktop/src-tauri/src/commands/channels/fetch.rs index c41a707341..36c24a35b7 100644 --- a/desktop/src-tauri/src/commands/channels/fetch.rs +++ b/desktop/src-tauri/src/commands/channels/fetch.rs @@ -9,6 +9,12 @@ use crate::{app_state::AppState, models::ChannelInfo, nostr_convert, relay::query_relay}; pub(super) const DIRECTORY_PAGE_SIZE: usize = 500; +// Keep this aligned with the relay's aggregate explicit-`#h` request bound. +// Each filter carries one channel so the relay can use its channel_id index. +const LAST_MESSAGE_QUERY_CHANNEL_BATCH_SIZE: usize = 128; +// Human-visible channel activity that drives sidebar Recent ordering. Keep this +// aligned with desktop/src/shared/constants/kinds.ts::CHANNEL_MESSAGE_EVENT_KINDS. +const CHANNEL_RECENCY_EVENT_KINDS: [u16; 4] = [9, 40002, 45001, 45003]; pub(super) fn advance_directory_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) { let last = page @@ -129,6 +135,33 @@ pub(super) fn compute_channels_hash(channels: &[ChannelInfo]) -> String { // ── Core fetch implementation ───────────────────────────────────────────────── +pub(super) fn last_message_filter(channel_id: &str) -> serde_json::Value { + serde_json::json!({ + "kinds": CHANNEL_RECENCY_EVENT_KINDS, + "#h": [channel_id], + "limit": 1 + }) +} + +pub(super) fn last_message_filter_batches( + filters: &[serde_json::Value], +) -> Vec<&[serde_json::Value]> { + filters + .chunks(LAST_MESSAGE_QUERY_CHANNEL_BATCH_SIZE) + .collect() +} + +async fn query_last_messages( + state: &AppState, + filters: &[serde_json::Value], +) -> Result, String> { + let mut messages = Vec::with_capacity(filters.len()); + for batch in last_message_filter_batches(filters) { + messages.extend(query_relay(state, batch).await?); + } + Ok(messages) +} + /// Whether `fetch_channels` includes the unbounded all-open directory scan. /// /// The 60s channel poll uses [`DirectoryScope::MemberOnly`]: it resolves only @@ -154,7 +187,10 @@ pub(super) enum DirectoryScope { /// metadata source (pending-owned ids when member-only, else the all-open /// kind:39000 scan), and the hidden-DM snapshot (kind:30622). /// - Phase 2 (parallel): member counts (kind:39002 batch) and last-message -/// timestamps (per-channel kind:9/40002), fanned out over the merged set. +/// timestamps (bounded per-channel human-visible activity batches), fanned +/// out over the merged set. Member-count failures degrade to zero; timestamp +/// failures abort so cached recency is never replaced by a false +/// authoritative empty result. pub(super) async fn fetch_channels( state: &AppState, scope: DirectoryScope, @@ -336,19 +372,13 @@ pub(super) async fn fetch_channels( } // 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. + // (step 5). Member-count failures degrade to zero. Timestamp failures + // abort this refresh so the frontend keeps its previous Recent ordering. 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!({ - "kinds": [9, 40002], - "#h": [id], - "limit": 1 - }) - }) + .map(|id| last_message_filter(id)) .collect(); // Bind both filter arrays before the join so their lifetimes cover @@ -361,10 +391,14 @@ pub(super) async fn fetch_channels( 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), + // Step 5: preserve one indexed filter per channel while keeping + // every relay request within its aggregate explicit-channel cap. + query_last_messages(state, &last_msg_filters), ); + // Message timestamps drive the user-selected Recent ordering. Unlike + // member counts, a failed query must not masquerade as an authoritative + // empty result and clear every cached timestamp in the frontend. + let messages = message_result?; let membership = collect_members_by_channel(&members_result.unwrap_or_default()); for channel in &mut channels { @@ -376,7 +410,7 @@ pub(super) async fn fetch_channels( let mut last_message_by_channel: std::collections::HashMap = std::collections::HashMap::new(); - for ev in &message_result.unwrap_or_default() { + for ev in &messages { 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()) diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index e78d4498c8..91e636d5f2 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -461,3 +461,55 @@ fn starter_match_requires_open_unarchived_stream_by_normalized_name() { channel.archived_at = Some("2026-07-16T00:00:00Z".to_string()); assert!(!is_matching_starter_channel(&channel, spec)); } + +#[test] +fn last_message_filter_covers_all_human_visible_activity_kinds() { + let filter = last_message_filter("forum-1"); + + assert_eq!( + filter, + serde_json::json!({ + "kinds": [9, 40002, 45001, 45003], + "#h": ["forum-1"], + "limit": 1 + }) + ); +} + +#[test] +fn last_message_filters_stay_within_relay_channel_cap() { + let filters: Vec = (0..257) + .map(|index| serde_json::json!({"#h": [format!("channel-{index}")]})) + .collect(); + + let batches = last_message_filter_batches(&filters); + + assert_eq!( + batches.iter().map(|batch| batch.len()).collect::>(), + [128, 128, 1] + ); + assert_eq!(batches.concat(), filters); +} + +fn member(pubkey: &str) -> crate::models::ChannelMemberInfo { + crate::models::ChannelMemberInfo { + pubkey: pubkey.to_string(), + role: "member".to_string(), + is_agent: false, + joined_at: None, + display_name: None, + } +} + +#[test] +fn profile_join_pubkeys_caps_in_roster_order() { + let members = vec![member(PK_A), member(PK_B), member(PK_C)]; + + assert_eq!( + profile_join_pubkeys(&members, 2), + vec![PK_A.to_string(), PK_B.to_string()] + ); + assert_eq!(profile_join_pubkeys(&members, 3).len(), 3); + assert_eq!(profile_join_pubkeys(&members, 10).len(), 3); + assert!(profile_join_pubkeys(&[], 10).is_empty()); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 85e9df0b10..7cb2d8e3b8 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -12,6 +12,7 @@ mod agent_settings; mod agent_update_rollback; mod agents; mod canvas; +mod channel_reconnect_repair; mod channel_templates; mod channel_window; mod channels; @@ -81,6 +82,7 @@ pub use agent_providers::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; +pub use channel_reconnect_repair::*; pub use channel_templates::*; pub use channel_window::*; pub use channels::*; diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 2b5b601de4..2bdf054426 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -37,11 +37,11 @@ use super::{pipeline::start_auto_enabled_transcription, HuddlePhase}; pub fn voice_mode_guidelines(parent_channel_id: &str) -> String { format!( "\ -You are in a live voice huddle attached to channel {parent_channel_id}. -Only messages sent with `buzz messages send` to this huddle channel are spoken aloud, in the order sent; everything else you produce is silent. -When a user addresses you, your FIRST tool call must send a brief spoken reply to this channel, before any file read, search, or other tool call. The usual rule against bare acknowledgments does not apply here; the pickup is the feedback that you heard them. +You are in a live voice huddle. Its attached main channel is {parent_channel_id}; that is not the live huddle channel. +The channel UUID in the current `[Context]` block is the live huddle channel. Only messages sent with `buzz messages send` to that current Context channel are spoken aloud, in the order sent; everything else you produce is silent. +When a user addresses you, your FIRST tool call must send a brief spoken reply to the current Context channel, before any file read, search, or other tool call. The usual rule against bare acknowledgments does not apply here; the pickup is the feedback that you heard them. Then work, sending each useful sentence as its own message the moment it is ready—a few sentences per answer, not a monologue. -Speak plainly without markdown; post code or long detail to the attached channel instead. +Speak plainly without markdown; post code or long detail to the attached main channel instead. If you are not addressed, stay silent." ) } @@ -303,11 +303,13 @@ mod tests { fn voice_mode_guidelines_pin_spoken_reply_as_first_tool_call() { let guidelines = voice_mode_guidelines("parent-channel"); assert_eq!(guidelines.lines().count(), 6); - assert!(guidelines.contains("Only messages sent with `buzz messages send`")); + assert!(guidelines.contains("Its attached main channel is parent-channel")); + assert!(guidelines.contains("that is not the live huddle channel")); + assert!(guidelines.contains("current `[Context]` block is the live huddle channel")); + assert!(guidelines.contains("buzz messages send` to that current Context channel")); assert!(guidelines.contains("your FIRST tool call must send a brief spoken reply")); assert!(guidelines.contains("before any file read, search, or other tool call")); assert!(guidelines.contains("rule against bare acknowledgments does not apply here")); - assert!(guidelines.contains("parent-channel")); } #[test] diff --git a/desktop/src-tauri/src/huddle/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs index 34dec53094..383a7e8210 100644 --- a/desktop/src-tauri/src/huddle/audio_output.rs +++ b/desktop/src-tauri/src/huddle/audio_output.rs @@ -97,3 +97,70 @@ pub(crate) fn open_output_sink_by_name( rodio::DeviceSinkBuilder::open_default_sink().map_err(|e| format!("audio output: {e}")) } + +fn device_type_is_isolated(device_type: rodio::cpal::DeviceType) -> bool { + use rodio::cpal::DeviceType; + matches!( + device_type, + DeviceType::Headphones + | DeviceType::Headset + | DeviceType::Earpiece + | DeviceType::HearingAid + ) +} + +/// Conservative route-isolation query using cpal's safe structured device +/// description. This is intentionally re-evaluated at confirmed local onset, +/// so a route change cannot leave a stale isolated capability behind. +pub(crate) fn output_route_is_isolated(preferred: Option<&str>) -> bool { + use rodio::cpal::traits::HostTrait; + use rodio::DeviceTrait; + + let host = rodio::cpal::default_host(); + let device = match preferred.filter(|name| !name.is_empty()) { + Some(name) => { + let Ok(devices) = host.output_devices() else { + return false; + }; + let mut matches = devices.filter(|device| { + device + .description() + .ok() + .map(|description| description.name().to_owned()) + == Some(name.to_owned()) + }); + let Some(device) = matches.next() else { + return false; + }; + if matches.next().is_some() { + return false; + } + device + } + None => match host.default_output_device() { + Some(device) => device, + None => return false, + }, + }; + + device + .description() + .is_ok_and(|description| device_type_is_isolated(description.device_type())) +} + +#[cfg(test)] +mod route_isolation_tests { + use super::device_type_is_isolated; + use rodio::cpal::DeviceType; + + #[test] + fn only_positive_isolated_terminal_types_are_accepted() { + assert!(device_type_is_isolated(DeviceType::Headphones)); + assert!(device_type_is_isolated(DeviceType::Headset)); + assert!(device_type_is_isolated(DeviceType::Earpiece)); + assert!(device_type_is_isolated(DeviceType::HearingAid)); + assert!(!device_type_is_isolated(DeviceType::Speaker)); + assert!(!device_type_is_isolated(DeviceType::Virtual)); + assert!(!device_type_is_isolated(DeviceType::Unknown)); + } +} diff --git a/desktop/src-tauri/src/huddle/human_floor.rs b/desktop/src-tauri/src/huddle/human_floor.rs new file mode 100644 index 0000000000..1643880c42 --- /dev/null +++ b/desktop/src-tauri/src/huddle/human_floor.rs @@ -0,0 +1,73 @@ +//! Shared human-floor handle backed by the TTS playback coordinator. + +use std::sync::Arc; + +use super::tts_playback::{HumanFloorAuthorization, PlaybackCoordinator}; + +#[derive(Clone)] +pub(crate) struct HumanFloor { + playback: Arc, +} + +impl std::fmt::Debug for HumanFloor { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("HumanFloor").finish_non_exhaustive() + } +} + +impl Default for HumanFloor { + fn default() -> Self { + Self::new() + } +} + +impl HumanFloor { + pub(crate) fn new() -> Self { + Self { + playback: Arc::new(PlaybackCoordinator::unbound()), + } + } + + pub(super) fn playback(&self) -> Arc { + Arc::clone(&self.playback) + } + + #[cfg(test)] + pub(crate) fn is_blocked(&self) -> bool { + self.playback.human_floor_blocked() + } + + pub(crate) fn epoch(&self) -> u64 { + self.playback.human_floor_epoch() + } + + pub(super) fn authorization(&self, epoch: u64) -> HumanFloorAuthorization { + self.playback.human_floor_authorization(epoch) + } + + #[cfg(test)] + pub(crate) fn permits(&self, epoch: u64) -> bool { + self.authorization(epoch) == HumanFloorAuthorization::Permitted + } + + pub(crate) fn enter_local(&self, route_isolated: bool, sustained_coupled_speech: bool) -> bool { + self.playback + .enter_local_human_floor(route_isolated, sustained_coupled_speech) + } + + pub(crate) fn leave_local(&self) { + self.playback.leave_local_human_floor(); + } + + pub(crate) fn enter_remote(&self, peer: u8) { + self.playback.enter_remote_human_floor(peer); + } + + pub(crate) fn leave_remote(&self, peer: u8) { + self.playback.leave_remote_human_floor(peer); + } + + pub(crate) fn clear_remote(&self) { + self.playback.clear_remote_human_floor(); + } +} diff --git a/desktop/src-tauri/src/huddle/latency_bench.rs b/desktop/src-tauri/src/huddle/latency_bench.rs index 710854b533..f928ddbce0 100644 --- a/desktop/src-tauri/src/huddle/latency_bench.rs +++ b/desktop/src-tauri/src/huddle/latency_bench.rs @@ -141,6 +141,7 @@ fn baseline_stt_fake_llm_tts_first_audio() { tts_dir, Arc::clone(&tts_active), Arc::clone(&tts_cancel), + super::human_floor::HumanFloor::new(), "eve", None, // default output device None, // no Tauri app handle @@ -152,7 +153,14 @@ fn baseline_stt_fake_llm_tts_first_audio() { ); let t = Instant::now(); - let (stt, mut text_rx) = SttPipeline::new(stt_dir, None, None).expect("stt pipeline"); + let (stt, mut text_rx) = SttPipeline::new( + stt_dir, + None, + None, + super::human_floor::HumanFloor::new(), + None, + ) + .expect("stt pipeline"); // Recognizer loads inside the worker thread; give it time, then verify // liveness via a first throwaway feed below. std::thread::sleep(Duration::from_secs(2)); diff --git a/desktop/src-tauri/src/huddle/local_barge_in.rs b/desktop/src-tauri/src/huddle/local_barge_in.rs new file mode 100644 index 0000000000..ac57c685ad --- /dev/null +++ b/desktop/src-tauri/src/huddle/local_barge_in.rs @@ -0,0 +1,151 @@ +//! Local VAD barge-in policy and coupled-output debounce. + +use super::human_floor::HumanFloor; + +/// Whether local audio should use VAD barge-in for this frame. +/// +/// This currently matches STT's `vad_flush_allowed`, but the two decisions are +/// kept separate deliberately: one assigns cancellation ownership and the +/// other controls utterance endpointing. +pub(super) fn enabled(ptt_mode: bool, manually_open: bool, ptt_held: bool) -> bool { + !ptt_mode || (manually_open && !ptt_held) +} + +/// Consecutive 16 ms VAD-positive frames required to restore local barge-in +/// on acoustically coupled output. The prior implementation shipped 20 frames +/// after 5 frames caused speaker-bleed self-cancellation (`b29c8cdaa^`). +const COUPLED_BARGE_IN_FRAMES: usize = 20; + +#[derive(Debug, Default)] +pub(super) struct LocalBargeIn { + acquired_floor: bool, + coupled_positive_frames: usize, +} + +impl LocalBargeIn { + pub(super) fn observe( + &mut self, + probability: f32, + confirmed_onset: bool, + human_floor: &HumanFloor, + output_device: Option<&str>, + onset_threshold: f32, + ) { + if self.acquired_floor { + return; + } + let sustained_coupled = self.track_sustained_coupled(probability, onset_threshold); + if !confirmed_onset && !sustained_coupled { + return; + } + let route_isolated = super::audio_output::output_route_is_isolated(output_device); + self.acquire(human_floor, route_isolated, sustained_coupled); + } + + pub(super) fn acquire( + &mut self, + human_floor: &HumanFloor, + route_isolated: bool, + sustained_coupled: bool, + ) { + self.acquired_floor = human_floor.enter_local(route_isolated, sustained_coupled); + } + + fn track_sustained_coupled(&mut self, probability: f32, onset_threshold: f32) -> bool { + if probability > onset_threshold { + self.coupled_positive_frames = self.coupled_positive_frames.saturating_add(1); + } else { + self.coupled_positive_frames = 0; + } + self.coupled_positive_frames >= COUPLED_BARGE_IN_FRAMES + } + + pub(super) fn release(&mut self, human_floor: &HumanFloor) { + if self.acquired_floor { + human_floor.leave_local(); + } + *self = Self::default(); + } +} + +#[derive(Debug)] +pub(super) struct WorkerLocalBargeIn { + state: LocalBargeIn, + human_floor: HumanFloor, +} + +impl WorkerLocalBargeIn { + pub(super) fn new(human_floor: HumanFloor) -> Self { + Self { + state: LocalBargeIn::default(), + human_floor, + } + } +} + +impl std::ops::Deref for WorkerLocalBargeIn { + type Target = LocalBargeIn; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +impl std::ops::DerefMut for WorkerLocalBargeIn { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.state + } +} + +impl Drop for WorkerLocalBargeIn { + fn drop(&mut self) { + self.state.release(&self.human_floor); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manual_open_mic_enables_vad_barge_in_in_ptt_mode() { + assert!(enabled(true, true, false)); + assert!(!enabled(true, false, false)); + assert!(!enabled(true, true, true)); + assert!(enabled(false, false, false)); + } + + #[test] + fn manual_open_ptt_sustained_speech_acquires_coupled_floor() { + assert!(enabled(true, true, false)); + let human_floor = HumanFloor::new(); + let mut barge_in = LocalBargeIn::default(); + for _ in 0..COUPLED_BARGE_IN_FRAMES { + let sustained = barge_in.track_sustained_coupled(0.9, 0.5); + if sustained { + barge_in.acquire(&human_floor, false, true); + } + } + assert!(barge_in.acquired_floor); + assert!(human_floor.is_blocked()); + } + + #[test] + fn coupled_barge_in_requires_twenty_consecutive_positive_frames() { + let mut barge_in = LocalBargeIn::default(); + for _ in 0..COUPLED_BARGE_IN_FRAMES - 1 { + assert!(!barge_in.track_sustained_coupled(0.9, 0.5)); + } + assert!(barge_in.track_sustained_coupled(0.9, 0.5)); + } + + #[test] + fn coupled_barge_in_debounce_resets_on_a_non_speech_frame() { + let mut barge_in = LocalBargeIn::default(); + for _ in 0..COUPLED_BARGE_IN_FRAMES - 1 { + assert!(!barge_in.track_sustained_coupled(0.9, 0.5)); + } + assert!(!barge_in.track_sustained_coupled(0.1, 0.5)); + assert!(!barge_in.track_sustained_coupled(0.9, 0.5)); + } +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 1feb2073b0..e4124de15a 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -28,9 +28,11 @@ pub mod agent_voice; pub mod agents; pub mod audio_output; mod commands; +mod human_floor; pub mod jitter; #[cfg(test)] mod latency_bench; +mod local_barge_in; pub mod models; pub mod pipeline; pub mod playout; @@ -42,6 +44,8 @@ pub mod state; pub mod stt; pub mod transcription; pub mod tts; +#[path = "tts_playback.rs"] +mod tts_playback; pub mod tts_settings; mod tts_voice_import; mod tts_voice_registry; diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index afa7aed8e0..f814f77599 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -311,6 +311,8 @@ pub(crate) async fn maybe_start_stt_pipeline( stt_starting, ptt_active_for_stt, manual_mic_unmuted_for_stt, + human_floor, + output_device, old_stt, ) = { let mut hs = state.huddle()?; @@ -346,6 +348,13 @@ pub(crate) async fn maybe_start_stt_pipeline( stt_starting, ptt, manual_mic_unmuted, + hs.human_floor.clone(), + state + .huddle_audio + .output_device + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(), old, ) }; @@ -353,7 +362,13 @@ pub(crate) async fn maybe_start_stt_pipeline( drop(old_stt); let constructed = tokio::task::spawn_blocking(move || { - stt::SttPipeline::new(model_dir, ptt_active_for_stt, manual_mic_unmuted_for_stt) + stt::SttPipeline::new( + model_dir, + ptt_active_for_stt, + manual_mic_unmuted_for_stt, + human_floor, + output_device, + ) }) .await; let (pipeline, text_rx) = match constructed { @@ -457,7 +472,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result Result f32 { ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) } +fn update_remote_release_deadline( + peer: u8, + is_dtx: bool, + remote_floor_owners: &std::collections::HashSet, + deadlines: &mut std::collections::HashMap, + now: tokio::time::Instant, +) { + if !is_dtx { + deadlines.remove(&peer); + } else if remote_floor_owners.contains(&peer) { + deadlines + .entry(peer) + .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + } +} + +fn release_expired_remote_floors( + now: tokio::time::Instant, + owners: &mut std::collections::HashSet, + deadlines: &mut std::collections::HashMap, + human_floor: &HumanFloor, +) { + let released: Vec = deadlines + .iter() + .filter_map(|(peer, deadline)| (*deadline <= now).then_some(*peer)) + .collect(); + for peer in released { + deadlines.remove(&peer); + owners.remove(&peer); + human_floor.leave_remote(peer); + } +} + fn should_recover_playout(depth: usize, currently_recovering: bool) -> bool { if currently_recovering { depth > PLAYOUT_QUEUE_RECOVERY_END @@ -171,6 +206,7 @@ pub(crate) async fn run_playout_recv_loop( initial_peers: Vec<(u8, String)>, tts_active: Arc, tts_cancel: Arc, + human_floor: HumanFloor, ) { use rodio::buffer::SamplesBuffer; use std::num::NonZero; @@ -183,9 +219,11 @@ pub(crate) async fn run_playout_recv_loop( initial_peers.into_iter().collect(); let mut active_indices: std::collections::HashSet = std::collections::HashSet::new(); let mut speaker_levels: std::collections::HashMap = std::collections::HashMap::new(); + let mut remote_release_deadlines: std::collections::HashMap = + std::collections::HashMap::new(); + let mut remote_floor_owners: std::collections::HashSet = std::collections::HashSet::new(); let mut frame_counts: std::collections::HashMap = std::collections::HashMap::new(); let mut last_frame_reset = tokio::time::Instant::now(); - let mut tts_was_active = false; let mut speaker_tick = tokio::time::interval(std::time::Duration::from_millis(SPEAKER_TICK_MS)); speaker_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -247,6 +285,12 @@ pub(crate) async fn run_playout_recv_loop( } } _ = speaker_tick.tick() => { + release_expired_remote_floors( + tokio::time::Instant::now(), + &mut remote_floor_owners, + &mut remote_release_deadlines, + &human_floor, + ); if let Some(ref app) = app_handle { use tauri::Emitter; let pubkeys: Vec = active_indices @@ -303,6 +347,13 @@ pub(crate) async fn run_playout_recv_loop( // by an idle peer to keep the codec alive — they // don't mean the peer is speaking, and shouldn't // make their tile flash for the 500 ms speaker tick. + update_remote_release_deadline( + peer_idx, + is_dtx, + &remote_floor_owners, + &mut remote_release_deadlines, + tokio::time::Instant::now(), + ); if !is_dtx { active_indices.insert(peer_idx); let level = normalized_speaker_level(header.level_dbov); @@ -312,14 +363,9 @@ pub(crate) async fn run_playout_recv_loop( .or_insert(level); } - // TTS interrupt frame counter — reset on TTS rising edge. - let tts_now = tts_active.load(Ordering::Acquire); - if tts_now && !tts_was_active { - frame_counts.clear(); - last_frame_reset = tokio::time::Instant::now(); - } - tts_was_active = tts_now; - + // Track remote speech independently of TTS liveness so a + // human who starts while output is idle still owns the + // floor and rejects delayed synthesis. let slot = match peers.entry(peer_idx) { std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), std::collections::hash_map::Entry::Vacant(e) => { @@ -347,11 +393,10 @@ pub(crate) async fn run_playout_recv_loop( slot.last_packet_at = tokio::time::Instant::now(); } - // Count remote-speech frame arrivals for the TTS - // interrupt. DTX/comfort frames don't count — they - // mean the peer is silent, just keeping the codec - // state alive. - if tts_now && !is_dtx { + // Count remote-speech frame arrivals for floor onset. + // DTX/comfort frames don't count — they mean the peer + // is silent, just keeping the codec state alive. + if !is_dtx { if last_frame_reset.elapsed() >= FRAME_WINDOW { frame_counts.clear(); last_frame_reset = tokio::time::Instant::now(); @@ -359,7 +404,11 @@ pub(crate) async fn run_playout_recv_loop( let count = frame_counts.entry(peer_idx).or_insert(0); *count = count.saturating_add(1); if *count >= REMOTE_SPEECH_THRESHOLD { - tts_cancel.store(true, Ordering::Release); + human_floor.enter_remote(peer_idx); + remote_floor_owners.insert(peer_idx); + if tts_active.load(Ordering::Acquire) { + tts_cancel.store(true, Ordering::Release); + } } } } @@ -384,6 +433,9 @@ pub(crate) async fn run_playout_recv_loop( { peers.remove(&key); frame_counts.remove(&key); + remote_release_deadlines.remove(&key); + remote_floor_owners.remove(&key); + human_floor.leave_remote(key); active_indices.remove(&key); speaker_levels.remove(&key); } @@ -407,6 +459,16 @@ pub(crate) async fn run_playout_recv_loop( replacement.get(idx) == index_to_pubkey.get(idx) }; peers.retain(|idx, _| identity_unchanged(idx)); + for idx in index_to_pubkey + .keys() + .filter(|idx| !identity_unchanged(idx)) + .copied() + .collect::>() + { + human_floor.leave_remote(idx); + remote_release_deadlines.remove(&idx); + remote_floor_owners.remove(&idx); + } frame_counts.retain(|idx, _| identity_unchanged(idx)); active_indices.retain(identity_unchanged); speaker_levels.retain(|idx, _| identity_unchanged(idx)); @@ -418,6 +480,9 @@ pub(crate) async fn run_playout_recv_loop( let key = idx as u8; index_to_pubkey.remove(&key); frame_counts.remove(&key); + remote_release_deadlines.remove(&key); + remote_floor_owners.remove(&key); + human_floor.leave_remote(key); active_indices.remove(&key); speaker_levels.remove(&key); // Dropping Player detaches its queue from the @@ -441,6 +506,7 @@ pub(crate) async fn run_playout_recv_loop( } } + human_floor.clear_remote(); if let Some(ref app) = app_handle { use tauri::Emitter; let _ = app.emit( @@ -454,6 +520,50 @@ pub(crate) async fn run_playout_recv_loop( mod tests { use super::*; + #[test] + fn continuous_dtx_does_not_extend_remote_floor_deadline() { + let peer = 7; + let started = tokio::time::Instant::now(); + let owners = std::collections::HashSet::from([peer]); + let mut deadlines = std::collections::HashMap::new(); + + update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + let armed = deadlines[&peer]; + for elapsed_ms in [100, 200, 300, 400] { + update_remote_release_deadline( + peer, + true, + &owners, + &mut deadlines, + started + std::time::Duration::from_millis(elapsed_ms), + ); + } + + assert_eq!(deadlines[&peer], armed); + assert!(armed <= started + REMOTE_RELEASE_DEBOUNCE); + + let human_floor = HumanFloor::new(); + human_floor.enter_remote(peer); + let mut owners = owners; + release_expired_remote_floors(armed, &mut owners, &mut deadlines, &human_floor); + assert!(!human_floor.is_blocked()); + assert!(owners.is_empty()); + assert!(deadlines.is_empty()); + } + + #[test] + fn dtx_from_non_owner_does_not_arm_remote_floor_deadline() { + let mut deadlines = std::collections::HashMap::new(); + update_remote_release_deadline( + 7, + true, + &std::collections::HashSet::new(), + &mut deadlines, + tokio::time::Instant::now(), + ); + assert!(deadlines.is_empty()); + } + #[test] fn speaker_level_maps_conversational_range() { assert_eq!(normalized_speaker_level(-127), 0.0); diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index 3f2aa76a56..37e5cf5e63 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -58,9 +58,13 @@ pub(crate) async fn connect_audio_relay( let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); // TTS interrupt flags — recv task cancels TTS when remote humans speak. - let (tts_cancel, tts_active) = { + let (tts_cancel, tts_active, human_floor) = { let hs = state.huddle()?; - (Arc::clone(&hs.tts_cancel), Arc::clone(&hs.tts_active)) + ( + Arc::clone(&hs.tts_cancel), + Arc::clone(&hs.tts_active), + hs.human_floor.clone(), + ) }; let app_handle = state.app_handle.lock().ok().and_then(|g| g.clone()); @@ -180,6 +184,7 @@ pub(crate) async fn connect_audio_relay( initial_peers, tts_cancel, tts_active, + human_floor, output_device_name, }) .await @@ -214,6 +219,7 @@ struct AudioRelayPipelineArgs { initial_peers: Vec<(u8, String)>, tts_cancel: Arc, tts_active: Arc, + human_floor: super::human_floor::HumanFloor, output_device_name: Option, } @@ -227,6 +233,7 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String initial_peers, tts_cancel, tts_active, + human_floor, output_device_name, } = args; @@ -336,6 +343,7 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String initial_peers, tts_active, tts_cancel, + human_floor, )); // Wait for either task to finish, then abort the survivor. diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index c615ff19c2..9a7ba89d1b 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -11,6 +11,7 @@ use std::sync::{ }; use super::agent_voice::AgentVoiceSettings; +use super::human_floor::HumanFloor; use super::{stt, tts}; /// Voice input mode: push-to-talk (PTT) or voice-activity detection (VAD). @@ -106,6 +107,10 @@ pub struct HuddleState { /// restarts — both STT and TTS reference the same flag for the entire huddle. #[serde(skip)] pub tts_cancel: Arc, + /// Shared human-floor state. Confirmed local or remote human speech hard + /// cancels TTS and blocks stale/new playback until every source releases. + #[serde(skip)] + pub human_floor: HumanFloor, /// Sentinel: true while a TTS pipeline is being constructed (outside the lock). /// Prevents TOCTOU races where two concurrent callers both pass the `is_some()` /// check and both spawn TTS worker threads — the loser's thread would leak. @@ -189,6 +194,7 @@ impl Clone for HuddleState { transcription_user_controlled: self.transcription_user_controlled, tts_active: Arc::clone(&self.tts_active), tts_cancel: Arc::clone(&self.tts_cancel), + human_floor: self.human_floor.clone(), tts_starting: Arc::clone(&self.tts_starting), stt_starting: Arc::clone(&self.stt_starting), last_agent_refresh: self.last_agent_refresh, @@ -203,6 +209,8 @@ impl Clone for HuddleState { impl Default for HuddleState { fn default() -> Self { + let tts_cancel = Arc::new(AtomicBool::new(false)); + let human_floor = HumanFloor::new(); Self { phase: HuddlePhase::Idle, parent_channel_id: None, @@ -220,7 +228,8 @@ impl Default for HuddleState { transcription_enabled: false, transcription_user_controlled: false, tts_active: Arc::new(AtomicBool::new(false)), - tts_cancel: Arc::new(AtomicBool::new(false)), + tts_cancel, + human_floor, tts_starting: Arc::new(AtomicBool::new(false)), stt_starting: Arc::new(AtomicBool::new(false)), last_agent_refresh: None, diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 8185944834..3721c2fa6e 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -32,6 +32,8 @@ use std::{ use tokio::sync::mpsc as tokio_mpsc; +use super::{human_floor::HumanFloor, local_barge_in}; + // ── Public pipeline handle ──────────────────────────────────────────────────── /// Bounded audio queue capacity. @@ -62,11 +64,11 @@ pub struct SttPipeline { impl SttPipeline { /// Spawn the pipeline thread. /// - /// Mic input is transcribed even while agent TTS is playing: the huddle UI - /// already tells users to wear headphones, so speaker bleed is accepted in - /// exchange for never dropping human speech that overlaps agent audio. - /// Local mic frames still never cancel TTS — push-to-talk and remote - /// participant speech remain the explicit barge-in paths. + /// Mic input is transcribed even while agent TTS is playing. In open-mic + /// VAD mode, confirmed speech acquires the shared human floor: immediately + /// on an isolated output route, or after the restored 320 ms sustained- + /// speech debounce on an acoustically coupled route. Push-to-talk retains + /// its explicit shortcut cancellation path. /// /// `ptt_active` and `manual_mic_unmuted` are present when the PTT shortcut /// is enabled. The pipeline accepts speech while either input path is open; @@ -85,6 +87,8 @@ impl SttPipeline { model_dir: PathBuf, ptt_active: Option>, manual_mic_unmuted: Option>, + human_floor: HumanFloor, + output_device: Option, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); let (text_tx, text_rx) = tokio_mpsc::channel::(64); @@ -103,6 +107,8 @@ impl SttPipeline { shutdown_worker, ptt_active_worker, manual_mic_unmuted_worker, + human_floor, + output_device, ) }) .map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?; @@ -205,6 +211,7 @@ const MIN_VOICED_FRAMES: usize = 12; #[derive(Debug, PartialEq, Eq)] enum VadFrameAction { None, + ConfirmedOnset, Speech, FirstSilence, Flush, @@ -268,7 +275,7 @@ impl VadEndpoint { for buffered in self.pre_roll.drain(..) { self.speech_buf.extend_from_slice(&buffered); } - return VadFrameAction::Speech; + return VadFrameAction::ConfirmedOnset; } if probability > VAD_OFFSET_THRESHOLD { @@ -342,6 +349,45 @@ fn stt_speculative_decode() -> bool { std::env::var("BUZZ_STT_SPECULATIVE").is_ok_and(|v| v == "1") } +#[derive(Debug)] +enum SttLoopInput { + Tick, + Batch(Vec>), +} + +fn run_stt_receive_loop( + audio_rx: Receiver>, + shutdown: &AtomicBool, + human_floor: HumanFloor, + mut process: impl FnMut(SttLoopInput, &mut local_barge_in::LocalBargeIn), +) { + let mut local_barge_in_state = local_barge_in::WorkerLocalBargeIn::new(human_floor); + + loop { + // Check shutdown flag before blocking. + if shutdown.load(Ordering::Acquire) { + break; + } + + process(SttLoopInput::Tick, &mut local_barge_in_state); + + // Use recv_timeout so we can periodically check the shutdown flag. + let bytes = match audio_rx.recv_timeout(RECV_TIMEOUT) { + Ok(b) => b, + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, // Sender dropped. + }; + + // Drain any additional pending messages to batch-process. + let mut batch = vec![bytes]; + while let Ok(b) = audio_rx.try_recv() { + batch.push(b); + } + process(SttLoopInput::Batch(batch), &mut local_barge_in_state); + } +} + +#[allow(clippy::too_many_arguments)] fn stt_worker( model_dir: PathBuf, audio_rx: Receiver>, @@ -349,6 +395,8 @@ fn stt_worker( shutdown: Arc, ptt_active: Option>, manual_mic_unmuted: Option>, + human_floor: HumanFloor, + output_device: Option, ) { // ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ─────────────── use rubato::{Fft, FixedSync, Resampler}; @@ -416,7 +464,6 @@ fn stt_worker( // computed at. Valid only while no new voiced frame has arrived since. let speculative_enabled = stt_speculative_decode(); let mut speculative: Option<(String, usize)> = None; - // ── 5. Main loop ────────────────────────────────────────────────────────── let mut transmit_was_active = ptt_active .as_ref() @@ -424,73 +471,69 @@ fn stt_worker( || manual_mic_unmuted .as_ref() .is_some_and(|manual| manual.load(Ordering::Acquire)); - loop { - // Check shutdown flag before blocking. - if shutdown.load(Ordering::Acquire) { - break; - } + run_stt_receive_loop( + audio_rx, + &shutdown, + human_floor.clone(), + |input, local_barge_in_state| { + match input { + SttLoopInput::Tick => { + // Track the combined manual/PTT transmission edge. When both paths + // close, the worklet stops sending frames, so flush here rather than + // waiting for silence that will never arrive. + if let Some(ref ptt) = ptt_active { + let transmit_now = ptt.load(Ordering::Acquire) + || manual_mic_unmuted + .as_ref() + .is_some_and(|manual| manual.load(Ordering::Acquire)); + if transmit_was_active + && !transmit_now + && endpoint.in_speech + && !endpoint.speech_buf.is_empty() + { + flush_to_stt( + &endpoint.speech_buf, + endpoint.voiced_frames, + &recognizer, + &text_tx, + ); + endpoint.reset_segment(); + local_barge_in_state.release(&human_floor); + } + transmit_was_active = transmit_now; + } + } + SttLoopInput::Batch(batch) => { + for bytes in batch { + // Convert raw bytes to f32 samples (little-endian). + let samples_48k = bytes_to_f32(&bytes); + input_buf_48k.extend_from_slice(&samples_48k); - // Track the combined manual/PTT transmission edge. When both paths - // close, the worklet stops sending frames, so flush here rather than - // waiting for silence that will never arrive. - if let Some(ref ptt) = ptt_active { - let transmit_now = ptt.load(Ordering::Acquire) - || manual_mic_unmuted - .as_ref() - .is_some_and(|manual| manual.load(Ordering::Acquire)); - if transmit_was_active - && !transmit_now - && endpoint.in_speech - && !endpoint.speech_buf.is_empty() - { - flush_to_stt( - &endpoint.speech_buf, - endpoint.voiced_frames, - &recognizer, - &text_tx, - ); - endpoint.reset_segment(); + // Resample in chunk_in-sized blocks. + while input_buf_48k.len() >= chunk_in { + let chunk: Vec = input_buf_48k.drain(..chunk_in).collect(); + let resampled = resample_chunk(&mut resampler, &chunk); + process_16k_samples( + &resampled, + &mut leftover_16k, + &mut vad, + &mut endpoint, + flush_frames, + (speculative_enabled, &mut speculative), + &recognizer, + &text_tx, + ptt_active.as_ref(), + manual_mic_unmuted.as_ref(), + &human_floor, + local_barge_in_state, + output_device.as_deref(), + ); + } + } + } } - transmit_was_active = transmit_now; - } - - // Use recv_timeout so we can periodically check the shutdown flag. - let bytes = match audio_rx.recv_timeout(RECV_TIMEOUT) { - Ok(b) => b, - Err(mpsc::RecvTimeoutError::Timeout) => continue, - Err(mpsc::RecvTimeoutError::Disconnected) => break, // Sender dropped. - }; - - // Drain any additional pending messages to batch-process. - let mut batch = vec![bytes]; - while let Ok(b) = audio_rx.try_recv() { - batch.push(b); - } - - for bytes in batch { - // Convert raw bytes to f32 samples (little-endian). - let samples_48k = bytes_to_f32(&bytes); - input_buf_48k.extend_from_slice(&samples_48k); - - // Resample in chunk_in-sized blocks. - while input_buf_48k.len() >= chunk_in { - let chunk: Vec = input_buf_48k.drain(..chunk_in).collect(); - let resampled = resample_chunk(&mut resampler, &chunk); - process_16k_samples( - &resampled, - &mut leftover_16k, - &mut vad, - &mut endpoint, - flush_frames, - (speculative_enabled, &mut speculative), - &recognizer, - &text_tx, - ptt_active.as_ref(), - manual_mic_unmuted.as_ref(), - ); - } - } - } + }, + ); // No final flush — leave_huddle/end_huddle emit lifecycle events before // the STT worker exits, so a final flush would post a kind:9 message AFTER @@ -546,6 +589,9 @@ fn process_16k_samples( text_tx: &tokio_mpsc::Sender, ptt_active: Option<&Arc>, manual_mic_unmuted: Option<&Arc>, + human_floor: &HumanFloor, + local_barge_in_state: &mut local_barge_in::LocalBargeIn, + output_device: Option<&str>, ) { let (speculative_enabled, speculative) = speculative; leftover.extend_from_slice(samples); @@ -562,7 +608,27 @@ fn process_16k_samples( // VAD mode, or with a manually open mic once the shortcut is up. let flush_allowed = vad_flush_allowed(ptt_active.is_some(), manually_open, ptt_held); - match endpoint.process_frame(frame, prob, accepts_audio, flush_allowed, flush_frames) { + let action = + endpoint.process_frame(frame, prob, accepts_audio, flush_allowed, flush_frames); + // Open-mic VAD semantics also apply when a PTT-mode user manually + // opens the mic. A held shortcut keeps its explicit key-down cancel. + let local_barge_in = local_barge_in::enabled(ptt_active.is_some(), manually_open, ptt_held); + if local_barge_in { + local_barge_in_state.observe( + prob, + action == VadFrameAction::ConfirmedOnset, + human_floor, + output_device, + VAD_ONSET_THRESHOLD, + ); + } else { + local_barge_in_state.release(human_floor); + } + + match action { + VadFrameAction::ConfirmedOnset => { + speculative.take(); + } VadFrameAction::Speech => { // New voiced audio invalidates any speculative decode. speculative.take(); @@ -594,6 +660,9 @@ fn process_16k_samples( ), } endpoint.reset_segment(); + if local_barge_in { + local_barge_in_state.release(human_floor); + } } VadFrameAction::None => {} } @@ -607,6 +676,9 @@ fn process_16k_samples( text_tx, ); endpoint.reset_segment(); + if local_barge_in { + local_barge_in_state.release(human_floor); + } speculative.take(); } } @@ -686,200 +758,5 @@ fn bytes_to_f32(bytes: &[u8]) -> Vec { use super::drain_until_shutdown; #[cfg(test)] -mod tests { - use super::{ - has_enough_voiced_audio, vad_flush_allowed, VadEndpoint, VadFrameAction, MIN_VOICED_FRAMES, - SILENCE_FLUSH_FRAMES, VAD_FRAME_SAMPLES, VAD_ONSET_FRAMES, VAD_PRE_ROLL_FRAMES, - }; - - fn frame(value: f32) -> Vec { - vec![value; VAD_FRAME_SAMPLES] - } - - #[test] - fn short_vad_blips_do_not_reach_the_recognizer() { - assert!(!has_enough_voiced_audio(1)); - assert!(!has_enough_voiced_audio(MIN_VOICED_FRAMES - 1)); - assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); - } - - #[test] - fn confirmed_onset_prepends_pre_roll_once() { - let mut endpoint = VadEndpoint::new(); - for value in 0..VAD_PRE_ROLL_FRAMES - VAD_ONSET_FRAMES { - assert_eq!( - endpoint.process_frame(frame(value as f32), 0.0, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::None - ); - } - for value in 0..VAD_ONSET_FRAMES { - let action = endpoint.process_frame( - frame(100.0 + value as f32), - 0.9, - true, - true, - SILENCE_FLUSH_FRAMES, - ); - if value + 1 == VAD_ONSET_FRAMES { - assert_eq!(action, VadFrameAction::Speech); - } else { - assert_eq!(action, VadFrameAction::None); - } - } - - assert_eq!( - endpoint.speech_buf.len(), - VAD_PRE_ROLL_FRAMES * VAD_FRAME_SAMPLES - ); - assert_eq!(endpoint.speech_buf[0], 0.0); - assert_eq!(endpoint.speech_buf[VAD_FRAME_SAMPLES], 1.0); - assert_eq!(endpoint.pre_roll.len(), 0); - endpoint.process_frame(frame(200.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - assert_eq!( - endpoint.speech_buf.len(), - (VAD_PRE_ROLL_FRAMES + 1) * VAD_FRAME_SAMPLES - ); - } - - #[test] - fn onset_requires_consecutive_high_frames() { - let mut endpoint = VadEndpoint::new(); - for probability in [0.9, 0.9, 0.2, 0.9, 0.9] { - assert_eq!( - endpoint.process_frame(frame(1.0), probability, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::None - ); - } - assert_eq!( - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::Speech - ); - } - - #[test] - fn offset_hysteresis_preserves_borderline_speech() { - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - assert_eq!( - endpoint.process_frame(frame(2.0), 0.4, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::Speech - ); - assert_eq!(endpoint.silence_frames, 0); - } - - #[test] - fn below_offset_threshold_starts_silence() { - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - assert_eq!( - endpoint.process_frame(frame(0.0), 0.3, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::FirstSilence - ); - assert_eq!(endpoint.silence_frames, 1); - } - - #[test] - fn short_segment_reaches_the_visible_drop_path() { - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - let mut action = VadFrameAction::None; - for _ in 0..SILENCE_FLUSH_FRAMES { - action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); - } - assert_eq!(action, VadFrameAction::Flush); - assert!(!has_enough_voiced_audio(endpoint.voiced_frames)); - assert!(!endpoint.speech_buf.is_empty()); - } - - #[test] - fn silence_flush_retains_only_hangover_audio() { - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - let speech_len = endpoint.speech_buf.len(); - for index in 1..=SILENCE_FLUSH_FRAMES { - let action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); - if index == SILENCE_FLUSH_FRAMES { - assert_eq!(action, VadFrameAction::Flush); - } - } - assert_eq!( - endpoint.speech_buf.len(), - speech_len + 6 * VAD_FRAME_SAMPLES - ); - } - - #[test] - fn flush_boundary_never_double_includes_audio() { - const SEGMENT_N_MARKER: f32 = 777.0; - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame( - frame(SEGMENT_N_MARKER), - 0.9, - true, - true, - SILENCE_FLUSH_FRAMES, - ); - } - for _ in 0..SILENCE_FLUSH_FRAMES { - endpoint.process_frame( - frame(SEGMENT_N_MARKER), - 0.0, - true, - true, - SILENCE_FLUSH_FRAMES, - ); - } - endpoint.reset_segment(); - - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - let leaked = endpoint - .speech_buf - .iter() - .filter(|sample| **sample == SEGMENT_N_MARKER) - .count(); - assert_eq!(leaked, 0, "segment N audio leaked into segment N+1"); - } - - #[test] - fn reset_prevents_pre_roll_from_leaking_between_segments() { - const SEGMENT_N_MARKER: f32 = 777.0; - let mut endpoint = VadEndpoint::new(); - endpoint.pre_roll.push_back(frame(SEGMENT_N_MARKER)); - endpoint.reset_segment(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - let leaked = endpoint - .speech_buf - .iter() - .filter(|sample| **sample == SEGMENT_N_MARKER) - .count(); - assert_eq!(leaked, 0, "segment N pre-roll leaked into segment N+1"); - } - - #[test] - fn held_push_to_talk_never_silence_flushes() { - // Pure VAD mode: silence always ends the utterance. - assert!(vad_flush_allowed(false, false, false)); - // Shortcut configured, nothing transmitting: nothing to flush anyway, - // but the pause path stays closed. - assert!(!vad_flush_allowed(true, false, false)); - // Shortcut held: "I am not done talking" — never flush on silence, - // regardless of the manual mic state. - assert!(!vad_flush_allowed(true, false, true)); - assert!(!vad_flush_allowed(true, true, true)); - // Manually open mic with the shortcut up: normal VAD behavior. - assert!(vad_flush_allowed(true, true, false)); - } -} +#[path = "stt_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/stt_tests.rs b/desktop/src-tauri/src/huddle/stt_tests.rs new file mode 100644 index 0000000000..3fba9adafa --- /dev/null +++ b/desktop/src-tauri/src/huddle/stt_tests.rs @@ -0,0 +1,255 @@ +use std::sync::{atomic::AtomicBool, mpsc, Arc, Barrier}; + +use super::{ + has_enough_voiced_audio, run_stt_receive_loop, vad_flush_allowed, HumanFloor, SttLoopInput, + VadEndpoint, VadFrameAction, MIN_VOICED_FRAMES, SILENCE_FLUSH_FRAMES, VAD_FRAME_SAMPLES, + VAD_ONSET_FRAMES, VAD_PRE_ROLL_FRAMES, +}; + +#[derive(Clone, Copy)] +enum WorkerExit { + Shutdown, + SenderDisconnect, +} + +fn assert_worker_exit_releases_floor(exit: WorkerExit) { + let human_floor = HumanFloor::new(); + let shutdown = Arc::new(AtomicBool::new(false)); + let (audio_tx, audio_rx) = mpsc::channel(); + let acquired = Arc::new(Barrier::new(2)); + let worker_floor = human_floor.clone(); + let worker_shutdown = Arc::clone(&shutdown); + let worker_acquired = Arc::clone(&acquired); + let worker = std::thread::spawn(move || { + run_stt_receive_loop( + audio_rx, + &worker_shutdown, + worker_floor.clone(), + |input, local_barge_in_state| { + if matches!(input, SttLoopInput::Batch(_)) && !worker_floor.is_blocked() { + local_barge_in_state.acquire(&worker_floor, true, false); + worker_acquired.wait(); + } + }, + ); + }); + + audio_tx.send(Vec::new()).expect("worker receiver is open"); + acquired.wait(); + assert!(human_floor.is_blocked()); + match exit { + WorkerExit::Shutdown => { + shutdown.store(true, std::sync::atomic::Ordering::Release); + } + WorkerExit::SenderDisconnect => drop(audio_tx), + } + worker.join().expect("worker exits cleanly"); + + let replacement_epoch = human_floor.epoch(); + assert!( + human_floor.permits(replacement_epoch), + "fresh TTS authorization must proceed after worker exit" + ); + assert!(human_floor.enter_local(true, false)); +} + +#[test] +fn worker_shutdown_releases_local_floor_for_replacement() { + assert_worker_exit_releases_floor(WorkerExit::Shutdown); +} + +#[test] +fn worker_channel_disconnect_releases_local_floor_for_replacement() { + assert_worker_exit_releases_floor(WorkerExit::SenderDisconnect); +} + +fn frame(value: f32) -> Vec { + vec![value; VAD_FRAME_SAMPLES] +} + +#[test] +fn short_vad_blips_do_not_reach_the_recognizer() { + assert!(!has_enough_voiced_audio(1)); + assert!(!has_enough_voiced_audio(MIN_VOICED_FRAMES - 1)); + assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); +} + +#[test] +fn confirmed_onset_prepends_pre_roll_once() { + let mut endpoint = VadEndpoint::new(); + for value in 0..VAD_PRE_ROLL_FRAMES - VAD_ONSET_FRAMES { + assert_eq!( + endpoint.process_frame(frame(value as f32), 0.0, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::None + ); + } + for value in 0..VAD_ONSET_FRAMES { + let action = endpoint.process_frame( + frame(100.0 + value as f32), + 0.9, + true, + true, + SILENCE_FLUSH_FRAMES, + ); + if value + 1 == VAD_ONSET_FRAMES { + assert_eq!(action, VadFrameAction::ConfirmedOnset); + } else { + assert_eq!(action, VadFrameAction::None); + } + } + + assert_eq!( + endpoint.speech_buf.len(), + VAD_PRE_ROLL_FRAMES * VAD_FRAME_SAMPLES + ); + assert_eq!(endpoint.speech_buf[0], 0.0); + assert_eq!(endpoint.speech_buf[VAD_FRAME_SAMPLES], 1.0); + assert_eq!(endpoint.pre_roll.len(), 0); + endpoint.process_frame(frame(200.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + assert_eq!( + endpoint.speech_buf.len(), + (VAD_PRE_ROLL_FRAMES + 1) * VAD_FRAME_SAMPLES + ); +} + +#[test] +fn onset_requires_consecutive_high_frames() { + let mut endpoint = VadEndpoint::new(); + for probability in [0.9, 0.9, 0.2, 0.9, 0.9] { + assert_eq!( + endpoint.process_frame(frame(1.0), probability, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::None + ); + } + assert_eq!( + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::ConfirmedOnset + ); +} + +#[test] +fn offset_hysteresis_preserves_borderline_speech() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + assert_eq!( + endpoint.process_frame(frame(2.0), 0.4, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::Speech + ); + assert_eq!(endpoint.silence_frames, 0); +} + +#[test] +fn below_offset_threshold_starts_silence() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + assert_eq!( + endpoint.process_frame(frame(0.0), 0.3, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::FirstSilence + ); + assert_eq!(endpoint.silence_frames, 1); +} + +#[test] +fn short_segment_reaches_the_visible_drop_path() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let mut action = VadFrameAction::None; + for _ in 0..SILENCE_FLUSH_FRAMES { + action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); + } + assert_eq!(action, VadFrameAction::Flush); + assert!(!has_enough_voiced_audio(endpoint.voiced_frames)); + assert!(!endpoint.speech_buf.is_empty()); +} + +#[test] +fn silence_flush_retains_only_hangover_audio() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let speech_len = endpoint.speech_buf.len(); + for index in 1..=SILENCE_FLUSH_FRAMES { + let action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); + if index == SILENCE_FLUSH_FRAMES { + assert_eq!(action, VadFrameAction::Flush); + } + } + assert_eq!( + endpoint.speech_buf.len(), + speech_len + 6 * VAD_FRAME_SAMPLES + ); +} + +#[test] +fn flush_boundary_never_double_includes_audio() { + const SEGMENT_N_MARKER: f32 = 777.0; + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame( + frame(SEGMENT_N_MARKER), + 0.9, + true, + true, + SILENCE_FLUSH_FRAMES, + ); + } + for _ in 0..SILENCE_FLUSH_FRAMES { + endpoint.process_frame( + frame(SEGMENT_N_MARKER), + 0.0, + true, + true, + SILENCE_FLUSH_FRAMES, + ); + } + endpoint.reset_segment(); + + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let leaked = endpoint + .speech_buf + .iter() + .filter(|sample| **sample == SEGMENT_N_MARKER) + .count(); + assert_eq!(leaked, 0, "segment N audio leaked into segment N+1"); +} + +#[test] +fn reset_prevents_pre_roll_from_leaking_between_segments() { + const SEGMENT_N_MARKER: f32 = 777.0; + let mut endpoint = VadEndpoint::new(); + endpoint.pre_roll.push_back(frame(SEGMENT_N_MARKER)); + endpoint.reset_segment(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let leaked = endpoint + .speech_buf + .iter() + .filter(|sample| **sample == SEGMENT_N_MARKER) + .count(); + assert_eq!(leaked, 0, "segment N pre-roll leaked into segment N+1"); +} + +#[test] +fn held_push_to_talk_never_silence_flushes() { + // Pure VAD mode: silence always ends the utterance. + assert!(vad_flush_allowed(false, false, false)); + // Shortcut configured, nothing transmitting: nothing to flush anyway, + // but the pause path stays closed. + assert!(!vad_flush_allowed(true, false, false)); + // Shortcut held: "I am not done talking" — never flush on silence, + // regardless of the manual mic state. + assert!(!vad_flush_allowed(true, false, true)); + assert!(!vad_flush_allowed(true, true, true)); + // Manually open mic with the shortcut up: normal VAD behavior. + assert!(vad_flush_allowed(true, true, false)); +} diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 5c6bbc4c77..920143e93a 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -46,6 +46,7 @@ use std::{ time::{Duration, Instant}, }; +use super::human_floor::HumanFloor; use super::pocket::{ load_text_to_speech, load_voice_style, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, }; @@ -53,10 +54,8 @@ use super::preprocessing::preprocess_for_tts; #[path = "tts_voice_transition.rs"] mod voice_transition; +use super::tts_playback::*; use voice_transition::*; -#[path = "tts_playback.rs"] -mod playback; -use playback::*; #[path = "tts_startup.rs"] mod startup; use startup::await_worker_startup; @@ -153,6 +152,7 @@ pub struct TtsPipeline { /// Kept alive here so the Arc isn't dropped — the worker holds a clone. #[allow(dead_code)] cancel: Arc, + human_floor: HumanFloor, /// Internal cancellation used only for voice changes. Kept separate so a /// concurrent human barge-in always clears every queued message. voice_cancel: Arc, @@ -185,6 +185,7 @@ impl TtsPipeline { model_dir: PathBuf, tts_active: Arc, cancel: Arc, + human_floor: HumanFloor, voice: &str, output_device: Option, activity_app: Option, @@ -196,6 +197,7 @@ impl TtsPipeline { let shutdown_worker = Arc::clone(&shutdown); let cancel_worker = Arc::clone(&cancel); + let worker_human_floor = human_floor.clone(); let voice_cancel = Arc::new(AtomicBool::new(false)); let worker_voice_cancel = Arc::clone(&voice_cancel); let tts_active_worker = Arc::clone(&tts_active); @@ -227,6 +229,7 @@ impl TtsPipeline { worker_voice_change_ack, ), text_rx, + worker_human_floor, ( tts_active_worker, shutdown_worker, @@ -249,6 +252,7 @@ impl TtsPipeline { tts_active, shutdown, cancel, + human_floor, voice_cancel, voice, voice_generation, @@ -275,10 +279,130 @@ impl Drop for TtsPipeline { // ── Worker thread ───────────────────────────────────────────────────────────── +fn authorize_or_defer_queued_text( + human_floor: &HumanFloor, + deferred_text: &mut VecDeque, + queued_text: QueuedText, +) -> Result { + match human_floor.authorization(queued_text.floor_epoch) { + HumanFloorAuthorization::Blocked => { + deferred_text.push_front(queued_text); + Err(HumanFloorAuthorization::Blocked) + } + HumanFloorAuthorization::Stale => { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=barge_in route_id={}", + queued_text.route_id + ); + Err(HumanFloorAuthorization::Stale) + } + HumanFloorAuthorization::Permitted => Ok(queued_text), + } +} + +struct TtsAppendContext<'a> { + playback: &'a PlaybackCoordinator, + #[cfg(test)] + human_floor: &'a HumanFloor, + cancel: &'a AtomicBool, + voice_cancel: &'a AtomicBool, + shutdown: &'a AtomicBool, + tts_active: &'a AtomicBool, + speaker_generations: &'a SpeakerGenerations, + active_speaker: &'a ActiveSpeaker, + activity_frames: &'a Mutex>, + channels: NonZero, + rate: NonZero, +} + +fn append_worker_audio( + context: &TtsAppendContext<'_>, + prepared: PreparedModelAudio, + route_id: u64, + speaker_pubkey: Option<&str>, + speaker_generation: u64, + floor_epoch: u64, +) -> bool { + // Keep the shared floor in this context so the regression can mutation-check + // that authorization never moves back inside the coordinator callback. + #[cfg(test)] + let _ = context.human_floor; + let sample_count = prepared.sample_count; + let chunk_index = prepared.chunk_index; + let activity = speaker_pubkey.map(|pubkey| { + build_tts_speaker_activity_frames(&prepared.buffer, pubkey, SAMPLE_RATE as usize) + }); + let floor_authorization = context.playback.append_if_human_floor_permits( + rodio::buffer::SamplesBuffer::new(context.channels, context.rate, prepared.buffer), + floor_epoch, + |player_empty| { + if context.cancel.load(Ordering::Acquire) + || context.voice_cancel.load(Ordering::Acquire) + || context.shutdown.load(Ordering::Acquire) + { + let reason = if context.shutdown.load(Ordering::Acquire) { + "shutdown" + } else if context.cancel.load(Ordering::Acquire) { + "barge_in" + } else { + "voice_switch" + }; + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}" + ); + return false; + } + if speaker_pubkey.is_some_and(|pubkey| { + current_speaker_generation(context.speaker_generations, pubkey) + != speaker_generation + }) { + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}" + ); + return false; + } + if let Some(pubkey) = speaker_pubkey { + let mut active = context + .active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if player_empty { + active.take(); + } + if active + .as_deref() + .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey)) + { + return false; + } + active.get_or_insert_with(|| pubkey.to_ascii_lowercase()); + context + .activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .extend(activity.unwrap_or_default()); + } + true + }, + // Publish activity under the coordinator: an append and the mic gate it + // implies are one transition, so cancellation cannot overwrite it. + || context.tts_active.store(true, Ordering::Release), + ); + if floor_authorization != HumanFloorAuthorization::Permitted { + return false; + } + eprintln!( + "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={chunk_index} sample_count={sample_count}" + ); + true +} + +#[allow(clippy::too_many_arguments)] fn tts_worker( model_dir: PathBuf, voice_state: WorkerVoiceState, text_rx: mpsc::Receiver, + human_floor: HumanFloor, control_state: WorkerControlState, output_device: Option, activity_app: Option, @@ -377,10 +501,11 @@ fn tts_worker( } }; - // One coordinator owns the current Player and every operation on it. - // Cancellation replaces its queue while the output stream and mixer stay - // alive, preserving cross-item pipelining without exposing Player handles. - let playback = Arc::new(PlaybackCoordinator::new(sink_handle.mixer())); + // One coordinator owns the current Player, floor state, and every operation. + // It was allocated with the huddle state so onset and playback share the + // same serialization boundary even before the TTS worker starts. + let playback = human_floor.playback(); + playback.bind_mixer(sink_handle.mixer()); playback_probe.install(Arc::clone(&playback)); // Prime the audio output stream with a short silent buffer. @@ -442,76 +567,33 @@ fn tts_worker( let tts_streaming = streaming_emit_frames(); let mut last_route_id = 0; let mut deferred_text = VecDeque::new(); + let append_context = TtsAppendContext { + playback: &playback, + #[cfg(test)] + human_floor: &human_floor, + cancel: &cancel, + voice_cancel: &voice_cancel, + shutdown: &shutdown, + tts_active: &tts_active, + speaker_generations: &speaker_generations, + active_speaker: &active_speaker, + activity_frames: &activity_frames, + channels, + rate, + }; let append_audio = |prepared: PreparedModelAudio, route_id: u64, speaker_pubkey: Option<&str>, - speaker_generation: u64| { - let sample_count = prepared.sample_count; - let chunk_index = prepared.chunk_index; - let activity = speaker_pubkey.map(|pubkey| { - build_tts_speaker_activity_frames(&prepared.buffer, pubkey, SAMPLE_RATE as usize) - }); - let accepted = playback.append_if( - SamplesBuffer::new(channels, rate, prepared.buffer), - |player_empty| { - if cancel.load(Ordering::Acquire) - || voice_cancel.load(Ordering::Acquire) - || shutdown.load(Ordering::Acquire) - { - let reason = if shutdown.load(Ordering::Acquire) { - "shutdown" - } else if cancel.load(Ordering::Acquire) { - "barge_in" - } else { - "voice_switch" - }; - eprintln!( - "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}" - ); - return false; - } - if speaker_pubkey.is_some_and(|pubkey| { - current_speaker_generation(&speaker_generations, pubkey) != speaker_generation - }) { - eprintln!( - "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}" - ); - return false; - } - if let Some(pubkey) = speaker_pubkey { - let mut active = active_speaker - .lock() - .unwrap_or_else(|error| error.into_inner()); - if player_empty { - active.take(); - } - if active - .as_deref() - .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey)) - { - return false; - } - active.get_or_insert_with(|| pubkey.to_ascii_lowercase()); - activity_frames - .lock() - .unwrap_or_else(|error| error.into_inner()) - .extend(activity.unwrap_or_default()); - } - true - }, - // Publish activity under the coordinator: an append and the mic - // gate it implies are one transition, so a cancellation that - // replaces this player cannot have its release overwritten by a - // `true` landing after the fact. - || tts_active.store(true, Ordering::Release), - ); - if !accepted { - return false; - } - eprintln!( - "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={chunk_index} sample_count={sample_count}" - ); - true + speaker_generation: u64, + floor_epoch: u64| { + append_worker_audio( + &append_context, + prepared, + route_id, + speaker_pubkey, + speaker_generation, + floor_epoch, + ) }; loop { @@ -632,7 +714,19 @@ fn tts_worker( thread::sleep(RECV_TIMEOUT); continue; } - let requested_voice = queued_text.voice_reference.unwrap_or_else(|| { + let mut queued_text = + match authorize_or_defer_queued_text(&human_floor, &mut deferred_text, queued_text) { + Ok(queued_text) => queued_text, + Err(HumanFloorAuthorization::Blocked) => { + thread::sleep(RECV_TIMEOUT); + continue; + } + Err(HumanFloorAuthorization::Stale) => continue, + Err(HumanFloorAuthorization::Permitted) => { + unreachable!("permitted text is returned") + } + }; + let requested_voice = queued_text.voice_reference.take().unwrap_or_else(|| { selected_voice .lock() .unwrap_or_else(|error| error.into_inner()) @@ -641,6 +735,7 @@ fn tts_worker( let raw_text = queued_text.text; let speaker_pubkey = queued_text.speaker_pubkey; let speaker_generation = queued_text.speaker_generation; + let floor_epoch = queued_text.floor_epoch; let route_id = queued_text.route_id; eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); @@ -751,6 +846,7 @@ fn tts_worker( route_id, speaker_pubkey.as_deref(), speaker_generation, + floor_epoch, ) { return false; } @@ -832,6 +928,7 @@ fn tts_worker( route_id, speaker_pubkey.as_deref(), speaker_generation, + floor_epoch, ) { synthesis_outcome = "cancelled"; break 'playback_chunks; @@ -860,6 +957,7 @@ fn tts_worker( route_id, speaker_pubkey.as_deref(), speaker_generation, + floor_epoch, ) { synthesis_outcome = "cancelled"; break 'playback_chunks; diff --git a/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs index 0ee472f0fd..5464fe0872 100644 --- a/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs +++ b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs @@ -6,9 +6,11 @@ impl TtsPipeline { /// Non-blocking. Returns `Err` if the queue is full (bounded at /// `TEXT_QUEUE_DEPTH`) — caller may log and discard. pub fn speak(&self, text: String) -> Result<(), String> { + let floor_epoch = self.human_floor.epoch(); self.text_tx .try_send(QueuedText { generation: self.voice_generation.load(Ordering::Acquire), + floor_epoch, route_id: 0, speaker_pubkey: None, speaker_generation: 0, @@ -28,6 +30,7 @@ impl TtsPipeline { TtsTextSender { text_tx: self.text_tx.clone(), generation: self.voice_generation.load(Ordering::Acquire), + human_floor: self.human_floor.clone(), speaker_generations: Arc::clone(&self.speaker_generations), } } diff --git a/desktop/src-tauri/src/huddle/tts_playback.rs b/desktop/src-tauri/src/huddle/tts_playback.rs index 93a5b16b8c..8a90c01899 100644 --- a/desktop/src-tauri/src/huddle/tts_playback.rs +++ b/desktop/src-tauri/src/huddle/tts_playback.rs @@ -1,7 +1,17 @@ -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::{ + collections::HashSet, + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::{Duration, Instant}, +}; use rodio::{mixer::Mixer, Player, Source}; +/// Conservative guard after rodio reports drained. Max measured about 12 ms +/// of cancellation tail on current-main CoreAudio and about 1 ms after player +/// replacement; 100 ms safely bounds those observed paths while the phase-1 +/// route matrix determines whether this can be narrowed. +const OUTPUT_TAIL_HANGOVER: Duration = Duration::from_millis(100); + /// Serializes every operation on the TTS player and owns the utterance-boundary /// bookkeeping that must change atomically when playback is replaced. /// @@ -10,18 +20,62 @@ use rodio::{mixer::Mixer, Player, Source}; /// independently valid at either value, and no mutable reference to the state /// leaves the locked operation that created it. pub(super) struct PlaybackCoordinator { - mixer: Mixer, + mixer: Mutex>, state: Mutex, } struct PlaybackState { - player: Player, + player: Option, /// `true` while no append has been committed since the last utterance /// boundary. Only `append_if` clears it, so it records appends that were /// actually queued — never one the authorization refused. first_append: bool, synthesis_in_flight: bool, synthesis_generation: u64, + output_lease: OutputLease, + human_floor: HumanFloorState, +} + +#[derive(Default)] +enum OutputLease { + #[default] + Inactive, + Active, + HangoverUntil(Instant), +} + +impl OutputLease { + fn is_live_at(&mut self, now: Instant) -> bool { + match self { + Self::Inactive => false, + Self::Active => true, + Self::HangoverUntil(deadline) if now < *deadline => true, + Self::HangoverUntil(_) => { + *self = Self::Inactive; + false + } + } + } + + fn begin_hangover(&mut self, now: Instant) { + if !matches!(self, Self::Inactive) { + *self = Self::HangoverUntil(now + OUTPUT_TAIL_HANGOVER); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum HumanFloorAuthorization { + Permitted, + Blocked, + Stale, +} + +#[derive(Default)] +struct HumanFloorState { + epoch: u64, + local: bool, + remote: HashSet, } pub(super) struct SynthesisFlightGuard { @@ -39,18 +93,35 @@ impl Drop for SynthesisFlightGuard { } impl PlaybackCoordinator { + #[cfg(test)] pub(super) fn new(mixer: &Mixer) -> Self { + let coordinator = Self::unbound(); + coordinator.bind_mixer(mixer); + coordinator + } + + pub(super) fn unbound() -> Self { Self { - mixer: mixer.clone(), + mixer: Mutex::new(None), state: Mutex::new(PlaybackState { - player: Player::connect_new(mixer), + player: None, first_append: true, synthesis_in_flight: false, synthesis_generation: 0, + output_lease: OutputLease::Inactive, + human_floor: HumanFloorState::default(), }), } } + pub(super) fn bind_mixer(&self, mixer: &Mixer) { + *self.mixer.lock().unwrap_or_else(PoisonError::into_inner) = Some(mixer.clone()); + let mut state = self.lock(); + if state.player.is_none() { + state.player = Some(Player::connect_new(mixer)); + } + } + fn lock(&self) -> MutexGuard<'_, PlaybackState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } @@ -61,6 +132,7 @@ impl PlaybackCoordinator { /// concurrent cancellation either replaces the queue before this append is /// authorized, or observes the committed state after it — never lands its /// own release between the two and gets overwritten. + #[cfg(test)] pub(super) fn append_if( &self, source: S, @@ -71,11 +143,15 @@ impl PlaybackCoordinator { S: Source + Send + 'static, { let mut state = self.lock(); - if !authorize(state.player.empty()) { + if !authorize(state.player.as_ref().is_none_or(Player::empty)) { return false; } - state.player.append(source); + let Some(player) = state.player.as_ref() else { + return false; + }; + player.append(source); state.first_append = false; + state.output_lease = OutputLease::Active; commit(); true } @@ -84,28 +160,31 @@ impl PlaybackCoordinator { where S: Source + Send + 'static, { - self.lock().player.append(source); + if let Some(player) = self.lock().player.as_ref() { + player.append(source); + } } pub(super) fn empty(&self) -> bool { - self.lock().player.empty() + self.lock().player.as_ref().is_none_or(Player::empty) } /// Observe playback emptiness under the coordinator so the onset decision /// for the audio being built is serialized with append and cancellation. pub(super) fn prepare_audio(&self, prepare: impl FnOnce(bool) -> R) -> R { let state = self.lock(); - let empty = state.player.empty(); + let empty = state.player.as_ref().is_none_or(Player::empty); prepare(empty) } pub(super) fn release_if_drained(&self, release: impl FnOnce()) -> bool { let mut state = self.lock(); - if !state.player.empty() || state.first_append { + if !state.player.as_ref().is_none_or(Player::empty) || state.first_append { return false; } release(); state.first_append = true; + state.output_lease.begin_hangover(Instant::now()); true } @@ -124,7 +203,7 @@ impl PlaybackCoordinator { pub(super) fn with_playback_live(&self, observe: impl FnOnce(bool) -> R) -> R { let state = self.lock(); - observe(!state.player.empty() || state.synthesis_in_flight) + observe(!state.player.as_ref().is_none_or(Player::empty) || state.synthesis_in_flight) } /// Replace live playback with a fresh queue, publishing the replacement @@ -142,21 +221,162 @@ impl PlaybackCoordinator { authorize: impl FnOnce() -> bool, commit: impl FnOnce(), ) -> bool { + let replacement = self + .mixer + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(Player::connect_new); let old_player = { let mut state = self.lock(); - if (state.player.empty() && !state.synthesis_in_flight) || !authorize() { + if (state.player.as_ref().is_none_or(Player::empty) && !state.synthesis_in_flight) + || !authorize() + { return false; } state.first_append = true; state.synthesis_in_flight = false; state.synthesis_generation = state.synthesis_generation.wrapping_add(1); - let old_player = std::mem::replace(&mut state.player, Player::connect_new(&self.mixer)); + state.output_lease.begin_hangover(Instant::now()); + let old_player = std::mem::replace(&mut state.player, replacement); commit(); old_player }; drop(old_player); true } + + #[cfg(test)] + pub(super) fn human_floor_blocked(&self) -> bool { + let state = self.lock(); + state.human_floor.local || !state.human_floor.remote.is_empty() + } + + pub(super) fn human_floor_epoch(&self) -> u64 { + self.lock().human_floor.epoch + } + + pub(super) fn human_floor_authorization(&self, epoch: u64) -> HumanFloorAuthorization { + Self::human_floor_authorization_locked(&self.lock(), epoch) + } + + fn human_floor_authorization_locked( + state: &PlaybackState, + epoch: u64, + ) -> HumanFloorAuthorization { + if state.human_floor.local || !state.human_floor.remote.is_empty() { + HumanFloorAuthorization::Blocked + } else if state.human_floor.epoch != epoch { + HumanFloorAuthorization::Stale + } else { + HumanFloorAuthorization::Permitted + } + } + + #[cfg(test)] + pub(super) fn human_floor_permits(&self, epoch: u64) -> bool { + self.human_floor_authorization(epoch) == HumanFloorAuthorization::Permitted + } + + pub(super) fn append_if_human_floor_permits( + &self, + source: S, + epoch: u64, + authorize: impl FnOnce(bool) -> bool, + commit: impl FnOnce(), + ) -> HumanFloorAuthorization + where + S: Source + Send + 'static, + { + let mut state = self.lock(); + let floor_authorization = Self::human_floor_authorization_locked(&state, epoch); + if floor_authorization != HumanFloorAuthorization::Permitted { + return floor_authorization; + } + if !authorize(state.player.as_ref().is_none_or(Player::empty)) { + return HumanFloorAuthorization::Stale; + } + let Some(player) = state.player.as_ref() else { + return HumanFloorAuthorization::Stale; + }; + player.append(source); + state.first_append = false; + state.output_lease = OutputLease::Active; + commit(); + HumanFloorAuthorization::Permitted + } + + pub(super) fn enter_local_human_floor( + &self, + route_isolated: bool, + sustained_coupled_speech: bool, + ) -> bool { + let replacement = self + .mixer + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(Player::connect_new); + let old_player = { + let mut state = self.lock(); + let output_live = + state.synthesis_in_flight || state.output_lease.is_live_at(Instant::now()); + if state.human_floor.local + || (output_live && !route_isolated && !sustained_coupled_speech) + { + return false; + } + state.human_floor.local = true; + Self::commit_human_floor_onset(&mut state, replacement) + }; + drop(old_player); + true + } + + pub(super) fn leave_local_human_floor(&self) { + self.lock().human_floor.local = false; + } + + pub(super) fn enter_remote_human_floor(&self, peer: u8) { + self.enter_human_floor(|floor| floor.remote.insert(peer)); + } + + pub(super) fn leave_remote_human_floor(&self, peer: u8) { + self.lock().human_floor.remote.remove(&peer); + } + + pub(super) fn clear_remote_human_floor(&self) { + self.lock().human_floor.remote.clear(); + } + + fn enter_human_floor(&self, enter: impl FnOnce(&mut HumanFloorState) -> bool) { + let replacement = self + .mixer + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(Player::connect_new); + let old_player = { + let mut state = self.lock(); + if !enter(&mut state.human_floor) { + return; + } + Self::commit_human_floor_onset(&mut state, replacement) + }; + drop(old_player); + } + + fn commit_human_floor_onset( + state: &mut PlaybackState, + replacement: Option, + ) -> Option { + state.human_floor.epoch = state.human_floor.epoch.wrapping_add(1); + state.first_append = true; + state.synthesis_in_flight = false; + state.synthesis_generation = state.synthesis_generation.wrapping_add(1); + state.output_lease.begin_hangover(Instant::now()); + std::mem::replace(&mut state.player, replacement) + } } #[cfg(test)] @@ -194,6 +414,185 @@ mod tests { ); } + fn one_second_source() -> SamplesBuffer { + SamplesBuffer::new( + NonZero::new(1).expect("nonzero channels"), + NonZero::new(24_000).expect("nonzero rate"), + vec![0.25; 24_000], + ) + } + + #[test] + fn floor_authorized_append_does_not_reenter_the_coordinator_lock() { + let (playback, _unpulled_source) = coordinator(); + let epoch = playback.human_floor_epoch(); + let (completed_tx, completed_rx) = std::sync::mpsc::sync_channel(1); + let worker = thread::spawn(move || { + let authorization = + playback.append_if_human_floor_permits(one_second_source(), epoch, |_| true, || {}); + completed_tx + .send(authorization) + .expect("completion receiver"); + }); + + assert_eq!( + completed_rx + .recv_timeout(Duration::from_secs(1)) + .expect("floor-authorized append must not deadlock"), + HumanFloorAuthorization::Permitted + ); + worker.join().expect("append worker"); + } + + #[test] + fn text_queued_during_a_held_floor_is_permitted_after_release() { + let (playback, _unpulled_source) = coordinator(); + assert!(playback.enter_local_human_floor(true, false)); + let queued_epoch = playback.human_floor_epoch(); + + assert_eq!( + playback.human_floor_authorization(queued_epoch), + HumanFloorAuthorization::Blocked + ); + playback.leave_local_human_floor(); + assert_eq!( + playback.human_floor_authorization(queued_epoch), + HumanFloorAuthorization::Permitted + ); + assert_eq!( + playback.append_if_human_floor_permits( + one_second_source(), + queued_epoch, + |_| true, + || {}, + ), + HumanFloorAuthorization::Permitted + ); + } + + #[test] + fn human_onset_replaces_playback_and_invalidates_late_append() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + let stale_epoch = playback.human_floor_epoch(); + + assert!(playback.enter_local_human_floor(true, false)); + + assert!(playback.empty()); + assert!(playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(stale_epoch)); + playback.leave_local_human_floor(); + assert!(!playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(stale_epoch)); + } + + #[test] + fn coupled_local_onset_while_idle_blocks_delayed_tts() { + let (playback, _unpulled_source) = coordinator(); + let delayed_tts_epoch = playback.human_floor_epoch(); + + assert!(playback.enter_local_human_floor(false, false)); + + assert!(playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(delayed_tts_epoch)); + } + + #[test] + fn coupled_local_onset_during_output_is_rejected_as_ambiguous_echo() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + let epoch = playback.human_floor_epoch(); + + assert!(!playback.enter_local_human_floor(false, false)); + + assert!(!playback.human_floor_blocked()); + assert!(playback.human_floor_permits(epoch)); + assert!(!playback.empty()); + } + + #[test] + fn sustained_coupled_speech_overrides_live_output_suppression() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + let stale_epoch = playback.human_floor_epoch(); + + assert!(playback.enter_local_human_floor(false, true)); + + assert!(playback.empty()); + assert!(playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(stale_epoch)); + } + + #[test] + fn coupled_local_onset_during_output_tail_hangover_is_rejected() { + let (playback, mut source) = coordinator(); + append_second(&playback); + while !playback.empty() { + assert!( + source.next().is_some(), + "the mixer source outlives the queue" + ); + } + assert!(playback.release_if_drained(|| {})); + + assert!(!playback.enter_local_human_floor(false, false)); + assert!(!playback.human_floor_blocked()); + } + + #[test] + fn coupled_local_onset_after_output_tail_hangover_is_accepted() { + let (playback, mut source) = coordinator(); + append_second(&playback); + while !playback.empty() { + assert!( + source.next().is_some(), + "the mixer source outlives the queue" + ); + } + assert!(playback.release_if_drained(|| {})); + playback.lock().output_lease = + OutputLease::HangoverUntil(Instant::now() - Duration::from_millis(1)); + + assert!(playback.enter_local_human_floor(false, false)); + assert!(playback.human_floor_blocked()); + } + + #[test] + fn accepted_append_renews_an_expiring_output_lease() { + let (playback, _unpulled_source) = coordinator(); + playback.lock().output_lease = + OutputLease::HangoverUntil(Instant::now() + Duration::from_millis(1)); + + append_second(&playback); + + assert!(matches!(playback.lock().output_lease, OutputLease::Active)); + } + + #[test] + fn remote_onset_while_idle_blocks_delayed_tts() { + let (playback, _unpulled_source) = coordinator(); + let delayed_tts_epoch = playback.human_floor_epoch(); + + playback.enter_remote_human_floor(7); + + assert!(playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(delayed_tts_epoch)); + } + + #[test] + fn local_and_remote_sources_hold_the_same_floor_until_each_releases() { + let (playback, _unpulled_source) = coordinator(); + assert!(playback.enter_local_human_floor(true, false)); + let local_epoch = playback.human_floor_epoch(); + playback.enter_remote_human_floor(7); + assert_ne!(playback.human_floor_epoch(), local_epoch); + + playback.leave_local_human_floor(); + assert!(playback.human_floor_blocked()); + playback.leave_remote_human_floor(7); + assert!(!playback.human_floor_blocked()); + } + #[test] fn cancel_replaces_playback_without_waiting_for_the_mixer() { let (playback, _unpulled_source) = coordinator(); diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 64fd6d8a94..75cfef26e5 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -622,6 +622,7 @@ pub async fn preview_pocket_voice( model_dir, active.clone(), cancel, + super::human_floor::HumanFloor::new(), &voice_name, output_device, None, diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 3358d52644..05c8320cd6 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -12,6 +12,115 @@ use std::sync::{Arc, Mutex}; #[path = "tts_tests/token_split.rs"] mod token_split; +// ── Human-floor queue authorization ─────────────────────────────────────── + +fn queued_text(route_id: u64, floor_epoch: u64) -> QueuedText { + QueuedText { + generation: 1, + floor_epoch, + route_id, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text: "queued while a human is speaking".to_string(), + } +} + +#[test] +fn production_worker_append_authorization_completes() { + let (completed_tx, completed_rx) = mpsc::sync_channel(1); + let worker = std::thread::spawn(move || { + let human_floor = HumanFloor::new(); + let playback = human_floor.playback(); + let channels = NonZero::new(1).expect("nonzero channels"); + let rate = NonZero::new(SAMPLE_RATE).expect("nonzero rate"); + let (mixer, _unpulled_source) = rodio::mixer::mixer(channels, rate); + playback.bind_mixer(&mixer); + let floor_epoch = human_floor.epoch(); + let cancel = AtomicBool::new(false); + let voice_cancel = AtomicBool::new(false); + let shutdown = AtomicBool::new(false); + let tts_active = AtomicBool::new(false); + let speaker_generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(None)); + let activity_frames = Mutex::new(VecDeque::new()); + let context = TtsAppendContext { + playback: &playback, + human_floor: &human_floor, + cancel: &cancel, + voice_cancel: &voice_cancel, + shutdown: &shutdown, + tts_active: &tts_active, + speaker_generations: &speaker_generations, + active_speaker: &active_speaker, + activity_frames: &activity_frames, + channels, + rate, + }; + + let accepted = append_worker_audio( + &context, + PreparedModelAudio { + buffer: vec![0.25; SAMPLE_RATE as usize], + sample_count: SAMPLE_RATE as usize, + chunk_index: 0, + }, + 40, + None, + 0, + floor_epoch, + ); + completed_tx + .send((accepted, tts_active.load(Ordering::Acquire))) + .expect("completion receiver"); + }); + + assert_eq!( + completed_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("production worker append authorization must not deadlock"), + (true, true) + ); + worker.join().expect("append worker"); +} + +#[test] +fn worker_queue_defers_text_while_floor_is_held_then_releases_it() { + let human_floor = HumanFloor::new(); + assert!(human_floor.enter_local(true, false)); + let floor_epoch = human_floor.epoch(); + let mut deferred = VecDeque::new(); + + assert!(matches!( + authorize_or_defer_queued_text(&human_floor, &mut deferred, queued_text(41, floor_epoch),), + Err(HumanFloorAuthorization::Blocked) + )); + assert_eq!(deferred.len(), 1, "held-floor text must stay queued"); + + human_floor.leave_local(); + let queued = deferred.pop_front().expect("deferred text"); + let released = authorize_or_defer_queued_text(&human_floor, &mut deferred, queued) + .expect("the same queue item is eligible after floor release"); + + assert_eq!(released.route_id, 41); + assert!(deferred.is_empty()); +} + +#[test] +fn worker_queue_drops_text_from_before_human_onset() { + let human_floor = HumanFloor::new(); + let stale_epoch = human_floor.epoch(); + assert!(human_floor.enter_local(true, false)); + human_floor.leave_local(); + let mut deferred = VecDeque::new(); + + assert!(matches!( + authorize_or_defer_queued_text(&human_floor, &mut deferred, queued_text(42, stale_epoch),), + Err(HumanFloorAuthorization::Stale) + )); + assert!(deferred.is_empty(), "pre-barge-in text must not replay"); +} + // ── Remote interrupt tracker ────────────────────────────────────────────── // // Models the per-peer frame counting logic in the recv task of diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs index bff5ab4f76..533127c1ae 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -16,6 +16,7 @@ fn inert_pipeline(cancel: Arc) -> TtsPipeline { tts_active: Arc::new(AtomicBool::new(false)), shutdown, cancel, + human_floor: HumanFloor::new(), voice_cancel: Arc::new(AtomicBool::new(false)), voice: Arc::new(std::sync::Mutex::new("reference_sample".to_string())), voice_generation: Arc::new(AtomicU64::new(1)), @@ -169,6 +170,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { )); text_tx .send(QueuedText { + floor_epoch: 0, generation: voice_generation.load(Ordering::Acquire), route_id: 1, speaker_pubkey: None, @@ -183,6 +185,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { let active = AtomicBool::new(true); let mut deferred_text = VecDeque::from([ QueuedText { + floor_epoch: 0, generation: 1, route_id: 2, speaker_pubkey: None, @@ -191,6 +194,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { text: "old message".to_string(), }, QueuedText { + floor_epoch: 0, generation: voice_generation.load(Ordering::Acquire), route_id: 3, speaker_pubkey: None, @@ -250,6 +254,7 @@ fn superseding_voice_change_removes_earlier_deferred_messages() { ) .expect("first voice change"); deferred_text.push_back(QueuedText { + floor_epoch: 0, generation: voice_generation.load(Ordering::Acquire), route_id: 4, speaker_pubkey: None, @@ -299,6 +304,7 @@ fn barge_in_clears_deferred_voice_change_messages() { let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); let (_text_tx, text_rx) = std::sync::mpsc::channel(); let mut deferred_text = VecDeque::from([QueuedText { + floor_epoch: 0, generation: 2, route_id: 5, speaker_pubkey: None, @@ -343,6 +349,7 @@ fn barge_in_during_a_voice_change_clears_post_change_messages() { ) .expect("voice change"); deferred_text.push_back(QueuedText { + floor_epoch: 0, generation: voice_generation.load(Ordering::Acquire), route_id: 6, speaker_pubkey: None, @@ -375,6 +382,7 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() let old_sender = TtsTextSender { text_tx, generation: voice_generation.load(Ordering::Acquire), + human_floor: HumanFloor::new(), speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())), }; let shutdown = AtomicBool::new(false); diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 78c9c3cc4d..11f3acd2e4 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -9,7 +9,7 @@ use std::{ }, }; -use super::{PlaybackCoordinator, SynthesisFlightGuard}; +use super::{HumanFloor, PlaybackCoordinator, SynthesisFlightGuard}; use crate::huddle::pocket::{load_voice_style, VoiceStyle, DEFAULT_VOICE, VOICE_FILE_EXT}; @@ -74,6 +74,7 @@ impl fmt::Debug for PlaybackProbe { #[derive(Debug)] pub(super) struct QueuedText { pub(super) generation: u64, + pub(super) floor_epoch: u64, pub(super) route_id: u64, pub(super) speaker_pubkey: Option, pub(super) speaker_generation: u64, @@ -85,6 +86,7 @@ pub(super) struct QueuedText { pub(crate) struct TtsTextSender { pub(super) text_tx: SyncSender, pub(super) generation: u64, + pub(super) human_floor: HumanFloor, pub(super) speaker_generations: SpeakerGenerations, } @@ -97,9 +99,11 @@ impl TtsTextSender { voice_reference: String, text: String, ) -> Result<(), String> { + let floor_epoch = self.human_floor.epoch(); self.text_tx .send(QueuedText { generation: self.generation, + floor_epoch, route_id, speaker_pubkey: Some(speaker_pubkey), speaker_generation, @@ -544,6 +548,7 @@ mod speaker_generation_tests { fn queued_speech(speaker_pubkey: &str, speaker_generation: u64) -> QueuedText { QueuedText { + floor_epoch: 0, generation: 1, route_id: 1, speaker_pubkey: Some(speaker_pubkey.to_string()), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f8601b66d0..71a5eb3806 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -314,16 +314,14 @@ pub fn run() { // agent spawns can resolve custom/preset runtime ids without // waiting for the frontend's discover_acp_providers call. This is // a pure directory scan — no PATH probing, no async work. - { - let custom_dir = app_handle - .path() - .app_data_dir() - .ok() - .map(|d| d.join("custom_harnesses")); - managed_agents::custom_harnesses::warm_harness_registry_from_dir( - custom_dir.as_deref(), - ); - } + let custom_harness_dir = app_handle + .path() + .app_data_dir() + .ok() + .map(|d| d.join("custom_harnesses")); + managed_agents::custom_harnesses::warm_harness_registry_from_dir( + custom_harness_dir.as_deref(), + ); // Store the AppHandle so huddle commands can emit `huddle-state-changed` // events via `huddle::emit_huddle_state` without threading the handle @@ -353,10 +351,7 @@ pub fn run() { // Route mesh-llm's download progress (model weights, runtime) // onto Tauri events so the UI can render real progress. crate::mesh_llm::install_progress_sink(&app_handle); - let mesh_app = app_handle.clone(); - tauri::async_runtime::spawn(async move { - crate::mesh_llm::start_coordinator(mesh_app).await; - }); + tauri::async_runtime::spawn(crate::mesh_llm::start_coordinator(app_handle.clone())); } // Start the localhost media streaming proxy. Uses the shared HTTP @@ -379,6 +374,7 @@ pub fn run() { if let Err(error) = ensure_nest() { eprintln!("buzz-desktop: failed to create nest: {error}"); } + archive::spawn_warm_init(app_handle.clone()); // Resolve the REPOS symlink from the persisted repos_dir BEFORE // agents are restored below, and decide whether restore is safe. @@ -646,6 +642,7 @@ pub fn run() { get_forum_posts, get_forum_thread, get_thread_replies, + get_channel_reconnect_repair, get_channel_window, get_channel_messages_before, edit_message, @@ -841,6 +838,9 @@ pub fn run() { archive::index_observer_channel_id, archive::read_unindexed_observer_rows, archive::get_agent_usage_series, + archive::get_observer_retention_days, + archive::set_observer_retention_days, + archive::archive_size_stats, archive::sync::announce_archive_sync_epoch, archive::sync::start_archive_sync, archive::sync::stop_archive_sync, @@ -913,7 +913,6 @@ pub fn run() { RunEvent::Exit => { shut_down_app(app_handle, &run_shutdown_done); app_handle.state::().release(); - #[cfg(all(feature = "mesh-llm", target_os = "macos"))] if restart_requested.load(Ordering::SeqCst) { relaunch_after_mesh_shutdown(app_handle); diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index bc0e3a6cda..78592357c9 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -9,10 +9,18 @@ use crate::managed_agents::{ AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, HarnessSource, }; +mod auth_status_cache; +mod login_shell; mod presets; mod runtime_metadata; #[macro_use] mod windows_install; +pub use login_shell::{find_nvm_default_bin, login_shell_path}; +pub(crate) use login_shell::{find_via_login_shell, refresh_login_shell_path}; +#[cfg(test)] +pub(crate) use login_shell::{ + is_login_shell_path_uninit, is_safe_nvm_tag, login_shell_candidates, parse_semver_tag, +}; pub(crate) use presets::{ canonical_harness_command, command_for_runtime_id, preset_harness_definitions, preset_harness_ids, @@ -558,18 +566,40 @@ pub fn resolve_command(command: &str) -> Option { } } - // Slow path: resolve and cache. + // Slow path: resolve and cache. Negative results are cached too: an absent + // command must not re-run `resolve_command_uncached` (which spawns a login + // shell via `find_via_login_shell`) on every cheap discovery — that spawn + // on the channel-switch/composer hot path is exactly what this cache exists + // to prevent. `clear_resolve_cache` (run by every forced discovery) is the + // invalidation seam, so a newly-installed binary is still found on refresh. let result = resolve_command_uncached(command); - if result.is_some() { - if let Ok(mut guard) = cache.lock() { - guard.insert(command.to_string(), result.clone()); - } + if let Ok(mut guard) = cache.lock() { + guard.insert(command.to_string(), result.clone()); } result } +/// Cache-only command resolution for the cheap discovery path. +/// +/// Consults the Buzz-managed shim dir (a filesystem stat, never a spawn) and +/// the resolve cache; on a miss it reports the command absent rather than +/// resolving live via `resolve_command_uncached` → `find_via_login_shell`, +/// which spawns a login shell on the channel-switch / composer hot path — the +/// freeze the cheap path exists to avoid. `resolve_command` (the forced path) +/// is the sole prober and cache populator. +pub fn resolve_command_cached(command: &str) -> Option { + if let Some(managed) = resolve_buzz_managed_command(command) { + return Some(managed); + } + resolve_cache() + .lock() + .ok() + .and_then(|guard| guard.get(command).cloned()) + .flatten() +} + /// Clear the resolve_command cache so that newly-installed binaries are detected. pub fn clear_resolve_cache() { let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner()); @@ -577,6 +607,9 @@ pub fn clear_resolve_cache() { // Also invalidate the adapter-availability cache so a freshly-installed // adapter is reflected the next time the summary builder checks the badge. clear_adapter_availability_cache(); + // And the auth-status cache so a forced re-discovery re-probes rather than + // reusing stale login state. + auth_status_cache::clear(); } // ── Adapter availability cache (Phase-2 badge fallback) ───────────────────── @@ -757,222 +790,10 @@ fn path_candidates_from_env_raw(basename: &str) -> Vec { .unwrap_or_default() } -/// Collect login shell candidates for the current platform. -/// -/// On Unix: `/bin/zsh`, `/bin/bash` (the historical defaults). -/// On Windows: Git Bash via `resolve_bash_path` — skips `BUZZ_SHELL` because -/// login-shell callers use bash-only `-l -c` syntax. -fn login_shell_candidates() -> Vec { - #[cfg(not(windows))] - { - vec![PathBuf::from("/bin/zsh"), PathBuf::from("/bin/bash")] - } - #[cfg(windows)] - { - super::git_bash::resolve_bash_path().into_iter().collect() - } -} - -/// Run a command in a login shell (tries zsh then bash on Unix, Git Bash on Windows). -/// Returns trimmed stdout if the command succeeds with non-empty output. -fn run_in_login_shell(args: &[&str]) -> Option { - for shell in login_shell_candidates() { - let mut cmd = Command::new(&shell); - cmd.args(args); - crate::util::configure_no_window(&mut cmd); - let Ok(output) = cmd.output() else { - continue; - }; - if !output.status.success() { - continue; - } - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !stdout.is_empty() { - return Some(stdout); - } - } - None -} - -fn find_via_login_shell(command: &str) -> Option { - let stdout = run_in_login_shell(&["-l", "-c", r#"command -v -- "$1""#, "_", command])?; - let resolved = stdout.lines().rfind(|line| !line.trim().is_empty())?; - let path = PathBuf::from(resolved.trim()); - (path.is_absolute() && is_executable_file(&path)).then_some(path) -} - -/// Three-state backing store for the login-shell PATH cache. -#[derive(Clone)] -enum LoginShellPath { - /// Cache has never been populated; the next call will spawn a login shell. - Uninit, - /// A login shell was invoked; the inner value is the PATH it returned - /// (`None` when the shell produced no output). - Probed(Option), -} - -fn path_cache() -> &'static std::sync::Mutex { - use std::sync::{Mutex, OnceLock}; - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(LoginShellPath::Uninit)) -} - -fn fetch_login_shell_path_inner() -> Option { - // On Windows, Git Bash's `echo $PATH` returns POSIX colon-delimited paths - // (`/mingw64/bin:/c/Users/...`) which poison native Windows children that - // split on `;`. login_shell_path() feeds agent_models, runtime, and - // cli_probe — all native processes. Return None so they inherit the real - // Windows PATH instead. - #[cfg(windows)] - { - return None; - } - - #[cfg(not(windows))] - { - let stdout = run_in_login_shell(&["-l", "-c", "echo $PATH"])?; - let last_line = stdout.lines().rfind(|l| !l.trim().is_empty())?; - Some(last_line.trim().to_string()) - } -} - -/// Return the user's full PATH from a login shell. -/// -/// The result is cached after the first call. Call [`refresh_login_shell_path`] -/// to invalidate the cache so the next call re-fetches — e.g. after the user -/// installs Node.js mid-session and clicks Retry. -/// -/// The lock is never held while the login shell spawns: we check for a cached -/// value, release the lock, run the shell, then re-lock to write. Two concurrent -/// callers may both run the shell (last-writer-wins is fine — both produce the -/// same result), but neither blocks a concurrent agent spawn on the Mutex. -pub fn login_shell_path() -> Option { - // Fast path: return cached result without spawning a shell. - { - let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - if let LoginShellPath::Probed(ref result) = *guard { - return result.clone(); - } - } - - // Slow path: spawn shell outside any lock. - let result = fetch_login_shell_path_inner(); - - // Write back; last-writer-wins is safe here. - { - let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Probed(result.clone()); - } - - result -} - -/// Invalidate the login-shell PATH cache so the next [`login_shell_path`] call -/// re-fetches from a fresh login shell. -/// -/// Called before every install/retry operation and on Doctor Re-run so a -/// newly-installed tool becomes visible without restarting the app. -pub(crate) fn refresh_login_shell_path() { - let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Uninit; -} - +/// Test-only counter for login-shell spawn attempts (see submodule). #[cfg(test)] -fn is_login_shell_path_uninit() -> bool { - matches!( - *path_cache().lock().unwrap_or_else(|e| e.into_inner()), - LoginShellPath::Uninit - ) -} - -/// Return `true` when `tag` is a safe nvm alias/version tag that can be joined -/// onto a `PathBuf` without escaping the nvm root. -/// -/// nvm uses tags like `v22.1.0` or `lts/hydrogen`. We allow ASCII alphanumeric -/// plus `. - / _` and require that no path component is `..` and that the tag -/// does not start with `/` (which would replace the base in `PathBuf::join`). -fn is_safe_nvm_tag(tag: &str) -> bool { - if tag.is_empty() { - return false; - } - // An absolute path in the alias file would let PathBuf::join silently - // replace the nvm root with an attacker-controlled path. - if tag.starts_with('/') { - return false; - } - // Reject any .. component to prevent upward traversal. - for component in tag.split('/') { - if component == ".." { - return false; - } - } - // Allow only the characters nvm uses in real tag names. - tag.chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '/' | '_')) -} - -/// Locate the `bin` directory for nvm's default Node.js version. -/// -/// Reads `~/.nvm/alias/default`; resolves at most one alias hop to handle -/// nvm alias chains; falls back to the highest-semver directory under -/// `~/.nvm/versions/node/`. Returns the `bin` subdirectory only when it exists. -/// -/// Cheap: at most two file reads or one `read_dir`. Never cached — computed -/// fresh per call so a mid-session `nvm install` is visible at the next spawn. -pub fn find_nvm_default_bin(home: &Path) -> Option { - let nvm_root = home.join(".nvm"); - let versions_root = nvm_root.join("versions").join("node"); - - // 1. Try alias/default, with at most one hop. - let default_alias = nvm_root.join("alias").join("default"); - if let Ok(content) = std::fs::read_to_string(&default_alias) { - let tag = content.trim().to_string(); - if is_safe_nvm_tag(&tag) { - let candidate = versions_root.join(&tag).join("bin"); - if candidate.is_dir() { - return Some(candidate); - } - // One alias hop: ~/.nvm/alias/ - let hop_file = nvm_root.join("alias").join(&tag); - if let Ok(hop_content) = std::fs::read_to_string(&hop_file) { - let hop_tag = hop_content.trim().to_string(); - if is_safe_nvm_tag(&hop_tag) { - let hop_candidate = versions_root.join(&hop_tag).join("bin"); - if hop_candidate.is_dir() { - return Some(hop_candidate); - } - } - } - } - } - - // 2. Fall back to highest-semver directory under ~/.nvm/versions/node/. - let entries = std::fs::read_dir(&versions_root).ok()?; - let best = entries - .filter_map(|e| e.ok()) - .filter_map(|e| { - let name = e.file_name(); - let s = name.to_string_lossy().into_owned(); - parse_semver_tag(&s).map(|v| (v, s)) - }) - .max_by(|(a, _), (b, _)| a.cmp(b)); - - let (_, tag) = best?; - let bin = versions_root.join(&tag).join("bin"); - bin.is_dir().then_some(bin) -} - -/// Parse a `vMAJ.MIN.PATCH` (or `vMAJ.MIN.PATCH-extra`) tag into a numeric -/// triple for semver comparison. -fn parse_semver_tag(s: &str) -> Option<(u64, u64, u64)> { - let s = s.strip_prefix('v')?; - let mut parts = s.splitn(3, '.'); - let major = parts.next()?.parse::().ok()?; - let minor = parts.next()?.parse::().ok()?; - let patch_str = parts.next()?; - let patch = patch_str.split('-').next()?.parse::().ok()?; - Some((major, minor, patch)) -} +#[path = "discovery/login_shell_spawn_probe.rs"] +pub(crate) mod login_shell_spawn_probe; pub(crate) fn find_command(command: &str) -> Option { resolve_command(command) @@ -1295,27 +1116,39 @@ struct PartialEntry { entry: AcpRuntimeCatalogEntry, } -fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntry { +fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) -> PartialEntry { + // Cheap path is cache-only (no login-shell spawn); forced path resolves live. + let resolve = if force { + resolve_command + } else { + resolve_command_cached + }; let adapter_result = runtime .commands .iter() - .find_map(|command| find_command(command).map(|path| (*command, path))); + .find_map(|command| resolve(command).map(|path| (*command, path))); let underlying_cli_found = runtime .underlying_cli - .map(|cli| find_command(cli).is_some()) + .map(|cli| resolve(cli).is_some()) .unwrap_or(false); let (mut availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // For codex-acp: when the adapter resolves as Available, probe its full - // version. An adapter below MIN_CODEX_ACP_VERSION is treated as outdated. + // For codex-acp: when the adapter resolves as Available, determine its full + // version. A forced discovery probes the binary (spawns a subprocess); the + // cheap default path reuses the last cached availability so it stays + // process-free. An adapter below MIN_CODEX_ACP_VERSION is treated as outdated. if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available && command.as_deref() == Some("codex-acp") { - if let Some(path_str) = &binary_path { - availability = codex_adapter_availability(&PathBuf::from(path_str)); + if force { + if let Some(path_str) = &binary_path { + availability = codex_adapter_availability(&PathBuf::from(path_str)); + } + } else if let Some(cached) = adapter_availability_cached() { + availability = cached; } } @@ -1328,7 +1161,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr let underlying_cli_path = runtime .underlying_cli - .and_then(find_command) + .and_then(resolve) .map(|p| p.display().to_string()); let default_args = command @@ -1373,8 +1206,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled ) && runtime_needs_npm(runtime) && buzz_managed_node_bin_dir().is_none() - && resolve_command("npm").is_none() - && resolve_command("node").is_none(); + && resolve("npm").is_none() + && resolve("node").is_none(); PartialEntry { runtime, @@ -1415,7 +1248,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr /// resolves, so it should not pay the cost of authenticating every catalog entry. pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option { known_acp_runtime_exact(runtime_id) - .map(discover_acp_runtime_phase1) + // Post-install verification wants fresh filesystem/version state, so + // probe rather than trust the cheap-path cache. + .map(|runtime| discover_acp_runtime_phase1(runtime, true)) .map(|partial| partial.entry.availability) } @@ -1438,47 +1273,24 @@ pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option, + force: bool, ) -> Vec { + // Cheap path is cache-only (no login-shell spawn); forced path resolves live. + let resolve = if force { + resolve_command + } else { + resolve_command_cached + }; + // Phase 1: build all builtin entries (fast — no probes yet). let mut partials: Vec = KNOWN_ACP_RUNTIMES .iter() - .map(discover_acp_runtime_phase1) + .map(|runtime| discover_acp_runtime_phase1(runtime, force)) .collect(); - // Phase 2: run auth probes in parallel for entries that need them. - // Spawn one thread per probeable entry; total cost = max(probe latency). - let probe_handles: Vec<(usize, std::thread::JoinHandle)> = partials - .iter() - .enumerate() - .filter_map(|(idx, partial)| { - if partial.entry.availability != AcpAvailabilityStatus::Available { - return None; - } - let probe_args = partial.runtime.auth_probe_args?; - // 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 || { - let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); - probe_auth_status(&binary_path, &refs) - }); - Some((idx, handle)) - }) - .collect(); - - // Collect probe results and patch entries. - for (idx, handle) in probe_handles { - let status = handle.join().unwrap_or(AuthStatus::Unknown); - let partial = &mut partials[idx]; - partial.entry.login_hint = - if matches!(status, AuthStatus::LoggedIn | AuthStatus::NotApplicable) { - None - } else { - partial.runtime.login_hint.map(str::to_string) - }; - partial.entry.auth_status = status; - } + // Phase 2: resolve each available runtime's auth status (forced discovery + // spawns parallel CLI probes and warms the cache; the cheap path reuses it). + auth_status_cache::resolve_auth_statuses(&mut partials, force); // Fill NotApplicable / Unknown for non-probed entries. for partial in &mut partials { @@ -1508,7 +1320,7 @@ pub fn discover_acp_runtimes_from( } seen_ids.insert(def.id.to_string()); - entries.push(preset_catalog_entry(def, find_command)); + entries.push(preset_catalog_entry(def, resolve)); } // Phase 3: load and append custom harness definitions. @@ -1523,8 +1335,8 @@ pub fn discover_acp_runtimes_from( continue; } - // Availability: command on PATH → Available, else NotInstalled. - let (availability, command, binary_path) = match find_command(&def.command) { + // Availability: command resolves → Available, else NotInstalled. + let (availability, command, binary_path) = match resolve(&def.command) { Some(path) => ( AcpAvailabilityStatus::Available, Some(def.command.clone()), diff --git a/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs b/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs new file mode 100644 index 0000000000..cae0d7e2c9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/auth_status_cache.rs @@ -0,0 +1,105 @@ +//! Auth-status cache for cheap ACP runtime discovery. +//! +//! A forced discovery (`discover_acp_providers(force: true)`) spawns one CLI +//! auth probe per available runtime — the expensive pipeline. The cheap default +//! discovery must not pay that cost, so it reuses the last known auth statuses +//! from this cache instead of probing. The cache is keyed by runtime id, warmed +//! by the forced probe phase, and cleared by `clear_resolve_cache` (which a +//! forced discovery calls before re-probing). + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use crate::managed_agents::AuthStatus; + +fn cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(super) fn clear() { + if let Ok(mut guard) = cache().lock() { + guard.clear(); + } +} + +pub(super) fn store(runtime_id: &str, status: &AuthStatus) { + if let Ok(mut guard) = cache().lock() { + guard.insert(runtime_id.to_string(), status.clone()); + } +} + +/// Last known auth status for `runtime_id`, or `AuthStatus::Unknown` when no +/// forced discovery has probed it yet. Never spawns a process. +pub(super) fn get(runtime_id: &str) -> AuthStatus { + cache() + .lock() + .ok() + .and_then(|g| g.get(runtime_id).cloned()) + .unwrap_or(AuthStatus::Unknown) +} + +#[cfg(test)] +pub(crate) fn len() -> usize { + cache().lock().map(|g| g.len()).unwrap_or(0) +} + +/// Resolve the auth status of every available, probeable runtime in `partials`, +/// patching each entry's `auth_status` + `login_hint` in place. +/// +/// Forced discovery spawns one CLI auth probe per available runtime (in +/// parallel; total cost = max(probe latency)) and warms this cache. The cheap +/// default path spawns nothing — it reuses the last cached status, falling back +/// to `Unknown` for a runtime never probed this session. +pub(super) fn resolve_auth_statuses(partials: &mut [super::PartialEntry], force: bool) { + use crate::managed_agents::AcpAvailabilityStatus; + + if force { + let probe_handles: Vec<(usize, std::thread::JoinHandle)> = partials + .iter() + .enumerate() + .filter_map(|(idx, partial)| { + if partial.entry.availability != AcpAvailabilityStatus::Available { + return None; + } + let probe_args = partial.runtime.auth_probe_args?; + // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). + let binary_path = super::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 || { + let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); + super::probe_auth_status(&binary_path, &refs) + }); + Some((idx, handle)) + }) + .collect(); + + for (idx, handle) in probe_handles { + let status = handle.join().unwrap_or(AuthStatus::Unknown); + store(&partials[idx].entry.id, &status); + patch_entry(&mut partials[idx], status); + } + } else { + for partial in partials.iter_mut() { + if partial.entry.availability != AcpAvailabilityStatus::Available + || partial.runtime.auth_probe_args.is_none() + { + continue; + } + let status = get(&partial.entry.id); + patch_entry(partial, status); + } + } +} + +fn patch_entry(partial: &mut super::PartialEntry, status: AuthStatus) { + partial.entry.login_hint = if matches!(status, AuthStatus::LoggedIn | AuthStatus::NotApplicable) + { + None + } else { + partial.runtime.login_hint.map(str::to_string) + }; + partial.entry.auth_status = status; +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs new file mode 100644 index 0000000000..d8f8e60354 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs @@ -0,0 +1,236 @@ +//! Login-shell PATH discovery and nvm fallback. +//! +//! Extracted verbatim from `discovery.rs` to keep that file under the +//! file-size ratchet. Covers login-shell candidate selection, the cached +//! login-shell PATH probe, and nvm default-bin resolution. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::is_executable_file; + +/// Test-only spawn counter lives beside `discovery.rs`; import it here so the +/// spawn-record call site stays byte-identical to the pre-extraction source. +#[cfg(test)] +use super::login_shell_spawn_probe; + +/// Collect login shell candidates for the current platform. +/// +/// On Unix: `/bin/zsh`, `/bin/bash` (the historical defaults). +/// On Windows: Git Bash via `resolve_bash_path` — skips `BUZZ_SHELL` because +/// login-shell callers use bash-only `-l -c` syntax. +pub(crate) fn login_shell_candidates() -> Vec { + #[cfg(not(windows))] + { + vec![PathBuf::from("/bin/zsh"), PathBuf::from("/bin/bash")] + } + #[cfg(windows)] + { + super::super::git_bash::resolve_bash_path() + .into_iter() + .collect() + } +} + +/// Run a command in a login shell (tries zsh then bash on Unix, Git Bash on Windows). +/// Returns trimmed stdout if the command succeeds with non-empty output. +fn run_in_login_shell(args: &[&str]) -> Option { + #[cfg(test)] + login_shell_spawn_probe::record(); + for shell in login_shell_candidates() { + let mut cmd = Command::new(&shell); + cmd.args(args); + crate::util::configure_no_window(&mut cmd); + let Ok(output) = cmd.output() else { + continue; + }; + if !output.status.success() { + continue; + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stdout.is_empty() { + return Some(stdout); + } + } + None +} + +pub(crate) fn find_via_login_shell(command: &str) -> Option { + let stdout = run_in_login_shell(&["-l", "-c", r#"command -v -- "$1""#, "_", command])?; + let resolved = stdout.lines().rfind(|line| !line.trim().is_empty())?; + let path = PathBuf::from(resolved.trim()); + (path.is_absolute() && is_executable_file(&path)).then_some(path) +} + +/// Three-state backing store for the login-shell PATH cache. +#[derive(Clone)] +enum LoginShellPath { + /// Cache has never been populated; the next call will spawn a login shell. + Uninit, + /// A login shell was invoked; the inner value is the PATH it returned + /// (`None` when the shell produced no output). + Probed(Option), +} + +fn path_cache() -> &'static std::sync::Mutex { + use std::sync::{Mutex, OnceLock}; + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(LoginShellPath::Uninit)) +} + +fn fetch_login_shell_path_inner() -> Option { + // On Windows, Git Bash's `echo $PATH` returns POSIX colon-delimited paths + // (`/mingw64/bin:/c/Users/...`) which poison native Windows children that + // split on `;`. login_shell_path() feeds agent_models, runtime, and + // cli_probe — all native processes. Return None so they inherit the real + // Windows PATH instead. + #[cfg(windows)] + { + return None; + } + + #[cfg(not(windows))] + { + let stdout = run_in_login_shell(&["-l", "-c", "echo $PATH"])?; + let last_line = stdout.lines().rfind(|l| !l.trim().is_empty())?; + Some(last_line.trim().to_string()) + } +} + +/// Return the user's full PATH from a login shell. +/// +/// The result is cached after the first call. Call [`refresh_login_shell_path`] +/// to invalidate the cache so the next call re-fetches — e.g. after the user +/// installs Node.js mid-session and clicks Retry. +/// +/// The lock is never held while the login shell spawns: we check for a cached +/// value, release the lock, run the shell, then re-lock to write. Two concurrent +/// callers may both run the shell (last-writer-wins is fine — both produce the +/// same result), but neither blocks a concurrent agent spawn on the Mutex. +pub fn login_shell_path() -> Option { + // Fast path: return cached result without spawning a shell. + { + let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + if let LoginShellPath::Probed(ref result) = *guard { + return result.clone(); + } + } + + // Slow path: spawn shell outside any lock. + let result = fetch_login_shell_path_inner(); + + // Write back; last-writer-wins is safe here. + { + let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + *guard = LoginShellPath::Probed(result.clone()); + } + + result +} + +/// Invalidate the login-shell PATH cache so the next [`login_shell_path`] call +/// re-fetches from a fresh login shell. +/// +/// Called before every install/retry operation and on Doctor Re-run so a +/// newly-installed tool becomes visible without restarting the app. +pub(crate) fn refresh_login_shell_path() { + let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + *guard = LoginShellPath::Uninit; +} + +#[cfg(test)] +pub(crate) fn is_login_shell_path_uninit() -> bool { + matches!( + *path_cache().lock().unwrap_or_else(|e| e.into_inner()), + LoginShellPath::Uninit + ) +} + +/// Return `true` when `tag` is a safe nvm alias/version tag that can be joined +/// onto a `PathBuf` without escaping the nvm root. +/// +/// nvm uses tags like `v22.1.0` or `lts/hydrogen`. We allow ASCII alphanumeric +/// plus `. - / _` and require that no path component is `..` and that the tag +/// does not start with `/` (which would replace the base in `PathBuf::join`). +pub(crate) fn is_safe_nvm_tag(tag: &str) -> bool { + if tag.is_empty() { + return false; + } + // An absolute path in the alias file would let PathBuf::join silently + // replace the nvm root with an attacker-controlled path. + if tag.starts_with('/') { + return false; + } + // Reject any .. component to prevent upward traversal. + for component in tag.split('/') { + if component == ".." { + return false; + } + } + // Allow only the characters nvm uses in real tag names. + tag.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '/' | '_')) +} + +/// Locate the `bin` directory for nvm's default Node.js version. +/// +/// Reads `~/.nvm/alias/default`; resolves at most one alias hop to handle +/// nvm alias chains; falls back to the highest-semver directory under +/// `~/.nvm/versions/node/`. Returns the `bin` subdirectory only when it exists. +/// +/// Cheap: at most two file reads or one `read_dir`. Never cached — computed +/// fresh per call so a mid-session `nvm install` is visible at the next spawn. +pub fn find_nvm_default_bin(home: &Path) -> Option { + let nvm_root = home.join(".nvm"); + let versions_root = nvm_root.join("versions").join("node"); + + // 1. Try alias/default, with at most one hop. + let default_alias = nvm_root.join("alias").join("default"); + if let Ok(content) = std::fs::read_to_string(&default_alias) { + let tag = content.trim().to_string(); + if is_safe_nvm_tag(&tag) { + let candidate = versions_root.join(&tag).join("bin"); + if candidate.is_dir() { + return Some(candidate); + } + // One alias hop: ~/.nvm/alias/ + let hop_file = nvm_root.join("alias").join(&tag); + if let Ok(hop_content) = std::fs::read_to_string(&hop_file) { + let hop_tag = hop_content.trim().to_string(); + if is_safe_nvm_tag(&hop_tag) { + let hop_candidate = versions_root.join(&hop_tag).join("bin"); + if hop_candidate.is_dir() { + return Some(hop_candidate); + } + } + } + } + } + + // 2. Fall back to highest-semver directory under ~/.nvm/versions/node/. + let entries = std::fs::read_dir(&versions_root).ok()?; + let best = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name(); + let s = name.to_string_lossy().into_owned(); + parse_semver_tag(&s).map(|v| (v, s)) + }) + .max_by(|(a, _), (b, _)| a.cmp(b)); + + let (_, tag) = best?; + let bin = versions_root.join(&tag).join("bin"); + bin.is_dir().then_some(bin) +} + +/// Parse a `vMAJ.MIN.PATCH` (or `vMAJ.MIN.PATCH-extra`) tag into a numeric +/// triple for semver comparison. +pub(crate) fn parse_semver_tag(s: &str) -> Option<(u64, u64, u64)> { + let s = s.strip_prefix('v')?; + let mut parts = s.splitn(3, '.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch_str = parts.next()?; + let patch = patch_str.split('-').next()?.parse::().ok()?; + Some((major, minor, patch)) +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs b/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs new file mode 100644 index 0000000000..a716dee9f5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs @@ -0,0 +1,21 @@ +//! Test-only counter for login-shell spawn attempts. +//! +//! `run_in_login_shell` is the single subprocess-spawning step on the +//! absent-command resolution path, so counting its calls proves whether a +//! cheap discovery re-spawns after a negative resolution was cached. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +static COUNT: AtomicUsize = AtomicUsize::new(0); + +pub(crate) fn record() { + COUNT.fetch_add(1, Ordering::SeqCst); +} + +pub(crate) fn reset() { + COUNT.store(0, Ordering::SeqCst); +} + +pub(crate) fn count() -> usize { + COUNT.load(Ordering::SeqCst) +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index d86e5f33f0..fd85309451 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -336,7 +336,7 @@ mod tests { let _path_guard = crate::managed_agents::lock_path_mutex(); let _registry_guard = registry_test_lock(); - let entry = super::super::discover_acp_runtimes_from(None) + let entry = super::super::discover_acp_runtimes_from(None, true) .into_iter() .find(|entry| entry.id == "devin") .expect("Devin preset should appear in the runtime catalog"); diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index f7e233fbe9..2d1db69293 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -4,11 +4,10 @@ use super::overrides::{divergent_agent_command_override, update_time_agent_comma use super::{ apply_agent_command_update, classify_runtime, codex_adapter_availability, codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, - effective_agent_command, find_nvm_default_bin, find_via_login_shell, - is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, probe_codex_acp_version, record_agent_command, refresh_login_shell_path, - try_record_agent_command, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + effective_agent_command, find_nvm_default_bin, is_login_shell_path_uninit, is_safe_nvm_tag, + managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, probe_codex_acp_version, + record_agent_command, refresh_login_shell_path, try_record_agent_command, + BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -94,24 +93,6 @@ fn normalizes_buzz_agent_args_to_empty() { ); } -#[test] -fn login_shell_lookup_treats_command_as_data() { - let marker = - std::env::temp_dir().join(format!("buzz-discovery-marker-{}", uuid::Uuid::new_v4())); - let payload = format!("doesnotexist; touch {} #", marker.display()); - - let resolved = find_via_login_shell(&payload); - - assert!( - resolved.is_none(), - "payload should not resolve to a command" - ); - assert!( - !marker.exists(), - "shell lookup must not execute injected commands" - ); -} - #[cfg(unix)] #[test] fn explicit_path_resolution_ignores_non_executable_files() { @@ -668,8 +649,8 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { // ── probe_codex_acp_version ─────────────────────────────────────────────────── +mod forced_discovery; mod managed_path_resolution; - #[cfg(unix)] #[test] fn probe_codex_acp_version_parses_full_semver_output() { @@ -1685,7 +1666,7 @@ fn custom_catalog_entry_carries_definition_env_for_edit_roundtrip() { ) .unwrap(); - let entries = discover_acp_runtimes_from(Some(dir.path())); + let entries = discover_acp_runtimes_from(Some(dir.path()), true); let entry = entries .iter() .find(|e| e.id == "env-harness") @@ -1715,7 +1696,7 @@ fn builtin_catalog_entry_has_empty_definition_env() { // publishes to the global registry. let _path_guard = crate::managed_agents::lock_path_mutex(); let _lock = registry_test_lock(); - let entries = discover_acp_runtimes_from(None); + let entries = discover_acp_runtimes_from(None, true); // Find any builtin entry (e.g. "goose" or "claude"). let builtin = entries .iter() @@ -1796,7 +1777,7 @@ fn discovery_publish_path_survives_mid_flight_save() { assert!(lookup_loaded_harness_by_id("mid-flight-save").is_some()); })); - let _entries = discover_acp_runtimes_from(Some(dir.path())); + let _entries = discover_acp_runtimes_from(Some(dir.path()), true); assert!( lookup_loaded_harness_by_id("mid-flight-save").is_some(), @@ -1829,7 +1810,7 @@ fn discovery_publish_path_drops_mid_flight_delete() { assert!(lookup_loaded_harness_by_id("mid-flight-delete").is_none()); })); - let _entries = discover_acp_runtimes_from(Some(dir.path())); + let _entries = discover_acp_runtimes_from(Some(dir.path()), true); assert!( lookup_loaded_harness_by_id("mid-flight-delete").is_none(), diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/forced_discovery.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/forced_discovery.rs new file mode 100644 index 0000000000..cfbad365e3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/forced_discovery.rs @@ -0,0 +1,163 @@ +// ── Cheap vs. forced discovery: the auth-probe split ──────────────────────── +// +// `discover_acp_providers(force: true)` spawns one CLI auth probe per available +// runtime; the cheap default path must reuse the last cached status and spawn +// nothing. These tests pin that split through the real `discover_acp_runtimes_from` +// pipeline with a fake `claude` CLI that records every invocation to a sentinel. + +/// Build a fake `claude` runtime on a fresh PATH: the adapter (`claude-agent-acp`) +/// and the CLI (`claude`). The CLI appends a line to `probe_log` each time it +/// runs and exits 0 (→ `LoggedIn`), so the log's existence proves whether the +/// auth probe was spawned. +#[cfg(unix)] +#[test] +fn forced_discovery_probes_auth_but_cheap_discovery_reuses_cached_status() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::{clear_resolve_cache, discover_acp_runtimes_from}; + use crate::managed_agents::{AcpAvailabilityStatus, AuthStatus}; + use std::os::unix::fs::PermissionsExt; + + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _registry_guard = registry_test_lock(); + + let dir = tempfile::tempdir().expect("tempdir"); + let probe_log = dir.path().join("claude-probe.log"); + + for name in ["claude-agent-acp", "claude"] { + let bin = dir.path().join(name); + // The adapter is never executed; only `claude` logs + exits 0. + let script = format!( + "#!/bin/sh\necho ran >> \"{}\"\nexit 0\n", + probe_log.display() + ); + std::fs::write(&bin, script).expect("write fake bin"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + } + + // Start from a clean resolve + auth cache, and a PATH that only sees our fakes. + clear_resolve_cache(); + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut new_path = vec![dir.path().to_path_buf()]; + new_path.extend(std::env::split_paths(&old_path)); + std::env::set_var("PATH", std::env::join_paths(&new_path).expect("join PATH")); + + let result = std::panic::catch_unwind(|| { + // ── Forced: probes run, status is LoggedIn, cache is warmed. ────────── + let forced = discover_acp_runtimes_from(None, true); + let claude = forced + .iter() + .find(|e| e.id == "claude") + .expect("claude entry present"); + assert_eq!(claude.availability, AcpAvailabilityStatus::Available); + assert_eq!(claude.auth_status, AuthStatus::LoggedIn); + assert!( + probe_log.exists(), + "forced discovery must spawn the auth probe" + ); + assert!( + super::super::auth_status_cache::len() > 0, + "forced discovery must warm the auth-status cache" + ); + + // ── Cheap: no probe spawned, status reused from cache. ──────────────── + std::fs::remove_file(&probe_log).expect("clear probe log"); + let cheap = discover_acp_runtimes_from(None, false); + let claude = cheap + .iter() + .find(|e| e.id == "claude") + .expect("claude entry present"); + assert_eq!( + claude.availability, + AcpAvailabilityStatus::Available, + "cheap path keeps availability (resolved from cache)" + ); + assert_eq!( + claude.auth_status, + AuthStatus::LoggedIn, + "cheap path must reuse the cached auth status" + ); + assert!( + !probe_log.exists(), + "cheap discovery must not spawn any auth probe" + ); + }); + + // Restore global state before propagating any panic. + std::env::set_var("PATH", &old_path); + clear_resolve_cache(); + if let Err(e) = result { + std::panic::resume_unwind(e); + } +} + +/// Before any forced probe warms the resolve cache, the cheap path resolves +/// nothing live — it must not resolve a present-but-uncached binary by spawning +/// a login shell to discover it. This is the flip side of the zero-spawn +/// contract: cache-only resolution cannot see a binary the forced path has not +/// yet cached. The forced path (exercised on every surface mount) resolves it +/// and warms the cache; a subsequent cheap call then sees it Available (covered +/// by `forced_discovery_probes_auth_but_cheap_discovery_reuses_cached_status`). +/// +/// The assertion is scoped to what holds on any machine: the fake PATH-only +/// `claude` CLI must not be resolved by the cheap path (availability is never +/// `Available`, auth stays `Unknown`) and no login shell is spawned. It does +/// not pin the exact `NotInstalled` vs `CliMissing` variant, because a real +/// Buzz-managed `claude-agent-acp` shim on the host resolves via a filesystem +/// stat (production-correct, never a spawn) and yields `CliMissing` — a genuine +/// environment difference, not a regression. +#[cfg(unix)] +#[test] +fn cheap_discovery_reports_absent_before_any_forced_probe() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::{ + clear_resolve_cache, discover_acp_runtimes_from, login_shell_spawn_probe, + }; + use crate::managed_agents::{AcpAvailabilityStatus, AuthStatus}; + use std::os::unix::fs::PermissionsExt; + + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _registry_guard = registry_test_lock(); + + let dir = tempfile::tempdir().expect("tempdir"); + for name in ["claude-agent-acp", "claude"] { + let bin = dir.path().join(name); + std::fs::write(&bin, "#!/bin/sh\nexit 0\n").expect("write fake bin"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + } + + clear_resolve_cache(); // also clears the auth-status cache + login_shell_spawn_probe::reset(); + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut new_path = vec![dir.path().to_path_buf()]; + new_path.extend(std::env::split_paths(&old_path)); + std::env::set_var("PATH", std::env::join_paths(&new_path).expect("join PATH")); + + let result = std::panic::catch_unwind(|| { + let cheap = discover_acp_runtimes_from(None, false); + let claude = cheap + .iter() + .find(|e| e.id == "claude") + .expect("claude entry present"); + assert_ne!( + claude.availability, + AcpAvailabilityStatus::Available, + "cache-only cheap discovery must not resolve the PATH-only claude CLI live" + ); + assert_eq!( + claude.auth_status, + AuthStatus::Unknown, + "an unresolved runtime with no cached status stays Unknown" + ); + assert_eq!( + login_shell_spawn_probe::count(), + 0, + "cheap discovery must not spawn a login shell to resolve the PATH-only CLI" + ); + }); + + std::env::set_var("PATH", &old_path); + clear_resolve_cache(); + if let Err(e) = result { + std::panic::resume_unwind(e); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs index 0795bb2345..5369b6321b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs @@ -1,5 +1,28 @@ use crate::managed_agents::discovery::{clear_resolve_cache, resolve_command}; +/// A login-shell command lookup must treat its argument as pure data — a +/// payload containing shell metacharacters must never execute. +#[test] +fn login_shell_lookup_treats_command_as_data() { + use super::super::find_via_login_shell; + + let _guard = crate::managed_agents::lock_path_mutex(); + let marker = + std::env::temp_dir().join(format!("buzz-discovery-marker-{}", uuid::Uuid::new_v4())); + let payload = format!("doesnotexist; touch {} #", marker.display()); + + let resolved = find_via_login_shell(&payload); + + assert!( + resolved.is_none(), + "payload should not resolve to a command" + ); + assert!( + !marker.exists(), + "shell lookup must not execute injected commands" + ); +} + /// The legacy Goose Windows installer wrote `%USERPROFILE%\goose\goose.exe`, /// a directory on no standard PATH. `resolve_command_uncached` finds binaries /// outside PATH only by scanning `common_binary_paths()`, so that directory @@ -88,3 +111,79 @@ fn resolve_command_prefers_buzz_managed_npm_shim_over_path() { "Buzz-managed npm shim must win over PATH/global shims" ); } + +/// The cheap discovery path must never spawn a login shell — not even on a +/// cold cache. +/// +/// `force: false` resolves commands from cache only (`resolve_command_cached`): +/// on a resolve-cache miss it reports the command absent instead of falling +/// through to `resolve_command_uncached` → `find_via_login_shell`, which spawns +/// zsh/bash. That spawn on the channel-switch/composer hot path is the exact +/// freeze source the cheap path exists to avoid, so a cold cheap call must +/// spawn zero login shells. The forced path remains the sole prober: the same +/// absent-command fixture spawns at least once under `force: true`, proving the +/// cheap-path zero is real and not a fixture that never reaches the probe. +#[cfg(unix)] +#[test] +fn cheap_discovery_never_spawns_login_shell_even_when_cold() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::{ + clear_resolve_cache, discover_acp_runtimes_from, login_shell_spawn_probe, + }; + use std::fs; + use tempfile::tempdir; + + // Serialize with every other test that spawns a login shell: the spawn + // counter and the PATH/login-shell caches are process-global. + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _registry = registry_test_lock(); + + // A custom harness whose command cannot resolve anywhere, so the resolver + // reaches `find_via_login_shell` under the forced (live) path. + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("absent-harness.json"), + r#"{ + "id": "absent-harness", + "label": "Absent Harness", + "command": "buzz-absent-command-xyzzy", + "args": [] + }"#, + ) + .unwrap(); + + // Cold cache, cheap path: must spawn ZERO login shells (cache-only resolve + // reports the absent command missing without probing). + clear_resolve_cache(); + login_shell_spawn_probe::reset(); + let _ = discover_acp_runtimes_from(Some(dir.path()), false); + let cold_cheap = login_shell_spawn_probe::count(); + assert_eq!( + cold_cheap, 0, + "a cold cheap discovery must not spawn any login shell, got {cold_cheap}" + ); + + // Second cheap discovery, still cold (no forced probe populated the cache): + // still zero — cache-only resolution never probes. + login_shell_spawn_probe::reset(); + let _ = discover_acp_runtimes_from(Some(dir.path()), false); + let second_cheap = login_shell_spawn_probe::count(); + assert_eq!( + second_cheap, 0, + "a repeated cheap discovery must not spawn any login shell, got {second_cheap}" + ); + + // Forced path over the SAME absent fixture: resolves live and reaches + // `find_via_login_shell` at least once. Proves the cheap-path zero above is + // genuine — the fixture does drive the probe when live resolution runs — + // not a vacuous zero from a fixture that never reaches it. + clear_resolve_cache(); + login_shell_spawn_probe::reset(); + let _ = discover_acp_runtimes_from(Some(dir.path()), true); + let forced = login_shell_spawn_probe::count(); + clear_resolve_cache(); + assert!( + forced >= 1, + "the forced path must probe the absent command via login shell at least once, got {forced}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/cli_tests.rs b/desktop/src-tauri/src/managed_agents/runtime/cli_tests.rs new file mode 100644 index 0000000000..2d4fee340a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/cli_tests.rs @@ -0,0 +1,38 @@ +//! Runtime CLI configuration regression tests kept beside the configured seam. + +use super::super::configure_runtime_cli; +use crate::managed_agents::known_acp_runtime; + +#[test] +fn claude_spawn_uses_the_probed_cli_executable() { + let _guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().expect("temp dir"); + let cli = temp + .path() + .join(format!("claude{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&cli, "").expect("write fake cli"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)) + .expect("make fake cli executable"); + } + let original_path = std::env::var_os("PATH"); + std::env::set_var("PATH", temp.path()); + // The resolver retains negative results across tests, so the fake CLI must + // invalidate both before configuration and after restoring PATH. + crate::managed_agents::clear_resolve_cache(); + + let mut command = std::process::Command::new("buzz-acp"); + configure_runtime_cli(&mut command, known_acp_runtime("claude-agent-acp")); + + if let Some(path) = original_path { + std::env::set_var("PATH", path); + } else { + std::env::remove_var("PATH"); + } + crate::managed_agents::clear_resolve_cache(); + assert!(command + .get_envs() + .any(|(key, value)| { key == "CLAUDE_CODE_EXECUTABLE" && value == Some(cli.as_os_str()) })); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index b54c0e7a05..8bedfe5320 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,5 +1,8 @@ use crate::managed_agents::known_acp_runtime; +#[path = "cli_tests.rs"] +mod cli_tests; + // ── desktop binary name tests ─────────────────────────────────────────── #[test] @@ -582,36 +585,6 @@ fn name_matches_interpreter_rejects_node_prefix() { assert!(!super::name_matches_interpreter("node-gyp")); } -#[test] -fn claude_spawn_uses_the_probed_cli_executable() { - let _guard = crate::managed_agents::lock_path_mutex(); - let temp = tempfile::tempdir().expect("temp dir"); - let cli = temp - .path() - .join(format!("claude{}", std::env::consts::EXE_SUFFIX)); - std::fs::write(&cli, "").expect("write fake cli"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)) - .expect("make fake cli executable"); - } - let original_path = std::env::var_os("PATH"); - std::env::set_var("PATH", temp.path()); - - let mut command = std::process::Command::new("buzz-acp"); - super::configure_runtime_cli(&mut command, super::known_acp_runtime("claude-agent-acp")); - - if let Some(path) = original_path { - std::env::set_var("PATH", path); - } else { - std::env::remove_var("PATH"); - } - assert!(command - .get_envs() - .any(|(key, value)| { key == "CLAUDE_CODE_EXECUTABLE" && value == Some(cli.as_os_str()) })); -} - #[test] fn codex_spawn_does_not_set_a_claude_executable() { let mut command = std::process::Command::new("buzz-acp"); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b6cbaab514..4a73c78064 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.17", + "version": "0.5.18", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs index fa06232923..fc4a14f2cd 100644 --- a/desktop/src/app/AppShell.helpers.test.mjs +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -3,6 +3,8 @@ import test from "node:test"; import { markAllReadSources, + activateDesktopNotificationTarget, + createDesktopNotificationActivationQueue, shouldBounceForChannelNotification, } from "./AppShell.helpers.ts"; @@ -31,6 +33,177 @@ test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => { ); }); +test("notification activation queue preserves click order", async () => { + const calls = []; + const resolvers = new Map(); + const queue = createDesktopNotificationActivationQueue((target) => { + calls.push(`start:${target.channelId}`); + return new Promise((resolve) => { + resolvers.set(target.channelId, () => { + calls.push(`finish:${target.channelId}`); + resolve(); + }); + }); + }); + + queue.enqueue({ channelId: "first", eventId: null, kind: null }); + queue.enqueue({ channelId: "second", eventId: null, kind: null }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["start:first"]); + + resolvers.get("first")(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["start:first", "finish:first", "start:second"]); + + resolvers.get("second")(); +}); + +test("notification activation queue drops pending targets after cancellation", async () => { + const calls = []; + let resolveFirst; + const queue = createDesktopNotificationActivationQueue((target) => { + calls.push(target.channelId); + if (target.channelId === "first") { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve(); + }); + + queue.enqueue({ channelId: "first", eventId: null, kind: null }); + queue.enqueue({ channelId: "second", eventId: null, kind: null }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["first"]); + + queue.cancel(); + resolveFirst(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["first"]); +}); + +test("notification activation queue aborts an in-flight activation", async () => { + let observedSignal; + let resolveActivation; + const queue = createDesktopNotificationActivationQueue((_target, signal) => { + observedSignal = signal; + return new Promise((resolve) => { + resolveActivation = resolve; + }); + }); + + queue.enqueue({ channelId: "first", eventId: null, kind: null }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(observedSignal.aborted, false); + + queue.cancel(); + assert.equal(observedSignal.aborted, true); + resolveActivation(); +}); + +test("notification activation queue reports failures and continues", async () => { + const calls = []; + const errors = []; + const queue = createDesktopNotificationActivationQueue( + async (target) => { + calls.push(target.channelId); + if (target.channelId === "first") { + throw new Error("navigation failed"); + } + }, + (error) => errors.push(error), + ); + + queue.enqueue({ channelId: "first", eventId: null, kind: null }); + queue.enqueue({ channelId: "second", eventId: null, kind: null }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(calls, ["first", "second"]); + assert.equal(errors.length, 1); + assert.match(errors[0].message, /navigation failed/); +}); + +test("notification activation starts routing before a hung reveal", async () => { + const calls = []; + let resolveNavigation; + let navigationSettled = false; + const activation = activateDesktopNotificationTarget( + { + channelId: "channel", + eventId: "event", + kind: 9, + }, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + revealWindow: () => new Promise(() => {}), + openSearchHit: (_hit, behavior) => { + calls.push(`message:${String(behavior?.force)}`); + return new Promise((resolve) => { + resolveNavigation = () => { + navigationSettled = true; + resolve(); + }; + }); + }, + }, + ); + + assert.deepEqual(calls, ["message:true"]); + resolveNavigation(); + await activation; + assert.equal(navigationSettled, true); +}); + +test("notification activation falls back to forced channel navigation", async () => { + const calls = []; + await activateDesktopNotificationTarget( + { channelId: "channel", eventId: null, kind: null }, + { + goChannel: async (channelId, behavior) => + calls.push(`${channelId}:${String(behavior?.force)}`), + goHome: async () => calls.push("home"), + openSearchHit: async () => calls.push("message"), + revealWindow: async () => calls.push("reveal"), + }, + ); + + assert.deepEqual(calls, ["channel:true", "reveal"]); +}); + +test("notification activation ignores reveal rejection after routing starts", async () => { + const calls = []; + await activateDesktopNotificationTarget( + { channelId: "channel", eventId: null, kind: null }, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + openSearchHit: async () => calls.push("message"), + revealWindow: async () => { + calls.push("reveal"); + throw new Error("reveal failed"); + }, + }, + ); + + assert.deepEqual(calls, ["channel", "reveal"]); +}); + +test("notification activation without a channel opens home", async () => { + const calls = []; + await activateDesktopNotificationTarget( + { channelId: null, eventId: "event", kind: 9 }, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + openSearchHit: async () => calls.push("message"), + revealWindow: async () => calls.push("reveal"), + }, + ); + + assert.deepEqual(calls, ["home", "reveal"]); +}); + test("markAllReadSources clears Inbox overrides and active thread activity", () => { const calls = []; diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index dd6b9195e8..9fc14736c7 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -141,6 +141,79 @@ export function toSearchHit( }; } +export function createDesktopNotificationActivationQueue( + activate: ( + target: DesktopNotificationTarget, + signal: AbortSignal, + ) => Promise, + onError?: (error: unknown) => void, +): { + cancel: () => void; + enqueue: (target: DesktopNotificationTarget) => void; +} { + const controller = new AbortController(); + let pending = Promise.resolve(); + + return { + cancel: () => { + controller.abort(); + }, + enqueue: (target) => { + // Preserve native click order when macOS drains multiple queued targets. + // Contain failures so one rejected navigation cannot poison later clicks. + pending = pending + .then(() => { + if (!controller.signal.aborted) { + return activate(target, controller.signal); + } + }) + .catch((error) => { + try { + onError?.(error); + } catch { + // Reporting must not poison the activation queue either. + } + }); + }, + }; +} + +export async function activateDesktopNotificationTarget( + target: DesktopNotificationTarget, + actions: { + goChannel: ( + channelId: string, + options?: { force?: boolean }, + ) => Promise; + goHome: () => Promise; + openSearchHit: ( + hit: SearchHit, + behavior?: { force?: boolean; signal?: AbortSignal }, + ) => Promise; + revealWindow: () => Promise; + }, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) { + return; + } + + let navigation: Promise; + if (!target.channelId) { + navigation = actions.goHome(); + } else { + const anchor = toSearchHit(target); + navigation = anchor + ? actions.openSearchHit(anchor, { force: true, signal }) + : actions.goChannel(target.channelId, { force: true }); + } + + // Native activation already foregrounds the app on macOS. Other platforms + // still get a best-effort reveal, but it must never gate click-through. + void actions.revealWindow().catch(() => undefined); + await navigation; +} + export function deriveShellRoute(pathname: string): { selectedChannelId: string | null; selectedView: AppView; diff --git a/desktop/src/app/navigation/searchHitNavigation.test.mjs b/desktop/src/app/navigation/searchHitNavigation.test.mjs new file mode 100644 index 0000000000..74e5f108af --- /dev/null +++ b/desktop/src/app/navigation/searchHitNavigation.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + activateDesktopNotificationTarget, + createDesktopNotificationActivationQueue, +} from "../AppShell.helpers.ts"; + +const { clearSearchHitEventCache, getCachedSearchHitEvent } = await import( + "./searchHitEventCache.ts" +); +const { openSearchHitWithNavigation } = await import( + "./searchHitNavigation.ts" +); + +const forumComment = { + eventId: "comment", + content: "reply", + kind: 45003, + pubkey: "author", + channelId: "old-community-channel", + channelName: "forum", + createdAt: 1, + score: 0, + threadRootId: null, +}; + +const plainMessage = { + ...forumComment, + eventId: "message", + kind: 9, + threadRootId: "thread-root", +}; + +test("search-hit navigation preserves forced message routing while active", async () => { + clearSearchHitEventCache(); + const calls = []; + const result = await openSearchHitWithNavigation(plainMessage, { + force: true, + goChannel: async (channelId, options) => { + calls.push({ channelId, options }); + return true; + }, + goForumPost: async () => false, + }); + + assert.equal(result, true); + assert.deepEqual(calls, [ + { + channelId: "old-community-channel", + options: { + force: true, + messageId: "message", + threadRootId: "thread-root", + }, + }, + ]); + assert.equal(getCachedSearchHitEvent("message")?.id, "message"); +}); + +test("cancelled search-hit navigation cannot repopulate cache or route", async () => { + clearSearchHitEventCache(); + let resolveLookup; + const destination = new Promise((resolve) => { + resolveLookup = resolve; + }); + const calls = []; + const controller = new AbortController(); + const navigation = openSearchHitWithNavigation( + forumComment, + { + goChannel: async () => calls.push("channel"), + goForumPost: async () => calls.push("forum"), + signal: controller.signal, + }, + () => destination, + ); + + controller.abort(); + clearSearchHitEventCache(); + resolveLookup({ + kind: "forum-post", + channelId: "old-community-channel", + postId: "old-community-post", + replyId: "comment", + }); + await navigation; + + assert.deepEqual(calls, []); + assert.equal(getCachedSearchHitEvent("comment"), null); +}); + +test("queue cancellation fences an in-flight forum-comment activation", async () => { + clearSearchHitEventCache(); + let resolveLookup; + const destination = new Promise((resolve) => { + resolveLookup = resolve; + }); + const calls = []; + const queue = createDesktopNotificationActivationQueue((target, signal) => + activateDesktopNotificationTarget( + target, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + openSearchHit: (hit, behavior) => + openSearchHitWithNavigation( + hit, + { + force: behavior?.force, + goChannel: async () => calls.push("channel"), + goForumPost: async () => calls.push("forum"), + signal: behavior?.signal, + }, + () => destination, + ), + revealWindow: async () => {}, + }, + signal, + ), + ); + + queue.enqueue({ + channelId: "old-community-channel", + eventId: "comment", + kind: 45003, + }); + await new Promise((resolve) => setImmediate(resolve)); + queue.cancel(); + clearSearchHitEventCache(); + resolveLookup({ + kind: "forum-post", + channelId: "old-community-channel", + postId: "old-community-post", + replyId: "comment", + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(calls, []); + assert.equal(getCachedSearchHitEvent("comment"), null); +}); diff --git a/desktop/src/app/navigation/searchHitNavigation.ts b/desktop/src/app/navigation/searchHitNavigation.ts new file mode 100644 index 0000000000..8523340d48 --- /dev/null +++ b/desktop/src/app/navigation/searchHitNavigation.ts @@ -0,0 +1,60 @@ +import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; +import type { SearchHit } from "@/shared/api/types"; + +type SearchHitNavigationActions = { + force?: boolean; + goChannel: ( + channelId: string, + options?: { + force?: boolean; + messageId?: string; + threadRootId?: string | null; + }, + ) => Promise; + goForumPost: ( + channelId: string, + postId: string, + options?: { force?: boolean; replyId?: string }, + ) => Promise; + signal?: AbortSignal; +}; + +export async function openSearchHitWithNavigation( + hit: SearchHit, + actions: SearchHitNavigationActions, + resolveDestination = resolveSearchHitDestination, +): Promise { + if (actions.signal?.aborted) { + return false; + } + + const isLifecycleBound = Boolean(actions.signal); + if (!isLifecycleBound) { + cacheSearchHitEvent(hit); + } + + const destination = await resolveDestination(hit); + if (!destination || actions.signal?.aborted) { + return false; + } + + if (isLifecycleBound) { + // Delay community-scoped writes for notification routing until async + // destination resolution completes and its owner is still current. + cacheSearchHitEvent(hit); + } + + if (destination.kind === "forum-post") { + return actions.goForumPost(destination.channelId, destination.postId, { + force: actions.force, + replyId: destination.replyId, + }); + } + + return actions.goChannel(destination.channelId, { + force: actions.force, + messageId: destination.messageId, + threadRootId: destination.threadRootId, + }); +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 53db19d378..c4776564e3 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,8 +6,7 @@ import { useRouter, } from "@tanstack/react-router"; -import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; -import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; import type { SearchHit } from "@/shared/api/types"; type NavigationBehavior = { @@ -247,6 +246,10 @@ export function useAppNavigation() { * firing. Used by the Drafts panel "Send message" confirm flow. */ autoSend?: string; + /** Navigate even when the destination matches the current href. + * Used by desktop-notification activation so a click is never + * silently swallowed (block/buzz#3509). */ + force?: boolean; messageId?: string; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ @@ -275,6 +278,7 @@ export function useAppNavigation() { }, }, { + force: options?.force, replace: options?.replace, resetScroll: options?.messageId ? true : undefined, }, @@ -298,6 +302,8 @@ export function useAppNavigation() { channelId: string, postId: string, options?: { + /** Navigate even when the destination matches the current href. */ + force?: boolean; replace?: boolean; replyId?: string; }, @@ -312,6 +318,7 @@ export function useAppNavigation() { search: options?.replyId ? { replyId: options.replyId } : {}, }, { + force: options?.force, replace: options?.replace, resetScroll: false, }, @@ -362,25 +369,23 @@ export function useAppNavigation() { ); const openSearchHit = React.useCallback( - async (hit: SearchHit) => { - cacheSearchHitEvent(hit); - - const destination = await resolveSearchHitDestination(hit); - if (!destination) { - return false; - } - - if (destination.kind === "forum-post") { - return goForumPost(destination.channelId, destination.postId, { - replyId: destination.replyId, - }); - } - - return goChannel(destination.channelId, { - messageId: destination.messageId, - threadRootId: destination.threadRootId, - }); - }, + async ( + hit: SearchHit, + behavior?: { + /** Navigate even when the destination matches the current href. + * Used by desktop-notification activation so a click is never + * silently swallowed (block/buzz#3509). */ + force?: boolean; + /** Stop notification-driven routing when its owning lifecycle ends. */ + signal?: AbortSignal; + }, + ) => + openSearchHitWithNavigation(hit, { + force: behavior?.force, + goChannel, + goForumPost, + signal: behavior?.signal, + }), [goChannel, goForumPost], ); diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 6792faf21a..b86b95363c 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -1,10 +1,10 @@ import * as React from "react"; import { + activateDesktopNotificationTarget, + createDesktopNotificationActivationQueue, shouldBounceForChannelNotification, - toSearchHit, } from "@/app/AppShell.helpers"; -import { getThreadReference } from "@/features/messages/lib/threading"; import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; @@ -14,15 +14,14 @@ import { revealDesktopAppWindow, sendDesktopNotification, } from "@/features/notifications/lib/desktop"; -import { - formatNotificationTitle, - truncateNotificationBody, -} from "@/features/notifications/lib/notificationFormat"; +import { formatMessageNotification } from "@/features/notifications/lib/notificationFormat"; +import { buildEventNotificationTarget } from "@/features/notifications/lib/target"; import { playNotificationSound, resolveSlotSound, shouldPlayNotificationSound, } from "@/features/notifications/lib/sound"; +import { useNotificationSenderName } from "@/features/notifications/useNotificationSenderName"; import type { Channel, RelayEvent } from "@/shared/api/types"; export function useAppShellDesktopNotifications({ @@ -37,11 +36,15 @@ export function useAppShellDesktopNotifications({ }: { channels: Channel[]; enabled: boolean; - goChannel: (channelId: string) => Promise; + goChannel: ( + channelId: string, + options?: { force?: boolean }, + ) => Promise; goHome: () => Promise; notificationSettings: NotificationSettings; openSearchHit: ( hit: import("@/shared/api/types").SearchHit, + behavior?: { force?: boolean }, ) => Promise; pubkey?: string; silentChannelIds?: ReadonlySet; @@ -53,6 +56,8 @@ export function useAppShellDesktopNotifications({ enabled: enabled && notificationSettings.desktopEnabled, }); + const resolveSenderName = useNotificationSenderName(); + const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { if (!enabled) return; @@ -73,22 +78,20 @@ export function useAppShellDesktopNotifications({ } const channelName = channel.name?.trim() || "Direct message"; - const body = truncateNotificationBody(event.content, "New message"); - const threadRootId = getThreadReference(event.tags).rootId ?? null; + const { title, body } = formatMessageNotification({ + source: "dm", + senderName: resolveSenderName(event.pubkey), + channelName, + content: event.content, + }); void sendDesktopNotification({ - title: channelName, + title, body, - target: { - channelId: channel.id, - channelName, - content: event.content, - createdAt: event.created_at, - eventId: event.id, - kind: event.kind, - pubkey: event.pubkey, - threadRootId, - }, + target: buildEventNotificationTarget(event, { + id: channel.id, + name: channelName, + }), }).then((didSend) => { if (!didSend) return; if (shouldPlayNotificationSound(channel.id, silentChannelIds)) { @@ -118,25 +121,20 @@ export function useAppShellDesktopNotifications({ const resolvedChannel = channels.find((c) => c.id === channelId); const channelName = resolvedChannel?.name?.trim() ?? null; - // channelLabel is "#name" for the toast title; channelName is the raw - // name stored in the navigation target for click-through routing. - const channelLabel = channelName ? `#${channelName}` : null; - const body = truncateNotificationBody(event.content, "New reply"); - const threadRootId = getThreadReference(event.tags).rootId ?? null; + const { title, body } = formatMessageNotification({ + source: "thread_reply", + senderName: resolveSenderName(event.pubkey), + channelName, + content: event.content, + }); void sendDesktopNotification({ - title: formatNotificationTitle({ prefix: "Reply", channelLabel }), + title, body, - target: { - channelId, - channelName, - content: event.content, - createdAt: event.created_at, - eventId: event.id, - kind: event.kind, - pubkey: event.pubkey, - threadRootId, - }, + target: buildEventNotificationTarget(event, { + id: channelId, + name: channelName, + }), }).then((didSend) => { if (!didSend) return; if (shouldPlayNotificationSound(channelId, silentChannelIds)) { @@ -152,21 +150,18 @@ export function useAppShellDesktopNotifications({ const handleDesktopNotificationAction = React.useEffectEvent( async ( target: import("@/features/notifications/lib/desktop").DesktopNotificationTarget, + signal: AbortSignal, ) => { - await revealDesktopAppWindow(); - - if (!target.channelId) { - void goHome(); - return; - } - - const anchor = toSearchHit(target); - if (!anchor) { - await goChannel(target.channelId); - return; - } - - await openSearchHit(anchor); + await activateDesktopNotificationTarget( + target, + { + goChannel, + goHome, + openSearchHit, + revealWindow: revealDesktopAppWindow, + }, + signal, + ); }, ); @@ -174,13 +169,19 @@ export function useAppShellDesktopNotifications({ if (!enabled) return; let isCancelled = false; let cleanup = () => {}; + const activationQueue = createDesktopNotificationActivationQueue( + (target, signal) => handleDesktopNotificationAction(target, signal), + (error) => { + console.error("Failed to activate desktop notification", error); + }, + ); void listenForDesktopNotificationActions((target) => { if (isCancelled) { return; } - void handleDesktopNotificationAction(target); + activationQueue.enqueue(target); }).then((dispose) => { if (isCancelled) { dispose(); @@ -192,6 +193,7 @@ export function useAppShellDesktopNotifications({ return () => { isCancelled = true; + activationQueue.cancel(); cleanup(); }; }, [enabled]); diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index e8b9320794..3d797b3bb7 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -1,9 +1,14 @@ import * as React from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; -import { applyTextZoomFactor } from "@/shared/lib/fontSizePreference"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; +/** + * Cmd +/- scales the real root font-size, so every rem in the app — text, + * spacing, widths, radii — zooms together. The Font size preference is a + * separate, text-only dial layered on top (see `styles/globals/typography.css`). + */ +const BASE_FONT_SIZE_PX = 16; const DEFAULT_ZOOM_FACTOR = 1; const MIN_ZOOM_FACTOR = 0.75; const MAX_ZOOM_FACTOR = 1.5; @@ -75,8 +80,15 @@ function readStoredZoomFactor() { return Math.min(Math.max(parsed, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR); } +function applyRootZoom(zoomFactor: number) { + document.documentElement.style.fontSize = + zoomFactor === DEFAULT_ZOOM_FACTOR + ? "" + : `${BASE_FONT_SIZE_PX * zoomFactor}px`; +} + function applyTextScale(zoomFactor: number) { - applyTextZoomFactor(zoomFactor); + applyRootZoom(zoomFactor); if (zoomFactor === DEFAULT_ZOOM_FACTOR) { window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY); return; @@ -95,7 +107,8 @@ export function useWebviewZoomShortcuts() { zoomFactorRef.current = storedZoomFactor; applyTextScale(storedZoomFactor); - // Keep the webview coordinate system stable; only text should scale. + // Pin the native webview zoom so the rem root is the only zoom dial and + // window/coordinate math stays stable. void webview.setZoom(DEFAULT_ZOOM_FACTOR).catch((error) => { console.error("Failed to reset webview zoom", error); }); @@ -126,7 +139,7 @@ export function useWebviewZoomShortcuts() { const storedZoomFactor = readStoredZoomFactor(); zoomFactorRef.current = storedZoomFactor; - applyTextZoomFactor(storedZoomFactor); + applyRootZoom(storedZoomFactor); } window.addEventListener("keydown", handleKeyDown); diff --git a/desktop/src/features/agents/acpRuntimesQuery.test.mjs b/desktop/src/features/agents/acpRuntimesQuery.test.mjs new file mode 100644 index 0000000000..c51dea05b8 --- /dev/null +++ b/desktop/src/features/agents/acpRuntimesQuery.test.mjs @@ -0,0 +1,491 @@ +/** + * Regression tests for the cheap/forced ACP runtime discovery split. + * + * Two IMPORTANT correctness contracts from the review of the split: + * + * (1) refreshAcpRuntimes() must never coalesce onto an in-flight *cheap* + * request. React Query's fetchQuery deduplicates on the shared query key, + * so a cheap fetch already running would otherwise satisfy the forced + * refresh with cached data and the forced { force: true } probe would + * never run. The fix runs the forced probe on a separate key, writes its + * result into the shared cache, then cancels the in-flight cheap query. + * This test holds a cheap request pending, fires refreshAcpRuntimes(), + * resolves the cheap request, and asserts a distinct { force: true } native + * call happened and the shared cache holds the forced result. + * + * (2) useAcpRuntimesQueryForced({ forceOnMount: false }) must consume shared + * state without mounting its own force effect. Onboarding mounts the hook + * once as the surface owner (forceOnMount default true) and once per row + * (forceOnMount false); entering the surface must cause exactly one forced + * native call before any user action. + * + * The Tauri IPC bridge is stubbed at globalThis.__TAURI_INTERNALS__.invoke so + * discoverAcpRuntimes() calls are intercepted by command name and the { force } + * payload is observed directly (same pattern as + * useLoadArchivedObserverEvents.test.mjs). + */ + +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +// ── Minimal DOM shim (subset used by other mounted-hook tests) ──────────────── + +function installDOMShim() { + if (globalThis.document) return; + + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + this._listeners[type] ??= []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + this._listeners[type] = (this._listeners[type] ?? []).filter( + (f) => f !== fn, + ); + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get nextSibling() { + return null; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((c) => c !== child); + this.childNodes = this.childNodes.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.children.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + this.children.splice(i, 0, newNode); + this.childNodes.splice(i, 0, newNode); + newNode.parentNode = this; + return newNode; + } + contains(node) { + if (!node) return false; + return this === node || this.children.some((c) => c?.contains?.(node)); + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + const n = new MinimalNode("#text"); + n.nodeValue = value; + n.nodeType = 3; + return n; + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeValue = value; + n.nodeType = 8; + return n; + } + get body() { + if (!this._body) this._body = this.createElement("body"); + return this._body; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + } + + globalThis.document = new MinimalDocument(); + globalThis.HTMLElement = MinimalNode; + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +} + +installDOMShim(); + +// ── Tauri IPC interceptor ───────────────────────────────────────────────────── + +/** @type {Array<{ command: string, args: unknown }>} */ +const calls = []; +/** @type {(args: unknown) => Promise} */ +let discoverHandler = () => Promise.resolve([]); + +globalThis.__TAURI_INTERNALS__ = { + invoke: (command, args) => { + calls.push({ command, args }); + if (command === "discover_acp_providers") return discoverHandler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback: () => Math.random(), +}; + +// ── Production imports (after shim + IPC stub) ──────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { QueryClient } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; + +import { + acpRuntimesQueryKey, + refreshAcpRuntimes, + useAcpRuntimesQueryForced, +} from "./acpRuntimesQuery.ts"; +import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery.ts"; + +// ── Wire-shape helper ───────────────────────────────────────────────────────── + +/** A raw discover_acp_providers row (snake_case wire shape). */ +function rawEntry(id, authStatusValue) { + return { + id, + label: id, + avatar_url: "", + availability: "available", + command: id, + binary_path: `/usr/bin/${id}`, + default_args: [], + mcp_command: null, + install_hint: "", + install_instructions_url: "", + can_auto_install: false, + underlying_cli_path: null, + node_required: false, + auth_status: { status: authStatusValue }, + source: "builtin", + }; +} + +function makeQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +/** A promise plus its resolver, for holding a request pending. */ +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +afterEach(() => { + calls.length = 0; + discoverHandler = () => Promise.resolve([]); +}); + +describe("refreshAcpRuntimes cannot dedup onto an in-flight cheap request", () => { + it("runs a distinct force:true probe and writes it into the shared cache", async () => { + const queryClient = makeQueryClient(); + queryClient.mount(); + + // 1. A cheap request (force:false) is in flight and held pending. + const cheap = deferred(); + discoverHandler = (args) => { + if (args?.force === false) return cheap.promise; + // 2. The forced request resolves immediately with distinct data. + return Promise.resolve([rawEntry("codex", "logged_in")]); + }; + + // Start the cheap fetch through the real cheap query path and leave pending. + const cheapFetch = queryClient.fetchQuery({ + queryKey: acpRuntimesQueryKey, + queryFn: () => discoverAcpRuntimes(), + staleTime: 30 * 60_000, + }); + await new Promise((r) => setImmediate(r)); + + // 3. Forced refresh fires while the cheap fetch is still pending. + const forced = await refreshAcpRuntimes(queryClient); + + // 4. Resolve the cheap request afterward; it must not be what the caller got. + cheap.resolve([rawEntry("codex", "unknown")]); + await cheapFetch.catch(() => {}); + + const forceCalls = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ); + assert.equal( + forceCalls.length, + 1, + "exactly one forced native probe must have run", + ); + assert.equal(forced[0]?.authStatus.status, "logged_in"); + assert.equal( + queryClient.getQueryData(acpRuntimesQueryKey)?.[0]?.authStatus.status, + "logged_in", + "shared cache must hold the forced result, not the later cheap one", + ); + + queryClient.unmount(); + }); +}); + +describe("useAcpRuntimesQueryForced surfaces forced-probe failures", () => { + it("projects a mount-time forced rejection into error with no unhandled rejection", async () => { + const unhandled = []; + const onUnhandled = (err) => unhandled.push(err); + process.on("unhandledRejection", onUnhandled); + + const queryClient = makeQueryClient(); + discoverHandler = (args) => + args?.force === true + ? Promise.reject(new Error("forced probe failed")) + : Promise.resolve([]); + + let latest = null; + function Consumer() { + latest = useAcpRuntimesQueryForced(); + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Consumer), + ), + ); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + assert.equal( + latest?.error instanceof Error && latest.error.message, + "forced probe failed", + "mount-time forced rejection must surface as the hook's error", + ); + assert.equal( + latest?.isError, + true, + "isError must reflect the forced failure", + ); + + // Drain the microtask queue so any stray rejection would have fired. + await new Promise((r) => setTimeout(r, 10)); + process.off("unhandledRejection", onUnhandled); + assert.deepEqual( + unhandled, + [], + "no unhandled rejection may escape the fire-and-forget mount force", + ); + + await act(async () => { + root.unmount(); + }); + }); + + it("surfaces an explicit-refresh rejection and clears it on the next success", async () => { + const unhandled = []; + const onUnhandled = (err) => unhandled.push(err); + process.on("unhandledRejection", onUnhandled); + + const queryClient = makeQueryClient(); + let failForced = true; + discoverHandler = (args) => { + if (args?.force !== true) return Promise.resolve([]); + return failForced + ? Promise.reject(new Error("refresh failed")) + : Promise.resolve([rawEntry("codex", "logged_in")]); + }; + + let latest = null; + function Consumer() { + // forceOnMount:false so the only forced probe is the explicit refresh. + latest = useAcpRuntimesQueryForced({ forceOnMount: false }); + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Consumer), + ), + ); + }); + + // Explicit refresh (button/polling shape): void-called, must not reject. + await act(async () => { + void latest.forceRefresh(); + await new Promise((r) => setTimeout(r, 50)); + }); + assert.equal( + latest?.error instanceof Error && latest.error.message, + "refresh failed", + "explicit-refresh rejection must surface as the hook's error", + ); + + // A subsequent successful refresh clears the error and delivers data. + failForced = false; + await act(async () => { + void latest.forceRefresh(); + await new Promise((r) => setTimeout(r, 50)); + }); + assert.equal( + latest?.error, + null, + "a later successful refresh clears the error", + ); + assert.equal( + queryClient.getQueryData(acpRuntimesQueryKey)?.[0]?.authStatus.status, + "logged_in", + "successful refresh writes the fresh catalog into the shared cache", + ); + + await new Promise((r) => setTimeout(r, 10)); + process.off("unhandledRejection", onUnhandled); + assert.deepEqual( + unhandled, + [], + "no unhandled rejection may escape a void forceRefresh() call", + ); + + await act(async () => { + root.unmount(); + }); + }); +}); + +describe("useAcpRuntimesQueryForced force-on-mount ownership", () => { + it("a later-mounted row does not fire a second forced probe", async () => { + const queryClient = makeQueryClient(); + discoverHandler = () => Promise.resolve([rawEntry("codex", "logged_in")]); + + // Onboarding's real sequence: the surface owner mounts and forces discovery; + // once its result renders, per-runtime rows mount. A row that shared the + // owner's default force-on-mount would fire a *second*, sequential forced + // probe (forced-key dedup cannot collapse it — the owner's fetch is already + // idle). Rows pass forceOnMount:false to consume shared state only. + function Owner() { + useAcpRuntimesQueryForced(); + return null; + } + function Row() { + useAcpRuntimesQueryForced({ forceOnMount: false }); + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + + // 1. Owner mounts and forces once; let the probe settle. + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Owner), + ), + ); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + const afterOwner = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ).length; + assert.equal(afterOwner, 1, "owner mount must force exactly once"); + + // 2. Rows mount after the owner's result settled; they must not re-probe. + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Owner), + React.createElement(Row), + React.createElement(Row), + React.createElement(Row), + ), + ); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + const forceCalls = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ); + assert.equal( + forceCalls.length, + 1, + "later-mounted rows must not trigger a second forced probe", + ); + const cheapCalls = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === false, + ); + assert.equal( + cheapCalls.length, + 0, + "the forced hook must never fire a cheap fetch (enabled: false observer)", + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/desktop/src/features/agents/acpRuntimesQuery.ts b/desktop/src/features/agents/acpRuntimesQuery.ts new file mode 100644 index 0000000000..0e76e25ee7 --- /dev/null +++ b/desktop/src/features/agents/acpRuntimesQuery.ts @@ -0,0 +1,135 @@ +import * as React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery"; + +/** + * Shared React Query key for the ACP runtime catalog. Every consumer (cheap or + * forced) reads and writes this one entry, so a forced refresh updates the same + * cache the hot-path `useAcpRuntimesQuery` renders from. + */ +export const acpRuntimesQueryKey = ["acp-runtimes"] as const; + +/** + * Separate key for the forced (full re-discovery) fetch. Forced refresh runs on + * *this* key, never the shared cheap key, so React Query's `fetchQuery` can + * never deduplicate a forced probe onto an in-flight cheap request for the + * shared key. The forced result is then written into the shared cache + * deliberately (see `refreshAcpRuntimes`). + */ +export const acpRuntimesForcedQueryKey = ["acp-runtimes", "forced"] as const; + +/** + * Run a forced (full re-discovery) refresh and write the result into the shared + * runtime-catalog cache. + * + * This is the only path that pays the expensive discovery pipeline (cache + * clear, PATH re-fetch, CLI auth probes). Surfaces that need fresh state call + * it deliberately: Settings/onboarding on open and on their refresh buttons, + * and the connect/install/save/delete mutations in `onSettled`. A bare + * `invalidateQueries` would only re-run the cheap query path and never + * re-probe, so the freshly-changed auth/catalog state would not be reflected. + * + * The forced fetch runs on its own key so it can never coalesce onto an + * in-flight *cheap* request for the shared key (which would satisfy the caller + * with cached availability and never run the `{ force: true }` probe). Its + * result is then written into the shared cache with `setQueryData` so hot + * surfaces rendering `useAcpRuntimesQuery` re-render with the fresh catalog. + * Concurrent forced callers still dedup on the forced key; the backend + * coalesces overlapping forced runs as a second layer. + */ +export async function refreshAcpRuntimes( + queryClient: ReturnType, +) { + try { + const result = await queryClient.fetchQuery({ + queryKey: acpRuntimesForcedQueryKey, + queryFn: () => discoverAcpRuntimes({ force: true }), + staleTime: 0, + gcTime: 0, + }); + queryClient.setQueryData(acpRuntimesQueryKey, result); + // A hot-surface cheap fetch may already be in flight on the shared key; cancel + // it so its (older, cached) result cannot land after and clobber the fresh + // forced catalog we just wrote. + await queryClient.cancelQueries({ queryKey: acpRuntimesQueryKey }); + return result; + } catch { + // The forced probe rejected. `fetchQuery` has already recorded the error in + // the forced key's query state, where `useAcpRuntimesQueryForced` projects + // it into the hook's returned `error`/`isError`. Swallow the rejection here + // — at the single source — so the many fire-and-forget callers (mount, + // sign-in polling, refresh buttons, and the four mutation `onSettled` + // paths) can keep `void refreshAcpRuntimes(...)` without ever leaking an + // unhandled rejection, and a new call site can never reintroduce one. The + // shared cache is left untouched so consumers keep the last good catalog + // alongside the surfaced error. + return undefined; + } +} + +/** + * ACP runtimes query for surfaces that need fresh auth/version state: Settings + * harness panels and onboarding. + * + * It reads the shared runtime catalog (`enabled: false`, so it never fires its + * own cheap fetch — the forced probe below is the only fetcher) and re-renders + * whenever `refreshAcpRuntimes` writes a fresh catalog into that cache. Loading + * *and error* state are taken from a disabled observer on the forced key, so + * refresh buttons and the onboarding spinner reflect the forced probe and a + * failed probe surfaces as `error`/`isError` rather than a silent empty + * catalog. `forceRefresh` drives explicit refresh buttons and sign-in + * polling. + * + * `forceOnMount` (default `true`) is the surface owner's one force-on-mount. + * Child rows that share the same surface must pass `forceOnMount: false`: they + * consume the shared query state and the `forceRefresh` callback, but must not + * mount a *second* force effect. Each mounted force effect is a distinct forced + * probe, so an owner + N rows would otherwise re-run the 20–65s pipeline N+1 + * times on entry (and race the catalog to a later state before the owner's + * first result renders). + */ +export function useAcpRuntimesQueryForced(options?: { + enabled?: boolean; + forceOnMount?: boolean; +}) { + const enabled = options?.enabled ?? true; + const forceOnMount = options?.forceOnMount ?? true; + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: acpRuntimesQueryKey, + queryFn: () => discoverAcpRuntimes(), + staleTime: 30 * 60_000, + // Read-only observer: the forced refresh is the fetcher for these surfaces, + // so this must never fire a cheap fetch (which would race and could + // overwrite the fresh forced result with cached data). + enabled: false, + }); + // Read-only observer on the forced key so the hook surfaces the forced + // probe's fetching *and error* state. `refreshAcpRuntimes` runs the fetch + // imperatively via `fetchQuery`; this disabled observer never fetches itself + // but reflects that query's state, so a rejected forced probe becomes a + // visible `error`/`isError` instead of an unhandled rejection with a silent + // empty/stale catalog. + const forcedQuery = useQuery({ + queryKey: acpRuntimesForcedQueryKey, + queryFn: () => discoverAcpRuntimes({ force: true }), + enabled: false, + }); + const forceRefresh = React.useCallback( + () => refreshAcpRuntimes(queryClient), + [queryClient], + ); + React.useEffect(() => { + if (enabled && forceOnMount) void forceRefresh(); + }, [enabled, forceOnMount, forceRefresh]); + const isFetching = query.isFetching || forcedQuery.isFetching; + return { + ...query, + error: forcedQuery.error ?? query.error, + isError: forcedQuery.isError || query.isError, + isFetching, + isLoading: isFetching && query.data === undefined, + forceRefresh, + }; +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 6d8ab4f6ea..5d0be06109 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -25,7 +25,6 @@ import { createManagedAgent, deleteManagedAgent, deleteCustomHarness, - discoverAcpRuntimes, discoverBackendProviders, discoverGitBashPrerequisite, discoverManagedAgentPrereqs, @@ -43,6 +42,7 @@ import { updateManagedAgent, } from "@/shared/api/tauri"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; +import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery"; import { setManagedAgentAutoRestart, setManagedAgentStartOnAppLaunch, @@ -50,6 +50,11 @@ import { stopManagedAgent, } from "@/shared/api/tauriManagedAgents"; import { bootstrapManagedAgentRuntimePairs } from "@/features/agents/managedAgentRuntimeHooks"; +import { + acpRuntimesQueryKey, + refreshAcpRuntimes, +} from "@/features/agents/acpRuntimesQuery"; +export { useAcpRuntimesQueryForced } from "@/features/agents/acpRuntimesQuery"; import { createPersona, deletePersona, @@ -123,7 +128,6 @@ export const managedAgentLogFocusRefetchPolicy = { export const relayAgentsQueryKey = ["relay-agents"] as const; export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; -export const acpRuntimesQueryKey = ["acp-runtimes"] as const; export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; @@ -199,12 +203,26 @@ function invalidateManagedAgentQueriesInBackground( ); } +/** + * Discover the ACP runtime catalog. + * + * This always serves the **cheap** backend path: the last cached runtime + * availability + auth statuses, no process spawns, low-millisecond. Hot + * surfaces (channel switch, composer, member bar) render from cache — a + * 30-minute `staleTime` keeps channel switches from re-triggering discovery. + * + * Fresh auth/version state (Settings, onboarding sign-in, post-mutation) comes + * from `refreshAcpRuntimes`, which runs the expensive forced path explicitly + * and writes the result into this same cache. Keeping the query's own + * `queryFn` cheap guarantees an automatic staleness refetch never re-runs the + * probe pipeline. + */ export function useAcpRuntimesQuery(options?: { enabled?: boolean }) { return useQuery({ enabled: options?.enabled ?? true, queryKey: acpRuntimesQueryKey, - queryFn: discoverAcpRuntimes, - staleTime: 60_000, + queryFn: () => discoverAcpRuntimes(), + staleTime: 30 * 60_000, }); } @@ -238,7 +256,7 @@ export function useConnectAcpRuntimeMutation() { mutationFn: (input: { runtimeId: string; methodId: string }) => connectAcpRuntime(input.runtimeId, input.methodId), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + void refreshAcpRuntimes(queryClient); void queryClient.invalidateQueries({ queryKey: acpAuthMethodsQueryKey }); void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); }, @@ -250,7 +268,7 @@ export function useInstallAcpRuntimeMutation() { return useMutation({ mutationFn: (runtimeId: string) => installAcpRuntime(runtimeId), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + void refreshAcpRuntimes(queryClient); void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); }, }); @@ -267,7 +285,7 @@ export function useSaveCustomHarnessMutation() { originalId?: string; }) => saveCustomHarness(definition, originalId), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + void refreshAcpRuntimes(queryClient); }, }); } @@ -277,7 +295,7 @@ export function useDeleteCustomHarnessMutation() { return useMutation({ mutationFn: (id: string) => deleteCustomHarness(id), onSettled: () => { - void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + void refreshAcpRuntimes(queryClient); }, }); } diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts index 70cbac68fd..37e0b8d6af 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.ts +++ b/desktop/src/features/agents/knownAgentPubkeys.ts @@ -1,4 +1,5 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; +import { channelAgentMembers } from "@/shared/lib/rosterDerivations"; /** * Pure merge behind `useKnownAgentPubkeys`: managed agents ∪ relay agents, @@ -62,10 +63,11 @@ export function mergeChannelKnownAgentPubkeys( relayAgents: readonly { pubkey: string }[] | undefined, ): ReadonlySet { const pubkeys = new Set(mergeKnownAgentPubkeys(managedAgents, relayAgents)); - for (const member of channelMembers ?? []) { - if (member.role === "bot" || member.isAgent) { - pubkeys.add(normalizePubkey(member.pubkey)); - } + // Identity-cached agent subset: avoids walking the full roster per call. + for (const member of channelMembers + ? channelAgentMembers(channelMembers) + : []) { + pubkeys.add(normalizePubkey(member.pubkey)); } return pubkeys; } diff --git a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs new file mode 100644 index 0000000000..f57c7f8154 --- /dev/null +++ b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isOtherSetupAgent } from "./otherSetupAgent.ts"; + +const OWNER = "a".repeat(64); +const AGENT = "b".repeat(64); + +test("fails closed while the local managed directory is unresolved", () => { + assert.equal( + isOtherSetupAgent({ + agentDirectoriesReady: false, + currentPubkey: OWNER, + managedAgents: [], + profileOwnerPubkey: OWNER, + pubkey: AGENT, + relayAgents: [], + }), + false, + ); +}); + +test("labels a viewer-owned non-local identity as another setup", () => { + assert.equal( + isOtherSetupAgent({ + agentDirectoriesReady: true, + currentPubkey: OWNER, + managedAgents: [], + profileOwnerPubkey: OWNER, + pubkey: AGENT, + relayAgents: [], + }), + true, + ); +}); diff --git a/desktop/src/features/agents/lib/otherSetupAgent.ts b/desktop/src/features/agents/lib/otherSetupAgent.ts new file mode 100644 index 0000000000..63438a983f --- /dev/null +++ b/desktop/src/features/agents/lib/otherSetupAgent.ts @@ -0,0 +1,39 @@ +import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function isOtherSetupAgent({ + agentDirectoriesReady, + currentPubkey, + managedAgents, + profileOwnerPubkey, + pubkey, + relayAgents, +}: { + agentDirectoriesReady: boolean; + currentPubkey?: string; + managedAgents: readonly ManagedAgent[]; + profileOwnerPubkey?: string | null; + pubkey: string; + relayAgents: readonly RelayAgent[]; +}): boolean { + if (!agentDirectoriesReady || !currentPubkey) return false; + + const normalizedPubkey = normalizePubkey(pubkey); + if ( + managedAgents.some( + (agent) => normalizePubkey(agent.pubkey) === normalizedPubkey, + ) + ) { + return false; + } + + const relayOwnerPubkey = relayAgents.find( + (agent) => normalizePubkey(agent.pubkey) === normalizedPubkey, + )?.ownerPubkey; + const ownerPubkey = profileOwnerPubkey ?? relayOwnerPubkey; + + return Boolean( + ownerPubkey && + normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey), + ); +} diff --git a/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx new file mode 100644 index 0000000000..e653943469 --- /dev/null +++ b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx @@ -0,0 +1,30 @@ +import { Cloud } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; + +const OTHER_SETUP_LABEL = "From another Buzz setup"; + +export function OtherSetupAgentMarker({ + className, + testId, +}: { + className?: string; + testId?: string; +}) { + return ( + + + ); +} diff --git a/desktop/src/features/agents/ui/RestartDiffBadge.tsx b/desktop/src/features/agents/ui/RestartDiffBadge.tsx index 1bdb781226..e15a57fde4 100644 --- a/desktop/src/features/agents/ui/RestartDiffBadge.tsx +++ b/desktop/src/features/agents/ui/RestartDiffBadge.tsx @@ -88,8 +88,8 @@ function ChangeDescription({ change }: { change: RestartChange }) { const TOOLTIP_CAP = 6; /** - * `tooltip` — renders inside the dark `bg-primary` tooltip; uses - * `text-primary-foreground` variants for contrast there. + * `tooltip` — renders inside the semantic secondary tooltip surface; uses + * `text-secondary-foreground` variants for contrast there. * `inline` — renders inside the amber Runtime banner or other light * surfaces; inherits foreground from the container instead. */ @@ -107,10 +107,10 @@ function DiffList({ cap !== undefined && entries.length > cap ? entries.length - cap : 0; const valueClass = - variant === "tooltip" ? "text-primary-foreground/80" : "text-foreground"; + variant === "tooltip" ? "text-secondary-foreground/80" : "text-foreground"; const overflowClass = variant === "tooltip" - ? "text-primary-foreground/60" + ? "text-secondary-foreground/60" : "text-muted-foreground"; return ( @@ -180,7 +180,7 @@ export function RestartDiffBadge({

Config changed since last start:

-

+

{autoRestartEnabled ? AUTO_RESTART_ON_BLURB : AUTO_RESTART_OFF_BLURB}

diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 8270bea11f..0627ad6ac3 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -15,6 +16,7 @@ import { } from "@/features/agents/hooks"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness"; import { usePresenceQuery } from "@/features/presence/hooks"; import type { AgentPersona, Channel, ManagedAgent } from "@/shared/api/types"; import { removeChannelMember } from "@/shared/api/tauri"; @@ -34,6 +36,7 @@ import { } from "../lib/instanceInputForDefinition"; export function useManagedAgentActions() { + const queryClient = useQueryClient(); const { globalConfig } = useGlobalAgentConfig(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); @@ -296,6 +299,9 @@ export function useManagedAgentActions() { await Promise.allSettled( channelIds.map((channelId) => removeChannelMember(channelId, pubkey)), ); + // Direct writes bypass the member mutations' invalidation; without this, + // the deleted agent stays in cached rosters for the freshness window. + await invalidateChannelMembersRosters(queryClient, channelIds); } async function handleDelete(pubkey: string) { diff --git a/desktop/src/features/channels/hooks.test.mjs b/desktop/src/features/channels/hooks.test.mjs index 7dee24392f..7f4ee63656 100644 --- a/desktop/src/features/channels/hooks.test.mjs +++ b/desktop/src/features/channels/hooks.test.mjs @@ -1,10 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { QueryClient } from "@tanstack/react-query"; + import { applyLastMessages, canFetchChannelsForIdentity, + channelsQueryKey, reconcileRefreshedCachedChannel, + refreshChannelsQuery, requireFullChannelList, upsertCachedChannel, upsertCachedChannelMember, @@ -14,7 +18,7 @@ function makeChannel( id, name, channelType = "stream", - { participantPubkeys = [], participants = [] } = {}, + { participantPubkeys = [], participants = [], lastMessageAt = null } = {}, ) { return { id, @@ -26,7 +30,7 @@ function makeChannel( purpose: null, memberCount: participantPubkeys.length, memberPubkeys: [...participantPubkeys], - lastMessageAt: null, + lastMessageAt, archivedAt: null, participants, participantPubkeys, @@ -36,6 +40,153 @@ function makeChannel( }; } +function deferred() { + let resolve; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function makeRefreshHarness({ cachedHash = "hash-1" } = {}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const start = makeChannel("general", "General", "stream", { + lastMessageAt: "2026-01-01T00:00:00.000Z", + }); + queryClient.setQueryData(channelsQueryKey, [start]); + const request = deferred(); + const calls = []; + const fetchChannels = (knownHash) => { + calls.push(knownHash); + return request.promise; + }; + const initialSnapshotPair = cachedHash + ? { channels: [start], hash: cachedHash } + : null; + + return { + calls, + fetchChannels, + initialSnapshotPair, + queryClient, + request, + start, + }; +} + +function setDisplayedRecency(queryClient, lastMessageAt) { + queryClient.setQueryData(channelsQueryKey, (channels) => + channels.map((channel) => + channel.id === "general" ? { ...channel, lastMessageAt } : channel, + ), + ); +} + +function refreshWithHarness(harness, fetchChannels = harness.fetchChannels) { + return harness.queryClient.fetchQuery({ + queryKey: channelsQueryKey, + queryFn: () => + refreshChannelsQuery({ + queryClient: harness.queryClient, + initialSnapshotPair: harness.initialSnapshotPair, + relayUrl: null, + ownerPubkey: null, + fetchChannels, + }), + }); +} + +const T1 = "2026-01-01T00:01:00.000Z"; +const T2 = "2026-01-01T00:02:00.000Z"; + +test("refreshChannelsQuery preserves a live update through matching not-modified settlement", async () => { + const harness = makeRefreshHarness(); + const refresh = refreshWithHarness(harness); + + assert.deepEqual(harness.calls, ["hash-1"]); + setDisplayedRecency(harness.queryClient, T2); + harness.request.resolve({ + hash: "hash-1", + channels: null, + lastMessages: { general: T1 }, + }); + + const result = await refresh; + assert.equal(result[0].lastMessageAt, T2); + assert.equal( + harness.queryClient.getQueryData(channelsQueryKey)[0].lastMessageAt, + T2, + ); +}); + +test("refreshChannelsQuery preserves a live update through authoritative full-list settlement", async () => { + const harness = makeRefreshHarness({ cachedHash: null }); + const refresh = refreshWithHarness(harness); + + assert.deepEqual(harness.calls, [null]); + setDisplayedRecency(harness.queryClient, T2); + harness.request.resolve({ + hash: "hash-2", + channels: [makeChannel("general", "General")], + lastMessages: { general: T1 }, + }); + + const result = await refresh; + assert.equal(result[0].lastMessageAt, T2); + assert.equal( + harness.queryClient.getQueryData(channelsQueryKey)[0].lastMessageAt, + T2, + ); +}); + +test("refreshChannelsQuery preserves a live update through mismatched not-modified retry", async () => { + const harness = makeRefreshHarness(); + const retry = deferred(); + const fetchChannels = (knownHash) => { + harness.calls.push(knownHash); + return harness.calls.length === 1 + ? Promise.resolve({ + hash: "mismatched-hash", + channels: null, + lastMessages: {}, + }) + : retry.promise; + }; + const refresh = refreshWithHarness(harness, fetchChannels); + + await Promise.resolve(); + assert.deepEqual(harness.calls, ["hash-1", null]); + setDisplayedRecency(harness.queryClient, T2); + retry.resolve({ + hash: "hash-2", + channels: [makeChannel("general", "General")], + lastMessages: { general: T1 }, + }); + + const result = await refresh; + assert.equal(result[0].lastMessageAt, T2); + assert.equal( + harness.queryClient.getQueryData(channelsQueryKey)[0].lastMessageAt, + T2, + ); +}); + +test("refreshChannelsQuery clears unchanged recency on authoritative absence", async () => { + const harness = makeRefreshHarness(); + const refresh = refreshWithHarness(harness); + + harness.request.resolve({ + hash: "hash-1", + channels: null, + lastMessages: {}, + }); + + const result = await refresh; + assert.equal(result[0].lastMessageAt, null); +}); + test("upsertCachedChannel_reseedsOpenedDmAfterStaleRefetch", () => { const staleChannels = [makeChannel("general", "General")]; const openedDm = makeChannel("new-dm", "Alice", "dm"); @@ -202,3 +353,22 @@ test("reconcileRefreshedCachedChannel_preservesRefreshedDmRecency", () => { ownerPubkey, ]); }); + +test("invalidateChannelMembersRosters dedupes and targets member keys", async () => { + const { invalidateChannelMembersRosters } = await import( + "./rosterFreshness.ts" + ); + const invalidated = []; + const queryClient = { + invalidateQueries: async ({ queryKey }) => { + invalidated.push(queryKey); + }, + }; + + await invalidateChannelMembersRosters(queryClient, ["ch-a", "ch-b", "ch-a"]); + + assert.deepEqual(invalidated, [ + ["channels", "ch-a", "members"], + ["channels", "ch-b", "members"], + ]); +}); diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 007ffeef44..9069b052da 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -1,5 +1,10 @@ import * as React from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { addChannelMembers, @@ -31,16 +36,23 @@ import type { SetChannelTopicInput, UpdateChannelInput, } from "@/shared/api/types"; -import type { OpenDmInput } from "@/shared/api/tauriChannels"; +import type { + GetChannelsPayload, + OpenDmInput, +} from "@/shared/api/tauriChannels"; +import { mergeConcurrentChannelRecency } from "@/features/channels/lib/channelRecencyMerge"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { useCommunities } from "@/features/communities/useCommunities"; -import { canAddChannelMembers } from "@/features/channels/lib/channelMemberAdmission"; import { inspectChannelSnapshot, type ChannelSnapshot, writeChannelSnapshot, } from "@/features/channels/channelSnapshot"; +import { + CHANNEL_MEMBERS_STALE_TIME_MS, + channelMembersQueryKey, +} from "@/features/channels/rosterFreshness"; export const channelsQueryKey = ["channels"] as const; /** Keeps focused polling at the established one-minute cadence. */ @@ -60,8 +72,6 @@ export const channelsFocusRefetchPolicy = { const channelsSnapshotPairKey = ["channels", "_snapshot-pair"] as const; const channelDetailQueryKey = (channelId: string) => ["channels", channelId, "detail"] as const; -const channelMembersQueryKey = (channelId: string) => - ["channels", channelId, "members"] as const; const channelTypeOrder = { stream: 0, forum: 1, @@ -312,6 +322,113 @@ export function requireFullChannelList(channels: Channel[] | null): Channel[] { return channels; } +export type RefreshChannelsQueryOptions = { + queryClient: QueryClient; + initialSnapshotPair: ChannelSnapshot | null; + relayUrl: string | null; + ownerPubkey: string | null; + fetchChannels?: (knownHash: string | null) => Promise; + persistSnapshot?: typeof writeChannelSnapshot; +}; + +/** + * Revalidates the channel query while preserving live recency updates that land + * during the request. Exported so the production query/cache interleaving can + * be regression-tested without replacing it with a helper-only simulation. + */ +export async function refreshChannelsQuery({ + queryClient, + initialSnapshotPair, + relayUrl, + ownerPubkey, + fetchChannels = getChannels, + persistSnapshot = writeChannelSnapshot, +}: RefreshChannelsQueryOptions): Promise { + // Revalidation uses only an authoritative list/hash pair. The displayed + // channels cache is intentionally ignored because successful mutations + // patch it before the relay's list/hash has necessarily caught up. + const cachedPair = + queryClient.getQueryData(channelsSnapshotPairKey) ?? + initialSnapshotPair; + const knownHash = cachedPair?.hash ?? null; + + const channelsAtRequestStart = + queryClient.getQueryData(channelsQueryKey); + const payload = await fetchChannels(knownHash); + + // A not-modified response is usable only when it echoes the exact hash + // that described the available list. Any other hash/list pairing fails + // slow-never-wrong by retrying without a hash. + const hasMatchingNotModifiedResponse = + payload.channels === null && + knownHash !== null && + payload.hash === knownHash; + const pairChannels = + payload.channels ?? + (hasMatchingNotModifiedResponse ? cachedPair?.channels : undefined); + + if (!pairChannels) { + // Missing cache or a mismatched not-modified response: discard the hash + // and fetch a complete authoritative list before updating persistence. + const full = await fetchChannels(null); + const authoritativeChannels = sortChannels( + applyLastMessages( + requireFullChannelList(full.channels), + full.lastMessages, + ), + ); + const displayedAtSettlement = + queryClient.getQueryData(channelsQueryKey); + const sorted = sortChannels( + mergeConcurrentChannelRecency( + authoritativeChannels, + displayedAtSettlement, + channelsAtRequestStart, + ), + ); + const pair = { channels: authoritativeChannels, hash: full.hash }; + queryClient.setQueryData(channelsSnapshotPairKey, pair); + if (relayUrl && ownerPubkey) { + persistSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash); + } + return sorted; + } + + const authoritativeChannels = sortChannels( + applyLastMessages(pairChannels, payload.lastMessages), + ); + const pair = { + channels: authoritativeChannels, + hash: payload.hash, + }; + queryClient.setQueryData(channelsSnapshotPairKey, pair); + // Merge against the displayed cache at settlement so a newer live + // timestamp cannot be rolled back by an older request result. This is + // required for both full-list and matching not-modified responses. + const displayedAtSettlement = + queryClient.getQueryData(channelsQueryKey); + const refreshedForDisplay = + payload.channels === null + ? sortChannels( + applyLastMessages( + displayedAtSettlement ?? authoritativeChannels, + payload.lastMessages, + ), + ) + : authoritativeChannels; + const sorted = sortChannels( + mergeConcurrentChannelRecency( + refreshedForDisplay, + displayedAtSettlement, + channelsAtRequestStart, + ), + ); + if (relayUrl && ownerPubkey) { + persistSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash); + } + return sorted; +} + export function useChannelsQuery(options?: { enabled?: boolean }) { const { activeCommunity } = useCommunities(); const relayUrl = activeCommunity?.relayUrl ?? null; @@ -351,75 +468,13 @@ export function useChannelsQuery(options?: { enabled?: boolean }) { relayUrl !== null && canFetchChannelsForIdentity(ownerPubkey, identityQuery.isError), queryKey: channelsQueryKey, - queryFn: async () => { - // Revalidation uses only an authoritative list/hash pair. The displayed - // channels cache is intentionally ignored because successful mutations - // patch it before the relay's list/hash has necessarily caught up. - const cachedPair = - queryClient.getQueryData(channelsSnapshotPairKey) ?? - initialSnapshotPair; - const knownHash = cachedPair?.hash ?? null; - - const payload = await getChannels(knownHash); - - // A not-modified response is usable only when it echoes the exact hash - // that described the available list. Any other hash/list pairing fails - // slow-never-wrong by retrying without a hash. - const hasMatchingNotModifiedResponse = - payload.channels === null && - knownHash !== null && - payload.hash === knownHash; - const pairChannels = - payload.channels ?? - (hasMatchingNotModifiedResponse ? cachedPair?.channels : undefined); - - if (!pairChannels) { - // Missing cache or a mismatched not-modified response: discard the hash - // and fetch a complete authoritative list before updating persistence. - const full = await getChannels(null); - const sorted = sortChannels( - applyLastMessages( - requireFullChannelList(full.channels), - full.lastMessages, - ), - ); - const pair = { channels: sorted, hash: full.hash }; - queryClient.setQueryData(channelsSnapshotPairKey, pair); - if (relayUrl && ownerPubkey) { - writeChannelSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash); - } - return sorted; - } - - const authoritativeChannels = sortChannels( - applyLastMessages(pairChannels, payload.lastMessages), - ); - const pair = { - channels: authoritativeChannels, - hash: payload.hash, - }; - queryClient.setQueryData(channelsSnapshotPairKey, pair); - // A matching not-modified result must merge timestamps into whatever is - // displayed at completion time. Reading through setQueryData avoids - // clobbering an optimistic mutation that landed while the request ran. - const sorted = - payload.channels === null - ? (queryClient.setQueryData( - channelsQueryKey, - (displayedChannels) => - sortChannels( - applyLastMessages( - displayedChannels ?? authoritativeChannels, - payload.lastMessages, - ), - ), - ) ?? authoritativeChannels) - : authoritativeChannels; - if (relayUrl && ownerPubkey) { - writeChannelSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash); - } - return sorted; - }, + queryFn: () => + refreshChannelsQuery({ + queryClient, + initialSnapshotPair, + relayUrl, + ownerPubkey, + }), // Paint the complete persisted list immediately. `initialDataUpdatedAt: 0` // deliberately keeps it stale so every boot still validates against the // relay; queryFn reads the matching hash from the same atomic document. @@ -574,7 +629,7 @@ export function useChannelMembersQuery( return getChannelMembers(channelId); }, - staleTime: 30_000, + staleTime: CHANNEL_MEMBERS_STALE_TIME_MS, }); } @@ -741,32 +796,6 @@ export function useDeleteChannelMutation(channelId: string | null) { }); } -/** - * Whether the signed-in identity may add *another* identity to this channel, - * per {@link canAddChannelMembers}. Both queries are the ones the channel UI - * already holds, so this shares their cache rather than fetching again. - */ -export function useCanAddChannelMembers(channelId: string | null) { - const channelsQuery = useChannelsQuery(); - const membersQuery = useChannelMembersQuery(channelId); - const identityQuery = useIdentityQuery(); - - const channel = - channelsQuery.data?.find((candidate) => candidate.id === channelId) ?? null; - const selfPubkey = identityQuery.data?.pubkey ?? null; - const selfRole = selfPubkey - ? (membersQuery.data?.find( - (member) => member.pubkey.toLowerCase() === selfPubkey.toLowerCase(), - )?.role ?? null) - : null; - - return canAddChannelMembers({ - channelType: channel?.channelType, - visibility: channel?.visibility, - selfRole, - }); -} - export function useAddChannelMembersMutation(channelId: string | null) { const queryClient = useQueryClient(); diff --git a/desktop/src/features/channels/lib/channelRecency.test.mjs b/desktop/src/features/channels/lib/channelRecency.test.mjs new file mode 100644 index 0000000000..30e57714c1 --- /dev/null +++ b/desktop/src/features/channels/lib/channelRecency.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyChannelLastMessageAt } from "./channelRecency.ts"; +import { mergeConcurrentChannelRecency } from "./channelRecencyMerge.ts"; + +function makeChannel(id, lastMessageAt = null) { + return { id, lastMessageAt }; +} + +test("applyChannelLastMessageAt advances only the matching channel", () => { + const general = makeChannel("general", "2026-01-01T00:00:00.000Z"); + const design = makeChannel("design", "2026-01-01T00:00:00.000Z"); + + const result = applyChannelLastMessageAt( + [general, design], + "design", + 1_767_225_660, + ); + + assert.notStrictEqual(result, undefined); + assert.strictEqual(result[0], general); + assert.notStrictEqual(result[1], design); + assert.equal(result[1].lastMessageAt, "2026-01-01T00:01:00.000Z"); +}); + +test("applyChannelLastMessageAt ignores stale or equal timestamps", () => { + const design = makeChannel("design", "2026-01-01T00:01:00.000Z"); + const channels = [design]; + + assert.strictEqual( + applyChannelLastMessageAt(channels, "design", 1_767_225_600), + channels, + ); + assert.strictEqual( + applyChannelLastMessageAt(channels, "design", "2026-01-01T00:01:00.000Z"), + channels, + ); +}); + +test("applyChannelLastMessageAt preserves the list for invalid or unknown updates", () => { + const channels = [makeChannel("design")]; + + assert.strictEqual( + applyChannelLastMessageAt(channels, "design", "not-a-date"), + channels, + ); + assert.strictEqual( + applyChannelLastMessageAt(channels, "unknown", 1_767_225_660), + channels, + ); + assert.strictEqual( + applyChannelLastMessageAt(undefined, "design", 1_767_225_660), + undefined, + ); +}); + +function mergeRecency(start, displayed, refreshed) { + return mergeConcurrentChannelRecency( + [makeChannel("general", refreshed)], + [makeChannel("general", displayed)], + [makeChannel("general", start)], + )[0]; +} + +test("mergeConcurrentChannelRecency preserves a newer live timestamp", () => { + const result = mergeRecency( + "2026-01-01T00:00:00Z", + "2026-01-01T00:02:00Z", + "2026-01-01T00:01:00Z", + ); + assert.equal(result.lastMessageAt, "2026-01-01T00:02:00Z"); +}); + +test("mergeConcurrentChannelRecency preserves monotonic and absence semantics", () => { + assert.equal( + mergeRecency( + "2026-01-01T00:02:00Z", + "2026-01-01T00:02:00Z", + "2026-01-01T00:01:00Z", + ).lastMessageAt, + "2026-01-01T00:02:00Z", + ); + assert.equal( + mergeRecency("2026-01-01T00:01:00Z", "2026-01-01T00:01:00Z", null) + .lastMessageAt, + null, + ); + assert.equal( + mergeRecency( + "2026-01-01T00:00:00Z", + "2026-01-01T00:01:00Z", + "2026-01-01T00:02:00Z", + ).lastMessageAt, + "2026-01-01T00:02:00Z", + ); +}); diff --git a/desktop/src/features/channels/lib/channelRecency.ts b/desktop/src/features/channels/lib/channelRecency.ts new file mode 100644 index 0000000000..30cc162504 --- /dev/null +++ b/desktop/src/features/channels/lib/channelRecency.ts @@ -0,0 +1,63 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { channelsQueryKey } from "@/features/channels/hooks"; +import type { Channel } from "@/shared/api/types"; + +function parseTimestamp(value: number | string | null | undefined) { + if (typeof value === "number") { + return Number.isFinite(value) ? value * 1_000 : null; + } + + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +export function applyChannelLastMessageAt( + current: Channel[] | undefined, + channelId: string, + lastMessageAt: number | string | null | undefined, +): Channel[] | undefined { + if (!current) { + return current; + } + + const candidateTimestamp = parseTimestamp(lastMessageAt); + if (candidateTimestamp === null) { + return current; + } + + let didUpdate = false; + const normalizedLastMessageAt = new Date(candidateTimestamp).toISOString(); + const nextChannels = current.map((channel) => { + if (channel.id !== channelId) { + return channel; + } + + const currentTimestamp = parseTimestamp(channel.lastMessageAt); + if (currentTimestamp !== null && candidateTimestamp <= currentTimestamp) { + return channel; + } + + didUpdate = true; + return { + ...channel, + lastMessageAt: normalizedLastMessageAt, + }; + }); + + return didUpdate ? nextChannels : current; +} + +export function updateChannelLastMessageAt( + queryClient: QueryClient, + channelId: string, + lastMessageAt: number | string | null | undefined, +) { + queryClient.setQueryData(channelsQueryKey, (current) => + applyChannelLastMessageAt(current, channelId, lastMessageAt), + ); +} diff --git a/desktop/src/features/channels/lib/channelRecencyMerge.ts b/desktop/src/features/channels/lib/channelRecencyMerge.ts new file mode 100644 index 0000000000..50cfd0ea45 --- /dev/null +++ b/desktop/src/features/channels/lib/channelRecencyMerge.ts @@ -0,0 +1,39 @@ +export type RecencyChannel = { + id: string; + lastMessageAt: string | null; +}; + +function timestamp(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +/** + * Keeps recency monotonic when a refresh settles. An authoritative absence may + * clear an unchanged value, but never a live value added during the request. + */ +export function mergeConcurrentChannelRecency( + refreshed: T[], + displayed: T[] | undefined, + atRequestStart: T[] | undefined, +): T[] { + if (!displayed) return refreshed; + const displayedById = new Map(displayed.map((c) => [c.id, c.lastMessageAt])); + const startById = new Map( + atRequestStart?.map((c) => [c.id, c.lastMessageAt]) ?? [], + ); + + return refreshed.map((channel) => { + const displayedValue = displayedById.get(channel.id); + const displayedAt = timestamp(displayedValue); + const refreshedAt = timestamp(channel.lastMessageAt); + const changed = displayedValue !== startById.get(channel.id); + const keepDisplayed = + displayedAt !== null && + (refreshedAt !== null ? displayedAt > refreshedAt : changed); + return keepDisplayed + ? { ...channel, lastMessageAt: displayedValue ?? null } + : channel; + }); +} diff --git a/desktop/src/features/channels/lib/huddleAvailability.test.mjs b/desktop/src/features/channels/lib/huddleAvailability.test.mjs index 061d2cdc42..8351651a75 100644 --- a/desktop/src/features/channels/lib/huddleAvailability.test.mjs +++ b/desktop/src/features/channels/lib/huddleAvailability.test.mjs @@ -87,11 +87,9 @@ test("canStartHuddleInChannel blocks non-participant DMs", () => { }); test("canStartHuddleInChannel keeps private channels member-gated", () => { - const privateChannel = channel({ visibility: "private" }); - assert.equal( canStartHuddleInChannel({ - channel: privateChannel, + channel: channel({ visibility: "private", isMember: false }), currentPubkey: SELF, selfMember: null, }), @@ -100,7 +98,7 @@ test("canStartHuddleInChannel keeps private channels member-gated", () => { assert.equal( canStartHuddleInChannel({ - channel: privateChannel, + channel: channel({ visibility: "private", isMember: false }), currentPubkey: SELF, selfMember: member(), }), @@ -108,6 +106,19 @@ test("canStartHuddleInChannel keeps private channels member-gated", () => { ); }); +test("canStartHuddleInChannel accepts channel-level membership without a roster", () => { + // The roster is fetched lazily; `channel.isMember` derives from the same + // kind:39002 event, so it must satisfy the private-channel gate on its own. + assert.equal( + canStartHuddleInChannel({ + channel: channel({ visibility: "private", isMember: true }), + currentPubkey: SELF, + selfMember: null, + }), + true, + ); +}); + test("canStartHuddleInChannel blocks archived channels and DMs", () => { assert.equal( canStartHuddleInChannel({ diff --git a/desktop/src/features/channels/lib/huddleAvailability.ts b/desktop/src/features/channels/lib/huddleAvailability.ts index 828712d9dd..15f5f57ab4 100644 --- a/desktop/src/features/channels/lib/huddleAvailability.ts +++ b/desktop/src/features/channels/lib/huddleAvailability.ts @@ -31,5 +31,10 @@ export function canStartHuddleInChannel({ ); } - return channel.visibility === "open" || selfMember !== null; + // `channel.isMember` and the roster's self entry derive from the same + // kind:39002 event; either satisfies the private-channel gate, so callers + // that no longer fetch the roster eagerly keep huddle access. + return ( + channel.visibility === "open" || channel.isMember || selfMember !== null + ); } diff --git a/desktop/src/features/channels/rosterFreshness.ts b/desktop/src/features/channels/rosterFreshness.ts new file mode 100644 index 0000000000..a0a3603165 --- /dev/null +++ b/desktop/src/features/channels/rosterFreshness.ts @@ -0,0 +1,45 @@ +/** + * Member-roster freshness policy and the invalidation helper for write paths + * that bypass the member mutations. Split from hooks.ts to keep that file + * under the per-file line cap; behavior unchanged. + */ + +import type { useQueryClient } from "@tanstack/react-query"; + +/** Single source for the members cache key; hooks.ts imports it from here. */ +export const channelMembersQueryKey = (channelId: string) => + ["channels", channelId, "members"] as const; + +/** + * Freshness window for the full member roster. Kept long because every + * membership change the client can observe invalidates this key explicitly: + * live join/leave/removed system messages for the active channel + * (useChannelSubscription), member-added/removed notifications targeting the + * current identity (useMembershipNotifications), and every membership + * mutation (add/remove/join/leave, template apply). The residual staleness is + * a third party joining a channel the viewer is not currently subscribed to, + * which corrects within this window. The previous 30s window put a full + * roster fetch (kind:39002 + a kind:0 batch over every member) on nearly + * every channel switch. + */ +export const CHANNEL_MEMBERS_STALE_TIME_MS = 5 * 60_000; + +/** + * Invalidates cached rosters for channels whose membership was written + * through direct `removeChannelMember` calls that bypass the member + * mutations (moderation kick, agent-deletion cleanup). The roster's long + * freshness window (CHANNEL_MEMBERS_STALE_TIME_MS) means any direct write + * path that skips this leaves the removed identity visible until the window + * lapses. Accepts a minimal client shape so node unit tests can stub it. + */ +export async function invalidateChannelMembersRosters( + queryClient: Pick, "invalidateQueries">, + channelIds: Iterable, +) { + const uniqueChannelIds = [...new Set(channelIds)]; + for (const channelId of uniqueChannelIds) { + await queryClient.invalidateQueries({ + queryKey: channelMembersQueryKey(channelId), + }); + } +} diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 512ee6899f..326866cf63 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -143,7 +143,7 @@ export function AddChannelBotTeamsSection({

{team.name}

{team.description ? ( -

+

{team.description}

) : null} @@ -153,15 +153,17 @@ export function AddChannelBotTeamsSection({ inChannelPersonaIds?.has(persona.id) ?? false; return (
- + {persona.displayName} {personaInChannel ? ( diff --git a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx new file mode 100644 index 0000000000..4990168b2f --- /dev/null +++ b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx @@ -0,0 +1,92 @@ +import { Bot } from "lucide-react"; +import type { UserSearchResult } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const MEMBER_ROW_INSET_DIVIDER_CLASS = + "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; + +export function formatAddCandidateName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +export function AddMemberSearchResultRow({ + disabled, + onSelect, + ownerLabel, + user, +}: { + disabled: boolean; + onSelect: (user: UserSearchResult) => void; + ownerLabel?: string | null; + user: UserSearchResult; +}) { + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 2debd9e7a9..a347cf41bb 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -65,7 +65,14 @@ export function ChannelMembersBar({ ); const { startHuddle, isStarting: isStartingHuddle } = useHuddle(); const queryClient = useQueryClient(); - const membersQuery = useChannelMembersQuery(channel.id); + // The roster is only needed for DM huddle composition (agent detection and + // participant naming). Streams/forums render the count from the channel + // summary and gate huddle access on `channel.isMember`, so mounting this + // bar must not put a full-roster fetch on the channel-switch path. + const membersQuery = useChannelMembersQuery( + channel.id, + channel.channelType === "dm", + ); const providersQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1ec6cee95e..82da2e4214 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -567,192 +567,194 @@ export const ChannelPane = React.memo(function ChannelPane({ } > {isHuddleTranscript ? null : header} - : undefined - } - huddleMemberPubkeys={huddleMemberPubkeys} - huddleMemberPubkeysPending={huddleMemberPubkeysPending} - isFetchingOlder={isFetchingOlder} - isFollowingThreadById={isFollowingThreadById} - isMessageUnreadById={isMessageUnreadById} - personaLookup={personaLookup} - profiles={profiles} - ownerProfiles={ownerProfiles} - unfollowThreadById={unfollowThreadById} - emptyDescription={ - activeChannel?.channelType === "forum" - ? "Select a stream or DM to load real message history in this first integration pass." - : "Messages and sub-replies will appear here once the relay has history for this channel." - } - emptyTitle={ - activeChannel - ? activeChannel.channelType === "forum" - ? "Forum channels are next" - : "No messages yet" - : "No channel selected" - } - isLoading={isHuddleTranscript ? false : isTimelineLoading} - entranceMessageId={entranceMessageId} - onEntranceMessageComplete={onEntranceMessageComplete} - mainEntries={mainTimelineEntries} - threadSummaries={threadSummaries} - messages={visibleMessages} - firstUnreadMessageId={firstUnreadMessageId} - unreadCount={unreadCount} - onDelete={onDelete} - onEdit={onEdit} - onMarkUnread={onMarkUnread} - onMarkRead={onMarkRead} - onReply={timelineReplyHandler} - onOpenThread={isHuddleTranscript ? undefined : onOpenThread} - channelName={activeChannel?.name} - channelType={activeChannel?.channelType ?? null} - isSendingVideoReviewComment={isSending} - onSendVideoReviewComment={ - activeChannel?.archivedAt ? undefined : onSendVideoReviewComment - } - onTargetReached={onTargetReached} - onToggleReaction={onToggleReaction} - targetMessageId={targetMessageId} - splitThreadPanelOpen={ - useSplitAuxiliaryPane && - !useFocusThreadDrawer && - Boolean(openThreadHeadId) - } - threadUnreadCounts={threadUnreadCounts} - /> - {isNonMemberView ? ( -
-
- - - Viewing{" "} - - #{activeChannel?.name} - - -
- -
- ) : ( -
- +
+ : undefined + } + huddleMemberPubkeys={huddleMemberPubkeys} + huddleMemberPubkeysPending={huddleMemberPubkeysPending} + isFetchingOlder={isFetchingOlder} + isFollowingThreadById={isFollowingThreadById} + isMessageUnreadById={isMessageUnreadById} + personaLookup={personaLookup} + profiles={profiles} + ownerProfiles={ownerProfiles} + unfollowThreadById={unfollowThreadById} + emptyDescription={ + activeChannel?.channelType === "forum" + ? "Select a stream or DM to load real message history in this first integration pass." + : "Messages and sub-replies will appear here once the relay has history for this channel." + } + emptyTitle={ + activeChannel + ? activeChannel.channelType === "forum" + ? "Forum channels are next" + : "No messages yet" + : "No channel selected" + } + isLoading={isHuddleTranscript ? false : isTimelineLoading} + entranceMessageId={entranceMessageId} + onEntranceMessageComplete={onEntranceMessageComplete} + mainEntries={mainTimelineEntries} + threadSummaries={threadSummaries} + messages={visibleMessages} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + onDelete={onDelete} + onEdit={onEdit} + onMarkUnread={onMarkUnread} + onMarkRead={onMarkRead} + onReply={timelineReplyHandler} + onOpenThread={isHuddleTranscript ? undefined : onOpenThread} + channelName={activeChannel?.name} + channelType={activeChannel?.channelType ?? null} + isSendingVideoReviewComment={isSending} + onSendVideoReviewComment={ + activeChannel?.archivedAt ? undefined : onSendVideoReviewComment + } + onTargetReached={onTargetReached} + onToggleReaction={onToggleReaction} + targetMessageId={targetMessageId} + splitThreadPanelOpen={ + useSplitAuxiliaryPane && + !useFocusThreadDrawer && + Boolean(openThreadHeadId) + } + threadUnreadCounts={threadUnreadCounts} + /> + {isNonMemberView ? (
- {isActiveWelcomeChannel && !timeoutState.active ? ( - - {welcomeKickoffStage} - - ) : null} - {timeoutState.active ? ( - + + + Viewing{" "} + + #{activeChannel?.name} + + +
+ +
+ ) : ( +
+ +
+ {isActiveWelcomeChannel && !timeoutState.active ? ( + + {welcomeKickoffStage} + + ) : null} + {timeoutState.active ? ( + + ) : null} + + - ) : null} - - - {/* The activity accessory is anchored in the dock's reserved + {/* The activity accessory is anchored in the dock's reserved bottom rail, so fading it cannot change the observed overlay height or move the conversation. Its natural content height remains responsive. */} - + +
-
- )} - {canDropInMainColumn && mainComposerMedia.isDragOver ? ( - - ) : null} + )} + {canDropInMainColumn && mainComposerMedia.isDragOver ? ( + + ) : null} +
) : null} diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index d5e287c20a..1aaad0e609 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -129,10 +129,12 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const; * header's breadcrumb, where the eye already is — the sliver carries no label of * its own. * - * `z-41` puts the overlay above the channel timeline, its `z-40` composer - * overlay and the `z-30` shared header backdrop, while staying below the global - * `z-45` top chrome. Setting z-index on the positioned container also gives the - * drawer its own stacking context, so the panel chrome inside it is isolated. + * `z-41` places the drawer above the channel section (whose inner `isolate` + * wrapper traps the timeline's z-50 pill, z-40 composer overlay, and z-50 drop + * overlay) and the `z-30` shared header backdrop, while staying below the + * global `z-45` top chrome. Setting z-index on the positioned container also + * gives the drawer its own stacking context, so the panel chrome inside is + * isolated. */ export function FocusThreadDrawer({ channelName, diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 9ec3151bb0..70c7bf10a8 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Bot, UserRoundPlus, X } from "lucide-react"; +import { UserRoundPlus, X } from "lucide-react"; import { invalidateChannelState, useAddChannelMembersMutation, @@ -14,6 +14,7 @@ import { getSharedChannelIds, isAgentIdentityInAllowedList, } from "@/features/agents/lib/agentAutocompleteEligibility"; +import { isOtherSetupAgent } from "@/features/agents/lib/otherSetupAgent"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; import { formatMemberName } from "@/features/channels/lib/memberUtils"; @@ -40,7 +41,6 @@ import type { ManagedAgent, UserSearchResult, } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; import { Dialog, DialogClose, @@ -50,13 +50,15 @@ import { } from "@/shared/ui/dialog"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; -import { cn } from "@/shared/lib/cn"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { UserAvatar } from "@/shared/ui/UserAvatar"; import { MODAL_SEARCH_INPUT_CLASS, MODAL_SEARCH_SHELL_CLASS, } from "@/shared/ui/modalSearchStyles"; +import { + AddMemberSearchResultRow, + formatAddCandidateName, +} from "./AddMemberSearchResultRow"; import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard"; import { useManagedAgentRuntimesQuery } from "@/features/agents/managedAgentRuntimeHooks"; import { @@ -68,16 +70,6 @@ import { useMembersSidebarActions } from "./useMembersSidebarActions"; import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; const MEMBER_ADD_RESULT_LIMIT = 50; const MEMBER_SEARCH_MIN_QUERY_LENGTH = 2; -const MEMBER_ROW_INSET_DIVIDER_CLASS = - "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; - -function formatAddCandidateName(user: UserSearchResult) { - return ( - user.displayName?.trim() || - user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) - ); -} type AddMemberSearchCandidate = UserSearchResult & { isManagedAgent?: boolean; isMember?: boolean; @@ -193,6 +185,13 @@ export function MembersSidebar({ managedAgentsQuery, relayAgentsQuery, } = useClassifiedMembers(rawMembers, currentPubkey); + const agentDirectoriesReady = + managedAgentsQuery.data !== undefined && + managedAgentsQuery.error === null && + !managedAgentsQuery.isFetching && + relayAgentsQuery.data !== undefined && + relayAgentsQuery.error === null && + !relayAgentsQuery.isFetching; const activeMembers = React.useMemo( () => [...people, ...bots].sort((left, right) => @@ -611,6 +610,16 @@ export function MembersSidebar({ const managedAgent = memberIsBot ? managedAgentByPubkey.get(normalizePubkey(member.pubkey)) : undefined; + const showOtherSetupMarker = + memberIsBot && + isOtherSetupAgent({ + agentDirectoriesReady, + currentPubkey, + managedAgents: managedAgentsQuery.data ?? [], + profileOwnerPubkey: memberProfile?.ownerPubkey, + pubkey: member.pubkey, + relayAgents: relayAgentsQuery.data ?? [], + }); const managedAgentRuntime = memberIsBot && relayUrl ? findManagedAgentRuntime( @@ -675,6 +684,7 @@ export function MembersSidebar({ memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null } profileAvatarUrl={memberProfile?.avatarUrl ?? null} + showOtherSetupMarker={showOtherSetupMarker} viewerIsOwner={viewerIsOwner} />
@@ -913,78 +923,3 @@ function SearchResultSectionTitle({ ); } - -function AddMemberSearchResultRow({ - disabled, - onSelect, - ownerLabel, - user, -}: { - disabled: boolean; - onSelect: (user: UserSearchResult) => void; - ownerLabel?: string | null; - user: UserSearchResult; -}) { - return ( -
- -
- ); -} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 0257e52ebf..76750490cd 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -18,6 +18,7 @@ import { getManagedAgentPrimaryActionLabel, isManagedAgentActive, } from "@/features/agents/lib/managedAgentControlActions"; +import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { @@ -73,6 +74,7 @@ type MembersSidebarMemberCardProps = { onViewActivity?: (pubkey: string) => void; presenceStatus?: PresenceStatus | null; profileAvatarUrl?: string | null; + showOtherSetupMarker?: boolean; viewerIsOwner: boolean; }; @@ -141,6 +143,7 @@ export function MembersSidebarMemberCard({ onViewActivity, presenceStatus, profileAvatarUrl, + showOtherSetupMarker = false, viewerIsOwner, }: MembersSidebarMemberCardProps) { const roleLabel = formatRoleLabel(member, memberIsBot); @@ -177,21 +180,28 @@ export function MembersSidebarMemberCard({
{memberIsBot ? ( -
-
- - {memberLabel} - - -