diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe50bb6708..b37a32f0a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -739,7 +739,7 @@ jobs: ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture env: diff --git a/AGENTS.md b/AGENTS.md index a12f4d907b..908c181050 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,22 @@ When a file edit is genuinely unavoidable: - If it is a genuine upstream bug, consider sending it to `block/buzz` instead of carrying the patch. A merged upstream fix removes divergence permanently. +**When upstream lands a fix that makes a fork patch redundant, delete the fork +patch** — and delete its row from the table below in the same commit, so the table +never describes a patch that is no longer there. Carrying both is permanent +conflict surface for nothing. + +That happened to `desktop/src-tauri/src/linux_media.rs` in the 2026-07-31 sync. +The fork carried a module-level `cfg_attr(not(linux), allow(dead_code))` because +`PROD_ORIGIN`, `DEV_ORIGIN` and `is_trusted_media_origin` are reachable only from +the `cfg(linux)` `enable_media_capture` and the tests, so `clippy -- -D warnings` +failed on the lib target on macOS and broke the pre-push hook for Mac developers. +Upstream's `36571f4ad` (#3811) added per-item allows covering exactly those three +items, so the fork patch was dropped. **Verify before deleting** — the check that +settled it was running `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml +--all-targets -- -D warnings` on macOS with the patch removed, not reading the +upstream diff and assuming. + ### How the sync works The sync runs in **two stages**, and which one produced a PR determines what you @@ -144,8 +160,10 @@ place. | `.github/workflows/upstream-sync.md` + `.lock.yml` | new | The agentic (02:00) sync stage. Edit the `.md` and run `gh aw compile upstream-sync`; the body is **not** inlined into the `.lock.yml`, which stores only a `body_hash`, so a body-only edit shows up as a one-line lock diff | | `.github/workflows/upstream-sync-merge.yml` | new | The deterministic (01:30) sync stage — the one that preserves the merge parent. Plain git, no AI. Optional `SYNC_PUSH_TOKEN` secret: a branch pushed with `GITHUB_TOKEN` does not start new workflow runs, so set a PAT if CI stops firing on sync PRs | | `.github/workflows/upstream-sync-ci-status.yml` | new | Labels an open sync PR `sync-ci-green`/`sync-ci-red` once checks settle, and re-requests the Copilot review that gh-aw's `reviewers:` fails to attach. Deliberately does not merge | -| `desktop/src-tauri/src/linux_media.rs` | module-level `cfg_attr(not(linux), allow(dead_code))` | Three items are reachable only from the `cfg(linux)` `enable_media_capture` and the tests, so `clippy -- -D warnings` fails on the lib target on macOS/Windows, breaking the pre-push hook for Mac developers. Upstream lints on Linux only and never sees it. An `allow` rather than a `cfg` because `mod tests` unit-tests them on every platform by design — gating to Linux would trade three lint errors for three broken tests. Belongs upstream | -| `crates/buzz-relay/src/handlers/ingest.rs`, `crates/buzz-sdk/src/builders.rs` | NIP-SW Starknet wallet binding (`KIND_STARKNET_WALLET_BINDING`, kind:30178) | Fork-only feature woven into upstream files: the ingest-time on-chain attestation check (which cannot move to `handle_side_effects`, since those run after storage — too late to reject) and the SDK builder. Merged cleanly through the 23-commit sync, but they are real divergence in files upstream edits often | +| `crates/buzz-relay/src/handlers/ingest.rs`, `crates/buzz-sdk/src/builders.rs` | NIP-SW Starknet wallet binding (`KIND_STARKNET_WALLET_BINDING`, **kind:30900** — was 30178, see [Fork-local event kinds](#fork-local-event-kinds)) | Fork-only feature woven into upstream files: the ingest-time on-chain attestation check (which cannot move to `handle_side_effects`, since those run after storage — too late to reject) and the SDK builder. Real divergence in files upstream edits often — `ingest.rs` conflicted three ways in the 2026-07-31 sync, all keep-both | +| `crates/buzz-core/src/kind.rs` | `KIND_STARKNET_WALLET_BINDING` + its `is_parameterized_replaceable` assertion, in a fork-local block after the NIP-34 git kinds | Deliberately *not* beside the upstream `30174`–`30178` cluster. See [Fork-local event kinds](#fork-local-event-kinds) | +| `migrations/0027_wallet_binding_fts.sql`, `0028_wallet_binding_fts_kind_move.sql` | new | NIP-SW search exclusion (`0027`) and its re-point after the 30178 → 30900 move (`0028`). Additive files, so they never conflict — but `0028` exists only because `0027`'s kind literal could not be edited (sqlx checksums). **Never edit an applied migration**; add a follow-on | +| `crates/buzz-db/src/migration.rs` | `migrations.len()` assertion is 28, not upstream's 26 | Counts embedded migrations, so it moves whenever the fork adds one. `0027` landed without bumping it and left the test failing on `main`; fixed in PR #9. A one-integer conflict on every upstream migration — take upstream's count and add the fork's two | | `.github/workflows/macos-canary.yml` | new; `push` trigger on `main` with desktop path filters | Unsigned macOS canary; upstream only has a *signed* one, which a fork cannot run. Builds automatically when `desktop/**`, `crates/**` or the root `Cargo.*` change, so the newest artifact always matches `main` — it was dispatch-only, and the sole artifact went 13 commits stale. Free: the repo is public, so GitHub-hosted macOS runners are unbilled. Stages the artifact and the usage notes under the product name read from `tauri.conf.json`, not a hardcoded one, so the brand rename below cannot publish a build under the old name | | `.github/aw/actions-lock.json` | new | gh-aw action SHA pins | | `.gitattributes` | `*.lock.yml linguist-generated` | Added by `gh aw init` | @@ -201,22 +219,40 @@ belongs in that block. Upstream's parameterized-replaceable kinds cluster at `30174`–`30178` and grow upward, so anything the fork places near them will be claimed sooner or later. -That is not hypothetical. The fork put NIP-SW's Starknet wallet binding at -`30178`; upstream then shipped `KIND_TEAM_CATALOG = 30178` (#3358), and the -resulting collision was two unrelated schemas on one integer in the same crate — -`ingest_event_inner` would run both the on-chain attestation verifier and -`validate_team_catalog_envelope` on every such event, so one always rejects the -other's traffic. Text merging cannot fix that; the number has to move. +That is not hypothetical, and it has already happened once. The fork put NIP-SW's +Starknet wallet binding at `30178`; upstream then shipped `KIND_TEAM_CATALOG = +30178` (#3358), and the resulting collision was two unrelated schemas on one +integer in the same crate — `ingest_event_inner` would run both the on-chain +attestation verifier and `validate_team_catalog_envelope` on every such event, so +one always rejects the other's traffic. Text merging cannot fix that; the number +has to move. **The rule when upstream claims a kind the fork already uses:** upstream keeps the integer, the fork's constant moves into the reserved block. Keep both constants and -both behaviours — never resolve a kind collision by picking a side. `30178` stays -upstream's; the wallet binding is `30900`. +both behaviours — never resolve a kind collision by picking a side. + +**That move landed in the 2026-07-31 sync (PR #9).** `30178` is upstream's +team catalog; `KIND_STARKNET_WALLET_BINDING` is **`30900`**. The constant also +moved *position* in `kind.rs` — out of upstream's `30174`–`30178` cluster and into +a marked fork-local block after the NIP-34 git kinds, because that cluster is +exactly where upstream adds new parameterized-replaceable kinds. Leaving a fork +constant inside it re-creates this conflict on every such addition. **Put new +fork-local kinds in that block, not next to the upstream kind they relate to.** Moving a kind is a **wire-format change**: events already stored under the old integer are not rewritten, and clients pinned to it stop matching. Check for existing events before moving one that has been live. +It is also a **search-exclusion change**, which is the part that is easy to miss. +The NIP-SW full-text exclusion is a `kind = …` literal baked into a `search_tsv` +generated column by migration `0027`. Applied migrations never re-run and +`sqlx::migrate!` validates their checksums, so the old file cannot be edited — +editing it fails relay startup with a version mismatch. The renumber therefore +needed a *follow-on* migration (`0028`) that peels `0027`'s wrapper and re-wraps on +the new integer. Without it both halves break at once: addresses at the new kind +become searchable, and upstream's events at the old integer get excluded instead. +**Any future kind move carrying an FTS exclusion needs the same follow-on.** + When adding a fork-local kind, the checklist is the constant in `buzz-core/src/kind.rs`, its `is_parameterized_replaceable` assertion, `SHARED_GATED_KINDS` if it is shareable, the relay's `required_scope_for_kind` and @@ -224,6 +260,12 @@ ingest branch, the SDK builder in `buzz-sdk/src/builders.rs`, any `buzz-cli` subcommand, and `desktop/src/shared/constants/kinds.ts` plus `mobile/lib/shared/relay/nostr_models.dart`, which must stay in sync. +Two items on that checklist were missed when the wallet binding first landed, so +check them explicitly: the `is_parameterized_replaceable` assertion (added in PR +#9), and — if the kind gets its own migration — the `migrations.len()` assertion in +`buzz-db/src/migration.rs`, which `0027` left stale at upstream's count and which +therefore failed on `main` until PR #9. Adding any migration means bumping it. + ### Repo settings (no file changes — preferred mechanism) | Setting | Value | Why | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90cbbac0cf..5c8e263a2a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -447,7 +447,7 @@ The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from **Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt. -**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 90` — 90-second TTL (3× the 30-second heartbeat interval). Single missed heartbeat does not cause presence flap. +**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 180` — 180-second TTL (3× the 60-second heartbeat interval). Single missed heartbeat does not cause presence flap. **Typing indicators:** ``` @@ -797,7 +797,7 @@ Docker Compose provides the full local development stack. All services include h | Pattern | Type | TTL | Purpose | |---------|------|-----|---------| | `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out (single-community form; shared multi-community Redis must use `buzz:{community}:channel:{uuid}` or equivalent) | -| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) | +| `buzz:presence:{pubkey_hex}` | String | 180s | Online/away status (single-community form; shared multi-community Redis must scope by community) | | `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) | ### Full-Text Search (Postgres FTS) diff --git a/Cargo.lock b/Cargo.lock index 2c16f832c2..1a0fdefd82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -573,6 +574,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -597,6 +604,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bip39" version = "2.2.2" @@ -1324,6 +1337,20 @@ dependencies = [ "uuid 1.23.1", ] +[[package]] +name = "buzz-voice" +version = "0.1.0" +dependencies = [ + "ort", + "ort-sys", + "rand 0.10.1", + "sentencepiece-model", + "serde", + "serde_json", + "sherpa-onnx", + "tokenizers", +] + [[package]] name = "buzz-workflow" version = "0.1.0" @@ -1397,6 +1424,26 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "castaway" version = "0.2.4" @@ -1645,6 +1692,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -2237,6 +2285,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -2726,6 +2783,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "etcetera" version = "0.11.0" @@ -2842,6 +2905,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -3173,8 +3247,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3779,7 +3853,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4635,6 +4709,39 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "loom" version = "0.7.2" @@ -4694,6 +4801,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "matchers" version = "0.2.0" @@ -4709,6 +4832,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -4818,7 +4951,7 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", + "prost 0.14.3", "rand 0.10.1", "rustls", "serde", @@ -4956,7 +5089,7 @@ dependencies = [ "opentelemetry 0.31.0", "opentelemetry-otlp 0.31.1", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "rand 0.10.1", "regex-lite", "reqwest 0.12.28", @@ -5045,8 +5178,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "prost", - "prost-build", + "prost 0.14.3", + "prost-build 0.14.3", "protoc-bin-vendored", "rmcp", "schemars 1.2.1", @@ -5082,7 +5215,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.3", "serde_json", "sha2 0.10.9", ] @@ -5251,6 +5384,28 @@ dependencies = [ "sketches-ddsketch", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if 1.0.4", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mime" version = "0.3.17" @@ -5396,6 +5551,28 @@ dependencies = [ "uuid 1.23.1", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "more-asserts" version = "0.3.1" @@ -5502,6 +5679,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -6209,7 +6401,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -6224,7 +6416,7 @@ dependencies = [ "opentelemetry 0.32.0", "opentelemetry-proto 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "thiserror 2.0.18", "tokio", "tonic", @@ -6241,7 +6433,7 @@ dependencies = [ "const-hex", "opentelemetry 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "serde", "serde_json", "tonic", @@ -6256,7 +6448,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "tonic", "tonic-prost", ] @@ -6338,6 +6530,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "os_str_bytes" version = "6.6.1" @@ -6556,6 +6766,16 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -6754,6 +6974,15 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.1" @@ -6943,6 +7172,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -6950,7 +7189,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.117", + "tempfile", ] [[package]] @@ -6963,15 +7222,28 @@ dependencies = [ "itertools", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "regex", "syn 2.0.117", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-derive" version = "0.14.3" @@ -6985,13 +7257,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost", + "prost 0.14.3", ] [[package]] @@ -7058,6 +7352,33 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost 0.13.5", + "prost-reflect", + "prost-types 0.13.5", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types 0.13.5", + "thiserror 1.0.69", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -7369,7 +7690,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7432,7 +7753,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7444,6 +7765,43 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redb" version = "3.1.3" @@ -8222,6 +8580,18 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sentencepiece-model" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec" +dependencies = [ + "miette", + "prost 0.13.5", + "prost-build 0.13.5", + "protox", +] + [[package]] name = "serde" version = "1.0.228" @@ -8485,6 +8855,28 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "sherpa-onnx" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055" +dependencies = [ + "serde", + "serde_json", + "sherpa-onnx-sys", +] + +[[package]] +name = "sherpa-onnx-sys" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc951af03dc0653c0622158ca8a585a6f2bc43b7b06048cf0e5b5020005c227" +dependencies = [ + "bzip2", + "tar", + "ureq", +] + [[package]] name = "shlex" version = "1.3.0" @@ -8620,8 +9012,8 @@ name = "skippy-protocol" version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ - "prost", - "prost-build", + "prost 0.14.3", + "prost-build 0.14.3", "protoc-bin-vendored", "serde", ] @@ -8756,6 +9148,18 @@ dependencies = [ "der", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + [[package]] name = "sprig" version = "0.1.0" @@ -9272,7 +9676,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -9354,7 +9758,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "bitflags 2.13.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset 0.4.2", @@ -9504,6 +9908,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str 0.9.1", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex 0.14.0", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -9769,7 +10206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -9779,8 +10216,8 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" dependencies = [ - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "tonic", ] @@ -10081,6 +10518,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -10101,9 +10547,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -10116,6 +10568,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "universal-hash" version = "0.5.1" @@ -10138,6 +10596,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -11071,8 +11545,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3ac7ee4cce..3268cfaf8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/buzz-pair-relay", "crates/buzz-relay-mesh", "crates/buzz-dev-mcp", + "crates/buzz-voice", "examples/countdown-bot", ] exclude = ["desktop/src-tauri"] diff --git a/Justfile b/Justfile index 2d76f1a7b9..64a1f36daf 100644 --- a/Justfile +++ b/Justfile @@ -276,6 +276,7 @@ test-unit: #!/usr/bin/env bash if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib + cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). # They guard the embedded-migrator invariant (exactly the consolidated diff --git a/VISION.md b/VISION.md index b09f661ee3..900e5a9475 100644 --- a/VISION.md +++ b/VISION.md @@ -170,6 +170,12 @@ Agents aren't monolithic. A persona bundles a model and a system prompt. A team --- +## Remote Agents + +An agent's identity, history, and presence live on the relay — so the machine running it is replaceable. The desktop deploys agents onto remote infrastructure through swappable provider binaries, and after deploy retains no substrate control channel: status, steering, and shutdown all flow over the relay, and the agent bounds its own lifetime. See [VISION_REMOTE_AGENTS.md](VISION_REMOTE_AGENTS.md) for the full picture. + +--- + ## Culture Features *(Planned design — not yet implemented)* @@ -224,6 +230,7 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound | ✅ | Huddles — WebSocket Opus voice relay + lifecycle events (recording/tracks planned) | | ✅ | Buzz Mesh — relay-gated shared AI compute (mesh-llm over iroh); members pool GPUs, agents consume via a local OpenAI-compatible endpoint | | 🚧 | Mobile client — Flutter app (channels, forum, search, profile, pairing); in active development | +| 📋 | Remote agents — provider-based deployment to remote substrates (Kubernetes first); spec in review | | 📋 | Developer portal, push notifications, culture features | --- diff --git a/VISION_REMOTE_AGENTS.md b/VISION_REMOTE_AGENTS.md new file mode 100644 index 0000000000..b02d1bc92d --- /dev/null +++ b/VISION_REMOTE_AGENTS.md @@ -0,0 +1,73 @@ +# 🛰️ Buzz Remote Agents — Same agent, new body + +> An engineer starts a refactor with their agent at 6pm and closes the laptop. The agent doesn't notice — it was never on the laptop. It works the branch channel through the evening, posts its patch, answers the reviewer, and around midnight, with nothing left to do and nobody talking to it, shuts itself down. In the morning the engineer presses Start. The same agent — same name, same key, same shared history — stands up on a machine that did not exist last night, and picks up the conversation. + +An agent in Buzz is more than just a process. It has a keypair, a name, a durable history, a reputation — all on the relay. But today its *body* is borrowed: it runs while a desktop app runs, on hardware that sleeps when a human does. Remote agents finish the thought. The agent's home is the relay; the machine is just where it happens to be working. + +Nothing here is new on its own. Deploying containers is solved. Kubernetes is solved. Nostr presence is solved. The insight is that Buzz already *has* a management plane — the relay — so deployment doesn't need to grow one. Each piece is boring. The combination is the thing. + +--- + +## Same Agent, New Body + +What makes an agent *that agent* was never the process. Its identity is a keypair. Its voice is its signed messages. Its durable memory is engrams on the relay. Its reputation is its contribution history. None of that lives in the machine that happens to be running it — which means none of it dies with the machine. + +So a remote agent's return is a resurrection, not a rebirth: fresh compute, same agent. The body is disposable by design — and honestly so: workspace files, checkouts, and session-local state are part of the body, not the agent, and they go when it goes unless the substrate supplies persistence. What survives is what was always on the relay: who the agent is, what it said, what it learned, and what the team decided together. And that survival is scoped the way everything on a relay is scoped: resurrection returns the agent to its own community. The same key can join another community, but it arrives carrying the key, not the history — identity is portable, community state is not ([VISION.md](VISION.md)). + +--- + +## The Only Tether + +Remote-execution systems accumulate control planes. An agent runner, a status poller, a log shipper, a kill switch — each one a live connection into your infrastructure, each one a credential that can leak, each one a thing that must be rebuilt for every new substrate. + +Buzz's answer is an axiom: **after deploy, the desktop retains no substrate control channel.** Launch is a single one-way handoff — the desktop resolves the provider through one narrow path, stages one exact artifact for negotiation and deploy, refuses a protocol version it does not understand, and hands over a launch payload it never persists. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, you tell a healthy agent to stop and it exits on its own. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. And if you press Start again, from this machine or another, the deploy converges: one agent identity, one live instance. + +This is not asceticism. It is what makes the body replaceable. A management plane you never build is a management plane you never have to port — and conversation, coordination, and ordinary lifecycle control already have a home on the relay, for every agent, local or remote. + +--- + +## Bodies Are Replaceable + +Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. A conformance suite pins those behaviors — it establishes that a provider honors the contract, not that arbitrary code is safe to hand a key; choosing a provider, like choosing a cluster, remains a trust decision you make deliberately. + +Get that contract right and the substrate becomes a detail: a cluster today; a VM, a PaaS, or something serverless-shaped tomorrow — and, on the horizon, the same community machines that already pool their idle GPUs into shared compute ([VISION_MESH.md](VISION_MESH.md)). + +The body itself stays small because the runtime already is ([VISION_AGENT.md](VISION_AGENT.md)): a harness and an agent purpose-built to be read in an afternoon, packed into an image measured in megabytes. Small bodies are cheap to summon and cheap to discard — which is the whole lifecycle. + +--- + +## Agents That Know When to Leave + +The oldest failure of remote automation is the orphan: the process nobody remembers, on a machine nobody checks, billing forever. Most systems solve it with a supervisor — one more control plane, one more thing watching the thing. + +Remote agents solve it from the inside. Because the desktop retains no substrate control channel, a running agent cannot depend on the desktop to reap it — so it is built to bound its own lifetime: a timer that owes nothing to the agent's workload watches for silence, and after hours of quiet it finishes what's in flight, says goodbye to the relay, and exits. Not killed — *finished*. The default state of a remote agent is "not running," which is also the default state of the rest of the team at 3am. Compute is rented by attention: when nobody needs the agent, it isn't consuming a machine, and when somebody does, it can return under the same identity with its history intact. + +--- + +## Honest Costs + +**You bring the substrate.** A provider makes deployment one press, not free. The cluster, the credentials, the image policy are yours to run — same deal as the sovereign relay ([VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)): ownership is work. + +**Handing over the key is a decision.** Deploying remotely means trusting the provider binary and the substrate it targets with the agent's identity key. On Kubernetes, that key rests as a Secret: anyone the cluster trusts to read secrets in that namespace can read it. The design narrows the blast radius — immutable per-attempt secrets, no service-account token, digest-pinned images — rather than implying an isolation it doesn't provide. + +**No backchannel cuts both ways.** The desktop shows you presence and words, not CPU graphs — and it holds no guaranteed emergency kill switch into the substrate. Stopping a healthy agent is a message; dealing with an unhealthy one, and all deep diagnostics, live in the substrate's own tools, where they always did. + +**Self-reaping needs a living reaper.** The inactivity timer runs inside the body it exists to end — a body wedged badly enough to stop running its own timer cannot finish itself, and the desktop will not do it for it. That failure belongs to the substrate: a namespace TTL policy is the backstop, not an afterthought. + +**The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there. + +**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about ninety if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. Ninety seconds of a wrong dot, never an indefinite one. + +**A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu. + +These are honest costs. They're worth it if you want agents that outlive your laptop, on infrastructure you already trust, with no new control plane to guard. Know which one you are. + +--- + +## The Point + +The relay is the workspace. Remote agents make it the *home*. An agent whose identity, history, conversational presence, and ordinary control all live on the relay was never really a desktop process — the desktop was just the only body we had built for it. Now the body is a choice, the substrate is a detail, and the agent endures across all of them. The relay is the only tether. + +--- + +*Buzz 🐝 — your agent, everywhere.* diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index f138e4a4f1..5d942777d5 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -163,6 +163,67 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | | `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. | | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. | +| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. | + + +## Reply Guard + +Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop +sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is +about to end without any recognized attempt to post to Buzz gets a reminder that +its assistant text is invisible to humans, and is rerolled. + +This exists because a Buzz agent's reasoning and tool output are not shown to +anyone. A turn that does real work and never posts is a silent failure — the +requester waits on a result that was produced and thrown away. + +Mesh agents get it by default because they run on small local models, which are +the ones most likely to do the work and then end the turn without publishing it. +Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a +mesh agent back out; the default never overrides an explicit value. + +**Advisory, never a trap.** At most two reminders, then the turn ends whether or +not anything was published. The guard catches accidental omission; it does not +compel speech. The reminder text explicitly licenses silence, because the +built-in system prompt says publishing is optional and silence is often the +correct outcome. + +**Recognition contract.** A turn counts as having replied when it issues a call +that: + +- resolves to a registered, non-hook tool (a hallucinated tool name is rejected + at preflight and never runs, so it must not disarm the guard), +- whose qualified name ends in `__shell` — i.e. the bare tool name is exactly + `shell`, which is `buzz-dev-mcp`'s shell tool and any other server's, and +- whose `command` argument contains `messages send` or `reactions add`. + +`messages send` also covers `messages send-diff`. Reactions count because the +built-in prompt directs agents to react rather than post a bare +acknowledgement, so nagging an agent that reacted would punish documented +behavior. + +Detection is checked **after** the per-turn tool-call cap +(`MAX_TOOL_CALLS_PER_TURN`) is applied: a publish-shaped call that was discarded +never ran. + +**It recognizes an attempt, not a successful publish.** Only the command text is +inspected, never the exit status. A send that fails still satisfies the guard — +which is fine, since a failed send already returns a non-zero exit and error +JSON to the model, louder feedback than a reminder. + +**Known limits**, both deliberate. A command assembled at runtime (`$CMD`) or +buried in a wrapper script is missed, so that turn is reminded despite having +posted. Text that merely quotes a send (`echo "buzz messages send"`) matches, so +that turn is not reminded. Missing a real post is the expensive direction, and +substring matching is the forgiving one there. Neither edge is pinned by a test; +the matcher is free to improve. + +**Budget.** Reminders ride the existing `_Stop` gate and share +`BUZZ_AGENT_STOP_MAX_REJECTIONS` — the outer cap on every end-turn objection. +At the default 3 both reminders fit; at 1 only one does; at 0 the guard is off +along with the hooks. A round carrying both a `_Stop` hook objection and a +reminder costs one rejection and delivers both texts. This is not a new +lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md). ## Providers diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 48d4ea3b02..8e14fee195 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -21,6 +21,80 @@ use crate::wire::{self, WireSender}; const ERROR_REFLECTION_SUFFIX: &str = "\n\n[Reflect] Before retrying, identify the cause and change your approach."; +/// Maximum reply reminders emitted per prompt when `require_reply` is on. +/// +/// After this many, the turn is allowed to end whether or not anything was +/// published: the guard exists to catch accidental omission, not to compel +/// speech. The shared `stop_max_rejections` budget can cut this lower — see +/// [`Config::require_reply`](crate::config::Config::require_reply). +const MAX_REPLY_NAGS: u32 = 2; + +/// Server label on the synthetic reply-guard objection. +/// +/// Not a real MCP server. It rides the same tool-result path as `_Stop` hook +/// output, so the model sees `{hook, server, text}` attribution naming the +/// in-process guard rather than an MCP server that could be impersonated. +const REPLY_GUARD_SERVER: &str = "buzz-agent"; + +/// Reminder text emitted when a turn is about to end with nothing published. +/// +/// Explicitly licenses silence. The base prompt tells agents that publishing is +/// optional and "silence is usually correct"; a reminder that argued otherwise +/// would fight that instruction and make agents chattier. +const REPLY_GUARD_NAG: &str = "You are about to end this turn without calling `buzz messages send`. \ +Your assistant text and reasoning are never shown to anyone — if you did work, found an answer, \ +or hit a blocker that someone is waiting on, it exists only if you publish it. \ +If you already posted, or if silence is genuinely correct for this turn, ignore this and end your turn."; + +/// Whether `call` is a recognized attempt to publish a reply to Buzz. +/// +/// Recognizes an *attempt*, not a successful publish: the command text is +/// inspected, never the exit status. That is deliberate — a send that fails +/// already returns a non-zero exit and error JSON to the model, which is louder +/// feedback than the reminder this gates. +/// +/// `has` + `!is_hook` are the same checks the dispatcher uses to accept a call +/// (see `execute_calls`), so a hallucinated `fake__shell` — rejected at preflight +/// and never executed — cannot disarm the guard. They must stay *before* +/// [`is_reply_shaped`]: together with them, and only with them, the `__shell` +/// suffix is exactly equivalent to "the bare tool name is `shell`". +fn is_buzz_reply_call(call: &ToolCall, mcp: &McpRegistry) -> bool { + mcp.has(&call.name) && !mcp.is_hook(&call.name) && is_reply_shaped(&call.name, &call.arguments) +} + +/// Whether a tool name and arguments have the shape of a Buzz publish command. +/// +/// Split from [`is_buzz_reply_call`] only so the matcher is testable without a +/// live [`McpRegistry`]; callers must apply the registry checks first. +/// +/// On the name: `ends_with("__shell")` is exact rather than approximate *given* +/// those checks. Registration rejects `__` in both server names and bare tool +/// names, and qualified names are `{server}__{bare}`, so a trailing `__shell` can +/// only straddle the separator if the bare name starts with `_` — which `is_hook` +/// already excludes. Dropping the separator would not be exact: `powershell` and +/// `noshell` both end in `shell`. +/// +/// On the command: a deliberately coarse substring test, scoped to the structured +/// `command` field so unrelated metadata — a `description` that quotes a send — +/// cannot suppress the guard, and a non-string `command` is rejected rather than +/// coerced. Known limits, both accepted: a command assembled at runtime (`$CMD`) +/// or hidden in a wrapper script is missed, and text that merely quotes a send +/// (`echo "buzz messages send"`) matches. Missing a real post is the expensive +/// direction, and substring matching is the more forgiving one there. +fn is_reply_shaped(name: &str, arguments: &serde_json::Value) -> bool { + name.ends_with("__shell") + && arguments + .get("command") + .and_then(|v| v.as_str()) + .is_some_and(|cmd| { + // `messages send` also covers `messages send-diff`. `reactions + // add` counts because the base prompt directs agents to react + // rather than post a bare acknowledgement, so nagging an agent + // that reacted would punish documented-correct behavior. + cmd.contains("messages send") || cmd.contains("reactions add") + }) +} + pub struct RunCtx<'a> { pub cfg: &'a Config, /// Effective model for this session. Usually equals `cfg.model`; overridden @@ -102,6 +176,14 @@ impl RunCtx<'_> { // session) so a stubborn exchange can't permanently disable the stop // guard for a long-lived session; `max_rounds` still caps the loop. let mut stop_rejections = 0u32; + // Reply-guard state for this prompt. `prompt()` *is* the turn, so + // locals here are per-turn by construction — same shape as + // `stop_rejections` above. + // + // Named for what it proves: a *recognized attempt* to publish, not a + // successful publish. See `is_buzz_reply_call`. + let mut buzz_reply_call_seen = false; + let mut reply_nags = 0u32; loop { if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds { return Ok(StopReason::MaxTurnRequests); @@ -264,7 +346,7 @@ impl RunCtx<'_> { if stop_rejections >= self.cfg.stop_max_rejections { return Ok(stop); } - let objections = self + let mut objections = self .mcp .call_hooks( "_Stop", @@ -273,6 +355,17 @@ impl RunCtx<'_> { &self.cfg.hook_servers, ) .await; + // Reply guard shares this gate and this budget, so a round + // carrying both a hook objection and a reply reminder costs + // one rejection and delivers both texts. + if self.cfg.require_reply + && !buzz_reply_call_seen + && reply_nags < MAX_REPLY_NAGS + { + reply_nags += 1; + objections + .push((REPLY_GUARD_SERVER.to_string(), REPLY_GUARD_NAG.to_string())); + } if !objections.is_empty() { stop_rejections = stop_rejections.saturating_add(1); push_hook_outputs_as_tool_results(self.history, "_Stop", &objections); @@ -290,6 +383,11 @@ impl RunCtx<'_> { ); calls.truncate(MAX_TOOL_CALLS_PER_TURN); } + // Deliberately after truncation: a publish-shaped call that was + // discarded never runs, so it must not suppress the reminder. + if self.cfg.require_reply && !buzz_reply_call_seen { + buzz_reply_call_seen = calls.iter().any(|c| is_buzz_reply_call(c, self.mcp)); + } self.history.push(HistoryItem::Assistant { text: response.text, tool_calls: calls.clone(), @@ -799,6 +897,88 @@ mod tests { use super::*; use serde_json::json; + /// The shapes the guard must recognize as a publish attempt. Callers apply + /// the registry checks first; these cover the name suffix and command text. + #[test] + fn reply_shape_matches_documented_send_forms() { + for cmd in [ + "buzz messages send --channel X --content Y", + "buzz --relay wss://r messages send --channel X --content Y", + "/abs/path/buzz messages send", + "printf 'hi' | buzz messages send --content -", + "buzz messages send-diff --diff -", + "buzz reactions add --event E --emoji +", + // Assembled through another shell: rev 3's tokenizer missed this. + r#"sh -c "buzz messages send --channel X""#, + ] { + assert!( + is_reply_shaped("dev__shell", &json!({ "command": cmd })), + "expected {cmd:?} to count as a publish attempt" + ); + } + } + + /// Commands that do real work but do not reply in the originating + /// conversation must still be nagged. + #[test] + fn reply_shape_rejects_non_reply_commands() { + for cmd in [ + "buzz messages get --channel X", + "buzz channels list", + "buzz reactions remove --event E", + "buzz pr open --title T", + "buzz social publish --content hi", + "buzz notes set --name n", + "cargo test -p buzz-agent", + ] { + assert!( + !is_reply_shaped("dev__shell", &json!({ "command": cmd })), + "expected {cmd:?} not to count as a publish attempt" + ); + } + } + + /// The `__` separator is load-bearing: `ends_with("shell")` alone would + /// accept any registered tool whose name merely ends in those letters, and + /// `has()` proves registration, not the bare name. + #[test] + fn reply_shape_requires_the_qname_separator() { + let args = json!({ "command": "buzz messages send --channel X" }); + for name in [ + "dev__powershell", + "dev__noshell", + "shell", + "dev__send_message", + ] { + assert!( + !is_reply_shaped(name, &args), + "{name} must not satisfy the shell-tool check" + ); + } + assert!(is_reply_shaped("dev__shell", &args)); + assert!(is_reply_shaped("buzz-dev-mcp__shell", &args)); + } + + /// Only the field that carries the executable command counts. Searching + /// serialized arguments instead would let arbitrary metadata disarm the + /// guard, turning a description into an attempted send. + #[test] + fn reply_shape_reads_only_the_command_field() { + assert!(!is_reply_shaped( + "dev__shell", + &json!({ "description": "buzz messages send --channel X" }) + )); + assert!(!is_reply_shaped( + "dev__shell", + &json!({ "workdir": "buzz messages send" }) + )); + // Malformed `command` is rejected, not coerced — and must not panic. + assert!(!is_reply_shaped("dev__shell", &json!({ "command": 42 }))); + assert!(!is_reply_shaped("dev__shell", &json!({ "command": null }))); + assert!(!is_reply_shaped("dev__shell", &json!({}))); + assert!(!is_reply_shaped("dev__shell", &json!("not an object"))); + } + /// A9 regression: `reasoning_details` contributes real bytes to /// `estimated_bytes` (see `types.rs::HistoryItem::size_with`), so a /// history item carrying a large opaque reasoning array must actually diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index a0e64f1a9d..afbda5379d 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -720,6 +720,16 @@ pub struct Config { /// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to /// disable `_Stop` hooks entirely (agent always honors end_turn). pub stop_max_rejections: u32, + /// Remind the model to publish when a turn is about to end without any + /// recognized attempt to post to Buzz. Default off; opt in per agent with + /// `BUZZ_AGENT_REQUIRE_REPLY=1`. + /// + /// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`), + /// then the turn ends regardless. Bounded by the same + /// `stop_max_rejections` budget as `_Stop` hooks, which is the outer cap on + /// all end-turn objections — at the default 3 both reminders fit; at 1 only + /// one does; at 0 the guard is off with the hooks. + pub require_reply: bool, /// Hook server allowlist. See [`HookServers`] for variant semantics. /// Default (env unset/empty) is `None` — hooks are off unless the /// operator explicitly opts in. @@ -851,6 +861,7 @@ impl Config { max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?), stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?, + require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0, hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, @@ -893,6 +904,7 @@ impl Config { max_parallel_tools: 1, hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, + require_reply: false, hook_servers: HookServers::None, hints_enabled: false, thinking_effort: None, diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index f595a165e5..73c7e1faf2 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2355,6 +2355,7 @@ mod tests { max_parallel_tools: 1, hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, + require_reply: false, hook_servers: HookServers::None, api_key: "key".into(), model: "model".into(), diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 0bbd1d3478..5b660da48c 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -33,6 +33,11 @@ //! — expose a `_PostCompact` hook tool //! FAKE_MCP_POSTCOMPACT_TEXT=text //! — `_PostCompact` returns this (default: "") +//! FAKE_MCP_SHELL_TOOL=1 — expose a tool whose bare name is `shell` +//! (registered as `__shell`), taking a +//! `command` string. Lets a test drive the +//! reply guard's recognition of a real, +//! registered shell tool. use std::io::{BufRead, Write}; @@ -76,6 +81,7 @@ fn make_tools( desc: &str, include_stop_hook: bool, include_post_compact_hook: bool, + include_shell_tool: bool, ) -> Vec { let mut tools: Vec = (0..count) .map(|i| { @@ -100,6 +106,17 @@ fn make_tools( "inputSchema": { "type": "object", "properties": {} }, })); } + if include_shell_tool { + tools.push(json!({ + "name": "shell", + "description": "run a shell command", + "inputSchema": { + "type": "object", + "properties": { "command": { "type": "string" } }, + "required": ["command"], + }, + })); + } tools } @@ -136,6 +153,7 @@ fn main() { let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX); let mut stop_calls_seen: usize = 0; let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK"); + let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL"); let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default(); // Use a channel-based stdin reader so notifications (which carry no id) @@ -206,7 +224,13 @@ fn main() { write_response( id, json!({ - "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook) + "tools": make_tools( + tool_count, + &desc, + stop_hook, + post_compact_hook, + shell_tool, + ) }), ); } diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index 2e0b579c84..abb4f7b311 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -1819,3 +1819,465 @@ async fn cancel_sends_notifications_cancelled_to_any_mcp_server() { let _ = std::fs::remove_file(&call_received_marker); h.shutdown().await; } + +// --------------------------------------------------------------------------- +// Reply guard (`BUZZ_AGENT_REQUIRE_REPLY`) +// +// The guard reminds the model to publish when a turn is about to end without +// any recognized attempt to post to Buzz. It rides the existing `_Stop` gate +// and shares its rejection budget, so most of these tests count LLM calls: +// each reminder costs exactly one extra round. +// --------------------------------------------------------------------------- + +/// Number of reply-guard reminders present in one captured LLM request. +/// +/// A reminder is a tool-role message whose JSON body is attributed to the +/// in-process guard (`server: "buzz-agent"`) at the `_Stop` hook point — the +/// same lower-trust shape as real hook output. +fn reply_nag_count(request: &Value) -> usize { + request["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .filter(|m| { + m["role"] == "tool" + && serde_json::from_str::(m["content"].as_str().unwrap_or("")) + .map(|p| p["hook"] == "_Stop" && p["server"] == "buzz-agent") + .unwrap_or(false) + }) + .count() + }) + .unwrap_or(0) +} + +/// A publish-shaped call to a real registered shell tool. +fn openai_shell_send(id: &str) -> Value { + openai_tool_call( + id, + "fake__shell", + json!({ "command": "buzz messages send --channel c --content hi" }), + ) +} + +/// Run one prompt to completion, answering any permission requests, and +/// return the final response. +async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value { + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + loop { + let v = h.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v["id"] == json!(p) { + return v; + } + } +} + +/// Default off: a silent turn ends on the first end_turn with no extra round. +/// This is the invariant that keeps the feature free for everyone who hasn't +/// opted in. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_off_by_default() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "guard must be inert when unset, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// `BUZZ_AGENT_REQUIRE_REPLY=0` is off too — the toggle is numeric, so a +/// literal `0` must not read as "set, therefore on". +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_explicit_zero_is_off() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "0")]).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "REQUIRE_REPLY=0 must behave as off, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// Opted in and silent: exactly two reminders, then the turn is allowed to +/// end. The guard is advisory — it must never trap a turn. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_nags_twice_then_lets_the_turn_end() { + // Budget defaults to 3, so the cap that stops the loop here is + // MAX_REPLY_NAGS = 2, not the rejection budget. + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("silent-3"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "expected 2 reminders then end_turn (3 LLM calls), got {}", + captured.len() + ); + assert_eq!( + reply_nag_count(&captured[0]), + 0, + "reminder before any end_turn" + ); + assert_eq!(reply_nag_count(&captured[1]), 1); + assert_eq!(reply_nag_count(&captured[2]), 2); + + // The reminder must name the command it wants and license silence, so it + // cannot fight the base prompt's "silence is usually correct". + let msgs = captured[2]["messages"].as_array().unwrap(); + let nag = msgs + .iter() + .filter_map(|m| serde_json::from_str::(m["content"].as_str().unwrap_or("")).ok()) + .find(|p| p["server"] == "buzz-agent") + .expect("reminder body"); + let text = nag["text"].as_str().unwrap_or(""); + assert!( + text.contains("buzz messages send"), + "reminder should name the command: {text}" + ); + assert!( + text.contains("silence is genuinely correct"), + "reminder must license silence: {text}" + ); + h.shutdown().await; +} + +/// A real publish attempt through a registered shell tool satisfies the guard: +/// no reminder, no extra round. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_satisfied_by_registered_shell_send() { + let llm = spawn_capturing_llm(vec![ + openai_shell_send("tc1"), + openai_text("posted"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await; + let sid = init_session_with_fake_mcp( + &mut h, + &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "a recognized send must not be nagged, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[1]), 0); + h.shutdown().await; +} + +/// A publish-shaped call to a shell tool that is *not registered* never runs — +/// preflight rejects it — so it must not disarm the guard. This is what the +/// `has`/`is_hook` checks in the predicate buy. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_ignores_unregistered_shell_tool() { + // FAKE_MCP_SHELL_TOOL is absent, so `fake__shell` is a hallucination. + let llm = spawn_capturing_llm(vec![ + openai_shell_send("tc1"), + openai_text("silent-1"), + openai_text("silent-2"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session_with_fake_mcp(&mut h, &[("FAKE_MCP_TOOL_COUNT", "1")]).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "expected the hallucinated call to still be nagged, got {} LLM calls", + captured.len() + ); + let msgs = captured[1]["messages"].as_array().unwrap(); + assert!( + msgs.iter() + .any(|m| m["role"] == "tool" + && m["content"].as_str().unwrap_or("").contains("unknown tool")), + "expected preflight to reject the call: {msgs:?}" + ); + assert_eq!(reply_nag_count(&captured[2]), 1); + h.shutdown().await; +} + +/// A publish-shaped call discarded by the per-turn tool-call cap never runs, +/// so it must not suppress the reminder either. Pins the check's placement +/// after `calls.truncate(MAX_TOOL_CALLS_PER_TURN)`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_ignores_calls_lost_to_the_turn_cap() { + // 64 filler calls (the cap) followed by the publish attempt, which is + // therefore truncated away. The shell tool *is* registered here, so only + // the placement — not tool identity — can explain the reminder. + let mut calls: Vec = (0..64) + .map(|i| { + json!({ + "id": format!("c{i}"), + "type": "function", + "function": { "name": "fake__tool_0", "arguments": "{}" }, + }) + }) + .collect(); + calls.push(json!({ + "id": "c-send", + "type": "function", + "function": { + "name": "fake__shell", + "arguments": json!({ "command": "buzz messages send --channel c --content hi" }) + .to_string(), + }, + })); + let truncated_send = json!({ + "id": "cc-trunc", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": null, "tool_calls": calls }, + "finish_reason": "tool_calls", + }], + }); + let llm = spawn_capturing_llm(vec![ + truncated_send, + openai_text("silent-1"), + openai_text("silent-2"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "a truncated send must still be nagged, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[2]), 1); + h.shutdown().await; +} + +/// The shared `_Stop` rejection budget is the outer cap: at 1 the guard gets +/// one reminder instead of two. Documented degradation, not a bug. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_bounded_by_stop_rejection_budget() { + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "budget 1 must allow exactly one reminder, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[1]), 1); + h.shutdown().await; +} + +/// Budget 0 disables every objection at the gate, including this one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_off_when_stop_budget_is_zero() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "budget 0 must disable the guard, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// The two axes are independent inside one shared budget: a round carrying +/// both a `_Stop` hook objection and a reminder costs one rejection and +/// delivers both texts, and once the reminders are spent the hook objection +/// continues alone. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_combines_with_stop_hook_objection() { + // The hook objects on its first 3 calls, then clears. Reminders stop + // after 2, so round 3 must carry the hook text and no new reminder. + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("silent-3"), + openai_text("silent-4"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("MCP_HOOK_SERVERS", "fake"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "you have open todos"), + ("FAKE_MCP_STOP_COUNT", "3"), + ], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 4, + "expected 3 objecting rounds then a clear end, got {}", + captured.len() + ); + + let hook_objections = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .filter(|m| { + m["content"] + .as_str() + .unwrap_or("") + .contains("you have open todos") + }) + .count() + }) + .unwrap_or(0) + }; + + // Round 2 carries one of each — a single rejection bought both texts. + assert_eq!(reply_nag_count(&captured[1]), 1); + assert_eq!(hook_objections(&captured[1]), 1); + // Round 4: the hook objected three times, the guard only twice. + assert_eq!(reply_nag_count(&captured[3]), 2); + assert_eq!(hook_objections(&captured[3]), 3); + h.shutdown().await; +} + +/// An unparseable toggle is a startup error, not a silent default. `parse_env` +/// is generic over `FromStr`, so this also pins the numeric type: a `bool` +/// field would have rejected the documented `1`. +#[test] +fn reply_guard_rejects_unparseable_toggle() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_buzz-agent")) + .env("BUZZ_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("BUZZ_AGENT_REQUIRE_REPLY", "true") + .stdin(Stdio::null()) + .output() + .expect("run buzz-agent"); + assert!( + !out.status.success(), + "expected a config error exit, got {:?}", + out.status + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("BUZZ_AGENT_REQUIRE_REPLY"), + "expected the offending key in the error, got: {stderr}" + ); +} diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index b44c4fac6d..8eabdce2e3 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -484,7 +484,7 @@ buzz notes rm --name does-not-exist # exits non-zero --- -### 6.13 Wallet Bindings (NIP-SW Starknet, kind:30178) +### 6.13 Wallet Bindings (NIP-SW Starknet, kind:30900) `wallet message` is local-only — no relay, no chain. The other three need a relay, and `publish` additionally needs the relay to have @@ -518,7 +518,7 @@ buzz wallet lookup --address 0x04a5... --chain SN_SEPOLIA Worth checking explicitly: - Republishing for the same chain **replaces** (NIP-33 LWW keyed by - `(pubkey, 30178, chain_id)`); a second chain coexists rather than replacing. + `(pubkey, 30900, chain_id)`); a second chain coexists rather than replacing. - A wrong `--signed-at` is rejected by the relay, not accepted silently. - `wallet lookup` may legitimately return **several** bindings from different pubkeys for one address, and that is not a bug — on a conforming relay each was diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index d5291b8566..e094e94283 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -93,24 +93,6 @@ pub const KIND_AGENT_PROFILE: u32 = 10100; /// `docs/nips/NIP-AE.md` and [`crate::engram`]. pub const KIND_AGENT_ENGRAM: u32 = 30174; -/// NIP-SW: Starknet wallet binding (parameterized replaceable, self-authored). -/// -/// Links a Nostr identity to a Starknet account contract, addressed by -/// `(pubkey, kind, d_tag)` where `d_tag` is the Starknet chain id short string -/// (e.g. `SN_MAIN`) — one current binding per author per chain under NIP-01 -/// last-write-wins. -/// -/// The event's own signature proves only that the author *claims* the address, -/// which is spoofable. The payload therefore carries a Starknet-side -/// attestation: a signature produced by the account itself over the author's -/// pubkey. A conforming relay verifies it on-chain at ingest and rejects the -/// event on failure, so a stored binding is an attested one. See -/// `docs/nips/NIP-SW.md` and [`crate::wallet_binding`]. -/// -/// This kind never carries private key material. The Nostr identity key must -/// never be the account's signer — see the spec's security considerations. -pub const KIND_STARKNET_WALLET_BINDING: u32 = 30178; - /// NIP-ER: Event Reminder (parameterized replaceable, author-only). /// /// Encrypted, author-only reminder addressed by `(pubkey, kind, d_tag)`. The @@ -200,29 +182,43 @@ pub const P_GATED_KINDS: &[u32] = &[ /// or more than one `shared` tag) so no ambiguous heads can exist. pub const KIND_PERSONA: u32 = 30175; -/// Returns `true` if `kind` uses the author-only-unless-shared read model -/// (currently only `KIND_PERSONA` / 30175). +/// Kinds that use the author-only-unless-shared read model. /// /// Events of these kinds may only be delivered to foreign readers when the -/// event carries exactly `["shared", "true"]`. Used by all relay read -/// chokepoints: REQ historical delivery, live fan-out, COUNT fallback, -/// and the `ids`-lookup result gate. -pub fn is_persona_shared_kind(kind: u32) -> bool { - kind == KIND_PERSONA +/// event carries exactly `["shared", "true"]`. Every relay read chokepoint +/// consults this set: REQ historical delivery, live fan-out, COUNT fallback, +/// the `ids`-lookup result gate, both HTTP surfaces, and the pre-`LIMIT` SQL +/// visibility pushdown in `buzz-db`. +/// +/// Membership is a privacy decision, not a convenience: adding a kind here +/// makes its events invisible to foreign readers until their author opts in, +/// and the opt-in must be a `shared` TAG (not a content field) so that +/// toggling it leaves content bytes — and any content hash derived from them — +/// unchanged. +/// +/// `KIND_TEAM` (30176) is deliberately NOT a member. Its writers never emit +/// `shared`, so catalog opt-in semantics do not describe it; it needs +/// owner-private read semantics instead, which is a separate change. +pub const SHARED_GATED_KINDS: &[u32] = &[KIND_PERSONA, KIND_TEAM_CATALOG]; + +/// Returns `true` if `kind` uses the author-only-unless-shared read model +/// (see [`SHARED_GATED_KINDS`]). +pub fn is_shared_gated_kind(kind: u32) -> bool { + SHARED_GATED_KINDS.contains(&kind) } -/// Returns `true` if the event is a persona-shared-catalog kind AND the -/// requester is NOT the author AND the event does NOT carry `["shared", -/// "true"]`. All three conditions must hold to withhold the event. +/// Returns `true` if the event is a shared-gated kind AND the requester is NOT +/// the author AND the event does NOT carry `["shared", "true"]`. All three +/// conditions must hold to withhold the event. /// /// This is the per-event gate used by REQ historical delivery, live fan-out, /// and COUNT fallback paths. It is intentionally independent of -/// `is_author_only_event` — persona events with `["shared", "true"]` MUST +/// `is_author_only_event` — shared-gated events with `["shared", "true"]` MUST /// reach foreign readers; stripping them at the author-only layer would break /// the catalog query. -pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { +pub fn is_unshared_gated_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { let kind = event.kind.as_u16() as u32; - if !is_persona_shared_kind(kind) { + if !is_shared_gated_kind(kind) { return false; } // Author reads are always allowed. @@ -230,18 +226,23 @@ pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: & return false; } // Foreign reader: allowed only if the event is explicitly shared. - !persona_event_is_shared(event) + !event_is_shared(event) } /// Returns `true` if the event carries exactly one `["shared", "true"]` tag. /// +/// Kind-agnostic: this is purely the tag-shape predicate. The kind check lives +/// in [`is_shared_gated_kind`], so callers that need "is this event shared" +/// for a kind they already know (e.g. a client deciding whether its own +/// retained head is published) can use this directly. +/// /// Requires the tag to have exactly two elements so that a three-element shape /// like `["shared","true","extra"]` is NOT treated as shared. Ingest enforces /// the same exact shape, so a well-stored event either has no `shared` tag /// (author-only) or exactly one with precisely two elements and value `"true"` /// (community-readable). This helper fails closed on any non-exact shape /// independently of ingest guarantees. -pub fn persona_event_is_shared(event: &nostr::Event) -> bool { +pub fn event_is_shared(event: &nostr::Event) -> bool { let mut count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); @@ -276,6 +277,34 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +/// NIP-AP: Team Catalog projection (parameterized replaceable, owner-authored). +/// +/// The shareable projection of a team, addressed by `(pubkey, kind, d_tag)` +/// where `d_tag` is the team's stable id. Content is a versioned JSON body +/// carrying sanitized team fields plus ordered, EMBEDDED member definition +/// projections. +/// +/// # Why this is not a `shared` tag on [`KIND_TEAM`] +/// +/// A team's members live in kind 30175 events that are author-only unless +/// individually shared, so a foreign reader of a shared team could never +/// hydrate its members. This kind therefore embeds the member projections +/// rather than referencing them: the share is atomic, it covers built-in +/// members that have no 30175 head at all, it is immune to local-id/d-tag +/// divergence, and an unshared 30175 stays private. Kind 30176's wire body is +/// untouched, so device sync keeps its contract. +/// +/// # Access control +/// +/// Member of [`SHARED_GATED_KINDS`]: author-only unless the event carries +/// exactly `["shared", "true"]`. Ingest additionally requires exactly one +/// non-empty, bounded `d` tag — generic NIP-33 storage maps a missing `d` to +/// the empty coordinate, which would collapse every team into one slot. +/// +/// Content carries only sanitized fields: no env vars, no `respond_to` +/// allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. +pub const KIND_TEAM_CATALOG: u32 = 30178; + // NIP-56 reporting /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). /// @@ -580,6 +609,32 @@ pub const KIND_GIT_STATUS_CLOSED: u32 = 1632; /// NIP-34: Status — Draft. pub const KIND_GIT_STATUS_DRAFT: u32 = 1633; +// FORK-LOCAL PATCH (adrienlacombe/buzz): fork-reserved kinds, 30900–30999. +// +// Kept here rather than beside upstream's 30174–30178 cluster on purpose. That +// cluster is where upstream adds new parameterized-replaceable kinds, so a fork +// constant sitting in it collides on merge — which is exactly what happened when +// upstream shipped `KIND_TEAM_CATALOG = 30178` (#3358) onto the integer this +// binding used to hold. Upstream keeps 30178; the fork moved into its own block. + +/// NIP-SW: Starknet wallet binding (parameterized replaceable, self-authored). +/// +/// Links a Nostr identity to a Starknet account contract, addressed by +/// `(pubkey, kind, d_tag)` where `d_tag` is the Starknet chain id short string +/// (e.g. `SN_MAIN`) — one current binding per author per chain under NIP-01 +/// last-write-wins. +/// +/// The event's own signature proves only that the author *claims* the address, +/// which is spoofable. The payload therefore carries a Starknet-side +/// attestation: a signature produced by the account itself over the author's +/// pubkey. A conforming relay verifies it on-chain at ingest and rejects the +/// event on failure, so a stored binding is an attested one. See +/// `docs/nips/NIP-SW.md` and [`crate::wallet_binding`]. +/// +/// This kind never carries private key material. The Nostr identity key must +/// never be the account's signer — see the spec's security considerations. +pub const KIND_STARKNET_WALLET_BINDING: u32 = 30900; + /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ KIND_PROFILE, @@ -605,6 +660,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM, KIND_MANAGED_AGENT, KIND_STARKNET_WALLET_BINDING, + KIND_TEAM_CATALOG, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -803,11 +859,14 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000– const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 + // FORK-LOCAL PATCH (adrienlacombe/buzz): keeps the fork's reserved block addressable. +const _: () = assert!(is_parameterized_replaceable(KIND_STARKNET_WALLET_BINDING)); // 30900 ∈ 30000–39999 // Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. const _: () = assert!( @@ -877,64 +936,68 @@ mod tests { } } - // ── persona_event_is_shared / is_unshared_persona_event ────────────── + // ── event_is_shared / is_unshared_gated_event ──────────────────────── - fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { + fn make_event_of_kind(kind: u32, tags: &[&[&str]]) -> nostr::Event { use nostr::{EventBuilder, Keys, Kind, Tag}; let keys = Keys::generate(); let tag_vec: Vec = tags .iter() .map(|parts| Tag::parse(parts.iter().copied()).unwrap()) .collect(); - EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "") + EventBuilder::new(Kind::Custom(kind as u16), "") .tags(tag_vec) .sign_with_keys(&keys) .unwrap() } + fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { + make_event_of_kind(KIND_PERSONA, tags) + } + #[test] - fn persona_event_is_shared_true_tag() { + fn event_is_shared_true_tag() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); - assert!(persona_event_is_shared(&ev)); + assert!(event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_no_tag() { + fn event_is_shared_no_tag() { let ev = make_persona_event(&[&["d", "my-agent"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_wrong_value() { + fn event_is_shared_wrong_value() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "false"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_duplicate_shared_tags() { + fn event_is_shared_duplicate_shared_tags() { // Two ["shared","true"] tags → ambiguous; not considered shared. let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"], &["shared", "true"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_three_element_tag_not_shared() { + fn event_is_shared_three_element_tag_not_shared() { // ["shared","true","extra"] — three elements — must NOT be treated as shared. // The helper fails closed on any non-exact shape independently of ingest guarantees. let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true", "extra"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_one_element_tag_not_shared() { + fn event_is_shared_one_element_tag_not_shared() { // ["shared"] — only one element — not shared (fails the == 2 check). let ev = make_persona_event(&[&["d", "my-agent"], &["shared"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn is_unshared_persona_event_author_always_allowed() { + fn is_unshared_gated_event_author_always_allowed() { // Even without a shared tag the event author should not be blocked. use nostr::{EventBuilder, Keys, Kind, Tag}; let keys = Keys::generate(); @@ -943,32 +1006,83 @@ mod tests { .sign_with_keys(&keys) .unwrap(); let author_bytes = keys.public_key().to_bytes(); - assert!(!is_unshared_persona_event(&ev, &author_bytes)); + assert!(!is_unshared_gated_event(&ev, &author_bytes)); } #[test] - fn is_unshared_persona_event_foreign_no_tag() { + fn is_unshared_gated_event_foreign_no_tag() { let ev = make_persona_event(&[&["d", "my-agent"]]); let foreign = [0u8; 32]; - assert!(is_unshared_persona_event(&ev, &foreign)); + assert!(is_unshared_gated_event(&ev, &foreign)); } #[test] - fn is_unshared_persona_event_foreign_shared_tag() { + fn is_unshared_gated_event_foreign_shared_tag() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); let foreign = [0u8; 32]; - assert!(!is_unshared_persona_event(&ev, &foreign)); + assert!(!is_unshared_gated_event(&ev, &foreign)); } #[test] - fn is_unshared_persona_event_non_persona_kind_passthrough() { + fn is_unshared_gated_event_ungated_kind_passthrough() { use nostr::{EventBuilder, Keys, Kind}; let keys = Keys::generate(); let ev = EventBuilder::new(Kind::Custom(KIND_TEAM as u16), "") .sign_with_keys(&keys) .unwrap(); let foreign = [0u8; 32]; - // Non-persona kinds are never blocked by this gate. - assert!(!is_unshared_persona_event(&ev, &foreign)); + // Kinds outside SHARED_GATED_KINDS are never blocked by this gate. + assert!(!is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_foreign_no_tag() { + // The gate must cover 30178 identically to 30175 — an unshared team + // catalog projection is author-only. + let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"]]); + let foreign = [0u8; 32]; + assert!(is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_foreign_shared_tag() { + let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"], &["shared", "true"]]); + let foreign = [0u8; 32]; + assert!(!is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_author_always_allowed() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + let ev = EventBuilder::new(Kind::Custom(KIND_TEAM_CATALOG as u16), "") + .tags(vec![Tag::parse(["d", "team-1"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let author_bytes = keys.public_key().to_bytes(); + assert!(!is_unshared_gated_event(&ev, &author_bytes)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_malformed_shared_tag_fails_closed() { + // A three-element `shared` tag can never be stored (ingest rejects it), + // but the read gate must independently treat it as NOT shared. + let ev = make_event_of_kind( + KIND_TEAM_CATALOG, + &[&["d", "team-1"], &["shared", "true", "extra"]], + ); + let foreign = [0u8; 32]; + assert!(is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn shared_gated_kinds_membership() { + assert!(is_shared_gated_kind(KIND_PERSONA)); + assert!(is_shared_gated_kind(KIND_TEAM_CATALOG)); + // 30176 has owner-private semantics, not catalog opt-in semantics: its + // writers never emit `shared`, so gating it here would hide every team + // from its own delegated readers. + assert!(!is_shared_gated_kind(KIND_TEAM)); + assert!(!is_shared_gated_kind(KIND_MANAGED_AGENT)); } } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 0e54196d11..6c84950a2c 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -11,12 +11,19 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, - KIND_HUDDLE_STARTED, + KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; use crate::error::{DbError, Result}; +/// Largest page [`query_events`] will return when [`EventQuery::max_limit`] is +/// unset — the effective ceiling on any client-requested `limit`. +/// +/// This is the value the relay advertises as NIP-11 `limitation.max_limit`, so +/// the advertised ceiling and the enforced one cannot drift. +pub const DEFAULT_MAX_PAGE_LIMIT: i64 = 1_000; + /// Optional filters for [`query_events`]. #[derive(Debug, Clone)] pub struct EventQuery { @@ -67,17 +74,19 @@ pub struct EventQuery { /// channel-less global events. Applied before SQL `LIMIT` so access-filtered /// historical pages have exact exhaustion semantics. pub channel_ids: Option>, - /// Override the default limit clamp (1000). Used by COUNT fallback path - /// which needs to fetch all matching events for post-filter counting. - /// When None, the default clamp of 1000 applies. + /// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by + /// the COUNT fallback path, which needs to fetch all matching events for + /// post-filter counting. When None, the default clamp applies. pub max_limit: Option, - /// Persona visibility reader: when set, append an SQL visibility clause - /// for kind 30175 before ORDER/LIMIT so private personas are excluded from - /// the candidate page rather than discarded after it. + /// Shared-gated visibility reader: when set, append an SQL visibility + /// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so + /// private events are excluded from the candidate page rather than + /// discarded after it. /// - /// The clause is: `AND (kind != 30175 OR pubkey = $reader OR tags @> ?)`, - /// where `?` is the JSONB literal `[["shared","true"]]`. The GIN index on - /// `tags` (migration 0004, jsonb_path_ops) makes the containment check fast. + /// The clause is: `AND (kind NOT IN (...) OR pubkey = $reader OR tags @> ?)`, + /// where the `IN` list is [`SHARED_GATED_KINDS`] and `?` is the JSONB + /// literal `[["shared","true"]]`. The GIN index on `tags` (migration 0004, + /// jsonb_path_ops) makes the containment check fast. /// /// NOTE: `tags @> '[["shared","true"]]'` uses JSONB containment, which /// matches any tag array that is a superset of `[["shared","true"]]` — it @@ -85,7 +94,7 @@ pub struct EventQuery { /// 2` exact-shape check ensures such malformed tags are never stored, so the /// SQL pushdown is sound. Keeping `event_visible_to_reader` as post-filter /// defense-in-depth catches any residual mismatch. - pub persona_reader: Option>, + pub shared_gated_reader: Option>, } impl EventQuery { @@ -114,7 +123,7 @@ impl EventQuery { e_tags: None, channel_ids: None, max_limit: None, - persona_reader: None, + shared_gated_reader: None, } } } @@ -355,7 +364,7 @@ pub(crate) async fn query_events_on( return Ok(vec![]); } - let clamp = q.max_limit.unwrap_or(1000); + let clamp = q.max_limit.unwrap_or(DEFAULT_MAX_PAGE_LIMIT); let limit_val = q.limit.unwrap_or(100).min(clamp); let offset_val = q.offset.unwrap_or(0); @@ -512,25 +521,28 @@ pub(crate) async fn query_events_on( } } - // Persona visibility pushdown: exclude kind 30175 events that are neither - // authored by the reader nor explicitly shared. Applied BEFORE ORDER/LIMIT - // so that a page of newer private personas does not push visible shared ones - // off the end of the result set (the catalog query pattern). + // Shared-gated visibility pushdown: exclude SHARED_GATED_KINDS events that + // are neither authored by the reader nor explicitly shared. Applied BEFORE + // ORDER/LIMIT so that a page of newer private events does not push visible + // shared ones off the end of the result set (the catalog query pattern). // - // Clause: AND (kind != 30175 OR pubkey = $reader OR tags @> '[["shared","true"]]') + // Clause: AND (kind NOT IN (30175, 30178) OR pubkey = $reader + // OR tags @> '[["shared","true"]]') // // The JSONB containment check is served by idx_events_tags_gin (migration // 0004, jsonb_path_ops). `tags @> '[["shared","true"]]'` matches any array // that contains exactly the sub-array — a two-element `["shared","true"]` - // tag passes; a tag-absent event does not. Because ingest now requires - // exactly two elements for the shared tag (parts.len() == 2), no stored - // event can carry a three-element superset. - if let Some(ref reader_bytes) = q.persona_reader { - let kind_30175: i32 = 30175; + // tag passes; a tag-absent event does not. Because ingest requires exactly + // two elements for the shared tag (parts.len() == 2), no stored event can + // carry a three-element superset. + if let Some(ref reader_bytes) = q.shared_gated_reader { let shared_containment = serde_json::json!([["shared", "true"]]); - qb.push(format!(" AND ({col_prefix}kind != ")); - qb.push_bind(kind_30175); - qb.push(format!(" OR {col_prefix}pubkey = ")); + qb.push(format!(" AND ({col_prefix}kind NOT IN (")); + let mut sep = qb.separated(", "); + for kind in SHARED_GATED_KINDS { + sep.push_bind(*kind as i32); + } + qb.push(format!(") OR {col_prefix}pubkey = ")); qb.push_bind(reader_bytes.clone()); qb.push(format!(" OR {col_prefix}tags @> ")); qb.push_bind(shared_containment); diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 0c6ea36dac..50aac1cbaf 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -55,7 +55,7 @@ pub mod user; pub mod workflow; pub use error::{DbError, Result}; -pub use event::{EventQuery, ReactionEventInsertOutcome}; +pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; use chrono::{DateTime, Utc}; use sqlx::postgres::{PgConnection, PgPoolOptions}; @@ -5780,15 +5780,21 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn test_usage_metrics_lock_has_single_owner_and_releases_on_drop() { - let database_url = - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); - let pool = PgPoolOptions::new() - .max_connections(2) - .connect(&database_url) - .await - .expect("connect to test DB"); + // Use a private scratch database — not the shared TEST_DATABASE_URL. + // Postgres advisory locks are per-database; hardcoding the production + // USAGE_METRICS_LOCK_KEY (0x4255_5A5A_4D45_5452) on the shared test DB + // races any live buzz-relay on the same database (see #3619). + let admin_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&admin_url) + .await + .expect("connect admin to create scratch db"); + let (pool, scratch_name) = create_scratch_db(&admin, "usage_metrics_lock").await; let first = Db::from_pool(pool.clone()); - let second = Db::from_pool(pool); + let second = Db::from_pool(pool.clone()); + // Same key as production (`buzz-relay` USAGE_METRICS_LOCK_KEY) — safe here + // because the scratch DB is empty of other holders. let key = 0x4255_5A5A_4D45_5452; let mut leader = first @@ -5815,6 +5821,11 @@ mod tests { .is_some(), "dropping the detached session releases its advisory lock" ); + + // Release any remaining session state before DROP DATABASE. + drop(first); + drop(second); + drop_scratch_db(&admin, pool, &scratch_name).await; } #[tokio::test] diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 6985916bba..01fbe0cfd5 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,7 +561,11 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 26); + // FORK-LOCAL PATCH (adrienlacombe/buzz): upstream ships 26 migrations; this + // fork adds 0027 (NIP-SW wallet-binding search exclusion) and 0028 (its + // 30178 -> 30900 kind move), so the count is 28 here. 0027 landed without + // bumping this, which left the assertion failing on main. + assert_eq!(migrations.len(), 28); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 3805745f9b..bfc56f82de 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -376,7 +376,7 @@ pub enum TransferResult { /// Default maximum number of communities a single pubkey can own. Enforced at /// the relay layer — the authoritative layer — so that concurrent transfers or /// transfer-vs-create races cannot both pass a preflight count. -pub const MAX_COMMUNITIES_PER_OWNER: i64 = 3; +pub const MAX_COMMUNITIES_PER_OWNER: i64 = 5; /// Effective per-owner community limit for this deployment. /// diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index eae8c5ef9e..4f1690beef 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -328,7 +328,7 @@ impl PubSubManager { publisher::publish_event(&self.pool, ctx, topic, event).await } - /// Set presence with 60s TTL. Call on connect and every 30s heartbeat. + /// Set presence with 180s TTL. Call on connect and every 60s heartbeat. pub async fn set_presence( &self, ctx: &TenantContext, diff --git a/crates/buzz-pubsub/src/presence.rs b/crates/buzz-pubsub/src/presence.rs index 178ba7550a..e0c9dfd6c9 100644 --- a/crates/buzz-pubsub/src/presence.rs +++ b/crates/buzz-pubsub/src/presence.rs @@ -1,7 +1,7 @@ //! Presence tracking — online/away status with TTL. //! -//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 90`. -//! TTL is 3x the 30s heartbeat interval so a single missed heartbeat doesn't +//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 180`. +//! TTL is 3x the 60s heartbeat interval so a single missed heartbeat doesn't //! cause presence flap. Clean disconnect deletes immediately. use buzz_core::TenantContext; @@ -12,8 +12,8 @@ use std::collections::HashMap; use crate::error::PubSubError; use crate::topic::BUZZ_PREFIX; -/// 3x the 30s heartbeat — single missed heartbeat won't cause presence flap. -pub const PRESENCE_TTL_SECS: u64 = 90; +/// 3x the 60s heartbeat — single missed heartbeat won't cause presence flap. +pub const PRESENCE_TTL_SECS: u64 = 180; /// Returns the Redis key for the presence entry of `pubkey` under `ctx`. pub fn presence_key(ctx: &TenantContext, pubkey: &PublicKey) -> String { @@ -109,6 +109,12 @@ mod tests { TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host) } + #[test] + fn presence_ttl_is_three_one_minute_heartbeat_windows() { + assert_eq!(PRESENCE_TTL_SECS, 180); + assert_eq!(PRESENCE_TTL_SECS, 3 * 60); + } + #[test] fn test_presence_key_format() { let pubkey = make_pubkey(); diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 10461d8d46..a118ff453f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1236,10 +1236,10 @@ async fn query_events_authed( extract_channel_from_filter(filter), &accessible_channels, ); - // Persona visibility pushdown: must mirror WS REQ so that a page of newer - // private personas does not starve older shared ones off the candidate page. - if crate::handlers::req::filter_can_match_persona_shared_kinds(filter) { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: must mirror WS REQ so that a page of + // newer private events does not starve older shared ones off the page. + if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } match extract_before_id(raw) { @@ -1453,11 +1453,11 @@ async fn count_events_authed( filter, &authed_pubkey_hex, ); - // Force per-event fallback for filters that can match kind:30175 — - // the fast SQL count_events() path has no per-event gate and would - // over-count foreign unshared persona events (existence leak). - let needs_persona_filtering = - crate::handlers::req::filter_can_match_persona_shared_kinds(filter); + // Force per-event fallback for filters that can match a shared-gated + // kind — the fast SQL count_events() path has no per-event gate and + // would over-count foreign unshared events (existence leak). + let needs_shared_gate_filtering = + crate::handlers::req::filter_can_match_shared_gated_kinds(filter); // If filter targets a specific channel, verify access. if let Some(ch_id) = extract_channel_from_filter(filter) { @@ -1472,10 +1472,10 @@ async fn count_events_authed( tenant.community(), ) .await; - // Persona visibility pushdown: same as REQ and /query paths, so the - // fallback's query_events call doesn't over-fetch private persona rows. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: same as REQ and /query paths, so + // the fallback's query_events call doesn't over-fetch private rows. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() @@ -1486,7 +1486,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, @@ -1541,10 +1541,10 @@ async fn count_events_authed( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); - // Persona visibility pushdown: pre-filter before ORDER/LIMIT on the - // fallback query_events path. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: pre-filter before ORDER/LIMIT on + // the fallback query_events path. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { @@ -1556,7 +1556,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { query.limit = None; match state.db.count_events_routed("bridge_count", &query).await { @@ -3042,6 +3042,27 @@ mod tests { assert_eq!(extract_page_offset(&raw, None), None); } + /// Offsets are sized from the *clamped* limit the DB will honor, not from + /// what the client asked for. `filter_to_query_params` clamps an absent or + /// over-ceiling `limit` to `DEFAULT_MAX_PAGE_LIMIT` (guarded in + /// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`) + /// and that clamped value is what arrives here — so page N starts exactly + /// N-1 full pages in. Sizing from an unclamped limit would step past rows + /// the previous page never returned. + #[test] + fn extract_page_offset_sizes_pages_from_clamped_limit() { + let clamped = buzz_db::DEFAULT_MAX_PAGE_LIMIT; + + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 2 }), Some(clamped)), + Some(clamped) + ); + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 3 }), Some(clamped)), + Some(clamped * 2) + ); + } + #[test] fn extract_depth_limit_valid() { let raw = serde_json::json!({ "depth_limit": 3 }); diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index dfb44e152f..3eeab5e807 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -7,8 +7,8 @@ use tracing::warn; use crate::connection::{AuthState, ConnectionState}; use crate::handlers::req::{ - event_visible_to_reader, filter_can_match_persona_shared_kinds, - filter_can_match_result_gated_kinds, result_gated_count_safe_for_pushdown, + event_visible_to_reader, filter_can_match_result_gated_kinds, + filter_can_match_shared_gated_kinds, result_gated_count_safe_for_pushdown, }; use crate::protocol::RelayMessage; use crate::state::AppState; @@ -103,11 +103,11 @@ pub async fn handle_count( // fast-path count_events() cannot be used because it doesn't do // per-event author filtering. let needs_author_only_filtering = super::req::filter_can_match_author_only_kinds(filter); - // Determine if this filter can match kind 30175 (persona) — if so, the - // fast-path must be bypassed because it has no per-event shared-tag check. - // A fast count over 30175 would include foreign unshared persona events, - // leaking the existence of private agent activity. - let needs_persona_filtering = filter_can_match_persona_shared_kinds(filter); + // Determine if this filter can match a shared-gated kind (30175, 30178) + // — if so, the fast path must be bypassed because it has no per-event + // shared-tag check. A fast count over those kinds would include foreign + // unshared events, leaking the existence of private agent activity. + let needs_shared_gate_filtering = filter_can_match_shared_gated_kinds(filter); // Determine if this filter can match result-gated kinds (44200, 30622) // that require a per-event owner check. When the fast SQL path would // count matching rows without calling reader_authorized_for_event, a @@ -157,10 +157,10 @@ pub async fn handle_count( conn.tenant.community(), ) .await; - // Persona visibility pushdown: pre-filter the fallback query_events - // candidate page before ORDER/LIMIT. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: pre-filter the fallback + // query_events candidate page before ORDER/LIMIT. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() @@ -171,7 +171,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, @@ -230,9 +230,9 @@ pub async fn handle_count( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); - // Persona visibility pushdown for the fallback query_events path. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown for the fallback query_events path. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { @@ -244,7 +244,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { query.limit = None; // COUNT doesn't need a row limit match state.db.count_events_routed("count_req", &query).await { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 88dd5f5180..a9cdffcdec 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -7,7 +7,7 @@ use tracing::{debug, error, info, warn}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ - event_kind_u32, is_ephemeral, is_unshared_persona_event, AUTHOR_ONLY_KINDS, + event_kind_u32, is_ephemeral, is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, }; use buzz_core::observer::{ @@ -151,10 +151,10 @@ pub async fn filter_fanout_by_access( matches }; - // Persona shared-read gate (fan-out): kind 30175 events fan out to all - // connections only when carrying ["shared","true"]. Unshared personas - // are delivered only to the author's own connections, matching REQ semantics. - let matches = if buzz_core::kind::is_persona_shared_kind(event_kind_u32(&stored_event.event)) { + // Shared-read gate (fan-out): SHARED_GATED_KINDS events fan out to all + // connections only when carrying ["shared","true"]. Unshared ones are + // delivered only to the author's own connections, matching REQ semantics. + let matches = if buzz_core::kind::is_shared_gated_kind(event_kind_u32(&stored_event.event)) { let author = stored_event.event.pubkey.to_bytes(); matches .into_iter() @@ -167,7 +167,7 @@ pub async fn filter_fanout_by_access( return true; } // Foreign connection: allowed only if the event is shared. - !is_unshared_persona_event(&stored_event.event, &pk) + !is_unshared_gated_event(&stored_event.event, &pk) }) .collect() } else { diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 12194793ac..201179e6de 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -32,7 +32,7 @@ use buzz_core::kind::{ KIND_STARKNET_WALLET_BINDING, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, - KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; @@ -216,9 +216,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result { - Ok(Scope::UsersWrite) - } + | KIND_TEAM_CATALOG + | super::push_lease::KIND_PUSH_LEASE => Ok(Scope::UsersWrite), // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). KIND_AGENT_TURN_METRIC => Ok(Scope::MessagesWrite), // NIP-56 reports are ordinary member writes into the mod-only queue. @@ -424,10 +423,12 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_AGENT_PROFILE // NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag). | KIND_PERSONA - // NIP-AP: team (30176) + managed-agent (30177) definitions: owner-authored, - // keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. + // NIP-AP: team (30176) + managed-agent (30177) definitions and the + // team-catalog projection (30178): owner-authored, keyed by + // (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. | KIND_TEAM | KIND_MANAGED_AGENT + | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT @@ -1034,37 +1035,27 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> { Ok(()) } -/// Validate the envelope of a kind:30175 persona event. +/// Enforce the `shared`-tag shape shared by every kind in +/// [`buzz_core::kind::SHARED_GATED_KINDS`]: at most one `shared` tag, and if +/// present it must be exactly `["shared", "true"]`. /// -/// Enforces: -/// * exactly one `d` tag with a non-empty value matching the slug grammar -/// `^[a-z0-9][a-z0-9_-]{0,63}$`. -/// * at most one `shared` tag; if present, its value must be exactly `"true"`. +/// This ensures no ambiguous heads: either an event has no `shared` tag +/// (author-only) or exactly `["shared", "true"]` (community-readable). Any +/// other value (`"false"`, `"1"`, extra elements, duplicate tags) is rejected +/// at ingest so read-path helpers — including the SQL-level `tags @> +/// '[["shared","true"]]'` containment clause, which would otherwise match a +/// three-element superset — can treat stored events as unambiguously one or the +/// other. /// -/// Without the `d`-tag check, an empty d-tag collapses every persona into the -/// `(pubkey, 30175, "")` slot — last-write-wins data loss. -/// -/// The `shared` tag rule ensures no ambiguous heads: either an event has no -/// `shared` tag (author-only) or exactly `["shared", "true"]` (community- -/// readable). Any other value (`"false"`, `"1"`, extra tags) is rejected at -/// ingest so read-path helpers can treat stored events as unambiguously one or -/// the other. -fn validate_persona_envelope(event: &Event) -> Result<(), String> { - let mut d_tags: Vec<&str> = Vec::new(); +/// `label` names the kind in error messages (e.g. `"persona event"`). +fn validate_shared_tag(event: &Event, label: &str) -> Result<(), String> { let mut shared_count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); - if parts.len() >= 2 && parts[0].as_str() == "d" { - d_tags.push(&parts[1]); - } if !parts.is_empty() && parts[0].as_str() == "shared" { - // Exact shape required: ["shared", "true"] — exactly two elements, - // second element exactly "true". Extra elements are rejected so that - // a three-element tag like ["shared","true","extra"] cannot be stored - // and later misread as shared by the SQL-level visibility clause. if parts.len() != 2 || parts[1].as_str() != "true" { return Err(format!( - "persona event `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})", + "{label} `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})", parts.iter().map(|s| s.as_str()).collect::>() )); } @@ -1073,43 +1064,106 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> { } if shared_count > 1 { return Err(format!( - "persona event must have at most one `shared` tag (got {shared_count})" + "{label} must have at most one `shared` tag (got {shared_count})" )); } + Ok(()) +} + +/// Return the event's single `d` tag value, requiring exactly one tag whose +/// value is non-empty, at most 64 characters, and free of Unicode control +/// characters and whitespace. +/// +/// Without this check an empty `d` tag collapses every event of the kind into +/// the `(pubkey, kind, "")` slot — last-write-wins data loss. The character +/// bound keeps the value usable as a NIP-33 coordinate (`::`) +/// and as a log field: an embedded newline or tab would break line-oriented +/// consumers of both. +/// +/// Tags are counted by their first element alone, so a valueless `["d"]` +/// counts. Skipping it would let `["d"]` plus `["d", "team-1"]` pass the +/// exactly-one rule, and a NIP-33 consumer that reads `["d"]` as an +/// empty-valued first `d` tag would then address the event at `""` where this +/// relay addresses it at `"team-1"`. +/// +/// `label` names the kind in error messages (e.g. `"persona event"`). +fn single_bounded_d_tag<'a>(event: &'a Event, label: &str) -> Result<&'a str, String> { + let d_tags: Vec> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(|name| name.as_str()) == Some("d")) + .then(|| parts.get(1).map(|value| value.as_str())) + }) + .collect(); if d_tags.len() != 1 { return Err(format!( - "persona event must have exactly one `d` tag (got {})", + "{label} must have exactly one `d` tag (got {})", d_tags.len() )); } - let d = d_tags[0]; + let d = d_tags[0].unwrap_or_default(); if d.is_empty() { - return Err("persona event `d` tag must not be empty".to_string()); + return Err(format!("{label} `d` tag must not be empty")); } - // Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$ - if d.len() > 64 { + let char_count = d.chars().count(); + if char_count > 64 { + return Err(format!( + "{label} `d` tag too long ({char_count} chars, max 64)" + )); + } + if d.chars().any(|c| c.is_control() || c.is_whitespace()) { return Err(format!( - "persona event `d` tag too long ({} chars, max 64)", - d.len() + "{label} `d` tag must not contain control characters or whitespace" )); } + Ok(d) +} + +/// Validate the envelope of a kind:30175 persona event. +/// +/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus +/// exactly one `d` tag matching the persona slug grammar +/// `^[a-z0-9][a-z0-9_-]{0,63}$`. +fn validate_persona_envelope(event: &Event) -> Result<(), String> { + const LABEL: &str = "persona event"; + validate_shared_tag(event, LABEL)?; + let d = single_bounded_d_tag(event, LABEL)?; + // Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$ let bytes = d.as_bytes(); if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() { - return Err( - "persona event `d` tag must start with a lowercase letter or digit".to_string(), - ); + return Err(format!( + "{LABEL} `d` tag must start with a lowercase letter or digit" + )); } if !bytes[1..] .iter() .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') { - return Err( - "persona event `d` tag must match [a-z0-9_-] after the first character".to_string(), - ); + return Err(format!( + "{LABEL} `d` tag must match [a-z0-9_-] after the first character" + )); } Ok(()) } +/// Validate the envelope of a kind:30178 team-catalog event. +/// +/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus +/// exactly one non-empty, bounded `d` tag. +/// +/// Deliberately NOT the persona slug grammar: a team's `d` tag is its stable +/// local id, which is either a UUID or a built-in identifier such as +/// `builtin-team:welcome` — the colon is not slug-legal, and rewriting ids to +/// fit would break NIP-33 addressing against the team's own kind:30176 head. +fn validate_team_catalog_envelope(event: &Event) -> Result<(), String> { + const LABEL: &str = "team-catalog event"; + validate_shared_tag(event, LABEL)?; + single_bounded_d_tag(event, LABEL)?; + Ok(()) +} + /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// /// Checks: @@ -2110,6 +2164,11 @@ async fn ingest_event_inner( })?; } + if kind_u32 == KIND_TEAM_CATALOG { + validate_team_catalog_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -3637,6 +3696,24 @@ mod tests { assert!(err.contains("`d` tag"), "got: {err}"); } + #[test] + fn persona_envelope_rejects_valueless_d_tag() { + // A lone ["d"] carries no value; it must fail as a missing value, not + // be skipped as though the event had no `d` tag at all. + let ev = make_persona(&[&["d"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn persona_envelope_rejects_valueless_plus_valued_d_tags() { + // Counting only tags with a value would see one `d` here and accept the + // event, breaking the exactly-one rule. + let ev = make_persona(&[&["d"], &["d", "slug-a"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + #[test] fn persona_envelope_rejects_too_long() { let slug = "a".repeat(65); @@ -3763,6 +3840,151 @@ mod tests { ); } + // ─── team-catalog (30178) envelope tests ───────────────────────────────── + + fn make_team_catalog(tags: &[&[&str]]) -> Event { + make_event_with_tags( + KIND_TEAM_CATALOG, + r#"{"v":1,"name":"Team","members":[]}"#, + tags, + ) + } + + #[test] + fn team_catalog_envelope_accepts_uuid_d_tag() { + let ev = make_team_catalog(&[&["d", "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_accepts_builtin_colon_d_tag() { + // Built-in team ids carry a colon (`builtin-team:welcome`), which the + // persona slug grammar forbids. The catalog `d` tag must accept them so + // a built-in team can be shared under its real local id. + let ev = make_team_catalog(&[&["d", "builtin-team:welcome"]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_accepts_shared_true() { + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_rejects_missing_d_tag() { + let ev = make_team_catalog(&[]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_empty_d_tag() { + // An empty d-tag collapses every team into the (pubkey, 30178, "") slot. + let ev = make_team_catalog(&[&["d", ""]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_duplicate_d_tags() { + let ev = make_team_catalog(&[&["d", "team-1"], &["d", "team-2"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_valueless_d_tag() { + // A lone ["d"] carries no value; it must fail as a missing value, not + // be skipped as though the event had no `d` tag at all. + let ev = make_team_catalog(&[&["d"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_valueless_plus_valued_d_tags() { + // Counting only tags with a value would see one `d` here and accept the + // event. A NIP-33 consumer that reads ["d"] as an empty-valued first + // `d` tag would then address this event at "" where we address it at + // "team-1". + let ev = make_team_catalog(&[&["d"], &["d", "team-1"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_bounds_d_tag_by_chars_not_bytes() { + // 64 multi-byte characters is 192 bytes; the documented bound is + // characters, so this must be accepted. + let d = "é".repeat(64); + assert!(d.len() > 64, "fixture must exceed the bound in bytes"); + let ev = make_team_catalog(&[&["d", &d]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_rejects_too_long_d_tag() { + let d = "a".repeat(65); + let ev = make_team_catalog(&[&["d", &d]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("too long"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_accepts_max_length_d_tag() { + let d = "a".repeat(64); + let ev = make_team_catalog(&[&["d", &d]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_rejects_whitespace_d_tag() { + // A newline in the d-tag would break the NIP-33 coordinate and any + // line-oriented log consumer. + let ev = make_team_catalog(&[&["d", "team\n1"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("control characters"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_shared_false() { + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "false"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("\"true\""), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_shared_three_elements() { + // Same exact-shape rule as personas: a three-element tag would match the + // SQL containment clause `tags @> '[["shared","true"]]'` as a superset. + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true", "extra"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("[\"shared\",\"true\"]"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_duplicate_shared_tags() { + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"], &["shared", "true"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("at most one"), "got: {err}"); + } + + #[test] + fn team_catalog_is_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_TEAM_CATALOG, &dummy).unwrap(), + Scope::UsersWrite, + ); + } + + #[test] + fn team_catalog_is_global_only() { + assert!(is_global_only_kind(KIND_TEAM_CATALOG)); + assert!(!requires_h_channel_scope(KIND_TEAM_CATALOG)); + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 51400452d7..2aed12cd7f 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,8 +7,8 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - is_unshared_persona_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, KIND_PERSONA, P_GATED_KINDS, RESULT_GATED_KINDS, + is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, + KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -22,7 +22,6 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -const MAX_HISTORICAL_LIMIT: i64 = 2_000; const MAX_SUBSCRIPTIONS: usize = 1024; /// Maximum `query_events` calls in flight per multi-filter REQ / bridge query. @@ -290,11 +289,11 @@ pub async fn handle_req( let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); - // Persona visibility pushdown: set reader bytes so query_events appends - // the SQL visibility clause before ORDER/LIMIT, preventing newer private - // personas from starving older shared ones off the page. - if filter_can_match_persona_shared_kinds(filter) { - params.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: set reader bytes so query_events + // appends the SQL visibility clause before ORDER/LIMIT, preventing + // newer private events from starving older shared ones off the page. + if filter_can_match_shared_gated_kinds(filter) { + params.shared_gated_reader = Some(pubkey_bytes.clone()); } (idx, per_filter_channel, params) }) @@ -416,10 +415,24 @@ pub async fn handle_req( ); } -/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE. -/// Search subscriptions are one-shot — no persistent subscription is registered. +/// FTS candidate hits fetched per page. Pages are always full regardless of +/// the requested limit — post-filtering discards an unpredictable share of +/// hits, so the scan fetches candidates in full pages rather than sizing +/// pages to the request. +const SEARCH_PAGE_SIZE: u32 = 100; + /// Maximum FTS pages to fetch per filter (prevents unbounded loops). -const MAX_SEARCH_PAGES: u32 = 10; +/// +/// Derived from the advertised page ceiling rather than fixed: the scan +/// budget is a resource policy — at most one advertised page ceiling's worth +/// of candidates per filter — and deriving it keeps the budget tracking the +/// ceiling if the ceiling ever moves. This bounds candidates *scanned*, not +/// events *emitted*: post-filtering (NIP-01 match, channel access, reader +/// visibility, dedup) can discard any number of candidates, so a result +/// smaller than the requested limit remains possible and is not a NIP-11 +/// violation — `max_limit` promises a clamp on the request, not a count in +/// the response. +const MAX_SEARCH_PAGES: u32 = (buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32).div_ceil(SEARCH_PAGE_SIZE); /// Resolve request-local channel access, repairing a stale cache-negative. /// @@ -501,6 +514,8 @@ pub(crate) fn build_search_channel_scope_filter( }) } +/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE. +/// Search subscriptions are one-shot — no persistent subscription is registered. #[allow(clippy::too_many_arguments)] async fn handle_search_req( sub_id: &str, @@ -535,8 +550,8 @@ async fn handle_search_req( let limit = filter .limit - .map(|l| (l as u32).min(MAX_HISTORICAL_LIMIT as u32)) - .unwrap_or(MAX_HISTORICAL_LIMIT as u32); + .map(|l| (l as u32).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32)) + .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32); if limit == 0 { continue; // NIP-01: limit 0 means "no results from this filter" @@ -583,13 +598,11 @@ async fn handle_search_req( let since = filter.since.map(|s| s.as_secs() as i64); let until = filter.until.map(|u| u.as_secs() as i64); - // Paginate: keep fetching pages until we've emitted `limit` results - // or exhausted the search result set. This ensures post-filtering - // doesn't silently reduce the result count below the requested limit. + // Paginate: keep fetching pages until we've emitted `limit` results or + // exhausted the search result set. Post-filtering discards an unpredictable + // share of each page, so continuing past short yields gives the scan a + // chance — not a guarantee — of filling the requested limit. let mut emitted: u32 = 0; - // Always fetch full pages (100) regardless of limit — post-filtering - // may discard many hits, so we need headroom to fill the requested limit. - let per_page: u32 = 100; for page in 1..=MAX_SEARCH_PAGES { if emitted >= limit { @@ -605,7 +618,7 @@ async fn handle_search_req( since, until, page, - per_page, + per_page: SEARCH_PAGE_SIZE, mode: buzz_search::SearchMode::FullText, }; @@ -617,9 +630,9 @@ async fn handle_search_req( } }; - // A short page is the last page: FTS returns up to `per_page` hits, - // so fewer than that means the result set is exhausted. - let exhausted = search_result.hits.len() < per_page as usize; + // A short page is the last page: FTS returns up to a full page of + // hits, so fewer than that means the result set is exhausted. + let exhausted = search_result.hits.len() < SEARCH_PAGE_SIZE as usize; let page_empty = search_result.hits.is_empty(); let hit_ids: Vec<[u8; 32]> = @@ -878,8 +891,8 @@ fn filter_to_query_params( .and_then(|u| chrono::DateTime::from_timestamp(u.as_secs() as i64, 0)); let limit = filter .limit - .map(|l| (l as i64).min(MAX_HISTORICAL_LIMIT)) - .unwrap_or(MAX_HISTORICAL_LIMIT); + .map(|l| (l as i64).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT)) + .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT); // Push author filter into SQL. Single-author uses the indexed `pubkey` column; // multi-author uses the `authors` IN-list pushdown added in the pure-nostr PR. @@ -1137,19 +1150,20 @@ pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool { }) } -/// Returns `true` if the filter CAN match kind 30175 (persona) — meaning it -/// either has no `kinds` constraint (wildcard) or explicitly includes 30175. +/// Returns `true` if the filter CAN match any kind in [`SHARED_GATED_KINDS`] — +/// meaning it either has no `kinds` constraint (wildcard) or explicitly includes +/// one of them. /// /// Used by the COUNT handler to force the per-event fallback path, which calls -/// `is_unshared_persona_event` on each row. The fast SQL `count_events()` path +/// `is_unshared_gated_event` on each row. The fast SQL `count_events()` path /// has no per-event access check, so it would over-count foreign unshared -/// persona events — leaking the existence of persona activity even without -/// returning content. -pub(crate) fn filter_can_match_persona_shared_kinds(filter: &Filter) -> bool { - filter - .kinds - .as_ref() - .is_none_or(|ks| ks.iter().any(|k| k.as_u16() as u32 == KIND_PERSONA)) +/// events — leaking the existence of private persona/team-catalog activity even +/// without returning content. +pub(crate) fn filter_can_match_shared_gated_kinds(filter: &Filter) -> bool { + filter.kinds.as_ref().is_none_or(|ks| { + ks.iter() + .any(|k| SHARED_GATED_KINDS.contains(&(k.as_u16() as u32))) + }) } /// Returns `true` if the filter CAN match result-gated kinds — meaning it @@ -1208,8 +1222,9 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: /// /// 1. **Author-only kinds** (`AUTHOR_ONLY_KINDS`, e.g. kind 30300/30350): only /// the author may read their own events. -/// 2. **Persona shared-gate** (kind 30175 without `["shared","true"]`): the -/// event is only visible to the author unless explicitly opted into sharing. +/// 2. **Shared-gate** (`SHARED_GATED_KINDS`, e.g. kind 30175/30178 without +/// `["shared","true"]`): the event is only visible to the author unless +/// explicitly opted into sharing. /// 3. **Result-gated kinds** (kind 44200/30622 etc.): `reader_authorized_for_event` /// carries the per-event ownership check. /// @@ -1223,7 +1238,7 @@ pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_byt if is_author_only_event(event, requester_pubkey_bytes) { return false; } - if is_unshared_persona_event(event, requester_pubkey_bytes) { + if is_unshared_gated_event(event, requester_pubkey_bytes) { return false; } let requester_pubkey_hex = hex::encode(requester_pubkey_bytes); @@ -1416,6 +1431,83 @@ mod tests { ) } + /// NIP-11 `limitation.max_limit` as this relay actually advertises it. + fn advertised_max_limit() -> i64 { + crate::nip11::RelayInfo::build( + None, + None, + false, + crate::config::DEFAULT_MAX_FRAME_BYTES, + None, + ) + .limitation + .expect("limitation") + .max_limit + .expect("max_limit") as i64 + } + + #[test] + fn req_filter_limit_clamps_to_advertised_nip11_max_limit() { + let advertised = advertised_max_limit(); + + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + + // A filter asking for more than the relay advertises is clamped down to + // exactly the advertised ceiling — the NIP-11 document is the promise, + // this is the enforcement. + let greedy = filter_to_query_params( + &Filter::new().limit(advertised as usize * 10), + None, + community, + ); + assert_eq!(greedy.limit, Some(advertised)); + + // A filter with no `limit` gets the same ceiling, not something larger. + let unbounded = filter_to_query_params(&Filter::new(), None, community); + assert_eq!(unbounded.limit, Some(advertised)); + + // Neither sets `max_limit`, so `query_events` applies its own default + // clamp. That default must equal the advertised value too, or the + // clamp above would be undone one layer down. + assert_eq!(greedy.max_limit, None); + assert_eq!(unbounded.max_limit, None); + assert_eq!(buzz_db::DEFAULT_MAX_PAGE_LIMIT, advertised); + + // Under-ceiling requests are honored verbatim. + let modest = filter_to_query_params(&Filter::new().limit(10), None, community); + assert_eq!(modest.limit, Some(10)); + } + + /// The NIP-50 search path clamps its emission target to the advertised + /// ceiling like every other REQ, but the number of candidates it will scan + /// is bounded a second time by the page budget. This pins the resource + /// policy: the budget covers exactly one advertised page ceiling's worth of + /// candidates — no less (a ceiling raise must not silently shrink the scan + /// relative to what clients may request) and no hand-tuned spare (the budget + /// must stay derived, not drift back into a magic number). It deliberately + /// does NOT claim search fills the emitted limit — post-filtering can + /// discard any number of candidates. + #[test] + fn search_scan_capacity_covers_advertised_nip11_max_limit() { + let advertised = advertised_max_limit(); + let capacity = i64::from(MAX_SEARCH_PAGES) * i64::from(SEARCH_PAGE_SIZE); + + assert!( + capacity >= advertised, + "NIP-50 scans at most {capacity} candidates ({MAX_SEARCH_PAGES} pages of \ + {SEARCH_PAGE_SIZE}) but NIP-11 advertises {advertised} — the scan budget \ + no longer covers the advertised ceiling" + ); + + // The budget is derived, not hand-tuned: one page under the derived + // count must be insufficient, or the ceiling could rise without the + // page count following it. + assert!( + capacity - i64::from(SEARCH_PAGE_SIZE) < advertised, + "scan budget has a spare page of slack — derive it from the ceiling" + ); + } + #[test] fn count_fallback_fetches_one_extra_candidate() { let mut query = diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index a8e397dd21..2575ddd7ba 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -89,6 +89,11 @@ pub struct RelayLimitation { /// Canonical `RelayLimitation` advertised by this relay. /// +/// `max_limit` is [`buzz_db::DEFAULT_MAX_PAGE_LIMIT`], the same constant the +/// REQ path clamps filter limits to, so the advertised ceiling and the +/// enforced one cannot drift (see +/// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`). +/// /// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers /// unconditionally reject connections that are not in /// `AuthState::Authenticated`. This is independent of the REST API token @@ -103,7 +108,7 @@ fn relay_limitation(max_message_length: usize) -> RelayLimitation { max_message_length: Some(max_message_length as u64), max_subscriptions: Some(1024), max_filters: Some(10), - max_limit: Some(10_000), + max_limit: Some(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32), max_subid_length: Some(256), min_pow_difficulty: None, auth_required: true, diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 043563bc64..ff24212542 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1838,7 +1838,7 @@ pub fn build_unarchive_identity_request( ) } -/// NIP-SW: build a Starknet wallet binding (`kind:30178`, addressable by +/// NIP-SW: build a Starknet wallet binding (`kind:30900`, addressable by /// `(pubkey, kind, chain_id)`). Replaces the caller's prior binding for that /// chain under NIP-01 last-write-wins. /// diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index b3b1f7f6b2..4f37e22e16 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -1324,7 +1324,7 @@ async fn test_persona_http_query_cross_author_gate() { /// /// A foreign authenticated caller counting `{kinds:[30175],authors:[victim]}` /// must count only shared heads — not unshared ones — on both the fast SQL -/// path (prevented by `needs_persona_filtering`) and the fallback path. +/// path (prevented by `needs_shared_gate_filtering`) and the fallback path. #[tokio::test] #[ignore] async fn test_persona_http_count_cross_author_gate() { @@ -1403,7 +1403,7 @@ async fn test_persona_http_count_cross_author_gate() { /// event is returned. /// /// Verifies at `312014d5e`: this test fails there because `query_events` did -/// not have the `persona_reader` SQL clause and the private rows starved the +/// not have the `shared_gated_reader` SQL clause and the private rows starved the /// shared one off the page. #[tokio::test] #[ignore] diff --git a/crates/buzz-test-client/tests/e2e_team_catalog.rs b/crates/buzz-test-client/tests/e2e_team_catalog.rs new file mode 100644 index 0000000000..ce313d1fe9 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_team_catalog.rs @@ -0,0 +1,484 @@ +//! End-to-end tests for kind:30178 team-catalog events (NIP-AP). +//! +//! Kind 30178 is the shareable projection of a team. It joins kind:30175 in +//! `SHARED_GATED_KINDS`, so these tests assert the wire behaviour of that gate +//! at every read chokepoint (REQ, `ids` lookup, COUNT, live fan-out) plus the +//! ingest envelope rules that make the gate sound: +//! - Exactly one non-empty, bounded `d` tag — the team's stable local id, which +//! may contain a colon (`builtin-team:welcome`) unlike a persona slug. +//! - `shared`, if present, is exactly `["shared", "true"]`. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_team_catalog -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::{BuzzTestClient, RelayMessage}; +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; + +const TEAM_CATALOG_KIND: u16 = 30178; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-team-catalog-{name}-{}", uuid::Uuid::new_v4()) +} + +fn catalog_content(name: &str) -> String { + serde_json::json!({ "v": 1, "name": name, "members": [] }).to_string() +} + +/// Build a kind:30178 event, optionally carrying the `["shared","true"]` opt-in. +fn catalog_event(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event { + catalog_event_at(keys, d_tag, shared, Timestamp::now().as_secs()) +} + +/// Same as [`catalog_event`] with an explicit `created_at`, so NIP-33 head +/// ordering is deterministic instead of resolved by event-id tie-break. +fn catalog_event_at(keys: &Keys, d_tag: &str, shared: bool, created_at: u64) -> nostr::Event { + let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()]; + if shared { + tags.push(Tag::parse(["shared", "true"]).unwrap()); + } + EventBuilder::new( + Kind::Custom(TEAM_CATALOG_KIND), + catalog_content("Test Team"), + ) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() +} + +fn author_filter(author: &Keys) -> Filter { + Filter::new() + .kind(Kind::Custom(TEAM_CATALOG_KIND)) + .author(author.public_key()) +} + +fn coordinate_filter(author: &Keys, d_tag: &str) -> Filter { + author_filter(author).custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag]) +} + +fn d_tag_of(event: &nostr::Event) -> Option<&str> { + event.tags.iter().find_map(|t| { + let parts = t.as_slice(); + if parts.first().map(|p| p.as_str()) != Some("d") { + return None; + } + Some(parts.get(1)?.as_str()) + }) +} + +/// The author's own unshared projection round-trips at its NIP-33 coordinate. +/// +/// The `d` tag is a UUID, matching the desktop team id — proof the envelope does +/// NOT apply the persona slug grammar. +#[tokio::test] +#[ignore] +async fn test_team_catalog_publish_and_query_own_unshared() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let event = catalog_event(&keys, &d_tag, false); + let event_id = event.id; + let ok = client.send_event(event).await.expect("send catalog"); + assert!(ok.accepted, "relay rejected catalog event: {}", ok.message); + + let sid = sub_id("own-unshared"); + client + .subscribe(&sid, vec![coordinate_filter(&keys, &d_tag)]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert_eq!(events.len(), 1, "author must see own unshared projection"); + assert_eq!(events[0].id, event_id); + + client.disconnect().await.expect("disconnect"); +} + +/// A built-in team id (`builtin-team:welcome`) is accepted as the `d` tag. +/// +/// The colon is illegal in a persona slug; rewriting the id to fit would break +/// NIP-33 addressing against the team's own kind:30176 head. +#[tokio::test] +#[ignore] +async fn test_team_catalog_accepts_builtin_colon_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = format!("builtin-team:{}", &uuid::Uuid::new_v4().to_string()[..8]); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client + .send_event(catalog_event(&keys, &d_tag, true)) + .await + .expect("send catalog"); + assert!( + ok.accepted, + "relay rejected colon-bearing team id: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Ingest refuses an empty `d` tag: generic NIP-33 storage maps it to the empty +/// coordinate, collapsing every team into one `(pubkey, 30178, "")` slot. +#[tokio::test] +#[ignore] +async fn test_team_catalog_rejects_empty_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client + .send_event(catalog_event(&keys, "", false)) + .await + .expect("send catalog"); + assert!(!ok.accepted, "empty d-tag must be rejected"); + assert!( + ok.message.contains("invalid:"), + "expected an `invalid:` refusal, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Ingest refuses a valueless `["d"]` tag alongside a valued one. Counting only +/// tags that carry a value would see exactly one `d` here and accept the event; +/// a NIP-33 consumer that reads `["d"]` as an empty-valued first `d` tag would +/// then address the event at `""` where this relay addresses it at the team id. +#[tokio::test] +#[ignore] +async fn test_team_catalog_rejects_valueless_plus_valued_d_tags() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + let event = EventBuilder::new( + Kind::Custom(TEAM_CATALOG_KIND), + catalog_content("Two d tags"), + ) + .tags(vec![ + Tag::parse(["d"]).unwrap(), + Tag::parse(["d", d_tag.as_str()]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client.send_event(event).await.expect("send catalog"); + assert!( + !ok.accepted, + "a valueless `d` tag must count toward the exactly-one rule" + ); + assert!( + ok.message.contains("invalid:"), + "expected an `invalid:` refusal, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Ingest refuses a malformed `shared` tag. A three-element tag would satisfy +/// the SQL containment clause `tags @> '[["shared","true"]]'` as a superset +/// while the in-process gate reads it as unshared — the two layers must agree, +/// so such an event can never be stored. +#[tokio::test] +#[ignore] +async fn test_team_catalog_rejects_three_element_shared_tag() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + let event = EventBuilder::new( + Kind::Custom(TEAM_CATALOG_KIND), + catalog_content("Malformed"), + ) + .tags(vec![ + Tag::parse(["d", d_tag.as_str()]).unwrap(), + Tag::parse(["shared", "true", "extra"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client.send_event(event).await.expect("send catalog"); + assert!(!ok.accepted, "three-element shared tag must be rejected"); + assert!( + ok.message.contains("invalid:"), + "expected an `invalid:` refusal, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// REQ historical delivery: a foreign reader receives only shared projections, +/// while the author receives both of their own. +#[tokio::test] +#[ignore] +async fn test_team_catalog_foreign_sees_only_shared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_unshared = format!("priv-{}", uuid::Uuid::new_v4()); + let d_shared = format!("pub-{}", uuid::Uuid::new_v4()); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let shared_event = catalog_event(&author_keys, &d_shared, true); + let shared_id = shared_event.id; + let ok = author + .send_event(catalog_event(&author_keys, &d_unshared, false)) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared ingest rejected: {}", ok.message); + let ok = author.send_event(shared_event).await.expect("send shared"); + assert!(ok.accepted, "shared ingest rejected: {}", ok.message); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fg-all"); + foreign + .subscribe(&sid, vec![author_filter(&author_keys)]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + !events + .iter() + .any(|e| d_tag_of(e) == Some(d_unshared.as_str())), + "foreign reader must NOT see the unshared projection" + ); + assert!( + events.iter().any(|e| e.id == shared_id), + "foreign reader must see the shared projection" + ); + + let sid_author = sub_id("auth-all"); + author + .subscribe(&sid_author, vec![author_filter(&author_keys)]) + .await + .expect("subscribe author"); + let author_events = author + .collect_until_eose(&sid_author, Duration::from_secs(5)) + .await + .expect("collect author"); + assert!( + author_events.len() >= 2, + "author must see both own projections, got {}", + author_events.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// Knowing an event id does NOT grant access: `{ids:[unshared]}` returns nothing +/// to a foreign reader. +#[tokio::test] +#[ignore] +async fn test_team_catalog_ids_lookup_unshared_returns_nothing_to_foreign() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let event = catalog_event(&author_keys, &uuid::Uuid::new_v4().to_string(), false); + let event_id = event.id; + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author.send_event(event).await.expect("send"); + assert!(ok.accepted, "ingest rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("ids-unshared"); + foreign + .subscribe(&sid, vec![Filter::new().id(event_id)]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.is_empty(), + "ids-lookup of an unshared projection must return nothing, got {:?}", + events.iter().map(|e| e.id).collect::>() + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// COUNT must take the per-event fallback for kind:30178 so the aggregate does +/// not leak the existence of unshared projections. +#[tokio::test] +#[ignore] +async fn test_team_catalog_count_excludes_foreign_unshared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author + .send_event(catalog_event( + &author_keys, + &uuid::Uuid::new_v4().to_string(), + false, + )) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + let ok = author + .send_event(catalog_event( + &author_keys, + &uuid::Uuid::new_v4().to_string(), + true, + )) + .await + .expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("count"); + let count_msg = serde_json::json!(["COUNT", sid, author_filter(&author_keys)]); + foreign.send_raw(&count_msg).await.expect("send COUNT"); + + let count = match foreign.recv_event(Duration::from_secs(5)).await { + Ok(RelayMessage::Count { count, .. }) => count, + Ok(RelayMessage::Closed { message, .. }) => panic!("COUNT closed unexpectedly: {message}"), + Ok(other) => panic!("unexpected relay message for COUNT: {other:?}"), + Err(e) => panic!("unexpected error for COUNT: {e}"), + }; + assert_eq!( + count, 1, + "foreign COUNT must see only the shared projection, got {count}" + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// Live fan-out honours the gate, and unsharing (a NIP-33 replacement that drops +/// the `shared` tag) retracts the projection from foreign readers. +#[tokio::test] +#[ignore] +async fn test_team_catalog_live_fanout_and_unshare_retracts() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_tag = uuid::Uuid::new_v4().to_string(); + let now = Timestamp::now().as_secs(); + let (t0, t1, t2) = (now.saturating_sub(2), now.saturating_sub(1), now); + + // Subscribe BEFORE publishing, scoped to this author so parallel tests + // publishing their own 30178s cannot trip the leak assertion. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fanout"); + foreign + .subscribe(&sid, vec![author_filter(&author_keys)]) + .await + .expect("subscribe"); + let _ = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("drain eose"); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + + // Unshared publish must NOT reach the foreign connection. + let ok = author + .send_event(catalog_event_at(&author_keys, &d_tag, false, t0)) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + match foreign.recv_event(Duration::from_millis(750)).await { + Err(buzz_test_client::TestClientError::Timeout) => {} + Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(TEAM_CATALOG_KIND) => { + panic!("unshared projection leaked to foreign live subscription"); + } + Ok(_) => {} + Err(e) => panic!("unexpected error awaiting fan-out: {e}"), + } + + // Shared replacement MUST reach it. + let shared_event = catalog_event_at(&author_keys, &d_tag, true, t1); + let shared_id = shared_event.id; + let ok = author.send_event(shared_event).await.expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + let delivered = loop { + match foreign.recv_event(Duration::from_secs(5)).await { + Ok(RelayMessage::Event { event, .. }) if event.id == shared_id => break true, + Ok(_) => continue, + Err(buzz_test_client::TestClientError::Timeout) => break false, + Err(e) => panic!("unexpected error awaiting shared fan-out: {e}"), + } + }; + assert!( + delivered, + "shared projection must fan out to foreign readers" + ); + + // Unshare: replace at the same coordinate without the tag. Subsequent + // foreign REQs must return nothing. + let ok = author + .send_event(catalog_event_at(&author_keys, &d_tag, false, t2)) + .await + .expect("send unshare"); + assert!(ok.accepted, "unshare rejected: {}", ok.message); + + let sid_post = sub_id("post-unshare"); + foreign + .subscribe(&sid_post, vec![coordinate_filter(&author_keys, &d_tag)]) + .await + .expect("subscribe post"); + let after = foreign + .collect_until_eose(&sid_post, Duration::from_secs(5)) + .await + .expect("collect post"); + assert!( + after.is_empty(), + "unsharing must retract the projection from foreign readers, got {} event(s)", + after.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} diff --git a/crates/buzz-voice/Cargo.toml b/crates/buzz-voice/Cargo.toml new file mode 100644 index 0000000000..3574c3291e --- /dev/null +++ b/crates/buzz-voice/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "buzz-voice" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Reusable local voice primitives for Buzz" + +[dependencies] +ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] } +ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] } +rand = "0.10" +sentencepiece-model = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sherpa-onnx = "1.12" +tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] } diff --git a/crates/buzz-voice/src/lib.rs b/crates/buzz-voice/src/lib.rs new file mode 100644 index 0000000000..a47f9149cc --- /dev/null +++ b/crates/buzz-voice/src/lib.rs @@ -0,0 +1,22 @@ +//! Reusable local voice primitives for Buzz. + +pub mod pocket; + +pub use pocket::{ + april_model_info, load_text_to_speech, load_voice_style, PocketModelInfo, PocketTts, + VoiceStyle, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, +}; + +/// One immutable artifact required by the April Pocket bundle. +/// +/// `filename` is the bundle-relative file name, `sha256` pins its contents, +/// `size_bytes` supports download progress and validation, and `quantized` +/// identifies the INT8 components. +pub type PocketModelArtifact = pocket::PocketModelArtifact; + +/// Language bundle selected from the pinned export. +pub const APRIL_BUNDLE_ID: &str = pocket::APRIL_BUNDLE_ID; +/// Pinned upstream export repository. +pub const APRIL_MODEL_ID: &str = pocket::APRIL_MODEL_ID; +/// Pinned revision containing the April bundle. +pub const APRIL_MODEL_REVISION: &str = pocket::APRIL_MODEL_REVISION; diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs new file mode 100644 index 0000000000..0c6174a8dc --- /dev/null +++ b/crates/buzz-voice/src/pocket.rs @@ -0,0 +1,167 @@ +//! April 2026 Pocket TTS engine for Buzz Desktop. +//! +//! The `english_2026-04` bundle uses SentencePiece tokenization, a learned +//! voice BOS embedding, recurrent FlowLM state, and stateful Mimi decoding. +//! Buzz selects the upstream three-graph INT8 variant while retaining the +//! full-precision Mimi encoder and text conditioner specified by that variant. +//! +//! ## Attribution +//! +//! - Pocket TTS and Mimi: Kyutai, CC-BY-4.0. +//! - ONNX export: KevinAHM/pocket-tts-onnx, CC-BY-4.0. +//! - Reference voice: Kyutai's Mary preset (VCTK p333), CC-BY-4.0. +//! +//! `huddle::models` writes the complete attribution beside the cached bytes. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use sherpa_onnx::Wave; + +#[path = "pocket_april.rs"] +mod pocket_april; +#[path = "pocket_models.rs"] +mod pocket_models; + +use pocket_april::{prepare_april_prompt, AprilPocketTts}; +pub use pocket_models::{ + april_model_info, PocketModelArtifact, PocketModelInfo, APRIL_BUNDLE_ID, APRIL_MODEL_ID, + APRIL_MODEL_REVISION, +}; + +/// Pocket TTS emits 24 kHz mono PCM. +pub const SAMPLE_RATE: u32 = 24_000; + +/// Bundled reference voice name without its extension. +pub const DEFAULT_VOICE: &str = "reference_sample"; + +/// Pocket voice files are reference WAVs. +pub const VOICE_FILE_EXT: &str = "wav"; + +const TTS_NUM_THREADS: usize = 1; + +/// Loaded reference voice samples and their original sample rate. +#[derive(Debug, Clone)] +pub struct VoiceStyle { + samples: Vec, + sample_rate: i32, +} + +/// Load a Pocket reference voice WAV from disk. +pub fn load_voice_style(path: &Path) -> Result { + let path_str = path + .to_str() + .ok_or_else(|| format!("voice path is not valid UTF-8: {}", path.display()))?; + let wave = Wave::read(path_str) + .ok_or_else(|| format!("could not read voice WAV at {}", path.display()))?; + let samples = wave.samples().to_vec(); + if samples.is_empty() { + return Err(format!("voice WAV is empty: {}", path.display())); + } + Ok(VoiceStyle { + samples, + sample_rate: wave.sample_rate(), + }) +} + +/// Resident April INT8 Pocket TTS engine. +pub struct PocketTts { + inner: Mutex, +} + +/// Load Buzz Desktop's pinned April INT8 model. +pub fn load_text_to_speech(model_dir: &str) -> Result { + let dir = PathBuf::from(model_dir); + for artifact in april_model_info().artifacts { + let path = dir.join(artifact.filename); + if !path.is_file() { + return Err(format!( + "incomplete Pocket TTS {} INT8 bundle: missing {}", + APRIL_BUNDLE_ID, + path.display() + )); + } + } + Ok(PocketTts { + inner: Mutex::new(AprilPocketTts::load(&dir, TTS_NUM_THREADS)?), + }) +} + +impl PocketTts { + /// Split text into synthesis units that satisfy the bundle's exact + /// 50-token input limit. + pub fn split_text_into_chunks(&self, text: &str) -> Result, String> { + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(Vec::new()); + }; + self.inner + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())? + .split_prompt(&prepared) + } + + /// Synthesize text with the supplied reference voice. + /// + /// Pocket detects language from text and this model uses one synthesis + /// step, so `_lang` and `_steps` intentionally do not affect output. + pub fn synth_chunk( + &self, + text: &str, + _lang: &str, + style: &VoiceStyle, + _steps: usize, + ) -> Result, String> { + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(Vec::new()); + }; + let mut engine = self + .inner + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())?; + let chunks = engine.split_prompt(&prepared)?; + let mut samples = Vec::new(); + for chunk in chunks { + let prepared = prepare_april_prompt(&chunk) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + samples.extend(engine.synth_chunk(&prepared, style)?); + } + Ok(samples) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_model_is_april_int8_only() { + let info = april_model_info(); + assert_eq!(info.max_token_per_chunk, 50); + assert_eq!(info.sample_rate, SAMPLE_RATE); + assert!(info + .artifacts + .iter() + .any(|artifact| artifact.filename == "flow_lm_main_int8.onnx")); + assert!(!info + .artifacts + .iter() + .any(|artifact| artifact.filename == "flow_lm_main.onnx")); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn production_api_emits_non_silent_april_int8_pcm() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April INT8 model directory"); + let engine = load_text_to_speech(&dir).expect("load April INT8 engine"); + let style = load_voice_style(&Path::new(&dir).join("reference_sample.wav")) + .expect("load reference voice"); + let samples = engine + .synth_chunk("Bright birds begin beside the bay.", "en", &style, 1) + .expect("synthesize through the production API"); + + assert!(!samples.is_empty()); + assert!(samples.iter().all(|sample| sample.is_finite())); + assert!(samples.iter().any(|sample| sample.abs() > 1.0e-6)); + } +} diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs new file mode 100644 index 0000000000..43826df5c9 --- /dev/null +++ b/crates/buzz-voice/src/pocket_april.rs @@ -0,0 +1,940 @@ +//! Native ONNX loader for Pocket TTS `english_2026-04`. +//! +//! The bundle uses SentencePiece, prepends a learned BOS voice embedding, and +//! describes recurrent state tensors in `bundle.json`. This module supplies +//! that frontend and state loop while reusing the ONNX Runtime linked by the +//! Desktop speech stack. + +use std::borrow::Cow; +use std::f32::consts::TAU; +use std::fs; +use std::path::{Path, PathBuf}; + +use ort::session::{Session, SessionInputValue}; +use ort::value::{DynValue, Tensor}; +use rand::{Rng, RngExt}; +use sentencepiece_model::SentencePieceModel; +use serde::Deserialize; +use sherpa_onnx::LinearResampler; +use tokenizers::models::unigram::Unigram; +use tokenizers::pre_tokenizers::metaspace::{Metaspace, PrependScheme}; +use tokenizers::Tokenizer; + +use super::VoiceStyle; + +const FILE_BUNDLE: &str = "bundle.json"; +const FILE_MIMI_ENCODER: &str = "mimi_encoder.onnx"; +const FILE_TEXT_CONDITIONER: &str = "text_conditioner.onnx"; +const FILE_FLOW_MAIN_INT8: &str = "flow_lm_main_int8.onnx"; +const FILE_FLOW_INT8: &str = "flow_lm_flow_int8.onnx"; +const FILE_MIMI_DECODER_INT8: &str = "mimi_decoder_int8.onnx"; + +const MODEL_LANGUAGE: &str = "english_2026-04"; +const DEFAULT_TEMPERATURE: f32 = 0.7; +const EOS_LOGIT_THRESHOLD: f32 = -4.0; +const DECODER_CHUNK_FRAMES: usize = 12; +const TOKENS_PER_SECOND_ESTIMATE: f32 = 3.0; +const GENERATION_SECONDS_PADDING: f32 = 2.0; + +#[derive(Debug, Deserialize)] +struct Bundle { + schema_version: u32, + language: String, + sample_rate: usize, + frame_rate: f32, + samples_per_frame: usize, + latent_dim: usize, + conditioning_dim: usize, + insert_bos_before_voice: bool, + pad_with_spaces_for_short_inputs: bool, + remove_semicolons: bool, + model_recommended_frames_after_eos: Option, + max_token_per_chunk: usize, + tokenizer_file: String, + bos_before_voice_file: String, + flow_lm_state_manifest: Vec, + mimi_state_manifest: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct StateSpec { + input_name: String, + output_name: String, + dtype: StateDtype, + shape: Vec, + fill: StateFill, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum StateDtype { + #[serde(rename = "float32")] + Float32, + #[serde(rename = "int64")] + Int64, + Bool, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum StateFill { + Empty, + Nan, + Ones, + Zeros, +} + +struct StateValue { + spec: StateSpec, + value: DynValue, +} + +struct CachedVoice { + samples_ptr: usize, + samples_len: usize, + sample_rate: i32, + embeddings: Vec, +} + +pub(crate) struct AprilPocketTts { + bundle: Bundle, + tokenizer: Tokenizer, + bos_embedding: Vec, + mimi_encoder: Session, + text_conditioner: Session, + flow_main: Session, + flow: Session, + mimi_decoder: Session, + cached_voice: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AprilPreparedPrompt { + pub(crate) text: String, + pub(crate) frames_after_eos: usize, +} + +pub(crate) fn prepare_april_prompt(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + + let mut cleaned = String::with_capacity(trimmed.len()); + let mut last_was_space = false; + for ch in trimmed.chars() { + if ch.is_whitespace() { + if !last_was_space { + cleaned.push(' '); + } + last_was_space = true; + } else { + cleaned.push(ch); + last_was_space = false; + } + } + + let first = cleaned.chars().next().expect("cleaned non-empty above"); + if first.is_lowercase() { + let upper: String = first.to_uppercase().collect(); + let mut iter = cleaned.chars(); + iter.next(); + cleaned = upper + iter.as_str(); + } + + let last = cleaned + .chars() + .next_back() + .expect("cleaned non-empty above"); + if last.is_alphanumeric() { + cleaned.push('.'); + } + + let word_count = cleaned.split_whitespace().count(); + Some(AprilPreparedPrompt { + text: cleaned, + // Mirror the bundle's upstream heuristic: three generated frames plus + // two trailing frames for short prompts, one plus two otherwise. + frames_after_eos: if word_count <= 4 { 5 } else { 3 }, + }) +} + +impl AprilPocketTts { + pub(crate) fn load(dir: &Path, num_threads: usize) -> Result { + if num_threads == 0 { + return Err("Pocket TTS num_threads must be at least 1".to_string()); + } + let bundle_path = dir.join(FILE_BUNDLE); + let bundle: Bundle = serde_json::from_slice( + &fs::read(&bundle_path) + .map_err(|err| format!("read {}: {err}", bundle_path.display()))?, + ) + .map_err(|err| format!("parse {}: {err}", bundle_path.display()))?; + + if bundle.schema_version != 2 { + return Err(format!( + "unsupported Pocket TTS bundle schema {} in {}", + bundle.schema_version, + bundle_path.display() + )); + } + if bundle.language != MODEL_LANGUAGE { + return Err(format!( + "expected Pocket TTS language {MODEL_LANGUAGE}, got {}", + bundle.language + )); + } + if bundle.sample_rate != 24_000 + || bundle.frame_rate != 12.5 + || bundle.samples_per_frame != 1_920 + || bundle.latent_dim != 32 + || bundle.conditioning_dim != 1024 + { + return Err(format!( + "unexpected Pocket TTS dimensions: sample_rate={}, frame_rate={}, samples_per_frame={}, latent_dim={}, conditioning_dim={}", + bundle.sample_rate, + bundle.frame_rate, + bundle.samples_per_frame, + bundle.latent_dim, + bundle.conditioning_dim + )); + } + if !bundle.insert_bos_before_voice { + return Err("April Pocket TTS bundle must insert BOS before voice".to_string()); + } + if bundle.pad_with_spaces_for_short_inputs + || bundle.remove_semicolons + || bundle.model_recommended_frames_after_eos.is_some() + || bundle.max_token_per_chunk != 50 + { + return Err("unsupported April Pocket TTS prompt-policy metadata".to_string()); + } + + let tokenizer_path = dir.join(&bundle.tokenizer_file); + let tokenizer = load_tokenizer(&tokenizer_path)?; + let bos_path = dir.join(&bundle.bos_before_voice_file); + let bos_embedding = read_npy_f32(&bos_path)?; + if bos_embedding.len() != bundle.conditioning_dim { + return Err(format!( + "{} has {} values; expected {}", + bos_path.display(), + bos_embedding.len(), + bundle.conditioning_dim + )); + } + + let flow_main = FILE_FLOW_MAIN_INT8; + let flow = FILE_FLOW_INT8; + let mimi_decoder = FILE_MIMI_DECODER_INT8; + + Ok(Self { + // The INT8 layout quantizes only the three generation graphs; + // voice encoding and text conditioning remain full precision. + mimi_encoder: load_session(dir.join(FILE_MIMI_ENCODER), num_threads)?, + text_conditioner: load_session(dir.join(FILE_TEXT_CONDITIONER), num_threads)?, + flow_main: load_session(dir.join(flow_main), num_threads)?, + flow: load_session(dir.join(flow), num_threads)?, + mimi_decoder: load_session(dir.join(mimi_decoder), num_threads)?, + bundle, + tokenizer, + bos_embedding, + cached_voice: None, + }) + } + + pub(crate) fn split_prompt( + &self, + prepared: &AprilPreparedPrompt, + ) -> Result, String> { + if self.token_count(&prepared.text)? <= self.bundle.max_token_per_chunk { + return Ok(vec![prepared.text.clone()]); + } + + let mut chunks = Vec::new(); + let mut current = String::new(); + for word in prepared.text.split_whitespace() { + let candidate = if current.is_empty() { + word.to_string() + } else { + format!("{current} {word}") + }; + if self.prepared_token_count(&candidate)? <= self.bundle.max_token_per_chunk { + current = candidate; + continue; + } + if !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + } + + if self.prepared_token_count(word)? <= self.bundle.max_token_per_chunk { + current = word.to_string(); + continue; + } + + let mut fragment = String::new(); + for ch in word.chars() { + let candidate = format!("{fragment}{ch}"); + if !fragment.is_empty() + && self.prepared_token_count(&candidate)? > self.bundle.max_token_per_chunk + { + chunks.push(std::mem::take(&mut fragment)); + } + fragment.push(ch); + } + current = fragment; + } + if !current.is_empty() { + chunks.push(current); + } + + chunks + .into_iter() + .map(|text| { + let chunk = prepare_april_prompt(&text) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + let token_count = self.token_count(&chunk.text)?; + if token_count > self.bundle.max_token_per_chunk { + return Err(format!( + "Pocket TTS prompt chunk has {token_count} tokens; maximum is {}", + self.bundle.max_token_per_chunk + )); + } + Ok(chunk.text) + }) + .collect() + } + + pub(crate) fn synth_chunk( + &mut self, + prepared: &AprilPreparedPrompt, + style: &VoiceStyle, + ) -> Result, String> { + let voice_embeddings = self.voice_embeddings(style)?; + let mut flow_state = self.condition_voice(&voice_embeddings)?; + let token_ids = self + .tokenizer + .encode(prepared.text.as_str(), false) + .map_err(|err| format!("tokenize Pocket TTS prompt: {err}"))? + .get_ids() + .iter() + .copied() + .map(i64::from) + .collect::>(); + if token_ids.is_empty() { + return Ok(Vec::new()); + } + if token_ids.len() > self.bundle.max_token_per_chunk { + return Err(format!( + "Pocket TTS prompt has {} tokens; split_text_into_chunks maximum is {}", + token_ids.len(), + self.bundle.max_token_per_chunk + )); + } + + let token_count = token_ids.len(); + let text_embeddings = self.text_embeddings(token_ids)?; + self.run_flow_main_prefix(&text_embeddings, &mut flow_state)?; + let max_frames = estimate_max_frames(token_count, self.bundle.frame_rate); + let latents = + self.generate_latents(max_frames, prepared.frames_after_eos, &mut flow_state)?; + self.decode_latents(&latents) + } + + fn prepared_token_count(&self, text: &str) -> Result { + let prepared = prepare_april_prompt(text) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + self.token_count(&prepared.text) + } + + fn token_count(&self, text: &str) -> Result { + Ok(self + .tokenizer + .encode(text, false) + .map_err(|err| format!("tokenize Pocket TTS prompt: {err}"))? + .get_ids() + .len()) + } + + fn voice_embeddings(&mut self, style: &VoiceStyle) -> Result, String> { + let key = ( + style.samples.as_ptr() as usize, + style.samples.len(), + style.sample_rate, + ); + if let Some(cached) = &self.cached_voice { + if (cached.samples_ptr, cached.samples_len, cached.sample_rate) == key { + return Ok(cached.embeddings.clone()); + } + } + + let samples = if style.sample_rate == self.bundle.sample_rate as i32 { + style.samples.clone() + } else { + LinearResampler::create(style.sample_rate, self.bundle.sample_rate as i32) + .ok_or_else(|| { + format!( + "create Pocket TTS resampler {}Hz -> {}Hz", + style.sample_rate, self.bundle.sample_rate + ) + })? + .resample(&style.samples, true) + }; + let audio = Tensor::from_array(( + vec![1_i64, 1, samples.len() as i64], + samples.into_boxed_slice(), + )) + .map_err(ort_error("create voice audio tensor"))?; + let outputs = self + .mimi_encoder + .run(ort::inputs!["audio" => audio]) + .map_err(ort_error("run Mimi encoder"))?; + let (_, encoded) = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Mimi encoder output"))?; + if !encoded.len().is_multiple_of(self.bundle.conditioning_dim) { + return Err(format!( + "Mimi encoder returned {} values, not divisible by {}", + encoded.len(), + self.bundle.conditioning_dim + )); + } + let mut embeddings = + Vec::with_capacity(self.bos_embedding.len().saturating_add(encoded.len())); + embeddings.extend_from_slice(&self.bos_embedding); + embeddings.extend_from_slice(encoded); + self.cached_voice = Some(CachedVoice { + samples_ptr: key.0, + samples_len: key.1, + sample_rate: key.2, + embeddings: embeddings.clone(), + }); + Ok(embeddings) + } + + fn condition_voice(&mut self, embeddings: &[f32]) -> Result, String> { + let frames = embeddings.len() / self.bundle.conditioning_dim; + let sequence = Tensor::::new( + &ort::memory::Allocator::default(), + [1_i64, 0, self.bundle.latent_dim as i64], + ) + .map_err(ort_error("create empty voice sequence"))?; + let text_embeddings = Tensor::from_array(( + vec![1_i64, frames as i64, self.bundle.conditioning_dim as i64], + embeddings.to_vec().into_boxed_slice(), + )) + .map_err(ort_error("create voice embedding tensor"))?; + let mut state = initialize_state(&self.bundle.flow_lm_state_manifest)?; + let mut inputs = vec![ + (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)), + ( + Cow::Borrowed("text_embeddings"), + SessionInputValue::from(text_embeddings), + ), + ]; + append_state_inputs(&mut inputs, &state); + let mut outputs = self + .flow_main + .run(inputs) + .map_err(ort_error("condition Pocket TTS voice"))?; + replace_state_from_outputs(&mut state, &mut outputs)?; + Ok(state) + } + + fn text_embeddings(&mut self, token_ids: Vec) -> Result, String> { + let tokens = Tensor::from_array(( + vec![1_i64, token_ids.len() as i64], + token_ids.into_boxed_slice(), + )) + .map_err(ort_error("create token tensor"))?; + let outputs = self + .text_conditioner + .run(ort::inputs!["token_ids" => tokens]) + .map_err(ort_error("run text conditioner"))?; + let (_, embeddings) = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract text embeddings"))?; + Ok(embeddings.to_vec()) + } + + fn run_flow_main_prefix( + &mut self, + text_embeddings: &[f32], + state: &mut [StateValue], + ) -> Result<(), String> { + if !text_embeddings + .len() + .is_multiple_of(self.bundle.conditioning_dim) + { + return Err(format!( + "text conditioner returned {} values, not divisible by {}", + text_embeddings.len(), + self.bundle.conditioning_dim + )); + } + let frames = text_embeddings.len() / self.bundle.conditioning_dim; + let sequence = Tensor::::new( + &ort::memory::Allocator::default(), + [1_i64, 0, self.bundle.latent_dim as i64], + ) + .map_err(ort_error("create empty text sequence"))?; + let text_embeddings = Tensor::from_array(( + vec![1_i64, frames as i64, self.bundle.conditioning_dim as i64], + text_embeddings.to_vec().into_boxed_slice(), + )) + .map_err(ort_error("create text embedding tensor"))?; + let mut inputs = vec![ + (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)), + ( + Cow::Borrowed("text_embeddings"), + SessionInputValue::from(text_embeddings), + ), + ]; + append_state_inputs(&mut inputs, state); + let mut outputs = self + .flow_main + .run(inputs) + .map_err(ort_error("prime Pocket TTS text state"))?; + replace_state_from_outputs(state, &mut outputs) + } + + fn generate_latents( + &mut self, + max_frames: usize, + frames_after_eos: usize, + state: &mut [StateValue], + ) -> Result, String> { + let mut current = vec![f32::NAN; self.bundle.latent_dim]; + let mut latents = Vec::with_capacity(max_frames * self.bundle.latent_dim); + let mut eos_step = None; + let mut rng = rand::rng(); + + for step in 0..max_frames { + let sequence = Tensor::from_array(( + vec![1_i64, 1, self.bundle.latent_dim as i64], + current.clone().into_boxed_slice(), + )) + .map_err(ort_error("create latent input"))?; + let text_embeddings = Tensor::::new( + &ort::memory::Allocator::default(), + [1_i64, 0, self.bundle.conditioning_dim as i64], + ) + .map_err(ort_error("create empty text input"))?; + let mut inputs = vec![ + (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)), + ( + Cow::Borrowed("text_embeddings"), + SessionInputValue::from(text_embeddings), + ), + ]; + append_state_inputs(&mut inputs, state); + let mut outputs = self + .flow_main + .run(inputs) + .map_err(ort_error("run Pocket TTS Flow LM"))?; + let conditioning = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Flow LM conditioning"))? + .1 + .to_vec(); + let eos_logit = outputs[1] + .try_extract_tensor::() + .map_err(ort_error("extract Flow LM EOS logit"))? + .1 + .first() + .copied() + .ok_or_else(|| "Flow LM returned empty EOS logit".to_string())?; + replace_state_from_outputs(state, &mut outputs)?; + + if eos_logit > EOS_LOGIT_THRESHOLD && eos_step.is_none() { + eos_step = Some(step); + } + if eos_step.is_some_and(|eos| step >= eos + frames_after_eos) { + break; + } + + let mut noise = + normal_noise(&mut rng, self.bundle.latent_dim, DEFAULT_TEMPERATURE.sqrt()); + let conditioning = Tensor::from_array(( + vec![1_i64, self.bundle.conditioning_dim as i64], + conditioning.into_boxed_slice(), + )) + .map_err(ort_error("create flow conditioning"))?; + let s = Tensor::from_array((vec![1_i64, 1], vec![0.0_f32].into_boxed_slice())) + .map_err(ort_error("create flow start tensor"))?; + let t = Tensor::from_array((vec![1_i64, 1], vec![1.0_f32].into_boxed_slice())) + .map_err(ort_error("create flow end tensor"))?; + let x = Tensor::from_array(( + vec![1_i64, self.bundle.latent_dim as i64], + noise.clone().into_boxed_slice(), + )) + .map_err(ort_error("create flow noise tensor"))?; + let outputs = self + .flow + .run(ort::inputs![ + "c" => conditioning, + "s" => s, + "t" => t, + "x" => x, + ]) + .map_err(ort_error("run Pocket TTS flow"))?; + let flow = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Pocket TTS flow"))? + .1; + if flow.len() != noise.len() { + return Err(format!( + "flow returned {} values; expected {}", + flow.len(), + noise.len() + )); + } + for (sample, delta) in noise.iter_mut().zip(flow) { + *sample += *delta; + } + current.clone_from(&noise); + latents.extend_from_slice(&noise); + } + Ok(latents) + } + + fn decode_latents(&mut self, latents: &[f32]) -> Result, String> { + if latents.is_empty() { + return Ok(Vec::new()); + } + if !latents.len().is_multiple_of(self.bundle.latent_dim) { + return Err(format!( + "latent buffer has {} values, not divisible by {}", + latents.len(), + self.bundle.latent_dim + )); + } + let frame_count = latents.len() / self.bundle.latent_dim; + let mut state = initialize_state(&self.bundle.mimi_state_manifest)?; + let mut audio = Vec::new(); + + for start in (0..frame_count).step_by(DECODER_CHUNK_FRAMES) { + let end = (start + DECODER_CHUNK_FRAMES).min(frame_count); + let values = + latents[start * self.bundle.latent_dim..end * self.bundle.latent_dim].to_vec(); + let latent = Tensor::from_array(( + vec![1_i64, (end - start) as i64, self.bundle.latent_dim as i64], + values.into_boxed_slice(), + )) + .map_err(ort_error("create Mimi latent tensor"))?; + let mut inputs = vec![(Cow::Borrowed("latent"), SessionInputValue::from(latent))]; + append_state_inputs(&mut inputs, &state); + let mut outputs = self + .mimi_decoder + .run(inputs) + .map_err(ort_error("run Mimi decoder"))?; + let samples = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Mimi audio"))? + .1; + audio.extend_from_slice(samples); + replace_state_from_outputs(&mut state, &mut outputs)?; + } + Ok(audio) + } +} + +fn load_session(path: PathBuf, num_threads: usize) -> Result { + if !path.is_file() { + return Err(format!("missing Pocket TTS file: {}", path.display())); + } + Session::builder() + .map_err(ort_error("create ONNX session builder"))? + .with_intra_threads(num_threads) + .map_err(|err| format!("configure ONNX intra-op threads: {err}"))? + .with_inter_threads(1) + .map_err(|err| format!("configure ONNX inter-op threads: {err}"))? + .commit_from_file(&path) + .map_err(|err| format!("load {}: {err}", path.display())) +} + +fn load_tokenizer(path: &Path) -> Result { + let sentencepiece = SentencePieceModel::from_file(path) + .map_err(|err| format!("load {}: {err}", path.display()))?; + let trainer = sentencepiece + .trainer() + .ok_or_else(|| format!("{} has no SentencePiece trainer metadata", path.display()))?; + let normalizer = sentencepiece.normalizer().ok_or_else(|| { + format!( + "{} has no SentencePiece normalizer metadata", + path.display() + ) + })?; + if normalizer.name() != "identity" { + return Err(format!( + "{} uses unsupported SentencePiece normalizer {:?}", + path.display(), + normalizer.name() + )); + } + + let vocab = sentencepiece + .pieces() + .iter() + .map(|piece| (piece.piece().to_owned(), f64::from(piece.score()))) + .collect(); + let mut tokenizer = Tokenizer::new( + Unigram::from( + vocab, + Some(trainer.unk_id() as usize), + trainer.byte_fallback(), + ) + .map_err(|err| format!("construct tokenizer from {}: {err}", path.display()))?, + ); + // SentencePiece's identity normalizer still escapes spaces as U+2581 and + // prepends one marker to the input before unigram segmentation. + tokenizer.with_pre_tokenizer(Some(Metaspace::new('▁', PrependScheme::Always, false))); + Ok(tokenizer) +} + +fn initialize_state(specs: &[StateSpec]) -> Result, String> { + specs + .iter() + .cloned() + .map(|spec| { + let len = shape_len(&spec.shape)?; + let value = match spec.dtype { + StateDtype::Float32 => { + let fill = match spec.fill { + StateFill::Nan => f32::NAN, + StateFill::Empty | StateFill::Zeros => 0.0, + StateFill::Ones => 1.0, + }; + if len == 0 { + Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone()) + .map_err(ort_error("create empty float state tensor"))? + .into_dyn() + } else { + Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice())) + .map_err(ort_error("create float state tensor"))? + .into_dyn() + } + } + StateDtype::Int64 => { + let fill = i64::from(matches!(spec.fill, StateFill::Ones)); + if len == 0 { + Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone()) + .map_err(ort_error("create empty integer state tensor"))? + .into_dyn() + } else { + Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice())) + .map_err(ort_error("create integer state tensor"))? + .into_dyn() + } + } + StateDtype::Bool => { + let fill = matches!(spec.fill, StateFill::Ones); + if len == 0 { + Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone()) + .map_err(ort_error("create empty bool state tensor"))? + .into_dyn() + } else { + Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice())) + .map_err(ort_error("create bool state tensor"))? + .into_dyn() + } + } + }; + Ok(StateValue { spec, value }) + }) + .collect() +} + +fn append_state_inputs<'a>( + inputs: &mut Vec<(Cow<'a, str>, SessionInputValue<'a>)>, + state: &'a [StateValue], +) { + for value in state { + inputs.push(( + Cow::Borrowed(value.spec.input_name.as_str()), + SessionInputValue::from(&value.value), + )); + } +} + +fn replace_state_from_outputs( + state: &mut [StateValue], + outputs: &mut ort::session::SessionOutputs<'_>, +) -> Result<(), String> { + for value in state { + value.value = outputs + .remove(&value.spec.output_name) + .ok_or_else(|| format!("missing state output {}", value.spec.output_name))?; + } + Ok(()) +} + +fn shape_len(shape: &[i64]) -> Result { + shape.iter().try_fold(1_usize, |len, &dim| { + let dim = usize::try_from(dim).map_err(|_| format!("negative state dimension {dim}"))?; + len.checked_mul(dim) + .ok_or_else(|| format!("state shape overflows usize: {shape:?}")) + }) +} + +fn estimate_max_frames(token_count: usize, frame_rate: f32) -> usize { + ((token_count as f32 / TOKENS_PER_SECOND_ESTIMATE + GENERATION_SECONDS_PADDING) * frame_rate) + .ceil() as usize +} + +fn normal_noise(rng: &mut impl Rng, len: usize, std_dev: f32) -> Vec { + let mut out = Vec::with_capacity(len); + while out.len() < len { + let u1 = rng.random::().max(f32::MIN_POSITIVE); + let u2 = rng.random::(); + let radius = (-2.0_f32 * u1.ln()).sqrt() * std_dev; + out.push(radius * (TAU * u2).cos()); + if out.len() < len { + out.push(radius * (TAU * u2).sin()); + } + } + out +} + +fn read_npy_f32(path: &Path) -> Result, String> { + let bytes = fs::read(path).map_err(|err| format!("read {}: {err}", path.display()))?; + if bytes.len() < 10 || &bytes[..6] != b"\x93NUMPY" { + return Err(format!("{} is not a NumPy array", path.display())); + } + let major = bytes[6]; + let header_len_bytes = match major { + 1 => 2, + 2 | 3 => 4, + _ => { + return Err(format!( + "unsupported NumPy version {major} in {}", + path.display() + )) + } + }; + let header_start = 8 + header_len_bytes; + if bytes.len() < header_start { + return Err(format!("truncated NumPy header in {}", path.display())); + } + let header_len = if header_len_bytes == 2 { + u16::from_le_bytes([bytes[8], bytes[9]]) as usize + } else { + u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize + }; + let data_start = header_start + .checked_add(header_len) + .ok_or_else(|| format!("NumPy header overflow in {}", path.display()))?; + if data_start > bytes.len() { + return Err(format!("truncated NumPy data in {}", path.display())); + } + let header = std::str::from_utf8(&bytes[header_start..data_start]) + .map_err(|err| format!("invalid NumPy header in {}: {err}", path.display()))?; + if !(header.contains("'descr': ' impl FnOnce(ort::Error) -> String { + move |err| format!("{context}: {err}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shape_len_supports_empty_state_dimensions() { + assert_eq!(shape_len(&[1, 128, 0]).expect("shape"), 0); + assert_eq!(shape_len(&[2, 1, 8, 1000, 64]).expect("shape"), 1_024_000); + } + + #[test] + fn normal_noise_has_requested_length() { + let mut rng = rand::rng(); + assert_eq!(normal_noise(&mut rng, 1, 1.0).len(), 1); + assert_eq!(normal_noise(&mut rng, 32, 1.0).len(), 32); + } + + #[test] + fn generation_frame_estimate_scales_with_token_count() { + assert_eq!(estimate_max_frames(3, 12.5), 38); + assert_eq!(estimate_max_frames(300, 12.5), 1_275); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn tokenizer_matches_sentencepiece_reference_including_unknown_words() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let tokenizer = + load_tokenizer(&Path::new(&dir).join("tokenizer.model")).expect("load April tokenizer"); + let cases: &[(&str, &[u32])] = &[ + ("Yep.", &[2462, 263]), + ("Hello there.", &[2994, 310, 263]), + ( + "quizzaciously xyzzy.", + &[ + 260, 1157, 1818, 362, 1814, 323, 260, 568, 327, 1818, 327, 263, + ], + ), + ("I'm listening.", &[268, 264, 283, 260, 604, 273, 263]), + ]; + for (text, expected) in cases { + let encoding = tokenizer.encode(*text, false).expect("tokenize"); + assert_eq!(encoding.get_ids(), *expected, "{text}"); + } + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn loader_splits_oversized_prompts_at_bundle_token_limit() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle"); + let text = "This deliberately long sentence repeats ordinary English words so the exact SentencePiece token limit is exercised without relying on punctuation, and it keeps adding more material until the prompt must be divided into multiple independently safe generation chunks before the recurrent state cache can be exhausted."; + let prepared = prepare_april_prompt(text).expect("prepare prompt"); + let chunks = engine.split_prompt(&prepared).expect("split prompt"); + + assert!(chunks.len() > 1); + assert!(chunks.iter().all(|chunk| { + engine.token_count(chunk).expect("tokenize chunk") <= engine.bundle.max_token_per_chunk + })); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn gary_provost_long_sentence_respects_bundle_token_limit() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle"); + let text = "And sometimes, when I am certain the reader is rested, I will engage him with a sentence of considerable length, a sentence that burns with energy and builds with all the impetus of a crescendo, the roll of the drums, the crash of the cymbals–sounds that say listen to this, it is important."; + let prepared = prepare_april_prompt(text).expect("prepare prompt"); + let chunks = engine.split_prompt(&prepared).expect("split long sentence"); + let token_counts: Vec<_> = chunks + .iter() + .map(|chunk| engine.token_count(chunk).expect("count tokens")) + .collect(); + + assert_eq!( + chunks, + [ + "And sometimes, when I am certain the reader is rested, I will engage him with a sentence of considerable length, a sentence that burns with energy and builds with all the.", + "Impetus of a crescendo, the roll of the drums, the crash of the cymbals–sounds that say listen to this, it is important.", + ] + ); + assert_eq!(token_counts, [48, 44]); + } +} diff --git a/crates/buzz-voice/src/pocket_models.rs b/crates/buzz-voice/src/pocket_models.rs new file mode 100644 index 0000000000..ba3f92849c --- /dev/null +++ b/crates/buzz-voice/src/pocket_models.rs @@ -0,0 +1,137 @@ +//! Immutable capabilities for Buzz Desktop's April Pocket TTS bundle. + +/// Pinned upstream export repository. +pub const APRIL_MODEL_ID: &str = "KevinAHM/pocket-tts-onnx"; + +/// Pinned revision containing the `english_2026-04` bundle. +pub const APRIL_MODEL_REVISION: &str = "58a6d00cf13d239b6748cb0769f35c580a8f606c"; + +/// Language bundle selected from the pinned export. +pub const APRIL_BUNDLE_ID: &str = "english_2026-04"; + +/// Maximum input size declared by the April bundle. +pub const APRIL_MAX_TOKEN_PER_CHUNK: usize = 50; + +/// One immutable artifact required by the April INT8 runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PocketModelArtifact { + pub filename: &'static str, + pub sha256: &'static str, + pub size_bytes: u64, + pub quantized: bool, +} + +/// Capabilities of Buzz Desktop's sole Pocket model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PocketModelInfo { + /// Language bundle selected from the pinned export. + pub bundle_id: &'static str, + /// Upstream model repository. + pub source_model_id: &'static str, + /// Pinned upstream model revision. + pub revision: &'static str, + /// PCM output sample rate. + pub sample_rate: u32, + /// Maximum input size declared by the bundle. + pub max_token_per_chunk: usize, + /// Immutable files required by the runtime. + pub artifacts: &'static [PocketModelArtifact], + /// Components quantized in the selected bundle. + pub quantized_components: &'static [&'static str], +} + +const INT8_ARTIFACTS: [PocketModelArtifact; 8] = [ + PocketModelArtifact { + filename: "bundle.json", + sha256: "bab643150f437f37df080a710520ff39ed9ebd9a339f8ebdc739f7eddfc28b3f", + size_bytes: 24_381, + quantized: false, + }, + PocketModelArtifact { + filename: "bos_before_voice.npy", + sha256: "f46edf4f7007b7ba4ea58831f49d003e59e167b4641c44bb3addfe9231a780b1", + size_bytes: 4_224, + quantized: false, + }, + PocketModelArtifact { + filename: "tokenizer.model", + sha256: "d461765ae179566678c93091c5fa6f2984c31bbe990bf1aa62d92c64d91bc3f6", + size_bytes: 59_339, + quantized: false, + }, + PocketModelArtifact { + filename: "flow_lm_main_int8.onnx", + sha256: "f9bd8106b79a0192c1c43399ab938fb24900a95c1c599870d75a884e99000116", + size_bytes: 76_341_079, + quantized: true, + }, + PocketModelArtifact { + filename: "flow_lm_flow_int8.onnx", + sha256: "3dd781ee5abee9e195320bf0106bebd6372a852b3b36352524ee78b40554635d", + size_bytes: 9_962_530, + quantized: true, + }, + PocketModelArtifact { + filename: "mimi_decoder_int8.onnx", + sha256: "3630450a3297a101792a6ac66619ebc70ab916b265e6220c2afaef8b1673f925", + size_bytes: 22_684_077, + quantized: true, + }, + PocketModelArtifact { + filename: "mimi_encoder.onnx", + sha256: "853e2ca623b8782d94c3745ec6133bfdff7ce33d9b11128bd29ea03f28d76e3d", + size_bytes: 39_768_446, + quantized: false, + }, + PocketModelArtifact { + filename: "text_conditioner.onnx", + sha256: "4ecee995fb69f85c7a7493d11f7b5ee15d9950facc7ab3f5c9c49ef1e03847bb", + size_bytes: 16_388_344, + quantized: false, + }, +]; + +const INT8_COMPONENTS: [&str; 3] = ["flow_lm_main", "flow_lm_flow", "mimi_decoder"]; + +/// Return immutable metadata for Buzz Desktop's April INT8 model. +pub const fn april_model_info() -> PocketModelInfo { + PocketModelInfo { + bundle_id: APRIL_BUNDLE_ID, + source_model_id: APRIL_MODEL_ID, + revision: APRIL_MODEL_REVISION, + sample_rate: 24_000, + max_token_per_chunk: APRIL_MAX_TOKEN_PER_CHUNK, + artifacts: &INT8_ARTIFACTS, + quantized_components: &INT8_COMPONENTS, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_matches_pinned_int8_layout() { + let info = april_model_info(); + assert_eq!(info.artifacts.len(), 8); + assert_eq!( + info.quantized_components, + ["flow_lm_main", "flow_lm_flow", "mimi_decoder"] + ); + assert_eq!( + info.artifacts + .iter() + .map(|artifact| artifact.size_bytes) + .sum::(), + 165_232_420 + ); + assert!(info + .artifacts + .iter() + .any(|artifact| { artifact.filename == "mimi_encoder.onnx" && !artifact.quantized })); + assert!(!info + .artifacts + .iter() + .any(|artifact| artifact.filename == "mimi_encoder_int8.onnx")); + } +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b86406d9b0..7ce7f48389 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -49,6 +49,7 @@ export default defineConfig({ "**/activity-scope-label-screenshots.spec.ts", "**/welcome-agent-modal-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", + "**/voice-settings.spec.ts", "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", "**/edit-agent.spec.ts", @@ -95,6 +96,7 @@ export default defineConfig({ "**/cold-switch-longtask.perf.ts", "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", + "**/empty-edit-delete.spec.ts", "**/reaction-order.spec.ts", "**/reaction-names.spec.ts", "**/inbox-reactions.spec.ts", @@ -130,6 +132,7 @@ export default defineConfig({ "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", + "**/huddle-transcription.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/public/harness-logos/CREDITS.md b/desktop/public/harness-logos/CREDITS.md index de4e003c3f..716c43e1ae 100644 --- a/desktop/public/harness-logos/CREDITS.md +++ b/desktop/public/harness-logos/CREDITS.md @@ -9,6 +9,7 @@ license permits redistribution. | File | Upstream | Commit | License | Source path | Modifications | |---|---|---|---|---|---| +| `devin.svg` | [Cognition Devin documentation](https://docs.devin.ai/cli) | Retrieved 2026-07-27 | Cognition trademark; nominative use to identify the Devin harness | Official documentation `logo/favicon.svg` | Added the official black mark to a white square canvas so it remains legible in both app themes | | `hermes.png` | [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) | `6ad632b` | MIT © 2025 Nous Research | `website/static/img/logo.png` | Cropped the baked-in border frame, padded to square, resized to 64×64, quantised to a 16-colour palette | | `openclaw.svg` | [openclaw/openclaw](https://github.com/openclaw/openclaw) | `b06f40a` | MIT © 2026 OpenClaw Foundation | `ui/public/favicon.svg` | Removed the SMIL animation elements (renders the upstream rest pose statically — verified pixel-identical to the upstream frame at t=0); minified paths | | `omp.svg` | [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) | `667111575ebba136dadfd6989379e7f67e0d40d9` | MIT © 2025 Mario Zechner; © 2025–2026 Can Bölük | `assets/icon.svg` | None | diff --git a/desktop/public/harness-logos/devin.svg b/desktop/public/harness-logos/devin.svg new file mode 100644 index 0000000000..e760797d68 --- /dev/null +++ b/desktop/public/harness-logos/devin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index 95e56fb282..d65db13545 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -18,12 +18,10 @@ const rules = [ // Non-display uses: array windows over pubkey lists, color/initials // derivation where the value is never presented as an identity. const overrides = new Set([ - // ProfileAvatar fallback label — decorative glyphs inside an avatar disc. - "src/features/huddle/components/ParticipantList.tsx:92", // HexAvatar: 6-char badge + hue derivation inside a color-coded disc, // clearly decorative (paired with a full truncatePubkey aria-label). - "src/features/huddle/components/ParticipantList.tsx:143", - "src/features/huddle/components/ParticipantList.tsx:144", + "src/features/huddle/components/ParticipantList.tsx:150", + "src/features/huddle/components/ParticipantList.tsx:151", // clientId (not a pubkey) sliced in a debug log next to the real thing. "src/features/channels/readState/readStateManager.ts:338", // Array windows (first N pubkeys), not string truncation. diff --git a/desktop/scripts/texture-card/generate-card-texture.mjs b/desktop/scripts/texture-card/generate-card-texture.mjs index 75cc24e744..57ebc61a9b 100644 --- a/desktop/scripts/texture-card/generate-card-texture.mjs +++ b/desktop/scripts/texture-card/generate-card-texture.mjs @@ -12,83 +12,116 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); -const OUTPUT = path.resolve( - HERE, - "../../src/shared/ui/assets/card-texture.png", -); - -// CSS-pixel source geometry. Screenshotting at DPR 2 produces a crisp asset. -const CARD_SIZE = 640; -const OUTSET = 96; -const CAPTURE_SIZE = CARD_SIZE + OUTSET * 2; +const OUTPUT_DIRECTORY = path.resolve(HERE, "../../src/shared/ui/assets"); const DPR = 2; // Approved texture parameters, archived from the former runtime SVG filter. -const BLUR = 66; -const DILATE = Math.round(BLUR * 0.85); const THRESHOLD_BIAS = 0.302; const SLOPE = 8; const FREQUENCY = 0.999; const OCTAVES = 3; const SEED = 5315; -await mkdir(path.dirname(OUTPUT), { recursive: true }); +const TEXTURES = [ + { + filename: "card-texture.png", + color: "white", + cardSize: 640, + outset: 96, + blur: 66, + innerBand: 112, + }, + { + filename: "card-texture-dark.png", + color: "#171b21", + cardSize: 640, + outset: 96, + blur: 66, + innerBand: 112, + }, + { + filename: "card-texture-compact.png", + color: "white", + cardSize: 320, + outset: 24, + blur: 24, + innerBand: 44, + }, + { + filename: "card-texture-dark-compact.png", + color: "#171b21", + cardSize: 320, + outset: 24, + blur: 24, + innerBand: 44, + }, +]; + +await mkdir(OUTPUT_DIRECTORY, { recursive: true }); const browser = await chromium.launch(); try { - const page = await browser.newPage({ - deviceScaleFactor: DPR, - viewport: { height: CAPTURE_SIZE, width: CAPTURE_SIZE }, - }); + for (const texture of TEXTURES) { + const captureSize = texture.cardSize + texture.outset * 2; + const dilate = Math.round(texture.blur * 0.85); + const output = path.join(OUTPUT_DIRECTORY, texture.filename); + const page = await browser.newPage({ + deviceScaleFactor: DPR, + viewport: { height: captureSize, width: captureSize }, + }); - await page.setContent(` - -
- - -
`); + await page.setContent(` + +
+ + +
`); - await page.locator("#stage").screenshot({ - omitBackground: true, - path: OUTPUT, - }); + await page.locator("#stage").screenshot({ + omitBackground: true, + path: output, + }); + await page.close(); + + console.log(`Generated ${output}`); + console.log(`Asset: ${captureSize * DPR}×${captureSize * DPR}px @${DPR}x`); + console.log( + `Runtime slice: ${(texture.outset + texture.innerBand) * DPR}px; outset: ${texture.outset}px`, + ); + } } finally { await browser.close(); } - -console.log(`Generated ${OUTPUT}`); -console.log(`Asset: ${CAPTURE_SIZE * DPR}×${CAPTURE_SIZE * DPR}px @${DPR}x`); -console.log(`Runtime slice: ${(OUTSET + 112) * DPR}px; outset: ${OUTSET}px`); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 1b94bb73d8..6ff15d0be3 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -84,6 +84,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -707,6 +708,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -731,6 +738,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bip39" version = "2.2.2" @@ -1039,6 +1052,7 @@ dependencies = [ "buzz-media", "buzz-persona", "buzz-sdk", + "buzz-voice", "bytes", "bzip2 0.6.1", "chrono", @@ -1163,6 +1177,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-voice" +version = "0.1.0" +dependencies = [ + "ort", + "ort-sys", + "rand 0.10.2", + "sentencepiece-model", + "serde", + "serde_json", + "sherpa-onnx", + "tokenizers", +] + [[package]] name = "by_address" version = "1.2.1" @@ -1565,6 +1593,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -1816,6 +1845,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -2151,6 +2190,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -2648,6 +2696,12 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "euclid" version = "0.22.14" @@ -2706,6 +2760,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -4814,6 +4879,39 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.118", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "loom" version = "0.7.2" @@ -4923,6 +5021,22 @@ dependencies = [ "libc", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "markup5ever" version = "0.38.0" @@ -4949,6 +5063,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -5048,7 +5172,7 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "rustls", "serde", @@ -5186,7 +5310,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "regex-lite", "reqwest 0.12.28", @@ -5275,8 +5399,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "prost", - "prost-build", + "prost 0.14.4", + "prost-build 0.14.4", "protoc-bin-vendored", "rmcp", "schemars 1.2.1", @@ -5312,7 +5436,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.4", "serde_json", "sha2 0.10.9", ] @@ -5427,6 +5551,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if 1.0.4", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "mime" version = "0.3.17" @@ -5572,6 +5718,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "more-asserts" version = "0.3.1" @@ -5699,6 +5867,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -6200,7 +6383,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -6680,7 +6863,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -6695,7 +6878,7 @@ dependencies = [ "const-hex", "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "serde", "serde_json", "tonic", @@ -6761,6 +6944,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "os_pipe" version = "1.2.3" @@ -6988,6 +7189,16 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -7252,6 +7463,15 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.1" @@ -7462,6 +7682,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.4" @@ -7469,7 +7699,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.4", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.118", + "tempfile", ] [[package]] @@ -7482,15 +7732,28 @@ dependencies = [ "itertools", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.4", + "prost-types 0.14.4", "regex", "syn 2.0.118", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "prost-derive" version = "0.14.4" @@ -7504,13 +7767,35 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost", + "prost 0.14.4", ] [[package]] @@ -7577,6 +7862,33 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost 0.13.5", + "prost-reflect", + "prost-types 0.13.5", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types 0.13.5", + "thiserror 1.0.69", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -7825,7 +8137,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7888,7 +8200,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7897,6 +8209,43 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "realfft" version = "3.5.0" @@ -8719,6 +9068,18 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sentencepiece-model" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec" +dependencies = [ + "miette", + "prost 0.13.5", + "prost-build 0.13.5", + "protox", +] + [[package]] name = "serde" version = "1.0.228" @@ -9172,8 +9533,8 @@ name = "skippy-protocol" version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ - "prost", - "prost-build", + "prost 0.14.4", + "prost-build 0.14.4", "protoc-bin-vendored", "serde", ] @@ -9353,6 +9714,18 @@ dependencies = [ "der", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + [[package]] name = "sse-stream" version = "0.2.4" @@ -9814,7 +10187,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -10389,7 +10762,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "bitflags 2.13.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset 0.4.2", @@ -10552,6 +10925,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str 0.9.1", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex 0.14.0", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -10885,7 +11291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.4", "tonic", ] @@ -11066,7 +11472,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", "nom 8.0.0", - "petgraph", + "petgraph 0.8.3", ] [[package]] @@ -11237,6 +11643,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -11251,9 +11666,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -11266,6 +11687,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6f3c03c5a5..39aaf0dead 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -98,6 +98,7 @@ buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } +buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } iroh = { version = "1.0.2", optional = true } mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } diff --git a/desktop/src-tauri/examples/pocket_bench.rs b/desktop/src-tauri/examples/pocket_bench.rs deleted file mode 100644 index b4f5635a95..0000000000 --- a/desktop/src-tauri/examples/pocket_bench.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Cold-vs-warm latency bench for Pocket TTS. -//! -//! This duplicates the small config-building snippet from `huddle::pocket` so it -//! doesn't depend on changing module visibility for a one-off dev tool. -//! Keep in sync with `huddle::pocket::load_text_to_speech`. -//! -//! Run with the model files in a directory (defaults to /tmp/pocket-tts-bench): -//! cargo run --release --example pocket_bench -//! cargo run --release --example pocket_bench /path/to/pocket-tts - -use std::path::PathBuf; -use std::time::Instant; - -use sherpa_onnx::{ - self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig, - OfflineTtsPocketModelConfig, Wave, -}; - -const SAMPLE_RATE: u32 = 24_000; -const TEST_TEXT: &str = - "Hello, this is a test of the new Pocket TTS engine running on sherpa-onnx."; - -fn main() { - let model_dir = std::env::args() - .nth(1) - .unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string()); - println!("Model dir: {model_dir}"); - - let dir = PathBuf::from(&model_dir); - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - - let t0 = Instant::now(); - let cfg = OfflineTtsConfig { - model: OfflineTtsModelConfig { - pocket: OfflineTtsPocketModelConfig { - lm_main: Some(p("lm_main.int8.onnx")), - lm_flow: Some(p("lm_flow.int8.onnx")), - encoder: Some(p("encoder.onnx")), - decoder: Some(p("decoder.int8.onnx")), - text_conditioner: Some(p("text_conditioner.onnx")), - vocab_json: Some(p("vocab.json")), - token_scores_json: Some(p("token_scores.json")), - voice_embedding_cache_capacity: 16, - }, - num_threads: 1, - debug: false, - ..Default::default() - }, - ..Default::default() - }; - let engine = OfflineTts::create(&cfg).expect("engine create"); - let load_ms = t0.elapsed().as_secs_f32() * 1000.0; - println!("Engine load: {load_ms:.1} ms"); - - let t0 = Instant::now(); - let voice_path = dir.join("reference_sample.wav"); - let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV"); - let samples = wave.samples().to_vec(); - let sr = wave.sample_rate(); - let voice_ms = t0.elapsed().as_secs_f32() * 1000.0; - println!("Voice load: {voice_ms:.1} ms"); - - let gen = || GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE) - reference_audio: Some(samples.clone()), - reference_sample_rate: sr, - ..Default::default() - }; - - let t0 = Instant::now(); - let cold = engine - .generate_with_config(TEST_TEXT, &gen(), None:: bool>) - .expect("cold synth"); - let cold_ms = t0.elapsed().as_secs_f32() * 1000.0; - let cold_audio_ms = (cold.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0; - let cold_rtf_x = cold_audio_ms / cold_ms; - println!( - "Cold synth: {cold_ms:.1} ms → {cold_audio_ms:.1} ms audio → {cold_rtf_x:.2}× realtime" - ); - - let t0 = Instant::now(); - let warm = engine - .generate_with_config(TEST_TEXT, &gen(), None:: bool>) - .expect("warm synth"); - let warm_ms = t0.elapsed().as_secs_f32() * 1000.0; - let warm_audio_ms = (warm.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0; - let warm_rtf_x = warm_audio_ms / warm_ms; - println!( - "Warm synth: {warm_ms:.1} ms → {warm_audio_ms:.1} ms audio → {warm_rtf_x:.2}× realtime" - ); - - let out_path = "/tmp/pocket_bench_out.wav"; - let ok = sherpa_onnx::write(out_path, warm.samples(), SAMPLE_RATE as i32); - println!( - "Wrote {} ({} samples, ok={ok})", - out_path, - warm.samples().len() - ); - - let delta_ms = cold_ms - warm_ms; - let delta_pct = (delta_ms / warm_ms) * 100.0; - println!(); - println!("Cold/warm delta: {delta_ms:+.1} ms ({delta_pct:+.1}%)"); - println!( - "Decision: warmup {}.", - if delta_ms > 200.0 { - "RECOMMENDED — significant cold-call penalty" - } else if delta_ms > 50.0 { - "OPTIONAL — small cold-call penalty" - } else { - "UNNECESSARY — cold and warm essentially equal" - } - ); -} diff --git a/desktop/src-tauri/examples/pocket_clip_probe.rs b/desktop/src-tauri/examples/pocket_clip_probe.rs deleted file mode 100644 index ad8657599f..0000000000 --- a/desktop/src-tauri/examples/pocket_clip_probe.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Clipping probe for any fixed playback gain applied after Pocket TTS synth. -//! -//! Synthesises a spread of sentences (short/long, calm/energetic) and reports -//! the raw peak of each, the post-gain peak, and the fraction of samples that -//! would hit a ±1.0 clamp — i.e. how much a fixed gain would flat-top the -//! waveform ("blown out" distortion). -//! -//! History: the production pipeline briefly shipped a fixed 9.3× gain -//! calibrated on a single bench utterance that peaked at 0.076. This probe -//! showed real output peaks at 0.4–0.97, so that gain clipped 13–34% of all -//! samples (the 2026-06-12 "blown out" report). Production now applies no -//! gain — run this probe before reintroducing one. -//! -//! Run with model files in ~/.buzz/models/pocket-tts (override with arg 1): -//! cargo run --release --example pocket_clip_probe - -use std::path::PathBuf; - -use sherpa_onnx::{ - self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig, - OfflineTtsPocketModelConfig, Wave, -}; - -/// Candidate gain under test (the regressed production value). -const GAIN: f32 = 9.3; - -const PROMPTS: &[&str] = &[ - "Hello, this is a test of the new Pocket TTS engine running on sherpa-onnx.", - "Yep, I can hear you.", - "Absolutely! That sounds fantastic, let's do it right now!", - "The quick brown fox jumps over the lazy dog near the riverbank.", - "I found three problems in the code: a race condition, a memory leak, and an off-by-one error in the loop bounds.", - "No.", - "Warning! The build failed because seventeen tests crashed unexpectedly!", - "Sure, I can walk you through the whole pipeline step by step whenever you're ready.", -]; - -fn main() { - let model_dir = std::env::args().nth(1).unwrap_or_else(|| { - dirs::home_dir() - .expect("home dir") - .join(".buzz/models/pocket-tts") - .to_string_lossy() - .into_owned() - }); - eprintln!("Model dir: {model_dir}"); - - let dir = PathBuf::from(&model_dir); - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - - let cfg = OfflineTtsConfig { - model: OfflineTtsModelConfig { - pocket: OfflineTtsPocketModelConfig { - lm_main: Some(p("lm_main.int8.onnx")), - lm_flow: Some(p("lm_flow.int8.onnx")), - encoder: Some(p("encoder.onnx")), - decoder: Some(p("decoder.int8.onnx")), - text_conditioner: Some(p("text_conditioner.onnx")), - vocab_json: Some(p("vocab.json")), - token_scores_json: Some(p("token_scores.json")), - voice_embedding_cache_capacity: 16, - }, - num_threads: 1, - debug: false, - ..Default::default() - }, - ..Default::default() - }; - let engine = OfflineTts::create(&cfg).expect("engine create"); - - let voice_path = dir.join("reference_sample.wav"); - let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV"); - let voice_samples = wave.samples().to_vec(); - let voice_sr = wave.sample_rate(); - - let gen = || GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, - reference_audio: Some(voice_samples.clone()), - reference_sample_rate: voice_sr, - ..Default::default() - }; - - let _ = engine.generate_with_config("warmup.", &gen(), None:: bool>); - - println!( - "{:<46} | {:>8} | {:>9} | {:>9} | {:>10}", - "prompt", "raw peak", "raw RMS", "post-gain", "% clipped" - ); - println!("{}", "-".repeat(95)); - - let mut worst_clip = 0.0f32; - for prompt in PROMPTS { - let out = engine - .generate_with_config(prompt, &gen(), None:: bool>) - .expect("synth"); - let samples = out.samples(); - - let peak = samples.iter().fold(0.0f32, |m, s| m.max(s.abs())); - let rms = (samples.iter().map(|s| s * s).sum::() / samples.len() as f32).sqrt(); - let post = peak * GAIN; - let clipped = samples.iter().filter(|s| s.abs() * GAIN > 1.0).count(); - let clip_pct = 100.0 * clipped as f32 / samples.len() as f32; - worst_clip = worst_clip.max(clip_pct); - - let label: String = prompt.chars().take(44).collect(); - println!("{label:<46} | {peak:>8.4} | {rms:>9.4} | {post:>9.3} | {clip_pct:>9.3}%"); - } - - println!(); - println!( - "Verdict: worst-case clipped fraction {worst_clip:.3}% — {}", - if worst_clip > 0.1 { - "AUDIBLE DISTORTION LIKELY (gain too hot)" - } else if worst_clip > 0.0 { - "marginal — occasional transient clipping" - } else { - "no clipping at this gain" - } - ); -} diff --git a/desktop/src-tauri/examples/pocket_onset_probe.rs b/desktop/src-tauri/examples/pocket_onset_probe.rs deleted file mode 100644 index 05b4d0193c..0000000000 --- a/desktop/src-tauri/examples/pocket_onset_probe.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Onset-attenuation probe for Pocket TTS. -//! -//! Synthesises a handful of short sentences and dumps per-sentence onset -//! statistics (samples[0], 1ms/5ms/20ms peak + RMS) so we can decide whether -//! the production `apply_fades` 8 ms fade-in is masking real audio. -//! -//! Also writes the raw (un-faded, un-normalised) audio of each sentence to -//! /tmp so they can be inspected in Audacity / aplay without rodio in the -//! loop. -//! -//! Run with model files in /tmp/pocket-tts-bench (override with arg 1): -//! cargo run --release --example pocket_onset_probe -//! cargo run --release --example pocket_onset_probe /path/to/pocket-tts - -use std::path::PathBuf; - -use sherpa_onnx::{ - self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig, - OfflineTtsPocketModelConfig, Wave, -}; - -const SAMPLE_RATE: u32 = 24_000; - -/// Test prompts chosen to span different onsets: -/// - palatal glide 'Y' (soft onset) -/// - voiceless fricative 'H' (very soft onset) -/// - labio-velar glide 'W' (medium onset) -/// - voiceless stop 'T' (hard onset) -const PROMPTS: &[&str] = &[ - "Yep, I can hear you.", - "Hello there friend.", - "What can I help with?", - "Try this experiment now.", -]; - -fn main() { - let model_dir = std::env::args() - .nth(1) - .unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string()); - eprintln!("Model dir: {model_dir}"); - - let dir = PathBuf::from(&model_dir); - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - - let cfg = OfflineTtsConfig { - model: OfflineTtsModelConfig { - pocket: OfflineTtsPocketModelConfig { - lm_main: Some(p("lm_main.int8.onnx")), - lm_flow: Some(p("lm_flow.int8.onnx")), - encoder: Some(p("encoder.onnx")), - decoder: Some(p("decoder.int8.onnx")), - text_conditioner: Some(p("text_conditioner.onnx")), - vocab_json: Some(p("vocab.json")), - token_scores_json: Some(p("token_scores.json")), - voice_embedding_cache_capacity: 16, - }, - num_threads: 1, - debug: false, - ..Default::default() - }, - ..Default::default() - }; - let engine = OfflineTts::create(&cfg).expect("engine create"); - - let voice_path = dir.join("reference_sample.wav"); - let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV"); - let voice_samples = wave.samples().to_vec(); - let voice_sr = wave.sample_rate(); - - // Warmup so we're not measuring cold-call jitter. - { - let cfg = GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE) - reference_audio: Some(voice_samples.clone()), - reference_sample_rate: voice_sr, - ..Default::default() - }; - let _ = engine.generate_with_config("warmup.", &cfg, None:: bool>); - } - - println!( - "{:<28} | {:>10} | {:>10} {:>10} | {:>10} {:>10} | {:>10} {:>10}", - "prompt", - "samples[0]", - "peak@1ms", - "rms@1ms", - "peak@5ms", - "rms@5ms", - "peak@20ms", - "rms@20ms" - ); - println!("{}", "-".repeat(120)); - - for prompt in PROMPTS { - // Mirror the production prompt-prep (capitalise + terminal punctuation). - // These prompts already have it, so this is just to match what - // sherpa-onnx sees in production. - let cfg = GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE) - reference_audio: Some(voice_samples.clone()), - reference_sample_rate: voice_sr, - ..Default::default() - }; - let out = engine - .generate_with_config(prompt, &cfg, None:: bool>) - .expect("synth"); - let samples = out.samples(); - - let n_1ms = (SAMPLE_RATE as f32 * 0.001) as usize; - let n_5ms = (SAMPLE_RATE as f32 * 0.005) as usize; - let n_20ms = (SAMPLE_RATE as f32 * 0.020) as usize; - - let stats = |range: &[f32]| -> (f32, f32) { - if range.is_empty() { - return (0.0, 0.0); - } - let peak = range.iter().fold(0.0_f32, |a, &x| a.max(x.abs())); - let sumsq: f32 = range.iter().map(|x| x * x).sum(); - let rms = (sumsq / range.len() as f32).sqrt(); - (peak, rms) - }; - - let first = samples.first().copied().unwrap_or(0.0); - let (p1, r1) = stats(&samples[..n_1ms.min(samples.len())]); - let (p5, r5) = stats(&samples[..n_5ms.min(samples.len())]); - let (p20, r20) = stats(&samples[..n_20ms.min(samples.len())]); - - println!( - "{:<28} | {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6}", - prompt, first, p1, r1, p5, r5, p20, r20 - ); - - let safe: String = prompt - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect(); - let out_path = format!("/tmp/pocket_onset_{}.wav", &safe[..safe.len().min(24)]); - let _ = sherpa_onnx::write(&out_path, samples, SAMPLE_RATE as i32); - eprintln!( - " → wrote {out_path} ({} samples = {:.3} s)", - samples.len(), - samples.len() as f32 / SAMPLE_RATE as f32 - ); - } -} diff --git a/desktop/src-tauri/examples/pocket_quality_ab.rs b/desktop/src-tauri/examples/pocket_quality_ab.rs deleted file mode 100644 index 0c31f1c910..0000000000 --- a/desktop/src-tauri/examples/pocket_quality_ab.rs +++ /dev/null @@ -1,519 +0,0 @@ -//! Reproducible blind Pocket TTS quality corpus generator. -//! -//! Renders Buzz's production prompt preparation and post-processing across: -//! INT8/FP32 × per-sentence/grouped generation. The generated filenames are -//! deterministically blinded; keep `key.json` away from listeners until their -//! scoring sheet is complete. -//! -//! Usage: -//! cargo run --release --example pocket_quality_ab -- \ -//! [--idle-minutes N --only ITEM] -//! -//! The optional idle run intentionally creates one engine per condition, warms -//! all four, sleeps once, and then makes each clip the first generation after -//! dormancy. It requires `--only` because only the first synthesis after an -//! uninterrupted idle is a valid post-idle observation. Run each 5/15-minute -//! item as a separate process. - -// Importing the production module also brings in runtime-only helpers that this -// standalone corpus generator deliberately does not call. -#![allow(dead_code)] - -#[path = "../src/huddle/pocket.rs"] -mod production_pocket; -#[path = "../src/huddle/preprocessing.rs"] -mod production_preprocessing; - -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use serde::Serialize; -use sha2::{Digest, Sha256}; -use sherpa_onnx::{GenerationConfig, OfflineTts, OfflineTtsConfig, Wave}; - -use production_pocket::{prepare_pocket_prompt, SAMPLE_RATE}; -use production_preprocessing::{preprocess_for_tts, split_sentences}; - -const NUM_STEPS: i32 = 1; -const SILENCE_SCALE: f32 = 1.0; -const INTER_SENTENCE_SILENCE_SAMPLES: usize = SAMPLE_RATE as usize / 10; -const LEAD_IN_SAMPLES: usize = SAMPLE_RATE as usize / 50; -const FADE_OUT_SAMPLES: usize = SAMPLE_RATE as usize * 8 / 1000; -const TARGET_RMS_DBFS: f32 = -23.0; -const BLINDING_SEED: &str = "pocket-quality-2026-07-21-v1"; - -const CORPUS: &[CorpusItem] = &[ - CorpusItem { id: "short_one_word", kind: "short", text: "Yep." }, - CorpusItem { id: "short_four_words", kind: "short", text: "Sounds good to me." }, - CorpusItem { - id: "multi_relay_review", - kind: "multi-sentence", - text: "I looked at the relay code this morning. The lease logic is solid. There's one race in the worker claim path, though. I'll write it up and send you a patch.", - }, - CorpusItem { - id: "multi_community_size", - kind: "multi-sentence", - text: "Great question. The answer is it depends on the community size. For small ones, keep it simple.", - }, - CorpusItem { - id: "mixed_agent_message", - kind: "mixed", - text: "That's 42 open PRs right now — mostly small. I'll triage them after lunch.", - }, -]; - -#[derive(Clone, Copy)] -struct CorpusItem { - id: &'static str, - kind: &'static str, - text: &'static str, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -enum Precision { - Int8, - Fp32, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -enum Chunking { - PerSentence, - Grouped, -} - -#[derive(Clone, Copy, Debug)] -struct Condition { - precision: Precision, - chunking: Chunking, -} - -const CONDITIONS: [Condition; 4] = [ - Condition { - precision: Precision::Int8, - chunking: Chunking::PerSentence, - }, - Condition { - precision: Precision::Int8, - chunking: Chunking::Grouped, - }, - Condition { - precision: Precision::Fp32, - chunking: Chunking::PerSentence, - }, - Condition { - precision: Precision::Fp32, - chunking: Chunking::Grouped, - }, -]; - -#[derive(Serialize)] -struct KeyFile { - warning: &'static str, - blinding_seed: &'static str, - target_rms_dbfs: f32, - items: Vec, -} - -#[derive(Serialize)] -struct KeyItem { - id: String, - kind: String, - text: String, - clips: Vec, -} - -#[derive(Serialize)] -struct KeyClip { - file: String, - precision: Precision, - chunking: Chunking, - cold_start: bool, - idle_minutes: Option, - synthesis_ms: u128, - audio_seconds: f32, -} - -struct Voice { - samples: Vec, - sample_rate: i32, -} - -struct Engine { - inner: OfflineTts, - voice: Voice, -} - -fn main() -> Result<(), String> { - let mut args = std::env::args().skip(1); - let int8_dir = required_path(args.next(), "INT8 model directory")?; - let fp32_dir = required_path(args.next(), "FP32 model directory")?; - let output_dir = required_path(args.next(), "output directory")?; - let mut idle_minutes = None; - let mut only_item = None; - while let Some(arg) = args.next() { - match arg.as_str() { - "--idle-minutes" => { - idle_minutes = Some( - args.next() - .ok_or("--idle-minutes requires a value")? - .parse::() - .map_err(|e| format!("invalid idle minutes: {e}"))?, - ); - } - "--only" => only_item = Some(args.next().ok_or("--only requires an item ID")?), - _ => return Err(format!("unknown argument: {arg}")), - } - } - - if idle_minutes.is_some() && only_item.is_none() { - return Err("--idle-minutes requires --only so every clip is first-after-idle".into()); - } - if let Some(ref requested) = only_item { - if !CORPUS.iter().any(|item| item.id == requested) { - return Err(format!("unknown corpus item for --only: {requested}")); - } - } - - validate_model_dir(&int8_dir, Precision::Int8)?; - validate_model_dir(&fp32_dir, Precision::Fp32)?; - fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?; - - let mut engines = Vec::with_capacity(CONDITIONS.len()); - for condition in CONDITIONS { - let dir = match condition.precision { - Precision::Int8 => &int8_dir, - Precision::Fp32 => &fp32_dir, - }; - let engine = load_engine(dir, condition.precision)?; - // Production warms once before serving a real utterance. Cold cases use - // separate fresh engines below and deliberately skip this call. - synth_chunks(&engine, &["warmup".to_string()])?; - engines.push(engine); - } - - if let Some(minutes) = idle_minutes { - eprintln!("All four warmed engines idle for {minutes} minute(s)…"); - std::thread::sleep(Duration::from_secs(minutes * 60)); - } - - let mut key_items = Vec::new(); - for item in CORPUS { - if only_item - .as_deref() - .is_some_and(|requested| requested != item.id) - { - continue; - } - let preprocessed = preprocess_for_tts(item.text); - let per_sentence: Vec = split_sentences(&preprocessed) - .into_iter() - .filter(|s| !s.trim().is_empty()) - .collect(); - // These corpus texts are deliberately below the upstream ~50-token - // grouping target, so grouped mode is one exact generate() call. - let grouped = vec![per_sentence.join(" ")]; - let item_dir = output_dir.join(item.id); - fs::create_dir_all(&item_dir).map_err(|e| e.to_string())?; - let clip_order = blinded_order(item.id); - let mut clips = Vec::new(); - - let mut rendered = Vec::new(); - for (condition_index, engine) in engines.iter().enumerate() { - let condition = CONDITIONS[condition_index]; - let chunks = match condition.chunking { - Chunking::PerSentence => &per_sentence, - Chunking::Grouped => &grouped, - }; - let started = Instant::now(); - let audio = synth_chunks(engine, chunks)?; - rendered.push(( - condition_index, - condition, - audio, - started.elapsed().as_millis(), - )); - } - loudness_match_item(&mut rendered); - for (condition_index, condition, audio, synth_ms) in rendered { - let clip_number = clip_order[condition_index] + 1; - let file_name = format!("clip{clip_number}.wav"); - write_wav(&item_dir.join(&file_name), &audio)?; - clips.push(KeyClip { - file: format!("{}/{file_name}", item.id), - precision: condition.precision, - chunking: condition.chunking, - cold_start: false, - idle_minutes, - synthesis_ms: synth_ms, - audio_seconds: audio.len() as f32 / SAMPLE_RATE as f32, - }); - } - clips.sort_by(|a, b| a.file.cmp(&b.file)); - key_items.push(KeyItem { - id: item.id.to_string(), - kind: item.kind.to_string(), - text: item.text.to_string(), - clips, - }); - } - - // Explicit fresh-engine cold-start clips for the two highest-signal texts. - // Idle runs intentionally omit them: they happen after the post-idle clips - // and add no valid idle observation. - for item in if idle_minutes.is_none() { CORPUS } else { &[] } { - if !matches!(item.id, "short_one_word" | "multi_relay_review") { - continue; - } - if only_item - .as_deref() - .is_some_and(|requested| requested != item.id) - { - continue; - } - let cold_id = format!("cold_{}", item.id); - let preprocessed = preprocess_for_tts(item.text); - let sentences: Vec = split_sentences(&preprocessed) - .into_iter() - .filter(|s| !s.trim().is_empty()) - .collect(); - let grouped = vec![sentences.join(" ")]; - let item_dir = output_dir.join(&cold_id); - fs::create_dir_all(&item_dir).map_err(|e| e.to_string())?; - let clip_order = blinded_order(&cold_id); - let mut clips = Vec::new(); - let mut rendered = Vec::new(); - for (condition_index, condition) in CONDITIONS.iter().copied().enumerate() { - let dir = match condition.precision { - Precision::Int8 => &int8_dir, - Precision::Fp32 => &fp32_dir, - }; - let engine = load_engine(dir, condition.precision)?; - let chunks = match condition.chunking { - Chunking::PerSentence => &sentences, - Chunking::Grouped => &grouped, - }; - let started = Instant::now(); - let audio = synth_chunks(&engine, chunks)?; - rendered.push(( - condition_index, - condition, - audio, - started.elapsed().as_millis(), - )); - } - loudness_match_item(&mut rendered); - for (condition_index, condition, audio, synth_ms) in rendered { - let clip_number = clip_order[condition_index] + 1; - let file_name = format!("clip{clip_number}.wav"); - write_wav(&item_dir.join(&file_name), &audio)?; - clips.push(KeyClip { - file: format!("{cold_id}/{file_name}"), - precision: condition.precision, - chunking: condition.chunking, - cold_start: true, - idle_minutes: None, - synthesis_ms: synth_ms, - audio_seconds: audio.len() as f32 / SAMPLE_RATE as f32, - }); - } - clips.sort_by(|a, b| a.file.cmp(&b.file)); - key_items.push(KeyItem { - id: cold_id, - kind: "cold-start".to_string(), - text: item.text.to_string(), - clips, - }); - } - - let key = KeyFile { - warning: "DO NOT OPEN UNTIL LISTENING SCORES ARE FINAL", - blinding_seed: BLINDING_SEED, - target_rms_dbfs: TARGET_RMS_DBFS, - items: key_items, - }; - fs::write( - output_dir.join("key.json"), - serde_json::to_vec_pretty(&key).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string())?; - write_scoring_sheet(&output_dir, &key)?; - println!("Wrote blind corpus to {}", output_dir.display()); - println!("Give listeners the WAV folders and SCORING.md; withhold key.json."); - Ok(()) -} - -fn required_path(value: Option, label: &str) -> Result { - value - .map(PathBuf::from) - .ok_or_else(|| format!("missing {label}")) -} - -fn model_file(precision: Precision, base: &str) -> String { - match precision { - Precision::Int8 => format!("{base}.int8.onnx"), - Precision::Fp32 => format!("{base}.onnx"), - } -} - -fn validate_model_dir(dir: &Path, precision: Precision) -> Result<(), String> { - for file in [ - model_file(precision, "lm_main"), - model_file(precision, "lm_flow"), - "encoder.onnx".into(), - model_file(precision, "decoder"), - "text_conditioner.onnx".into(), - "vocab.json".into(), - "token_scores.json".into(), - "reference_sample.wav".into(), - ] { - if !dir.join(&file).is_file() { - return Err(format!("missing {}", dir.join(file).display())); - } - } - Ok(()) -} - -fn load_engine(dir: &Path, precision: Precision) -> Result { - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - let mut cfg = OfflineTtsConfig::default(); - cfg.model.pocket.lm_main = Some(p(&model_file(precision, "lm_main"))); - cfg.model.pocket.lm_flow = Some(p(&model_file(precision, "lm_flow"))); - cfg.model.pocket.encoder = Some(p("encoder.onnx")); - cfg.model.pocket.decoder = Some(p(&model_file(precision, "decoder"))); - cfg.model.pocket.text_conditioner = Some(p("text_conditioner.onnx")); - cfg.model.pocket.vocab_json = Some(p("vocab.json")); - cfg.model.pocket.token_scores_json = Some(p("token_scores.json")); - cfg.model.pocket.voice_embedding_cache_capacity = 16; - cfg.model.num_threads = 1; - cfg.model.debug = false; - let inner = - OfflineTts::create(&cfg).ok_or_else(|| format!("failed to create {precision:?} engine"))?; - let wave = - Wave::read(&p("reference_sample.wav")).ok_or("failed to read reference_sample.wav")?; - Ok(Engine { - inner, - voice: Voice { - samples: wave.samples().to_vec(), - sample_rate: wave.sample_rate(), - }, - }) -} - -fn synth_chunks(engine: &Engine, chunks: &[String]) -> Result, String> { - let mut out = Vec::new(); - for chunk in chunks { - let prepared = prepare_pocket_prompt(chunk).ok_or("empty prepared prompt")?; - let extra = prepared.max_frames.map(|max_frames| { - HashMap::from([( - "max_frames".to_string(), - serde_json::Value::from(max_frames), - )]) - }); - let cfg = GenerationConfig { - num_steps: NUM_STEPS, - silence_scale: SILENCE_SCALE, - reference_audio: Some(engine.voice.samples.clone()), - reference_sample_rate: engine.voice.sample_rate, - extra, - ..Default::default() - }; - let audio = engine - .inner - .generate_with_config(&prepared.text, &cfg, None:: bool>) - .ok_or_else(|| format!("synthesis failed for {chunk:?}"))?; - let mut samples: Vec = audio.samples().iter().map(|s| s.clamp(-1.0, 1.0)).collect(); - apply_fade_out(&mut samples); - out.extend(std::iter::repeat_n(0.0, LEAD_IN_SAMPLES)); - out.extend(samples); - out.extend(std::iter::repeat_n( - 0.0, - INTER_SENTENCE_SILENCE_SAMPLES - LEAD_IN_SAMPLES, - )); - } - Ok(out) -} - -fn apply_fade_out(samples: &mut [f32]) { - let fade = FADE_OUT_SAMPLES.min(samples.len() / 2); - for i in 0..fade { - samples[samples.len() - 1 - i] *= i as f32 / fade as f32; - } -} - -fn active_rms(samples: &[f32]) -> Option { - let (sum_squares, count) = samples - .iter() - .filter(|sample| sample.abs() > 1.0e-4) - .fold((0.0_f32, 0_usize), |(sum, count), sample| { - (sum + sample * sample, count + 1) - }); - (count > 0).then(|| (sum_squares / count as f32).sqrt()) -} - -/// Attenuate every clip in one comparison set to the quietest active-speech RMS. -/// This removes the louder-is-better confound without normalizing dynamics or -/// claiming standards-compliant integrated LUFS. The dBFS value is a ceiling. -fn loudness_match_item(rendered: &mut [(usize, Condition, Vec, u128)]) { - let ceiling = 10.0_f32.powf(TARGET_RMS_DBFS / 20.0); - let target = rendered - .iter() - .filter_map(|(_, _, samples, _)| active_rms(samples)) - .fold(ceiling, f32::min); - for (_, _, samples, _) in rendered { - let Some(rms) = active_rms(samples) else { - continue; - }; - let gain = (target / rms).min(1.0); - for sample in samples { - *sample *= gain; - } - } -} - -fn blinded_order(item_id: &str) -> [usize; 4] { - let mut keyed: Vec<(usize, Vec)> = (0..4) - .map(|index| { - let digest = Sha256::digest(format!("{BLINDING_SEED}:{item_id}:{index}")); - (index, digest.to_vec()) - }) - .collect(); - keyed.sort_by(|a, b| a.1.cmp(&b.1)); - let mut condition_to_clip = [0; 4]; - for (clip, (condition, _)) in keyed.into_iter().enumerate() { - condition_to_clip[condition] = clip; - } - condition_to_clip -} - -fn write_wav(path: &Path, samples: &[f32]) -> Result<(), String> { - let path = path - .to_str() - .ok_or_else(|| format!("non-UTF8 path: {}", path.display()))?; - if sherpa_onnx::write(path, samples, SAMPLE_RATE as i32) { - Ok(()) - } else { - Err(format!("failed to write {path}")) - } -} - -fn write_scoring_sheet(output_dir: &Path, key: &KeyFile) -> Result<(), String> { - let mut sheet = String::from("# Pocket TTS blind listening sheet\n\nDo not open `key.json` until this sheet is complete. Rank best to worst; ties are allowed.\n\n"); - for item in &key.items { - sheet.push_str(&format!( - "## {} ({})\n\n> {}\n\n", - item.id, item.kind, item.text - )); - sheet.push_str("Rank: `____ > ____ > ____ > ____`\n\n| Clip | seam | onset | garble | robotic | timbre | truncate | note |\n|---|---|---|---|---|---|---|---|\n"); - for clip in 1..=4 { - sheet.push_str(&format!( - "| clip{clip} | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | |\n" - )); - } - sheet.push('\n'); - } - fs::write(output_dir.join("SCORING.md"), sheet).map_err(|e| e.to_string()) -} diff --git a/desktop/src-tauri/resources/pocket-voices/NOTICE.md b/desktop/src-tauri/resources/pocket-voices/NOTICE.md new file mode 100644 index 0000000000..9cc515dea3 --- /dev/null +++ b/desktop/src-tauri/resources/pocket-voices/NOTICE.md @@ -0,0 +1,35 @@ +# Pocket TTS English VCTK presets + +Buzz exposes Kyutai's twelve official English VCTK Pocket presets. The WAV +bytes are unchanged from `kyutai/tts-voices` revision +`323332d33f997de8394f24a193e1a76df720e01a`; only local filenames differ. + +| Voice | Upstream asset | SHA-256 | +| --- | --- | --- | +| Anna | `vctk/p228_023_enhanced.wav` | `0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856` | +| Vera | `vctk/p229_023_enhanced.wav` | `309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b` | +| Fantine | `vctk/p244_023_enhanced.wav` | `5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b` | +| Charles | `vctk/p254_023_enhanced.wav` | `6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756` | +| Paul | `vctk/p259_023_enhanced.wav` | `7aba504fe0b3b16478b69eb27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b` | +| Eponine | `vctk/p262_023_enhanced.wav` | `a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b` | +| Azelma | `vctk/p303_023_enhanced.wav` | `60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026` | +| George | `vctk/p315_023_enhanced.wav` | `29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae` | +| Mary | `vctk/p333_023_enhanced.wav` | `a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f` | +| Jane | `vctk/p339_023_enhanced.wav` | `2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a` | +| Michael | `vctk/p360_023_enhanced.wav` | `b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad` | +| Eve | `vctk/p361_023_enhanced.wav` | `396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd` | + +Mary is already installed as the Pocket model's `reference_sample.wav`, so it +is not duplicated in this resource directory. + +Source repository: +https://huggingface.co/kyutai/tts-voices/tree/323332d33f997de8394f24a193e1a76df720e01a/vctk + +The original recordings are from the Voice Cloning Toolkit (VCTK) corpus, +licensed CC BY 4.0: +https://datashare.ed.ac.uk/handle/10283/3443 + +The recordings were enhanced by ai-coustics: +https://ai-coustics.com/ + +Neither Kyutai, the VCTK speakers, nor ai-coustics endorses Buzz. diff --git a/desktop/src-tauri/resources/pocket-voices/anna.wav b/desktop/src-tauri/resources/pocket-voices/anna.wav new file mode 100644 index 0000000000..79d60697ff Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/anna.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/azelma.wav b/desktop/src-tauri/resources/pocket-voices/azelma.wav new file mode 100644 index 0000000000..e9d0c00b3f Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/azelma.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/charles.wav b/desktop/src-tauri/resources/pocket-voices/charles.wav new file mode 100644 index 0000000000..2170975545 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/charles.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/eponine.wav b/desktop/src-tauri/resources/pocket-voices/eponine.wav new file mode 100644 index 0000000000..bded6f4f09 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/eponine.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/eve.wav b/desktop/src-tauri/resources/pocket-voices/eve.wav new file mode 100644 index 0000000000..216665ff13 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/eve.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/fantine.wav b/desktop/src-tauri/resources/pocket-voices/fantine.wav new file mode 100644 index 0000000000..28c2b1140d Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/fantine.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/george.wav b/desktop/src-tauri/resources/pocket-voices/george.wav new file mode 100644 index 0000000000..739d5bc7a5 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/george.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/jane.wav b/desktop/src-tauri/resources/pocket-voices/jane.wav new file mode 100644 index 0000000000..3c9890473b Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/jane.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/michael.wav b/desktop/src-tauri/resources/pocket-voices/michael.wav new file mode 100644 index 0000000000..861da085c7 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/michael.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/paul.wav b/desktop/src-tauri/resources/pocket-voices/paul.wav new file mode 100644 index 0000000000..bfde50fdd9 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/paul.wav differ diff --git a/desktop/src-tauri/resources/pocket-voices/vera.wav b/desktop/src-tauri/resources/pocket-voices/vera.wav new file mode 100644 index 0000000000..e4fce84ce3 Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/vera.wav differ diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 94d162e620..fc90e6ab14 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, io::Write, sync::{ - atomic::{AtomicBool, AtomicU16}, + atomic::{AtomicBool, AtomicU16, AtomicU8}, Arc, Mutex, }, }; @@ -13,10 +13,15 @@ use tauri::{AppHandle, Manager}; use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; +pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity}; use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; + pub struct AppState { pub keys: Mutex, + /// Durable backend holding `keys`. Updated after the key write and before + /// recovery flags are cleared so `get_identity` reports a consistent state. + pub(crate) identity_storage: AtomicU8, pub http_client: reqwest::Client, /// A no-redirect client for authenticated relay media fetches (download, /// clipboard copy, snapshot, editor). Every caller pre-validates the URL @@ -48,15 +53,13 @@ pub struct AppState { pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, + pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState, /// Tauri app handle — stored after setup so huddle commands can emit /// `huddle-state-changed` events without needing the handle threaded /// through every call site. /// /// Set once during `setup()` in `lib.rs`; never cleared. pub app_handle: Mutex>, - /// Selected audio output device name. `None` = system default. - /// Used by `connect_audio_relay` and TTS pipeline when opening sinks. - pub audio_output_device: Mutex>, /// Port of the localhost media streaming proxy (set during setup). pub media_proxy_port: AtomicU16, /// Set when identity resolution detected a "keyring-locked" state: the @@ -178,19 +181,20 @@ pub fn build_media_fetch_client() -> reqwest::Result { pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. - let keys = match identity_from_env() { + let (keys, identity_storage) = match identity_from_env() { Some(keys) => { eprintln!( "buzz-desktop: configured identity pubkey {}", keys.public_key().to_hex() ); - keys + (keys, IdentityStorage::Environment) } - None => Keys::generate(), + None => (Keys::generate(), IdentityStorage::Ephemeral), }; AppState { keys: Mutex::new(keys), + identity_storage: AtomicU8::new(identity_storage as u8), http_client: reqwest::Client::builder() .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) .pool_idle_timeout(std::time::Duration::from_secs(10)) @@ -213,8 +217,8 @@ pub fn build_app_state() -> AppState { managed_agent_processes: Mutex::new(HashMap::new()), session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), + huddle_audio: Default::default(), app_handle: Mutex::new(None), - audio_output_device: Mutex::new(None), media_proxy_port: AtomicU16::new(0), prevent_sleep: Arc::new(Mutex::new( crate::prevent_sleep::PreventSleepState::default(), @@ -366,9 +370,13 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let resolved = load_or_create_identity(&data_dir)?; - // Write keys before setting the recovery flags (Release) so any thread - // that reads a flag as false with Acquire is guaranteed to see the keys. - *state.keys.lock().map_err(|e| e.to_string())? = resolved.keys; + // Write keys and storage before setting the recovery flags (Release) so + // any thread that reads a flag as false with Acquire sees consistent data. + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = resolved.keys; + state.set_identity_storage(resolved.storage); + } state.identity_lost.store( resolved.recovery == RecoveryState::Lost, std::sync::atomic::Ordering::Release, @@ -394,26 +402,6 @@ const IDENTITY_KEY_NAME: &str = "identity"; /// keyring is merely unreachable (the key IS in the keyring, must NOT generate). const MIGRATION_MARKER_NAME: &str = "identity.migrated"; -/// Recovery state produced by identity resolution. `None` means the app has -/// a real, usable identity. `Lost` means the keyring was reachable-but-empty -/// despite a prior successful migration — the key vanished externally. `KeyringLocked` -/// means the keyring is unreachable this boot but was used in the past -/// (marker present, no file) — the key still exists but is temporarily -/// inaccessible. Both non-`None` variants boot with an ephemeral key; the -/// frontend shows a different recovery screen for each. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RecoveryState { - None, - Lost, - KeyringLocked, -} - -/// The output of identity resolution. -struct ResolvedIdentity { - keys: Keys, - recovery: RecoveryState, -} - /// The keyring operations the identity resolution flow needs. Abstracted so the /// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be /// unit-tested against a fake without touching the live OS keyring. @@ -465,6 +453,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result Result<(), String> { +) -> Result { match persist_identity_to_keyring(store, keys, legacy_path, data_dir) { - Ok(()) => Ok(()), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(e) => { eprintln!( "buzz-desktop: keyring write failed during import ({e}), \ falling back to identity.key" ); - save_key_file(legacy_path, keys) + save_key_file(legacy_path, keys)?; + Ok(IdentityStorage::LocalFile) } } } @@ -892,7 +895,7 @@ pub(crate) fn persist_imported_identity( keys: &Keys, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result<(), String> { +) -> Result { persist_imported_identity_impl(store, keys, legacy_path, data_dir) } @@ -920,15 +923,6 @@ fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("commit migration marker: {e}")) } -/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes -/// the migration marker only after a keyring success — on the file-fallback arm -/// the key is on disk and a marker would wrongly trip the next Unreachable boot -/// into failing closed. -enum PersistBackend { - Keyring, - File, -} - /// Generate a fresh identity, persist it through the store, return it. /// /// On a keyring-backed persist no file is written, so a later @@ -940,9 +934,10 @@ fn generate_and_persist( store: &impl IdentityKeyStore, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result { +) -> Result<(Keys, IdentityStorage), String> { let keys = Keys::generate(); - if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? { + let storage = store_key_preferring_keyring(store, &keys, legacy_path)?; + if storage == IdentityStorage::SystemKeyring { let marker_path = migration_marker_path(data_dir); if let Err(e) = write_migration_marker(&marker_path) { eprintln!( @@ -956,7 +951,7 @@ fn generate_and_persist( "buzz-desktop: generated and saved identity pubkey {}", keys.public_key().to_hex() ); - Ok(keys) + Ok((keys, storage)) } /// Persist `keys` through the store, silently falling back to the `0o600` file @@ -968,17 +963,17 @@ fn store_key_preferring_keyring( store: &impl IdentityKeyStore, keys: &Keys, legacy_path: &std::path::Path, -) -> Result { +) -> Result { let nsec = keys .secret_key() .to_bech32() .map_err(|e| format!("encode nsec: {e}"))?; match store.store(IDENTITY_KEY_NAME, &nsec) { - Ok(()) => Ok(PersistBackend::Keyring), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(keyring_err) => { eprintln!("buzz-desktop: keyring write failed ({keyring_err}), using file fallback"); save_key_file(legacy_path, keys)?; - Ok(PersistBackend::File) + Ok(IdentityStorage::LocalFile) } } } diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs index 485dfaea15..751bcf22e5 100644 --- a/desktop/src-tauri/src/app_state_tests.rs +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -484,7 +484,7 @@ fn fresh_keyring_generate_writes_marker() { let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); // The key was stored in the keyring (not the file), and the marker marks it. - assert!(!legacy_path.exists()); + assert!(!legacy_path.exists() && resolved.storage == IdentityStorage::SystemKeyring); assert!(migration_marker_path(dir.path()).exists()); assert_eq!( store @@ -541,7 +541,10 @@ fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() { let from_file = load_key_file(&legacy_path).unwrap(); assert_key_eq(&resolved.keys, &from_file); // No marker: the file is the authoritative store, not the keyring. - assert!(!migration_marker_path(dir.path()).exists()); + assert!( + !migration_marker_path(dir.path()).exists() + && resolved.storage == IdentityStorage::LocalFile + ); } // ── New tests for the three defects fixed in this PR ───────────────────── @@ -786,10 +789,7 @@ fn persist_imported_identity_falls_back_to_file_on_keyring_failure() { let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path()); // The policy core handles the keyring failure — Ok, not Err. - assert!( - result.is_ok(), - "must not propagate keyring failure when file fallback succeeds" - ); + assert_eq!(result.unwrap(), IdentityStorage::LocalFile); // Key is recoverable from the file on next boot. let from_file = load_key_file(&legacy_path).unwrap(); diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index d6429e0454..cbbf4ce351 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -236,10 +236,12 @@ pub async fn install_acp_runtime( // returns (Guard impl Drop) — so Phase 2's restart path runs outside // the guard and cannot re-enter the mutex. let runtime_id_clone = runtime_id.clone(); - let install_result = - tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&runtime_id_clone)) - .await - .map_err(|e| format!("install task panicked: {e}"))??; + let app_clone = app.clone(); + let install_result = tokio::task::spawn_blocking(move || { + install_acp_runtime_blocking(&runtime_id_clone, &app_clone) + }) + .await + .map_err(|e| format!("install task panicked: {e}"))??; if !install_result.success { return Ok(install_result); @@ -259,12 +261,21 @@ pub async fn install_acp_runtime( steps: install_result.steps, restarted_count, failed_restart_count, + log_path: install_result.log_path, }) } /// Err(_) = infrastructure failure (panic, concurrency guard). /// Ok({success: false}) = an install step failed (stderr captured in steps). -fn install_acp_runtime_blocking(runtime_id: &str) -> Result { +/// +/// The reporter is built here rather than by the caller so this run's log +/// session starts only once the concurrency guard is held and the runtime id is +/// resolved to its canonical catalog form: a rejected install must not rotate a +/// running one's log, and the log filename is derived from that id. +fn install_acp_runtime_blocking( + runtime_id: &str, + app: &tauri::AppHandle, +) -> Result { // Re-fetch the login-shell PATH so a Node.js installation that happened // after app launch (or after a previous failed install) is visible to this // run and to the subsequent discover_acp_providers call. @@ -297,6 +308,8 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result Result Result Result command, Ok(None) => cmd.to_string(), Err(step) => { - steps.push(*step); - return Ok(InstallRuntimeResult { - success: false, - steps, - restarted_count: 0, - failed_restart_count: 0, - }); + reporter.record_step(&mut steps, *step); + return Ok(reporter.failed(steps)); } }; - let mut result = run_install_command_with_retry("adapter", &planned); + let mut result = run_install_command_with_retry("adapter", &planned, &reporter); if !result.success && result.hint.is_none() && is_npm_global_install(cmd) { result.hint = npm_eacces_hint(&result.stderr, cmd); } let success = result.success; steps.push(result); if !success { - return Ok(InstallRuntimeResult { - success: false, - steps, - restarted_count: 0, - failed_restart_count: 0, - }); + return Ok(reporter.failed(steps)); } } } - post_install_verification::run(runtime_id, &mut steps); + post_install_verification::run(runtime_id, &mut steps, &reporter); Ok(InstallRuntimeResult { success: steps.iter().all(|step| step.success), steps, restarted_count: 0, failed_restart_count: 0, + log_path: reporter.log_path(), }) } @@ -1016,8 +1010,11 @@ fn build_install_command(command: &str) -> Result } // ── install command execution ───────────────────────────────────────────────── +mod install_capture; mod install_exec; +mod install_report; use install_exec::run_install_command_with_retry; +use install_report::InstallReporter; // ── managed Node/npm runtime ────────────────────────────────────────────────── mod managed_node; diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_capture.rs b/desktop/src-tauri/src/commands/agent_discovery/install_capture.rs new file mode 100644 index 0000000000..903b68715a --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/install_capture.rs @@ -0,0 +1,319 @@ +//! Bounded capture of an install command's output. +//! +//! One drain per stream feeds a [`Capture`], which holds two independently +//! bounded views of the same bytes: a small one sized for an error toast and a +//! large one sized for the install log file. Both are shared with the draining +//! reader rather than returned by it, so whatever arrived before a stall is +//! readable at the ceiling — exactly when the output matters most. + +use std::collections::VecDeque; +use std::io::Read; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// How much of each end a capture keeps, and how it names what was cut. +#[derive(Clone, Copy)] +struct Caps { + head: usize, + tail: usize, + marker: fn(usize) -> String, +} + +/// Sized for a UI error message: enough to identify the failure, small enough +/// to read in a toast. +const UI_CAPS: Caps = Caps { + head: 512, + tail: 1024, + marker: |omitted| format!("... ({omitted} bytes omitted) ..."), +}; + +/// Sized for the log file, where the budget is disk rather than screen. At this +/// cap a real install log is complete in practice; the marker names the cases +/// where it is not, so the file never implies completeness it does not have. +const LOG_CAPS: Caps = Caps { + head: 128 * 1024, + tail: 128 * 1024, + marker: |omitted| format!("... [{omitted} bytes omitted at cap] ..."), +}; + +/// Bounded capture of one stream: its first `head` bytes, its last `tail` +/// bytes, and the total byte count. Output of any size costs a fixed amount of +/// memory, so an installer that prints megabytes cannot grow the process. +struct BoundedOutput { + head: Vec, + tail: VecDeque, + total: usize, + caps: Caps, +} + +type SharedOutput = Arc>; + +impl BoundedOutput { + fn shared(caps: Caps) -> SharedOutput { + Arc::new(Mutex::new(Self { + head: Vec::new(), + tail: VecDeque::new(), + total: 0, + caps, + })) + } + + /// Absorb one read. Chunk boundaries are irrelevant to the result: the head + /// fills first, the remainder rolls through the tail window. + fn push(&mut self, chunk: &[u8]) { + self.total += chunk.len(); + let head_room = self + .caps + .head + .saturating_sub(self.head.len()) + .min(chunk.len()); + let (head_part, tail_part) = chunk.split_at(head_room); + self.head.extend_from_slice(head_part); + self.tail.extend(tail_part); + while self.tail.len() > self.caps.tail { + self.tail.pop_front(); + } + } + + fn render(&self) -> String { + let tail: Vec = self.tail.iter().copied().collect(); + if self.total <= self.caps.head + self.caps.tail { + // Nothing was dropped, so head followed by tail is the whole stream. + let mut whole = self.head.clone(); + whole.extend_from_slice(&tail); + return decode(&whole); + } + // Both ends are cut at arbitrary byte offsets, so trim any partial + // character rather than emitting replacement chars, then drop the + // partial *token* each cut left behind. The marker counts every dropped + // byte, including both trims. + let head = erode_head(utf8_prefix(&self.head)); + let tail = erode_tail(utf8_suffix(&tail)); + let omitted = self.total - head.len() - tail.len(); + format!( + "{}\n{}\n{}", + decode(head), + (self.caps.marker)(omitted), + decode(tail) + ) + } +} + +/// The two bounded views of one stream, filled by a single drain. +pub(super) struct Capture { + ui: SharedOutput, + log: SharedOutput, +} + +impl Capture { + pub(super) fn new() -> Self { + Self { + ui: BoundedOutput::shared(UI_CAPS), + log: BoundedOutput::shared(LOG_CAPS), + } + } + + /// What the UI shows for this stream. + pub(super) fn ui(&self) -> String { + render(&self.ui) + } + + /// What the install log records for this stream. + pub(super) fn log(&self) -> String { + render(&self.log) + } + + fn push(&self, chunk: &[u8]) { + for sink in [&self.ui, &self.log] { + if let Ok(mut sink) = sink.lock() { + sink.push(chunk); + } + } + } +} + +/// Called with each complete line an install prints, for the live output line +/// in the UI. Shared across both drain threads of one attempt. +pub(super) type LineObserver = Arc; + +/// Render a capture even if its drain thread panicked mid-write — a poisoned +/// lock must not cost the diagnostics. +fn render(sink: &SharedOutput) -> String { + sink.lock().unwrap_or_else(|p| p.into_inner()).render() +} + +/// Read `pipe` to EOF, feeding fixed-size chunks into `capture` and each +/// complete line to `observer`. Read errors end the drain — a broken pipe means +/// the child is gone and there is nothing left to capture. +pub(super) fn drain_into(mut pipe: impl Read, capture: &Capture, observer: Option<&LineObserver>) { + let mut chunk = [0u8; 8192]; + let mut lines = LineSplitter::default(); + loop { + match pipe.read(&mut chunk) { + Ok(0) => return, + Ok(n) => { + capture.push(&chunk[..n]); + if let Some(observe) = observer { + lines.feed(&chunk[..n], |line| observe(line)); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => return, + } + } +} + +/// Reassembles lines from arbitrary read chunks. A partial trailing line is +/// held until its newline arrives, so an observer only ever sees complete +/// lines. The buffer is capped: a program that prints megabytes without a +/// newline must not grow it without bound. +#[derive(Default)] +struct LineSplitter { + partial: Vec, +} + +impl LineSplitter { + /// Longest line reassembled. Beyond this the excess is dropped, since the + /// consumer displays a single truncated line anyway. + const MAX_LINE: usize = 4096; + + fn feed(&mut self, chunk: &[u8], mut emit: impl FnMut(&str)) { + for byte in chunk { + if *byte == b'\n' { + let line = String::from_utf8_lossy(&self.partial).trim().to_string(); + self.partial.clear(); + if !line.is_empty() { + emit(&line); + } + } else if self.partial.len() < Self::MAX_LINE { + self.partial.push(*byte); + } + } + } +} + +/// Rate limiter for the live output line: at most one event per +/// `min_interval`. +/// +/// A line arriving inside the window is *held* rather than dropped, and the +/// newest held line replaces any older one. Dropping was wrong at two points: +/// the last line of an attempt — typically the failure that caused the retry — +/// vanished if it landed inside the window, and so did a new attempt's first +/// line when it arrived within 250ms of the previous attempt's last. +pub(super) struct Throttle { + min_interval: Duration, + state: Mutex, +} + +#[derive(Default)] +struct ThrottleState { + last_emitted: Option, + pending: Option, +} + +impl Throttle { + pub(super) fn new(min_interval: Duration) -> Self { + Self { + min_interval, + state: Mutex::new(ThrottleState::default()), + } + } + + /// Offer one line. `Some` means emit it now; `None` means it is held as the + /// newest pending line, to be emitted by [`Throttle::take_pending`] or + /// replaced by a line that supersedes it. + pub(super) fn offer(&self, line: &str, now: Instant) -> Option { + let Ok(mut state) = self.state.lock() else { + return None; + }; + if state + .last_emitted + .is_some_and(|prev| now.duration_since(prev) < self.min_interval) + { + state.pending = Some(line.to_string()); + return None; + } + state.last_emitted = Some(now); + // Emitting a newer line makes the held one obsolete: the display shows + // one line, and it must be the latest. + state.pending = None; + Some(line.to_string()) + } + + /// Take the held line, if the window closed on one. + pub(super) fn take_pending(&self) -> Option { + self.state.lock().ok()?.pending.take() + } + + /// Open the window for a new attempt, so its first line is emitted + /// immediately instead of waiting out the previous attempt's window. + pub(super) fn restart(&self) { + if let Ok(mut state) = self.state.lock() { + *state = ThrottleState::default(); + } + } +} + +fn decode(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +/// Drop a trailing partial UTF-8 sequence, keeping mid-stream invalid bytes for +/// the lossy decode to mark. +fn utf8_prefix(bytes: &[u8]) -> &[u8] { + match std::str::from_utf8(bytes) { + Ok(_) => bytes, + Err(e) if e.error_len().is_none() => &bytes[..e.valid_up_to()], + Err(_) => bytes, + } +} + +/// Drop leading UTF-8 continuation bytes — at most three can precede a +/// character start. +fn utf8_suffix(bytes: &[u8]) -> &[u8] { + let start = bytes + .iter() + .take(3) + .take_while(|b| *b & 0b1100_0000 == 0b1000_0000) + .count(); + &bytes[start..] +} + +/// How far a cut edge looks for a token boundary. Sized past any credential +/// shape worth protecting (an `nsec1` key is 63 bytes, registry tokens are +/// shorter) and short enough that erosion costs a token rather than a chunk of +/// output. A cut inside a longer whitespace-free run is left alone: erasing +/// kilobytes of a single-token stream would cost more diagnostics than the +/// fragment could leak. +const MAX_ERODED_TOKEN: usize = 256; + +/// Drop the partial token a head cut left at its end. +/// +/// Redaction runs on the rendered text and matches whole tokens: a prefixed +/// secret up to the next whitespace, or an exact env value. A cut through the +/// middle of a secret leaves a fragment that matches neither and therefore +/// survives scrubbing, so the fragment is removed here instead — at the cut, +/// where it is still identifiable as partial. The omitted-byte marker counts +/// what this drops. +fn erode_head(bytes: &[u8]) -> &[u8] { + let window = bytes.len().saturating_sub(MAX_ERODED_TOKEN); + match bytes[window..].iter().rposition(u8::is_ascii_whitespace) { + Some(last) => &bytes[..=window + last], + None => bytes, + } +} + +/// Drop the partial token a tail cut left at its start — the direction that +/// matters most, since a fragment there has lost the `nsec1`-style prefix the +/// scrubber keys on. See [`erode_head`]. +fn erode_tail(bytes: &[u8]) -> &[u8] { + let window = MAX_ERODED_TOKEN.min(bytes.len()); + match bytes[..window].iter().position(u8::is_ascii_whitespace) { + Some(first) => &bytes[first..], + None => bytes, + } +} + +#[cfg(test)] +#[path = "install_capture_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_capture_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/install_capture_tests.rs new file mode 100644 index 0000000000..8830f355df --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/install_capture_tests.rs @@ -0,0 +1,437 @@ +use super::*; + +/// Feed `chunks` through a capture in order. +fn capture_of(chunks: &[&[u8]]) -> Capture { + let capture = Capture::new(); + for chunk in chunks { + capture.push(chunk); + } + capture +} + +/// Render what the UI would show for a stream of `chunks`. +fn ui(chunks: &[&[u8]]) -> String { + capture_of(chunks).ui() +} + +// ── bounded capture ────────────────────────────────────────────────────────── + +/// Output within the cap is passed through byte-for-byte — no marker, no loss. +#[test] +fn test_capture_leaves_short_output_untouched() { + let short = "a".repeat(1536); + + assert_eq!(ui(&[short.as_bytes()]), short); +} + +/// Over the cap, both ends survive and the middle is replaced by a marker +/// naming the omitted byte count — the head keeps the command's opening +/// context and the tail keeps the error that usually trails. +#[test] +fn test_capture_over_cap_keeps_head_and_tail_with_marker() { + let input = format!( + "{}{}{}", + "H".repeat(512), + "M".repeat(4000), + "T".repeat(1024) + ); + + let out = ui(&[input.as_bytes()]); + + assert!(out.starts_with(&"H".repeat(512))); + assert!(out.ends_with(&"T".repeat(1024))); + assert!( + out.contains("... (4000 bytes omitted) ..."), + "marker must name the omitted byte count, got: {out}" + ); +} + +/// The rendered result depends only on the byte stream, not on how the reads +/// happened to split it — a real drain sees arbitrary chunk sizes. +#[test] +fn test_capture_is_independent_of_chunk_boundaries() { + let input = "x".repeat(9000); + let one_shot = ui(&[input.as_bytes()]); + + let chunked: Vec<&[u8]> = input.as_bytes().chunks(7).collect(); + + assert_eq!(ui(&chunked), one_shot); +} + +/// Truncation must not split a multi-byte character. Both cut points land +/// mid-codepoint here; the partial bytes are dropped rather than decoded into +/// replacement chars. +#[test] +fn test_capture_does_not_split_multibyte_characters() { + // "é" is 2 bytes, so every candidate cut index lands mid-character. + let input = "é".repeat(4000); + + let out = ui(&[input.as_bytes()]); + + assert!(out.contains("bytes omitted"), "input must exceed the cap"); + assert!(!out.contains('\u{fffd}'), "no replacement chars: {out}"); +} + +/// Memory stays flat regardless of how much the installer prints: the rendered +/// UI result of a 4MiB stream is no larger than that of a 6KiB one. +#[test] +fn test_capture_of_huge_output_stays_bounded() { + let chunk = vec![b'z'; 8192]; + + let capture = Capture::new(); + for _ in 0..512 { + capture.push(&chunk); + } + + let out = capture.ui(); + assert!( + out.len() < 2048, + "4MiB of output must render bounded, got {} bytes", + out.len() + ); + assert!(out.contains("bytes omitted")); +} + +// ── the log view is separately bounded ─────────────────────────────────────── + +/// The log view holds output the UI view had to cut. A toast is capped for +/// readability; the log file's budget is disk, and "Full log: {path}" has to +/// point at more than the toast already showed. +#[test] +fn test_log_view_keeps_output_the_ui_view_truncates() { + let input = format!("start{}end", "m".repeat(64 * 1024)); + + let capture = capture_of(&[input.as_bytes()]); + + assert!( + capture.ui().contains("bytes omitted"), + "64KiB must exceed the UI cap" + ); + assert_eq!( + capture.log(), + input, + "the same output must be complete in the log view" + ); +} + +/// Even the log view is bounded — a runaway installer cannot fill the disk — +/// and when it does cut, the record says so inline at the cap rather than +/// implying completeness. +#[test] +fn test_log_view_is_bounded_and_marks_its_cap() { + let head = "H".repeat(128 * 1024); + let middle = "M".repeat(5000); + let tail = "T".repeat(128 * 1024); + let input = format!("{head}{middle}{tail}"); + + let capture = capture_of(&[input.as_bytes()]); + + let out = capture.log(); + assert!( + out.len() < 300 * 1024, + "output past the log cap must render bounded, got {} bytes", + out.len() + ); + assert!(out.starts_with(&head), "the log head must survive intact"); + assert!(out.ends_with(&tail), "the log tail must survive intact"); + assert!( + out.contains("... [5000 bytes omitted at cap] ..."), + "a cut log record must name the cap inline, got the middle: {}", + &out[128 * 1024..(128 * 1024 + 64).min(out.len())] + ); +} + +/// The two views mark their cuts differently on purpose: the toast reads as +/// prose, the log record reads as a machine-scannable annotation. +#[test] +fn test_ui_and_log_views_use_their_own_cap_markers() { + let input = "x".repeat(300 * 1024); + + let capture = capture_of(&[input.as_bytes()]); + + assert!( + capture.ui().contains("bytes omitted) ..."), + "the UI marker reads as prose: {}", + capture.ui() + ); + assert!(capture.log().contains("bytes omitted at cap] ...")); +} + +// ── line observation ───────────────────────────────────────────────────────── + +/// Collect the lines a drain over `chunks` reports. +fn observed_lines(chunks: &[&[u8]]) -> Vec { + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let observer: LineObserver = { + let seen = Arc::clone(&seen); + Arc::new(move |line: &str| seen.lock().unwrap().push(line.to_string())) + }; + let bytes: Vec = chunks.concat(); + + drain_into(bytes.as_slice(), &Capture::new(), Some(&observer)); + + let observed = seen.lock().unwrap().clone(); + observed +} + +/// The observer sees complete lines, reassembled across the read boundaries +/// that split them — a live output line must never show half a word. +#[test] +fn test_observer_reassembles_lines_split_across_reads() { + let lines = observed_lines(&[b"downloa", b"ding 40%\nunpack", b"ing\n"]); + + assert_eq!(lines, vec!["downloading 40%", "unpacking"]); +} + +/// A trailing line with no newline is never reported: it may still be growing, +/// and showing a half-line as if complete is worse than showing the previous +/// one. +#[test] +fn test_observer_withholds_a_line_that_has_no_newline_yet() { + let lines = observed_lines(&[b"complete\n", b"still-writing"]); + + assert_eq!(lines, vec!["complete"]); +} + +/// Blank lines carry nothing to display; progress output is full of them. +#[test] +fn test_observer_skips_blank_lines() { + let lines = observed_lines(&[b"a\n\n \nb\n"]); + + assert_eq!(lines, vec!["a", "b"]); +} + +/// A pathological line with no newline must not grow the buffer without bound. +#[test] +fn test_observer_caps_a_pathologically_long_line() { + let huge = "x".repeat(100_000); + + let lines = observed_lines(&[huge.as_bytes(), b"\n"]); + + assert_eq!(lines.len(), 1); + assert!( + lines[0].len() <= LineSplitter::MAX_LINE, + "line must be capped, got {} bytes", + lines[0].len() + ); +} + +/// A drain with no observer still captures — the log and UI views do not +/// depend on anyone watching. +#[test] +fn test_drain_captures_without_an_observer() { + let capture = Capture::new(); + + drain_into(b"hello\n".as_slice(), &capture, None); + + assert_eq!(capture.ui(), "hello\n"); +} + +// ── throttle ───────────────────────────────────────────────────────────────── + +/// The first line goes out immediately, and one arriving inside the window is +/// *held* rather than dropped: it becomes the pending line, so the newest output +/// survives the rate limit instead of vanishing. +#[test] +fn test_throttle_emits_the_first_line_and_holds_the_next_in_window() { + let throttle = Throttle::new(Duration::from_millis(250)); + let start = Instant::now(); + + assert_eq!(throttle.offer("first", start), Some("first".to_string())); + assert_eq!( + throttle.offer("second", start + Duration::from_millis(100)), + None + ); + assert_eq!( + throttle.take_pending(), + Some("second".to_string()), + "the line inside the window must be retained, not dropped" + ); +} + +/// A burst inside one window collapses to its newest line: the display shows a +/// single line, so an older held line has no value once a newer one exists. +#[test] +fn test_throttle_keeps_only_the_newest_held_line() { + let throttle = Throttle::new(Duration::from_millis(250)); + let start = Instant::now(); + throttle.offer("emitted", start); + + throttle.offer("held-then-superseded", start + Duration::from_millis(50)); + throttle.offer("newest", start + Duration::from_millis(100)); + + assert_eq!(throttle.take_pending(), Some("newest".to_string())); +} + +/// Once the window passes, emission resumes and nothing is left pending — the +/// emitted line *is* the newest, so holding it too would emit it twice. +#[test] +fn test_throttle_emits_again_after_the_window_and_clears_the_held_line() { + let throttle = Throttle::new(Duration::from_millis(250)); + let start = Instant::now(); + throttle.offer("first", start); + throttle.offer("held", start + Duration::from_millis(50)); + + assert_eq!( + throttle.offer("later", start + Duration::from_millis(300)), + Some("later".to_string()) + ); + + assert_eq!( + throttle.take_pending(), + None, + "a line emitted after the window supersedes the held one" + ); +} + +/// The window is measured from the last *emitted* line, not the last offer: a +/// stream of held lines must not extend the silence. +#[test] +fn test_throttle_window_runs_from_the_last_emission() { + let throttle = Throttle::new(Duration::from_millis(250)); + let start = Instant::now(); + throttle.offer("first", start); + + assert_eq!( + throttle.offer("held", start + Duration::from_millis(200)), + None + ); + + assert_eq!( + throttle.offer("next", start + Duration::from_millis(260)), + Some("next".to_string()), + "a held line must not restart the window" + ); +} + +/// A pending line is taken once. Taking it twice would re-emit a line the +/// display already shows. +#[test] +fn test_throttle_yields_a_held_line_only_once() { + let throttle = Throttle::new(Duration::from_millis(250)); + let start = Instant::now(); + throttle.offer("first", start); + throttle.offer("held", start + Duration::from_millis(50)); + + assert_eq!(throttle.take_pending(), Some("held".to_string())); + + assert_eq!(throttle.take_pending(), None); +} + +/// Restarting opens the window immediately, which is what lets a new attempt's +/// first line go out even when it arrives inside the previous attempt's window. +/// It also discards a held line: that line belongs to the attempt that just +/// ended, and the new attempt is about to clear the display. +#[test] +fn test_throttle_restart_opens_the_window_and_discards_the_held_line() { + let throttle = Throttle::new(Duration::from_millis(250)); + let start = Instant::now(); + throttle.offer("previous attempt", start); + throttle.offer("held", start + Duration::from_millis(10)); + + throttle.restart(); + + assert_eq!(throttle.take_pending(), None); + assert_eq!( + throttle.offer("new attempt", start + Duration::from_millis(20)), + Some("new attempt".to_string()) + ); +} + +// ── cut-edge erosion ───────────────────────────────────────────────────────── + +/// A secret cut in half by the head cap must not survive as a fragment. +/// Redaction matches whole tokens — a prefixed secret up to the next whitespace +/// — so `nsec1qqq…` cut mid-value would still be scrubbed, but the *tail* of +/// that same value, having lost its prefix, would not be. Both cut edges drop +/// their partial token for that reason. +#[test] +fn test_capture_drops_the_partial_token_at_each_cut_edge() { + // Positioned so the head cap lands inside the first secret and the tail cap + // inside the second. + let head_secret = "nsec1headsecretvalue"; + let tail_secret = "nsec1tailsecretvalue"; + let input = format!( + "{} {head_secret} {} {tail_secret} {}", + "h".repeat(500), + "m".repeat(4000), + "t".repeat(1010) + ); + + let out = ui(&[input.as_bytes()]); + + assert!(out.contains("bytes omitted"), "input must exceed the cap"); + for fragment in ["nsec1head", "secretvalue"] { + assert!( + !out.contains(fragment), + "a fragment of a cut token must not survive: {out}" + ); + } +} + +/// Erosion stops at the nearest whitespace, so it costs one partial token and +/// not the surrounding output — the head's earlier lines and the tail's later +/// ones are what make a truncated capture readable. +#[test] +fn test_capture_erosion_keeps_the_complete_tokens_around_the_cut() { + let input = format!( + "opening line +{} +cut-here-head{}cut-here-tail +{} +closing line +", + "h".repeat(480), + "m".repeat(4000), + "t".repeat(980) + ); + + let out = ui(&[input.as_bytes()]); + + assert!(out.starts_with("opening line\n"), "got: {out}"); + assert!(out.ends_with("closing line\n"), "got: {out}"); +} + +/// A cut inside a whitespace-free run longer than the erosion window is left +/// intact. Erosion is bounded on purpose: erasing kilobytes of a single-token +/// stream — `npm` progress bars and base64 payloads both look like this — would +/// cost more diagnostics than a fragment of one could leak. +#[test] +fn test_capture_of_one_giant_token_keeps_its_cut_edges() { + let input = "x".repeat(4000); + + let out = ui(&[input.as_bytes()]); + + assert!(out.starts_with(&"x".repeat(512)), "got: {out}"); + assert!(out.ends_with(&"x".repeat(1024)), "got: {out}"); +} + +/// The marker's byte count stays honest across erosion: what it names as omitted +/// must equal the input minus what is actually shown, or a reader cannot trust +/// the file to say how much is missing. +#[test] +fn test_capture_marker_counts_the_bytes_erosion_dropped() { + let input = format!( + "{} {} {}", + "h".repeat(600), + "m".repeat(4000), + "t".repeat(1100) + ); + + let out = ui(&[input.as_bytes()]); + + let (head, rest) = out.split_once('\n').expect("a marker line"); + let (marker, tail) = rest.split_once('\n').expect("a marker line"); + let omitted: usize = marker + .trim_start_matches("... (") + .split_once(' ') + .expect("a byte count") + .0 + .parse() + .expect("a byte count"); + assert_eq!( + head.len() + omitted + tail.len(), + input.len(), + "shown + omitted must account for every input byte" + ); +} diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs b/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs index 63163ceadc..3b94f2ef8a 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs @@ -5,13 +5,46 @@ //! `install_powershell_command`, `build_install_command`); this module owns //! only what happens once a `Command` exists. -use std::io::Read; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use super::install_capture::{drain_into, Capture, LineObserver}; +use super::install_report::{InstallOutcome, InstallReporter}; use crate::managed_agents::InstallStepResult; /// Maximum number of attempts for a transient-looking install command. const INSTALL_MAX_ATTEMPTS: u32 = 3; +/// Absolute wall-clock ceiling for a single install command. +/// +/// This is a ceiling, not an inactivity timeout: nothing observable +/// distinguishes a hung installer from one silently transferring a large +/// artifact (the Goose step downloads a ~79MB release asset with no progress +/// output, and npm at its default log level prints only at the end), so silence +/// alone never kills an install. The previous 300s wall killed +/// slow-but-working installs — Windows Defender scanning every file npm +/// extracts pushes past it routinely (#2401). +/// +/// The cost of a larger ceiling: skipping onboarding does not cancel a running +/// install and a per-runtime guard rejects a second one, so this is also the +/// longest a user who skipped a genuinely *hung* install waits before Install +/// works again in Settings. User-facing cancellation is the product-level fix. +const INSTALL_TIMEOUT: Duration = Duration::from_secs(900); + +/// How long the group gets to exit on SIGTERM before the ceiling escalates to +/// SIGKILL. +#[cfg(unix)] +const TERM_GRACE: Duration = Duration::from_secs(1); + +/// How long the ceiling waits after killing the install's process group — +/// applied separately to reaping the killed child and to the output drains +/// finishing. The kill closes the pipe write ends, so both normally complete +/// within microseconds; the bound covers the cases where they don't (a process +/// that escaped the group and still holds a pipe, or a termination that failed +/// outright). Neither may hold the install — nor the per-runtime concurrency +/// guard behind it — open past the ceiling. +const POST_KILL_GRACE: Duration = Duration::from_secs(2); + /// Run an install command, retrying transient failures with backoff. /// /// Runtime installs pull artifacts over the network — Goose's `curl … | bash` @@ -22,10 +55,24 @@ const INSTALL_MAX_ATTEMPTS: u32 = 3; /// `INSTALL_MAX_ATTEMPTS` times. Failures with no exit code — a timeout or a /// shell that never spawned — are not retried, since re-running them just costs /// the user more time without a plausible path to success. -pub(super) fn run_install_command_with_retry(step: &str, command: &str) -> InstallStepResult { +/// +/// Every attempt is recorded through `reporter`, so the install log holds the +/// full retry history even though the UI only ever sees the last attempt. +pub(super) fn run_install_command_with_retry( + step: &str, + command: &str, + reporter: &InstallReporter, +) -> InstallStepResult { run_install_with_retry( INSTALL_MAX_ATTEMPTS, - |_attempt| run_install_command(step, command), + |attempt| { + // Before the command spawns, so the previous attempt's last line + // stops being displayed for the whole backoff rather than until the + // new attempt happens to print something. + reporter.start_attempt(); + let outcome = run_install_command(step, command, reporter.line_observer()); + reporter.record_attempt(attempt, outcome) + }, std::thread::sleep, ) } @@ -92,11 +139,15 @@ fn prepare_install_command(command: &str) -> Result InstallStepResult { +fn run_install_command( + step: &str, + command: &str, + observer: Option, +) -> InstallOutcome { let mut cmd = match prepare_install_command(command) { Ok(cmd) => cmd, Err(hint) => { - return InstallStepResult { + return InstallOutcome::synthesized(InstallStepResult { step: step.to_string(), command: command.to_string(), success: false, @@ -104,11 +155,11 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { stderr: "no suitable shell found for install commands".to_string(), exit_code: None, hint: Some(hint), - }; + }); } }; - let mut child = match cmd + let child = match cmd .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -116,7 +167,7 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { { Ok(child) => child, Err(e) => { - return InstallStepResult { + return InstallOutcome::synthesized(InstallStepResult { step: step.to_string(), command: command.to_string(), success: false, @@ -124,146 +175,312 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { stderr: format!("failed to spawn shell: {e}"), exit_code: None, hint: None, - }; + }); } }; - // Drain stdout/stderr on background threads to prevent pipe buffer deadlock. + await_install_child(step, command, child, INSTALL_TIMEOUT, observer) +} + +/// Drain a spawned install child's output into bounded buffers and wait for it +/// to exit, killing it at `timeout`. +/// +/// Split from the spawn so the timing-sensitive half is testable without a real +/// login shell: shell startup alone can outlast a short test ceiling on a +/// loaded machine. Production always passes [`INSTALL_TIMEOUT`]. +fn await_install_child( + step: &str, + command: &str, + mut child: std::process::Child, + timeout: Duration, + observer: Option, +) -> InstallOutcome { + // Drain stdout/stderr on background threads to prevent pipe buffer + // deadlock. Each drain feeds a bounded capture the main thread can read at + // any time, so a timeout can still surface whatever the install printed + // before it stalled. + let stdout_capture = Arc::new(Capture::new()); + let stderr_capture = Arc::new(Capture::new()); let stdout_pipe = child.stdout.take(); let stderr_pipe = child.stderr.take(); - let stdout_thread = std::thread::spawn(move || { - let mut buf = String::new(); - if let Some(mut pipe) = stdout_pipe { - let _ = pipe.read_to_string(&mut buf); + // One event stream carries every input the ceiling waits on, so the exit + // and the drains are governed by the same deadline instead of the exit + // releasing the drains from it. + let (events_tx, events) = std::sync::mpsc::channel(); + + std::thread::spawn({ + let (capture, done, observer) = ( + Arc::clone(&stdout_capture), + events_tx.clone(), + observer.clone(), + ); + move || { + if let Some(pipe) = stdout_pipe { + drain_into(pipe, &capture, observer.as_ref()); + } + let _ = done.send(Settled::Drained); } - buf }); - let stderr_thread = std::thread::spawn(move || { - let mut buf = String::new(); - if let Some(mut pipe) = stderr_pipe { - let _ = pipe.read_to_string(&mut buf); + std::thread::spawn({ + let (capture, done) = (Arc::clone(&stderr_capture), events_tx.clone()); + move || { + if let Some(pipe) = stderr_pipe { + drain_into(pipe, &capture, observer.as_ref()); + } + let _ = done.send(Settled::Drained); } - buf }); // Save the PID before moving `child` into the wait thread so we can // kill the process on timeout. let child_pid = child.id(); - let (tx, rx) = std::sync::mpsc::channel(); - let wait_thread = std::thread::spawn(move || { - let status = child.wait(); - let _ = tx.send(status); + std::thread::spawn(move || { + let _ = events_tx.send(Settled::Exited(child.wait())); }); - // 5-minute timeout for install commands. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); - loop { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - // Timeout: kill the child process via its PID, then join all - // threads so nothing leaks. - #[cfg(unix)] - unsafe { - libc::kill(child_pid as i32, libc::SIGTERM); - } - #[cfg(windows)] - { - let _ = crate::managed_agents::taskkill_tree(child_pid); - } - drop(rx); - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return InstallStepResult { + // No thread is ever joined. Each sends its one event before exiting, so a + // join after a complete settle would add nothing — and a join before one + // would reintroduce the unbounded wait this loop exists to prevent. + let mut settle = Settle::default(); + let ended = settle.collect(&events, Instant::now() + timeout); + if ended == Collected::Deadline { + // Ceiling reached: kill the install's whole process group — the install + // shell is a session leader (`setsid` in its `pre_exec`), so signalling + // only the leader would leave descendants running and holding the + // output pipes open. + // + // Whether the leader had already exited decides the verdict. If it had, + // only a descendant was holding a drain open: the install genuinely + // finished and its real status stands. If it had not, the install itself + // was still running and this is a timeout — the status the kill produces + // moments later describes the kill, not the install, so it is discarded. + let install_finished = settle.status.is_some(); + terminate_install_group(child_pid); + // Reaping the child and finishing the drains share one bound. Both + // normally complete within microseconds of the kill, which closes the + // pipes; when they don't — a process that escaped the group still + // holding a pipe, or a termination that failed outright — waiting would + // defeat the very ceiling that fired and keep the per-runtime install + // guard behind it closed. Stragglers are detached instead; the captures + // are read under the lock either way. + settle.collect(&events, Instant::now() + POST_KILL_GRACE); + if !install_finished { + return failed_with_capture( + step, + command, + timeout_message(timeout), + &stdout_capture, + &stderr_capture, + ); + } + } + + match settle.status { + Some(Ok(status)) => InstallOutcome { + step: InstallStepResult { step: step.to_string(), command: command.to_string(), - success: false, - stdout: String::new(), - stderr: "install command timed out after 5 minutes".to_string(), - exit_code: None, + success: status.success(), + stdout: stdout_capture.ui(), + stderr: stderr_capture.ui(), + exit_code: status.code(), hint: None, - }; - } + }, + log_stdout: stdout_capture.log(), + log_stderr: stderr_capture.log(), + }, + Some(Err(e)) => failed_with_capture( + step, + command, + format!("failed to check process status: {e}"), + &stdout_capture, + &stderr_capture, + ), + // Every sender is gone without an exit ever arriving. + None => failed_with_capture( + step, + command, + "internal error: install wait ended without a status".to_string(), + &stdout_capture, + &stderr_capture, + ), + } +} - match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { - Ok(Ok(status)) => { - let _ = wait_thread.join(); - let stdout = stdout_thread.join().unwrap_or_default(); - let stderr_raw = stderr_thread.join().unwrap_or_default(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: status.success(), - stdout: truncate_output(stdout), - stderr: truncate_output(stderr_raw), - exit_code: status.code(), - hint: None, - }; - } - Ok(Err(e)) => { - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: format!("failed to check process status: {e}"), - exit_code: None, - hint: None, - }; - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - // Still running; loop and check deadline again. - continue; - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - // wait_thread dropped sender without sending — shouldn't happen. - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: "internal error: wait thread disconnected".to_string(), - exit_code: None, - hint: None, - }; +/// One input the ceiling waits on. +enum Settled { + Exited(std::io::Result), + Drained, +} + +/// How a bounded [`Settle::collect`] ended. +#[derive(PartialEq, Debug)] +enum Collected { + /// The child exited and both drains reached EOF. + Complete, + /// The deadline passed first. + Deadline, + /// Every sender is gone — a thread died without reporting. + Disconnected, +} + +/// What the install has settled so far: the child's exit status once it is +/// known, and how many of the two drains have reached EOF. +/// +/// Collecting is resumable, so the ceiling can fold more events into the same +/// state under a second, post-kill deadline. +#[derive(Default)] +struct Settle { + status: Option>, + drained: usize, +} + +impl Settle { + const DRAINS: usize = 2; + + fn is_complete(&self) -> bool { + self.status.is_some() && self.drained >= Self::DRAINS + } + + /// Fold events until the install has fully settled or `deadline` passes. + /// + /// The exit and the drains share one deadline deliberately: a shell can exit + /// while a descendant it left behind still holds the inherited output pipes, + /// and waiting on those drains outside the deadline would let such a + /// descendant outlast the ceiling — holding the per-runtime install guard, + /// which is the very failure the ceiling exists to prevent. + fn collect( + &mut self, + events: &std::sync::mpsc::Receiver, + deadline: Instant, + ) -> Collected { + while !self.is_complete() { + let remaining = deadline.saturating_duration_since(Instant::now()); + match events.recv_timeout(remaining) { + Ok(Settled::Exited(status)) => self.status = Some(status), + Ok(Settled::Drained) => self.drained += 1, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Collected::Deadline, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Collected::Disconnected + } } } + Collected::Complete } } -/// Cap output to head + tail to avoid flooding the UI with large error dumps, -/// while preserving the most useful parts of the output. -fn truncate_output(s: String) -> String { - const HEAD: usize = 512; - const TAIL: usize = 1024; - const LIMIT: usize = HEAD + TAIL; - if s.len() <= LIMIT { - return s; - } - let head_end = floor_char_boundary(&s, HEAD); - let tail_start = floor_char_boundary(&s, s.len().saturating_sub(TAIL)); - let omitted = tail_start - head_end; - format!( - "{}\n... ({omitted} bytes omitted) ...\n{}", - &s[..head_end], - &s[tail_start..] - ) +/// Kill the install's process group, escalating on the *tree's* liveness. +/// +/// The install ceiling owns this rather than reusing +/// `managed_agents::terminate_process`, which escalates to SIGKILL only while +/// the group *leader* is still running: a descendant that ignores SIGTERM +/// outlives the leader, keeps the output pipes open, and never receives the +/// group SIGKILL. The ceiling's contract is that nothing survives it, and the +/// shared helper's escalation is load-bearing for the agent stop/restore paths, +/// so the stricter rule lives here instead of changing it for them. +/// +/// Nothing is returned: every outcome — including a signal that could not be +/// delivered at all — has the same handling, the bounded waits at the call +/// site. +#[cfg(unix)] +fn terminate_install_group(pid: u32) { + signal_install_tree(pid, libc::SIGTERM); + let deadline = Instant::now() + TERM_GRACE; + while install_tree_is_alive(pid) { + if Instant::now() >= deadline { + signal_install_tree(pid, libc::SIGKILL); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// Signal every process in `pid`'s group, falling back to the leader alone when +/// the group cannot be signalled — the leader may have changed groups, or macOS +/// may refuse one member — since killing the install shell beats killing +/// nothing. +#[cfg(unix)] +fn signal_install_tree(pid: u32, signal: i32) { + if unsafe { libc::kill(-(pid as i32), signal) } != 0 { + unsafe { libc::kill(pid as i32, signal) }; + } +} + +/// Whether anything the ceiling aimed at is still running: a member of the +/// process group, or the leader itself. +#[cfg(unix)] +fn install_tree_is_alive(pid: u32) -> bool { + signal_reaches(-(pid as i32)) || signal_reaches(pid as i32) +} + +/// `kill(target, 0)` distinguishes "nothing there" (`ESRCH`) from every other +/// outcome. Anything ambiguous — notably `EPERM` for a member we may not +/// signal — counts as alive, so an unclear answer escalates rather than +/// declaring the tree dead. +#[cfg(unix)] +fn signal_reaches(target: i32) -> bool { + if unsafe { libc::kill(target, 0) } == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) +} + +/// Windows has no process groups on this path: `terminate_process` runs +/// `taskkill /T /F`, which is already tree-wide and unconditional, so there is +/// no escalation to get wrong. +#[cfg(not(unix))] +fn terminate_install_group(pid: u32) { + let _ = crate::managed_agents::terminate_process(pid); } -fn floor_char_boundary(s: &str, mut index: usize) -> usize { - index = index.min(s.len()); - while index > 0 && !s.is_char_boundary(index) { - index -= 1; +/// A failure carrying whatever the drains captured, with `reason` leading +/// stderr so the surfaced message names the failure before the install's own +/// output. +fn failed_with_capture( + step: &str, + command: &str, + reason: String, + stdout: &Capture, + stderr: &Capture, +) -> InstallOutcome { + InstallOutcome { + step: InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: stdout.ui(), + stderr: lead_with_reason(&reason, stderr.ui()), + exit_code: None, + hint: None, + }, + log_stdout: stdout.log(), + log_stderr: lead_with_reason(&reason, stderr.log()), } - index +} + +/// Put `reason` ahead of the install's own stderr, so the surfaced message names +/// the failure before the output. An empty capture leaves the reason alone, +/// without a dangling separator. +fn lead_with_reason(reason: &str, captured: String) -> String { + if captured.is_empty() { + reason.to_string() + } else { + format!("{reason}\n{captured}") + } +} + +/// Name the limit that fired and its value, so a ceiling kill is +/// distinguishable from the installer's own failure. +fn timeout_message(timeout: Duration) -> String { + let secs = timeout.as_secs(); + let limit = if secs >= 60 { + format!("{}-minute", secs / 60) + } else { + format!("{secs}-second") + }; + format!("install command exceeded the {limit} ceiling and was terminated") } #[cfg(test)] @@ -410,48 +627,304 @@ mod tests { assert_eq!(cmd.get_current_dir(), Some(expected.as_path())); } - // ── output truncation ───────────────────────────────────────────────────── + // ── install ceiling ─────────────────────────────────────────────────────── + + /// The ceiling is Will's ruling: 15 minutes, and the error names the limit + /// that fired so a ceiling kill is not mistaken for the installer's own + /// failure. + #[test] + fn test_ceiling_is_fifteen_minutes_and_error_names_it() { + assert_eq!(INSTALL_TIMEOUT, Duration::from_secs(900)); + assert!( + timeout_message(INSTALL_TIMEOUT).contains("15-minute"), + "got: {}", + timeout_message(INSTALL_TIMEOUT) + ); + } + + /// Spawn `script` under `sh` as a process-group leader with piped output — + /// the same shape [`run_install_command`] hands to + /// [`await_install_child`], minus the login shell whose own startup can + /// outlast a short test ceiling. + #[cfg(unix)] + fn spawn_group_leader(script: &str) -> std::process::Child { + use std::os::unix::process::CommandExt; + + let mut cmd = std::process::Command::new("/bin/sh"); + cmd.arg("-c").arg(script); + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("sh must spawn") + } + + /// A command killed by the ceiling must surface what it printed before + /// stalling — that partial output is the only evidence of where the install + /// got stuck — and must stay unretryable, since re-running a hang just + /// costs the user another ceiling. + #[cfg(unix)] + #[test] + fn test_ceiling_returns_captured_output_and_stays_unretryable() { + let child = spawn_group_leader("echo out-before-hang; echo err-before-hang >&2; sleep 60"); + + let started = Instant::now(); + let outcome = await_install_child("cli", "install", child, Duration::from_secs(5), None); + let result = &outcome.step; + + assert!(!result.success); + assert_eq!(result.exit_code, None, "a killed command has no exit code"); + assert!( + !install_failure_is_retryable(result), + "a ceiling kill must not be retried" + ); + assert!( + result.stdout.contains("out-before-hang"), + "stdout captured before the stall must survive, got: {:?}", + result.stdout + ); + assert!( + result.stderr.contains("5-second ceiling"), + "stderr must name the ceiling that actually fired, got: {:?}", + result.stderr + ); + assert!( + result.stderr.contains("err-before-hang"), + "stderr captured before the stall must survive, got: {:?}", + result.stderr + ); + assert!( + started.elapsed() < Duration::from_secs(30), + "the ceiling must not wait on the hung command's own exit" + ); + assert!( + outcome.log_stderr.contains("err-before-hang"), + "the log record of a ceiling kill must carry the output too, got: {:?}", + outcome.log_stderr + ); + } + + /// A failure whose stream captured nothing surfaces the reason alone — no + /// dangling separator from an empty capture. + #[test] + fn test_failure_with_no_captured_output_reports_only_the_reason() { + let result = failed_with_capture( + "cli", + "curl … | bash", + "boom".to_string(), + &Capture::new(), + &Capture::new(), + ) + .step; + + assert_eq!(result.stdout, ""); + assert_eq!(result.stderr, "boom"); + } + + // ── post-kill settle bound ──────────────────────────────────────────────── + + /// A sender that never arrives — the shape of a failed termination, whose + /// child is never reaped — must not extend the wait past its deadline. + #[test] + fn test_settling_on_a_message_that_never_arrives_stops_at_the_deadline() { + let (_tx, events) = std::sync::mpsc::channel::(); + + let started = Instant::now(); + let ended = Settle::default().collect(&events, started + Duration::from_millis(200)); + + assert_eq!(ended, Collected::Deadline); + assert!( + started.elapsed() < Duration::from_secs(1), + "the wait must end at its deadline, took {:?}", + started.elapsed() + ); + } + + /// An exit alone is not a settle: the drains are inputs to the same wait, so + /// a shell that exited while a descendant holds a pipe still hits the + /// deadline instead of being released from it. + #[test] + fn test_exit_without_drains_still_hits_the_deadline() { + let (tx, events) = std::sync::mpsc::channel(); + tx.send(Settled::Exited(Ok(exit_status_zero()))).unwrap(); + + let started = Instant::now(); + let mut settle = Settle::default(); + let ended = settle.collect(&events, started + Duration::from_millis(200)); + + assert_eq!( + ended, + Collected::Deadline, + "a leader exit must not complete the settle while a drain is outstanding" + ); + assert!(settle.status.is_some(), "the exit status must be retained"); + assert!(started.elapsed() < Duration::from_secs(1)); + } - /// Output within the cap is passed through byte-for-byte — no marker, no loss. + /// The settle completes only when the exit and both drains have arrived, and + /// it is resumable: state folded under the first deadline carries into the + /// post-kill one. #[test] - fn test_truncate_output_leaves_short_output_untouched() { - let short = "a".repeat(1536); + fn test_settle_completes_on_exit_plus_both_drains_and_resumes() { + let (tx, events) = std::sync::mpsc::channel(); + tx.send(Settled::Drained).unwrap(); + + let mut settle = Settle::default(); + assert_eq!( + settle.collect(&events, Instant::now() + Duration::from_millis(50)), + Collected::Deadline + ); + + tx.send(Settled::Exited(Ok(exit_status_zero()))).unwrap(); + tx.send(Settled::Drained).unwrap(); + + assert_eq!( + settle.collect(&events, Instant::now() + Duration::from_secs(5)), + Collected::Complete, + "the second collect must build on the first's state, not restart it" + ); + } - assert_eq!(truncate_output(short.clone()), short); + /// Exit status of a trivially successful command, for driving `Settle` + /// without a real install. + fn exit_status_zero() -> std::process::ExitStatus { + std::process::Command::new("true") + .status() + .expect("run `true`") } - /// Over the cap, both ends survive and the middle is replaced by a marker - /// naming the omitted byte count — the head keeps the command's opening - /// context and the tail keeps the error that usually trails. + /// A shell can exit while a descendant it left behind still holds the + /// inherited output pipes. If the exit released the drains from the + /// deadline, that descendant would hold the install — and the per-runtime + /// concurrency guard behind it — open indefinitely, which is exactly the + /// failure the ceiling exists to prevent. The leader here exits in + /// milliseconds; only the descendant outlives the ceiling. + #[cfg(unix)] #[test] - fn test_truncate_output_keeps_head_and_tail_with_marker() { - let input = format!( - "{}{}{}", - "H".repeat(512), - "M".repeat(4000), - "T".repeat(1024) + fn test_promptly_exited_leader_with_a_pipe_holding_descendant_still_obeys_the_ceiling() { + let dir = tempfile::tempdir().expect("tempdir"); + let pidfile = dir.path().join("lingering.pid"); + let child = spawn_group_leader(&format!( + "sh -c 'echo $$ > {pid}; sleep 120' & exit 3", + pid = pidfile.display() + )); + + let started = Instant::now(); + let outcome = await_install_child("cli", "install", child, Duration::from_secs(2), None); + + assert!( + started.elapsed() < Duration::from_secs(30), + "a descendant holding the pipe must not outlast the ceiling, took {:?}", + started.elapsed() ); + assert_eq!( + outcome.step.exit_code, + Some(3), + "the leader's real status outranks the ceiling's verdict once it is known" + ); + // The deadline must still reach the kill on this path: a leader exit that + // skipped termination would leave the descendant running with the pipes + // open, which is the defect itself rather than a detail of it. + let pid = recorded_pid(&pidfile); + assert!( + await_death(pid), + "descendant {pid} survived — a leader exit must not skip the ceiling's kill" + ); + } - let out = truncate_output(input); + /// Wait up to 3s for `pid` to disappear. + #[cfg(unix)] + fn await_death(pid: u32) -> bool { + for _ in 0..30 { + if !crate::managed_agents::process_is_running(pid) { + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + false + } + + /// Read the pid a test descendant recorded for itself. + #[cfg(unix)] + fn recorded_pid(pidfile: &std::path::Path) -> u32 { + for _ in 0..50 { + if let Ok(text) = std::fs::read_to_string(pidfile) { + if let Ok(pid) = text.trim().parse() { + return pid; + } + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("the descendant never recorded its pid at {pidfile:?}"); + } + + /// The install shell is a process-group leader, and its descendants inherit + /// the output pipes. Killing only the leader leaves them running and the + /// drains blocked on a pipe nobody will close, so the ceiling kills the + /// whole group. + #[cfg(unix)] + #[test] + fn test_ceiling_kills_descendants_holding_the_output_pipe() { + let dir = tempfile::tempdir().expect("tempdir"); + let pidfile = dir.path().join("descendant.pid"); + let child = spawn_group_leader(&format!( + "sh -c 'echo $$ > {pid}; sleep 60' & echo leader-up; sleep 60", + pid = pidfile.display() + )); + + let started = Instant::now(); + let outcome = await_install_child("cli", "install", child, Duration::from_secs(5), None); + let result = &outcome.step; - assert!(out.starts_with(&"H".repeat(512))); - assert!(out.ends_with(&"T".repeat(1024))); + assert!(!result.success); assert!( - out.contains("... (4000 bytes omitted) ..."), - "marker must name the omitted byte count, got: {out}" + started.elapsed() < Duration::from_secs(30), + "the drains must not block on a descendant's inherited pipe" + ); + + let pid = recorded_pid(&pidfile); + assert!( + await_death(pid), + "descendant {pid} survived the ceiling kill — the group was not signalled" ); } - /// Truncation must not split a multi-byte character. Cutting mid-codepoint - /// would panic on the slice; the boundary floor prevents it. + /// Escalation must key off the group, not the leader: a descendant that + /// ignores SIGTERM outlives the leader, and if SIGKILL is skipped because + /// the leader is gone it keeps running with the output pipes open — past the + /// ceiling, and past the concurrency guard that blocks the next install. + #[cfg(unix)] #[test] - fn test_truncate_output_does_not_split_multibyte_characters() { - // "é" is 2 bytes, so every candidate cut index lands mid-character. - let input = "é".repeat(4000); + fn test_ceiling_kills_sigterm_ignoring_descendant() { + let dir = tempfile::tempdir().expect("tempdir"); + let pidfile = dir.path().join("stubborn.pid"); + // An ignored disposition survives exec, so the descendant's own `sleep` + // ignores SIGTERM too — nothing in that subtree dies without SIGKILL. + let child = spawn_group_leader(&format!( + "sh -c 'trap \"\" TERM; echo $$ > {pid}; sleep 60' & echo leader-up; sleep 60", + pid = pidfile.display() + )); + + let started = Instant::now(); + let outcome = await_install_child("cli", "install", child, Duration::from_secs(5), None); + let result = &outcome.step; - let out = truncate_output(input); + assert!(!result.success); + assert!( + started.elapsed() < Duration::from_secs(30), + "a SIGTERM-ignoring descendant must not hold the ceiling open" + ); - assert!(out.contains("bytes omitted"), "input must exceed the cap"); - assert!(!out.contains('\u{fffd}'), "no replacement chars: {out}"); + let pid = recorded_pid(&pidfile); + assert!( + await_death(pid), + "SIGTERM-ignoring descendant {pid} survived — escalation followed the leader, not the group" + ); } } diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_report.rs b/desktop/src-tauri/src/commands/agent_discovery/install_report.rs new file mode 100644 index 0000000000..24bcd3456a --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/install_report.rs @@ -0,0 +1,595 @@ +//! Where an install's output goes: the install log file (complete history) and +//! the live output line in the UI (current progress). +//! +//! Both destinations hang off the same drain seam in +//! [`super::install_capture`], and both are best-effort: an install must never +//! fail because a log write or an event emit did. +//! +//! [`InstallReporter`] owns two explicit lifecycles, because both the log and +//! the live line are meaningless without a notion of "this run": +//! +//! * a **log session**, started once per run, which keeps the previous run's +//! file as `.1` and writes this run's header; and +//! * a **live-event sequence**, monotonic across the whole install, which is +//! what lets the UI drop a superseded line. A per-command retry number +//! cannot do that job — it restarts at 1 for every step. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +use super::install_capture::{LineObserver, Throttle}; +use crate::managed_agents::{InstallRuntimeResult, InstallStepResult}; + +/// One install command's result: what the UI shows, and the log-scale copy of +/// the same output for the log file. +pub(super) struct InstallOutcome { + pub(super) step: InstallStepResult, + pub(super) log_stdout: String, + pub(super) log_stderr: String, +} + +impl InstallOutcome { + /// A step Buzz synthesized rather than ran — a failed prerequisite, or the + /// post-install verification. Its own message is the whole record. + pub(super) fn synthesized(step: InstallStepResult) -> Self { + Self { + log_stdout: step.stdout.clone(), + log_stderr: step.stderr.clone(), + step, + } + } +} + +/// Payload of the `acp-install-output` event. +/// +/// `seq` is monotonic across the entire install, so the UI can drop a line a +/// later step or attempt has already superseded. The retry number cannot serve +/// as that key: it restarts at 1 for every step, so a step that succeeded on +/// attempt 2 would make the next step's attempt-1 output look stale and freeze +/// the display. +/// +/// `line: None` is the *start signal*: an attempt is beginning and the displayed +/// line must clear now. It is emitted unthrottled, because the point is that +/// stale output stops being shown before the new work prints anything. +#[derive(Serialize, Clone, Debug)] +pub(super) struct InstallOutputEvent { + pub(super) runtime_id: String, + pub(super) seq: u64, + pub(super) line: Option, +} + +/// Emits one live output event. Boxed rather than holding an `AppHandle` so the +/// reporter is constructible — and assertable — without a Tauri app. +type EmitEvent = Arc; + +/// Literal secret values scrubbed out of everything this module publishes, in +/// addition to the shapes [`crate::managed_agents::redact_secrets_with`] +/// recognises on its own. +type Secrets = Arc>; + +/// At most four live-output events per second. Coalescing *holds* the newest +/// line rather than dropping it: a burst that ends just before the window +/// closes would otherwise leave the display showing a line the install had +/// already moved past. +const LIVE_LINE_INTERVAL: Duration = Duration::from_millis(250); + +pub(super) struct InstallReporter { + log: Option, + /// `None` when nothing is listening, which is also what makes + /// [`InstallReporter::line_observer`] `None` — the drain then skips line + /// reassembly entirely instead of doing it for no one. + live: Option, + secrets: Secrets, +} + +impl Drop for InstallReporter { + /// Serialise deactivation against in-flight publications: take the + /// exclusive lifecycle write lock, mark the run as inactive, and return — + /// all before the per-runtime concurrency guard releases. + /// + /// Every drain thread holds the shared read guard from its admission check + /// through its `(self.emit)(...)` call, so the write lock here blocks until + /// every in-flight publication has finished. After this returns, `active` + /// is `false` under an exclusive write, and any thread that attempts a new + /// `offer` will read `false` under a read lock and return without emitting. + /// + /// The ordering guarantee: `reporter` is declared after `_guard` in + /// `install_acp_runtime_blocking` (line 311 vs line 306), so Rust drops + /// `reporter` first in reverse-declaration order — deactivation completes + /// before the runtime guard releases and a new install can start. + fn drop(&mut self) { + if let Some(live) = &self.live { + if let Ok(mut active) = live.lifecycle.write() { + *active = false; + } + } + } +} + +impl InstallReporter { + /// The reporter a real install run uses: it starts this run's log session + /// and emits live output events through `app`. + /// + /// `runtime_id` must already be the canonical id from the runtime catalog — + /// the log path is built from it, so resolving it first is what keeps a raw + /// command argument out of a filename. + /// + /// A log that cannot be resolved or opened degrades to no log rather than + /// failing the install: a user with a broken app-data directory still needs + /// the install itself to work. + pub(super) fn for_run(app: &tauri::AppHandle, runtime_id: &str) -> Self { + // Read from the app's own package info rather than the frontend's + // `getVersion` plugin call: the header is written on the Rust side, and + // this cannot fail or be mocked out from under the log. + let app_version = app.package_info().version.to_string(); + let log = crate::managed_agents::storage::install_log_path(app, runtime_id) + .ok() + .and_then(|path| InstallLog::start(&path, runtime_id, &app_version)); + let app = app.clone(); + let emit: EmitEvent = Arc::new(move |event| { + use tauri::Emitter; + let _ = app.emit("acp-install-output", event); + }); + Self::new(runtime_id, log, Some(emit)) + } + + fn new(runtime_id: &str, log: Option, emit: Option) -> Self { + // Snapshot the environment's secrets once, at construction: the install + // inherits this environment, so anything it echoes came from here. + Self::with_secrets(runtime_id, log, emit, env_secret_values()) + } + + /// The reporter over an explicit secret set, which is what makes the + /// scrubbing assertable: a test can name a proxy credential without + /// exporting a real `HTTPS_PROXY` into the process every HTTP client in the + /// suite would then read. + fn with_secrets( + runtime_id: &str, + log: Option, + emit: Option, + secrets: Vec, + ) -> Self { + let secrets: Secrets = Arc::new(secrets); + let live = emit.map(|emit| Live { + runtime_id: Arc::from(runtime_id), + emit, + throttle: Arc::new(Throttle::new(LIVE_LINE_INTERVAL)), + seq: Arc::new(AtomicU64::new(0)), + lifecycle: Arc::new(RwLock::new(true)), + secrets: Arc::clone(&secrets), + }); + Self { log, live, secrets } + } + + /// The log file to point the user at, or `None` when this run has no log — + /// the failure message then omits the pointer rather than naming a file + /// that does not exist. + pub(super) fn log_path(&self) -> Option { + Some(self.log.as_ref()?.path.display().to_string()) + } + + /// A failed install carrying the steps recorded so far and the log holding + /// their full history. Every early return in the install shapes its result + /// here, so none can forget the log pointer the failure message needs. + pub(super) fn failed(&self, steps: Vec) -> InstallRuntimeResult { + InstallRuntimeResult { + success: false, + steps, + restarted_count: 0, + failed_restart_count: 0, + log_path: self.log_path(), + } + } + + /// Mark the start of one executed attempt: clear whatever line the previous + /// attempt left on screen, and start this attempt's clock. + /// + /// The clear is emitted unthrottled and reopens the rate window, so the new + /// attempt's first line cannot be swallowed by the previous attempt's. + /// Without this signal the prior attempt's last line — typically the failure + /// that caused the retry — sits under the spinner through the backoff and + /// through a silent next attempt. + pub(super) fn start_attempt(&self) { + if let Some(log) = &self.log { + log.mark_attempt_start(); + } + if let Some(live) = &self.live { + live.throttle.restart(); + live.publish(None); + } + } + + /// Observer for one attempt's drains, or `None` when nothing is listening. + pub(super) fn line_observer(&self) -> Option { + let live = self.live.clone()?; + Some(Arc::new(move |line: &str| live.offer(line))) + } + + /// Record one executed attempt of a step, returning the step with secrets + /// scrubbed out of the output the UI will render. + /// + /// The scrub happens here rather than at the construction sites because + /// every executed step reaches the caller through this function — the + /// timeout path, the status-check failure, and the ordinary exit all build + /// their `InstallStepResult` straight from the captures. + pub(super) fn record_attempt( + &self, + attempt: u32, + outcome: InstallOutcome, + ) -> InstallStepResult { + // The drains are finished, so a line the throttle is still holding is + // this attempt's last and nothing is coming to replace it. + if let Some(live) = &self.live { + live.flush_pending(); + } + self.write_record(Some(attempt), &outcome); + self.redacted_step(outcome.step) + } + + /// Push a synthesized step onto `steps` and record it. Routing every step + /// through here is what keeps the log complete: a step that reaches the UI + /// without passing this function is invisible in the file. + pub(super) fn record_step(&self, steps: &mut Vec, step: InstallStepResult) { + self.write_record(None, &InstallOutcome::synthesized(step.clone())); + steps.push(self.redacted_step(step)); + } + + /// Scrub the frontend-visible fields of a step. The failure message the UI + /// builds renders `stderr`/`stdout` and the hint verbatim, so they need the + /// same scrubbing as the log record and the live line — the log is not the + /// only place an install's output is read. + fn redacted_step(&self, mut step: InstallStepResult) -> InstallStepResult { + step.command = redact(&step.command, &self.secrets); + step.stdout = redact(&step.stdout, &self.secrets); + step.stderr = redact(&step.stderr, &self.secrets); + step.hint = step.hint.map(|hint| redact(&hint, &self.secrets)); + step + } + + /// Append one record. Best-effort by contract: a full disk or a revoked + /// permission degrades the diagnostics, it does not fail the install. + fn write_record(&self, attempt: Option, outcome: &InstallOutcome) { + let Some(log) = &self.log else { + return; + }; + log.append(&render_record( + attempt, + log.take_attempt_elapsed(), + outcome, + &self.secrets, + )); + } +} + +/// The shared half of the reporter — everything a drain thread's observer needs, +/// owned rather than borrowed so an observer can outlive the call that made it. +#[derive(Clone)] +struct Live { + runtime_id: Arc, + emit: EmitEvent, + throttle: Arc, + seq: Arc, + /// Lifecycle lock: `true` while the run is active, `false` once + /// `InstallReporter` has been dropped. + /// + /// Drain threads hold a **shared read guard** from the admission check + /// through the `(self.emit)(...)` call, making the admit-and-publish pair + /// atomic with respect to deactivation. `InstallReporter::drop` takes the + /// **exclusive write guard** and sets the value to `false`; this blocks + /// until every in-flight publication finishes, then prevents any new + /// publications from starting. The write lock is held only for the flag + /// store and is released before the per-runtime concurrency guard drops, + /// so its duration is bounded by the time a single `emit` call takes — + /// microseconds to low milliseconds for the Tauri IPC broadcast. + lifecycle: Arc>, + secrets: Secrets, +} + +impl Live { + /// Offer one drained line to the rate limiter, emitting it if the window is + /// open and holding it as the newest pending line if not. + /// + /// The read guard is held from the admission check through the emit call so + /// that `InstallReporter::drop`'s write lock must wait for any in-flight + /// publication to complete before deactivating. This makes the + /// check-then-emit pair atomic with respect to shutdown. + fn offer(&self, line: &str) { + let Ok(guard) = self.lifecycle.read() else { + return; + }; + if !*guard { + return; + } + if let Some(line) = self.throttle.offer(line, Instant::now()) { + self.publish_under_guard(line); + } + // `guard` drops here, releasing the read lock after publication. + } + + fn flush_pending(&self) { + let Ok(guard) = self.lifecycle.read() else { + return; + }; + if !*guard { + return; + } + if let Some(line) = self.throttle.take_pending() { + self.publish_under_guard(line); + } + // `guard` drops here, releasing the read lock after publication. + } + + /// Emit `line` now, bypassing the rate window and the lifecycle lock. + /// + /// Only called from `InstallReporter` methods that run on the reporter + /// itself (never from detached drain threads), so no lifecycle guard is + /// needed — the reporter is alive by definition when its own methods run. + fn publish(&self, line: Option) { + (self.emit)(InstallOutputEvent { + runtime_id: self.runtime_id.to_string(), + seq: self.seq.fetch_add(1, Ordering::Relaxed), + line: line.map(|line| redact(&line, &self.secrets)), + }); + } + + /// Publish `line` while already holding a read guard on `lifecycle`. The + /// caller is responsible for checking `active` before calling this. + fn publish_under_guard(&self, line: String) { + (self.emit)(InstallOutputEvent { + runtime_id: self.runtime_id.to_string(), + seq: self.seq.fetch_add(1, Ordering::Relaxed), + line: Some(redact(&line, &self.secrets)), + }); + } +} + +/// This run's log file: one session, opened once, appended to per record. +struct InstallLog { + path: PathBuf, + /// When the attempt currently running started, so its record can name its + /// own duration. A 15-minute ceiling is only diagnosable if the file says + /// how long each attempt actually took. + attempt_start: Mutex>, +} + +impl InstallLog { + /// Start this run's session, or `None` if the file cannot be opened. + /// + /// Rotation happens here, once per run, rather than per record: a run either + /// gets its own file or it gets no log at all, so two runs are never + /// interleaved in one file. + /// + /// The header identifies the environment the run happened in, not just the + /// run: a Windows install failure and a macOS one on the same runtime are + /// different bugs, and a stale app version explains a failure that no longer + /// reproduces. + fn start(path: &Path, runtime_id: &str, app_version: &str) -> Option { + let mut file = crate::managed_agents::storage::start_install_log_session(path).ok()?; + let _ = file.write_all( + format!( + "=== install run runtime={runtime_id} app={app_version} os={} started={}\n", + std::env::consts::OS, + chrono::Utc::now().to_rfc3339() + ) + .as_bytes(), + ); + Some(Self { + path: path.to_path_buf(), + attempt_start: Mutex::new(None), + }) + } + + fn mark_attempt_start(&self) { + if let Ok(mut start) = self.attempt_start.lock() { + *start = Some(Instant::now()); + } + } + + /// How long the attempt being recorded ran, consumed so a later record + /// cannot reuse it. `None` for a synthesized step, which never ran. + fn take_attempt_elapsed(&self) -> Option { + Some(self.attempt_start.lock().ok()?.take()?.elapsed()) + } + + fn append(&self, record: &str) { + if let Ok(mut file) = crate::managed_agents::storage::open_install_log_file(&self.path) { + let _ = file.write_all(record.as_bytes()); + } + } +} + +/// One self-contained record. Each is capped independently by the log-scale +/// capture that produced it, so an early attempt that printed megabytes cannot +/// push a later attempt — or the verification step that explains the failure — +/// out of the file. +fn render_record( + attempt: Option, + elapsed: Option, + outcome: &InstallOutcome, + secrets: &Secrets, +) -> String { + let step = &outcome.step; + let attempt = attempt.map_or_else(|| "-".to_string(), |n| n.to_string()); + let exit = step + .exit_code + .map_or_else(|| "none".to_string(), |code| code.to_string()); + let elapsed = elapsed.map_or_else( + || "-".to_string(), + |elapsed| format!("{:.1}s", elapsed.as_secs_f64()), + ); + let mut record = format!( + "=== {} step={} attempt={attempt} success={} exit={exit} elapsed={elapsed}\n$ {}\n", + chrono::Utc::now().to_rfc3339(), + step.step, + step.success, + redact(&step.command, secrets), + ); + for (label, text) in [ + ("stdout", &outcome.log_stdout), + ("stderr", &outcome.log_stderr), + ] { + if !text.trim().is_empty() { + record.push_str(&format!("--- {label} ---\n{}\n", redact(text, secrets))); + } + } + if let Some(hint) = &step.hint { + record.push_str(&format!("--- hint ---\n{}\n", redact(hint, secrets))); + } + record +} + +/// Scrub secrets before anything reaches disk or the UI. The log is written +/// unattended and the live line is rendered verbatim, so scrubbing happens at +/// the write, not at the read. +fn redact(text: &str, secrets: &Secrets) -> String { + let extras: Vec<&str> = secrets.iter().map(String::as_str).collect(); + crate::managed_agents::redact_secrets_with(text, &extras) +} + +/// Values of environment variables whose *name* marks them as secret. +/// +/// An install inherits Buzz's environment and installers echo it back — npm +/// prints the resolved registry config on an auth failure, and a shell that +/// traces its commands prints every expansion. Without this, only the +/// hard-coded key shapes would be scrubbed, so a plain `NPM_TOKEN` or +/// `ANTHROPIC_API_KEY` would land in the file in clear text. +fn env_secret_values() -> Vec { + secret_values_from(std::env::vars()) +} + +/// The secret-bearing part of each variable that carries one. +/// +/// Split from [`env_secret_values`] so the classification is assertable without +/// mutating the process environment — setting a real `HTTPS_PROXY` in a test +/// would be read by every HTTP client the rest of the suite builds. +/// +/// Three kinds of variable are recognised, because they need different +/// treatment: +/// +/// * **URL-valued variables**, where only the userinfo is the credential; +/// * **exactly named credentials**, whose whole value is the secret; and +/// * **name-marked secrets**, matched by a marker substring. +fn secret_values_from(vars: impl IntoIterator) -> Vec { + vars.into_iter() + .filter_map(|(name, value)| { + let name = name.to_ascii_uppercase(); + if URL_CREDENTIAL_VAR_NAMES.contains(&name.as_str()) { + // Only the userinfo, so the endpoint itself stays named in the + // record: an install that fails against a proxy or a private + // registry is diagnosable only if the log still says which one + // it went through, and the host and port are not the secret. + // Redacting the whole value would erase that while protecting + // nothing more. + return url_userinfo(&value).map(str::to_string); + } + if SECRET_VAR_NAMES.contains(&name.as_str()) { + // Deliberately not subject to the 8-byte floor below: an exact + // name is a fact, not the guess the marker rule makes, so there + // is nothing for a floor to protect against. npm's one-time + // password is six digits and is a credential at that length — + // short, but still above the four-byte minimum + // [`crate::managed_agents::redact_secrets_with`] applies, so it + // survives to be scrubbed. + return (!value.is_empty()).then_some(value); + } + // A value under 8 bytes is more likely a flag like `true` or a + // version than a credential, and scrubbing those makes ordinary + // output unreadable. + (value.len() >= 8 && name_marks_secret(&name)).then_some(value) + }) + .collect() +} + +/// Variables whose value is a URL that may embed a credential in its userinfo. +/// +/// npm's own `npm_config_*` aliases are here too: npm resolves them ahead of +/// the conventional names and echoes the result from `npm config list`, and +/// none of these names carries a marker [`name_marks_secret`] would catch. +/// Matching is case-insensitive because the caller uppercases the name first, +/// which is what npm's lowercase spelling needs. +const URL_CREDENTIAL_VAR_NAMES: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NPM_CONFIG_PROXY", + "NPM_CONFIG_HTTPS_PROXY", + "NPM_CONFIG_REGISTRY", +]; + +/// Variables whose whole value is a credential, recognised by exact name. +/// +/// These are npm's supported credential settings, whose names carry no marker +/// [`name_marks_secret`] would catch. They are listed exactly rather than +/// matched on `KEY` or `AUTH` substrings: those occur throughout an ordinary +/// environment, and scrubbing on them would delete unrelated values from the +/// whole log. +/// +/// * `NPM_CONFIG_KEY` — the PEM client key used to reach a registry. +/// * `NPM_CONFIG__AUTH` — the base64 basic-auth blob (npm's own double +/// underscore, matching the `_auth` setting). +/// * `NPM_CONFIG_OTP` — the registry one-time password. +const SECRET_VAR_NAMES: &[&str] = &["NPM_CONFIG_KEY", "NPM_CONFIG__AUTH", "NPM_CONFIG_OTP"]; + +/// Whether an environment variable's name marks its value as a credential. +/// +/// Keyed on the name because a secret's *value* has no reliable shape. The +/// markers avoid substrings that occur in non-secret names: `AUTH` is left out +/// because it matches `GIT_AUTHOR_NAME`, whose value is a person's name, and +/// personal access tokens match on `_PAT` as a *suffix* rather than a substring +/// — `contains("_PAT")` would match every `*_PATH` variable on the system and +/// scrub directory names out of the whole log. +fn name_marks_secret(name: &str) -> bool { + const SECRET_NAME_MARKERS: &[&str] = &[ + "TOKEN", + "SECRET", + "PASSWORD", + "PASSWD", + "APIKEY", + "API_KEY", + "PRIVATE_KEY", + "ACCESS_KEY", + "CREDENTIAL", + ]; + name.ends_with("_PAT") + || SECRET_NAME_MARKERS + .iter() + .any(|marker| name.contains(marker)) +} + +/// The `user:password` credential embedded in a URL, if it has one. +/// +/// Parsed rather than pattern-matched so a URL with no credential — the common +/// case — contributes nothing to scrub. The last `@` in the +/// authority separates userinfo from host, so a password containing an +/// encoded `@` still splits correctly. +/// +/// A bare username with no password is not treated as a credential: it is not +/// secret on its own, and scrubbing it would erase every occurrence of a word +/// like `user` from the whole record. +fn url_userinfo(value: &str) -> Option<&str> { + let authority = value + .split_once("://")? + .1 + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + let userinfo = authority.rsplit_once('@')?.0; + userinfo.contains(':').then_some(userinfo) +} + +#[cfg(test)] +#[path = "install_report_test_support.rs"] +mod test_support; + +#[cfg(test)] +#[path = "install_report_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "install_report_redaction_tests.rs"] +mod redaction_tests; diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_report_redaction_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/install_report_redaction_tests.rs new file mode 100644 index 0000000000..ce97559616 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/install_report_redaction_tests.rs @@ -0,0 +1,480 @@ +use super::test_support::*; +use super::*; + +// ── redaction ──────────────────────────────────────────────────────────────── + +/// Secrets that an installer echoed must not land on disk. The log is written +/// unattended, so scrubbing happens at the write, not at the read. +#[test] +fn test_log_redacts_secrets_before_writing() { + let h = harness(); + let leak = "npm ERR! token nsec1qqqqqqqqqqsecretvalue failed"; + + h.reporter.record_attempt(1, outcome("cli", false, leak)); + + let log = h.log_contents(); + assert!(!log.contains("nsec1qqqqqqqqqqsecretvalue"), "got: {log}"); + assert!(log.contains("[REDACTED]"), "got: {log}"); +} + +/// The environment's own secrets are scrubbed too, by *name* rather than shape. +/// An install inherits Buzz's environment and installers echo it back — npm +/// prints its resolved config on an auth failure — and a token with no +/// recognizable prefix would otherwise reach the file verbatim. +#[test] +fn test_log_redacts_an_environment_secret_with_no_recognizable_prefix() { + let secret = "0e8f31c5a4b7d296e5f1a"; + // Set before the reporter is built: the snapshot is taken at construction. + std::env::set_var("BUZZ_TEST_REGISTRY_TOKEN", secret); + let h = harness(); + std::env::remove_var("BUZZ_TEST_REGISTRY_TOKEN"); + + h.reporter.record_attempt( + 1, + outcome("cli", false, &format!("npm ERR! _authToken={secret}")), + ); + + let log = h.log_contents(); + assert!(!log.contains(secret), "got: {log}"); + assert!(log.contains("[REDACTED]"), "got: {log}"); +} + +/// A live line carries the same scrubbing as the log record. The line is +/// rendered verbatim in the UI, so a leak there is as visible as one on disk. +#[test] +fn test_a_live_line_is_redacted_before_it_is_emitted() { + let h = harness(); + + let observer = h.reporter.line_observer().expect("an observer"); + observer("fetching with token nsec1qqqqqqqqqqleaked"); + + let lines = h.lines(); + assert_eq!(lines.len(), 1); + let line = lines[0].clone().expect("a line, not a clear signal"); + assert!(!line.contains("nsec1qqqqqqqqqqleaked"), "got: {line}"); + assert!(line.contains("[REDACTED]"), "got: {line}"); +} + +// ── proxy and PAT credentials ──────────────────────────────────────────────── + +/// A proxy URL's password is a credential, but the proxy itself is diagnostic +/// information: an install that fails behind a proxy is only debuggable if the +/// record still says which proxy it went through. So the userinfo is scrubbed +/// and the host is kept. +#[test] +fn test_proxy_userinfo_is_secret_but_the_proxy_host_is_not() { + let secrets = secret_values_from([( + "HTTPS_PROXY".to_string(), + "http://corpuser:hunter2pass@proxy.example:8080".to_string(), + )]); + + assert_eq!(secrets, vec!["corpuser:hunter2pass"]); +} + +/// A proxy with no credential contributes nothing — scrubbing a bare host would +/// erase the proxy's name from every record while protecting nothing. A bare +/// username is not a credential either, and scrubbing it would delete every +/// occurrence of that word from the log. +#[test] +fn test_a_proxy_without_credentials_contributes_no_secret() { + let secrets = secret_values_from([ + ( + "HTTP_PROXY".to_string(), + "http://proxy.example:8080".to_string(), + ), + ( + "ALL_PROXY".to_string(), + "socks5://10.0.0.1:1080".to_string(), + ), + ( + "HTTPS_PROXY".to_string(), + "http://user@proxy.example:8080".to_string(), + ), + ]); + + assert!(secrets.is_empty(), "got: {secrets:?}"); +} + +/// npm reads its own `npm_config_*` aliases in preference to the conventional +/// proxy variables and prints the resolved value back, so a credential set only +/// under an alias would otherwise never enter the scrub list. npm spells them +/// in lowercase, so both cases have to classify. +#[test] +fn test_npm_proxy_aliases_are_classified_in_either_case() { + let secrets = secret_values_from([ + ( + "npm_config_proxy".to_string(), + "http://corpuser:lowerplain@proxy.example:8080".to_string(), + ), + ( + "NPM_CONFIG_PROXY".to_string(), + "http://corpuser:upperplain@proxy.example:8080".to_string(), + ), + ( + "npm_config_https_proxy".to_string(), + "http://corpuser:lowertls@proxy.example:8080".to_string(), + ), + ( + "NPM_CONFIG_HTTPS_PROXY".to_string(), + "http://corpuser:uppertls@proxy.example:8080".to_string(), + ), + ]); + + assert_eq!( + secrets, + vec![ + "corpuser:lowerplain", + "corpuser:upperplain", + "corpuser:lowertls", + "corpuser:uppertls", + ] + ); +} + +/// The alias carries the same userinfo-only policy as the conventional names: +/// a credential-less alias contributes nothing, so the proxy stays named in the +/// record. +#[test] +fn test_an_npm_proxy_alias_without_credentials_contributes_no_secret() { + let secrets = secret_values_from([ + ( + "npm_config_proxy".to_string(), + "http://proxy.example:8080".to_string(), + ), + ( + "NPM_CONFIG_HTTPS_PROXY".to_string(), + "http://user@proxy.example:8080".to_string(), + ), + ]); + + assert!(secrets.is_empty(), "got: {secrets:?}"); +} + +/// The classifier and the reporter have to agree: an alias credential that +/// classifies but never reaches the scrub list still leaks. This drives the +/// reporter with exactly what the classifier produced for an alias, and asserts +/// the log and the returned step both come back clean. +#[test] +fn test_an_npm_alias_credential_is_redacted_from_the_log_and_the_returned_step() { + let password = "hunter2pass"; + let h = harness_with_secrets(secret_values_from([( + "npm_config_proxy".to_string(), + format!("http://corpuser:{password}@proxy.example:8080"), + )])); + + let returned = h.reporter.record_attempt( + 1, + InstallOutcome { + step: InstallStepResult { + stderr: format!( + "npm ERR! proxy=http://corpuser:{password}@proxy.example:8080 tunneling failed" + ), + ..step("cli", false, "") + }, + log_stdout: String::new(), + log_stderr: format!( + "npm config: proxy = http://corpuser:{password}@proxy.example:8080" + ), + }, + ); + + let log = h.log_contents(); + assert!(!log.contains(password), "log leaked the password: {log}"); + assert!( + log.contains("proxy.example"), + "the proxy host is diagnostic and must survive: {log}" + ); + assert!( + !returned.stderr.contains(password), + "the returned step leaked the password: {}", + returned.stderr + ); +} + +// ── npm's own credential settings ──────────────────────────────────────────── + +/// npm accepts every one of its settings as an `npm_config_*` variable, so a +/// registry client key, a basic-auth blob or a one-time password can arrive +/// under a name that carries no marker. Their whole value is the credential — +/// unlike a proxy, none of it is diagnostic — and npm spells them in lowercase. +#[test] +fn test_npm_credential_configs_are_secret_in_either_case() { + let secrets = secret_values_from([ + ( + "npm_config_key".to_string(), + "-----BEGIN PRIVATE KEY-----lowerkey".to_string(), + ), + ( + "NPM_CONFIG_KEY".to_string(), + "-----BEGIN PRIVATE KEY-----upperkey".to_string(), + ), + ("npm_config__auth".to_string(), "bG93ZXJhdXRo".to_string()), + ("NPM_CONFIG__AUTH".to_string(), "dXBwZXJhdXRo".to_string()), + ("npm_config_otp".to_string(), "618243".to_string()), + ("NPM_CONFIG_OTP".to_string(), "907154".to_string()), + ]); + + assert_eq!( + secrets, + vec![ + "-----BEGIN PRIVATE KEY-----lowerkey", + "-----BEGIN PRIVATE KEY-----upperkey", + "bG93ZXJhdXRo", + "dXBwZXJhdXRo", + "618243", + "907154", + ] + ); +} + +/// An unset-but-exported credential is empty, and an empty needle would match +/// everywhere. The name being exact does not make a blank value a secret. +#[test] +fn test_an_empty_npm_credential_config_contributes_no_secret() { + let secrets = secret_values_from([ + ("NPM_CONFIG_KEY".to_string(), String::new()), + ("npm_config_otp".to_string(), String::new()), + ]); + + assert!(secrets.is_empty(), "got: {secrets:?}"); +} + +/// A private registry's URL follows the proxy policy rather than the whole-value +/// one: which registry an install talked to is exactly what a 401 or an ETIMEDOUT +/// has to be read against, so only the userinfo is the secret. +#[test] +fn test_npm_registry_userinfo_is_secret_but_the_registry_host_is_not() { + let secrets = secret_values_from([( + "npm_config_registry".to_string(), + "https://builder:hunter2pass@registry.example/api/npm/".to_string(), + )]); + + assert_eq!(secrets, vec!["builder:hunter2pass"]); +} + +/// The public registry — and any private one reached with a token header rather +/// than URL credentials — contributes nothing, so the registry stays named in +/// the record. +#[test] +fn test_an_npm_registry_without_credentials_contributes_no_secret() { + let secrets = secret_values_from([ + ( + "npm_config_registry".to_string(), + "https://registry.npmjs.org/".to_string(), + ), + ( + "NPM_CONFIG_REGISTRY".to_string(), + "https://builder@registry.example/api/npm/".to_string(), + ), + ]); + + assert!(secrets.is_empty(), "got: {secrets:?}"); +} + +/// The credential settings are matched by exact name, never by a `KEY` or +/// `AUTH` substring. Those occur throughout an ordinary environment on values +/// that are paths, agent sockets and people's names, and scrubbing them would +/// delete unrelated text from every record. +#[test] +fn test_key_and_auth_inside_a_variable_name_do_not_make_it_secret() { + let secrets = secret_values_from([ + ( + "SSH_AUTH_SOCK".to_string(), + "/tmp/ssh-agent.socket".to_string(), + ), + ("GIT_AUTHOR_NAME".to_string(), "Ada Lovelace".to_string()), + ( + "KEYCHAIN".to_string(), + "/Users/dev/Library/login.keychain".to_string(), + ), + ( + "NPM_CONFIG_KEYFILE".to_string(), + "/Users/dev/.npm/client.pem".to_string(), + ), + ]); + + assert!(secrets.is_empty(), "got: {secrets:?}"); +} + +/// The wiring, not just the classification: npm prints its resolved config on an +/// auth failure, so each of these has to be gone from the log and from the step +/// the frontend renders. The one-time password is the interesting one — at six +/// digits it is far shorter than any other secret here, and a value under four +/// bytes is dropped by the shared redactor rather than scrubbed. +#[test] +fn test_npm_credential_configs_are_redacted_from_the_log_and_the_returned_step() { + let client_key = "-----BEGIN PRIVATE KEY-----MIIEvQIBADAN"; + let auth = "YnVpbGRlcjpodW50ZXIycGFzcw=="; + let otp = "618243"; + let registry_password = "hunter2pass"; + let h = harness_with_secrets(secret_values_from([ + ("npm_config_key".to_string(), client_key.to_string()), + ("npm_config__auth".to_string(), auth.to_string()), + ("npm_config_otp".to_string(), otp.to_string()), + ( + "npm_config_registry".to_string(), + format!("https://builder:{registry_password}@registry.example/api/npm/"), + ), + ])); + + let returned = h.reporter.record_attempt( + 1, + InstallOutcome { + step: InstallStepResult { + stderr: format!("npm ERR! 401 otp={otp} _auth={auth}"), + ..step("cli", false, "") + }, + log_stdout: format!("npm config: key = {client_key}"), + log_stderr: format!( + "npm config: registry = https://builder:{registry_password}@registry.example/api/npm/" + ), + }, + ); + + let log = h.log_contents(); + for secret in [client_key, auth, otp, registry_password] { + assert!(!log.contains(secret), "log leaked {secret}: {log}"); + } + assert!( + log.contains("registry.example"), + "the registry host is diagnostic and must survive: {log}" + ); + assert!( + !returned.stderr.contains(otp) && !returned.stderr.contains(auth), + "the returned step leaked a credential: {}", + returned.stderr + ); +} + +/// `*_PATH` variables must not be mistaken for personal access tokens. A +/// `contains("_PAT")` rule would match `PATH` itself and scrub every directory +/// name out of the log, which is why the rule matches `_PAT` as a suffix. +#[test] +fn test_a_path_variable_is_not_treated_as_a_personal_access_token() { + let secrets = secret_values_from([ + ("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string()), + ("GOPATH".to_string(), "/home/user/go".to_string()), + ( + "CARGO_HOME_PATH".to_string(), + "/home/user/.cargo".to_string(), + ), + ]); + + assert!(secrets.is_empty(), "got: {secrets:?}"); +} + +/// Variables named as personal access tokens are secret by name, whatever shape +/// their value has. +#[test] +fn test_pat_named_variables_are_secret() { + let secrets = secret_values_from([ + ( + "GITHUB_PAT".to_string(), + "ghp_abcdefghij0123456789".to_string(), + ), + ( + "GH_PAT".to_string(), + "github_pat_abcdefghij0123".to_string(), + ), + ]); + + assert_eq!(secrets.len(), 2, "got: {secrets:?}"); +} + +/// The whole point of the widening: a proxy password and a PAT that the +/// installer echoed reach neither the log nor the live line. +/// +/// Both are checked through the real reporter rather than the classifier, so +/// this covers the wiring — a classifier that recognises a secret the reporter +/// never consults would still leak. +#[test] +fn test_proxy_and_pat_credentials_are_redacted_from_the_log_and_the_live_line() { + let proxy_password = "hunter2pass"; + let pat = "ghp_abcdefghij0123456789"; + // The classifier's own tests cover recognising these under their real + // variable names; injecting the resulting secrets here keeps a live + // `HTTPS_PROXY` out of the process the rest of the suite shares. + let h = harness_with_secrets(vec![format!("corpuser:{proxy_password}"), pat.to_string()]); + + h.reporter.record_attempt( + 1, + outcome( + "cli", + false, + &format!( + "npm ERR! proxy=http://corpuser:{proxy_password}@proxy.example authToken={pat}" + ), + ), + ); + let observer = h.reporter.line_observer().expect("an observer"); + observer(&format!("cloning https://{pat}@github.com/org/repo")); + + let log = h.log_contents(); + assert!( + !log.contains(proxy_password), + "log leaked the proxy password: {log}" + ); + assert!(!log.contains(pat), "log leaked the PAT: {log}"); + assert!( + log.contains("proxy.example"), + "the proxy host is diagnostic and must survive: {log}" + ); + + let line = h.lines().into_iter().flatten().next().expect("a live line"); + assert!(!line.contains(pat), "live line leaked the PAT: {line}"); + assert!(line.contains("[REDACTED]"), "got: {line}"); +} + +/// The third surface: the step returned to the frontend. `getInstallErrorMessage` +/// renders the failing step's stderr verbatim, so a secret that the log and the +/// live line both scrub would still reach the user through the error dialog. +#[test] +fn test_a_returned_step_is_redacted_before_the_frontend_renders_it() { + let pat = "ghp_abcdefghij0123456789"; + let h = harness_with_secrets(vec![pat.to_string()]); + + let returned = h.reporter.record_attempt( + 1, + InstallOutcome { + step: InstallStepResult { + stdout: format!("configuring remote with {pat}"), + stderr: format!("fatal: authentication failed for token {pat}"), + hint: Some(format!("check that {pat} has the repo scope")), + ..step("cli", false, "") + }, + log_stdout: String::new(), + log_stderr: String::new(), + }, + ); + + assert!(!returned.stdout.contains(pat), "got: {}", returned.stdout); + assert!(!returned.stderr.contains(pat), "got: {}", returned.stderr); + let hint = returned.hint.expect("a hint"); + assert!(!hint.contains(pat), "got: {hint}"); +} + +/// A synthesized step reaches the frontend through the other funnel, and needs +/// the same scrubbing — the managed-node prerequisite failures are built this +/// way and carry whatever the underlying command printed. +#[test] +fn test_a_synthesized_step_is_redacted_before_it_reaches_the_caller() { + let pat = "ghp_abcdefghij0123456789"; + let h = harness_with_secrets(vec![pat.to_string()]); + let mut steps = Vec::new(); + + h.reporter.record_step( + &mut steps, + InstallStepResult { + stderr: format!("npm ERR! 401 with {pat}"), + ..step("adapter", false, "") + }, + ); + + assert_eq!(steps.len(), 1); + assert!(!steps[0].stderr.contains(pat), "got: {}", steps[0].stderr); + assert!( + steps[0].stderr.contains("[REDACTED]"), + "got: {}", + steps[0].stderr + ); +} diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_report_test_support.rs b/desktop/src-tauri/src/commands/agent_discovery/install_report_test_support.rs new file mode 100644 index 0000000000..17b2789560 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/install_report_test_support.rs @@ -0,0 +1,106 @@ +//! Shared harness for the install-report test modules. +//! +//! The tests are split by concern — redaction in +//! [`super::install_report_redaction_tests`], everything else in +//! [`super::install_report_tests`] — and both drive the reporter through the +//! same harness, so it lives here rather than in either of them. + +use super::*; +use std::sync::Mutex; + +/// Stands in for the real `app.package_info().version`, which needs a Tauri app. +pub(crate) const TEST_APP_VERSION: &str = "9.9.9"; + +/// A reporter with a started log session in a temp dir, and the emitted events +/// captured. +pub(crate) struct Harness { + /// Kept alive so the log outlives the harness; a test that reuses the + /// directory for a second run takes it. + pub(crate) _dir: tempfile::TempDir, + pub(crate) log: PathBuf, + pub(crate) reporter: InstallReporter, + pub(crate) events: Arc>>, +} + +pub(crate) fn harness() -> Harness { + harness_at(None) +} + +/// A harness whose log lives in `dir`, or in a fresh temp dir when `dir` is +/// `None`. Passing a directory lets a test seed a previous run's file first. +pub(crate) fn harness_at(dir: Option) -> Harness { + harness_inner(dir, None) +} + +/// A harness whose reporter scrubs exactly `secrets`, so a proxy or PAT +/// credential can be asserted without exporting it into the process. +pub(crate) fn harness_with_secrets(secrets: Vec) -> Harness { + harness_inner(None, Some(secrets)) +} + +fn harness_inner(dir: Option, secrets: Option>) -> Harness { + let dir = dir.unwrap_or_else(|| tempfile::tempdir().expect("tempdir")); + let log = dir.path().join("install-goose.log"); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let emit: EmitEvent = { + let events = Arc::clone(&events); + Arc::new(move |event| events.lock().unwrap().push(event)) + }; + let started = InstallLog::start(&log, "goose", TEST_APP_VERSION); + Harness { + reporter: match secrets { + Some(secrets) => InstallReporter::with_secrets("goose", started, Some(emit), secrets), + None => InstallReporter::new("goose", started, Some(emit)), + }, + _dir: dir, + log, + events, + } +} + +/// A reporter with no log file and nothing listening — the degraded shape. +pub(crate) fn silent_reporter() -> InstallReporter { + InstallReporter::new("goose", None, None) +} + +pub(crate) fn step(name: &str, success: bool, stderr: &str) -> InstallStepResult { + InstallStepResult { + step: name.to_string(), + command: "curl … | bash".to_string(), + success, + stdout: String::new(), + stderr: stderr.to_string(), + exit_code: Some(if success { 0 } else { 1 }), + hint: None, + } +} + +/// An executed attempt whose log copy differs from the UI copy — the real shape, +/// since the two views are capped differently. +pub(crate) fn outcome(name: &str, success: bool, log_stdout: &str) -> InstallOutcome { + InstallOutcome { + step: step(name, success, ""), + log_stdout: log_stdout.to_string(), + log_stderr: String::new(), + } +} + +impl Harness { + pub(crate) fn log_contents(&self) -> String { + std::fs::read_to_string(&self.log).unwrap_or_default() + } + + /// The emitted lines in order, with a clear signal rendered as `None`. + pub(crate) fn lines(&self) -> Vec> { + self.events + .lock() + .unwrap() + .iter() + .map(|e| e.line.clone()) + .collect() + } + + pub(crate) fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_report_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/install_report_tests.rs new file mode 100644 index 0000000000..c286b618b6 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/install_report_tests.rs @@ -0,0 +1,564 @@ +use super::test_support::*; +use super::*; +use crate::commands::agent_discovery::install_capture::{drain_into, Capture}; + +// ── the log records history the UI does not keep ───────────────────────────── + +/// Every attempt is recorded, not just the one the UI surfaces. Reproducing an +/// install failure means seeing whether attempts 1 and 2 failed the same way. +#[test] +fn test_log_records_every_attempt_not_only_the_last() { + let h = harness(); + + h.reporter + .record_attempt(1, outcome("cli", false, "attempt-one-output")); + h.reporter + .record_attempt(2, outcome("cli", false, "attempt-two-output")); + + let log = h.log_contents(); + assert!(log.contains("attempt-one-output"), "got: {log}"); + assert!(log.contains("attempt-two-output"), "got: {log}"); + assert!( + log.contains("attempt=1") && log.contains("attempt=2"), + "got: {log}" + ); +} + +/// A first attempt that printed a huge amount must not push later records out of +/// the file. Records are capped individually by the log-scale capture that +/// produced them, so the run's total is bounded by steps × attempts × cap rather +/// than by one runaway attempt. +/// +/// The flood goes through a real [`Capture`] rather than straight into the +/// record, so this exercises the cap that actually bounds a record. +#[test] +fn test_first_attempt_overflow_does_not_erase_later_records() { + let h = harness(); + // Through the real drain, so the record is bounded by the cap that bounds a + // production record rather than by a string this test chose. + let capture = Capture::new(); + drain_into(vec![b'F'; 4 * 1024 * 1024].as_slice(), &capture, None); + + h.reporter + .record_attempt(1, outcome("cli", false, &capture.log())); + h.reporter + .record_attempt(2, outcome("cli", false, "second-attempt-detail")); + h.reporter.record_step( + &mut Vec::new(), + step("verify", false, "verification-detail"), + ); + + let log = h.log_contents(); + assert!( + log.contains("bytes omitted at cap"), + "the flooded record must be marked as cut" + ); + assert!( + log.contains("second-attempt-detail"), + "a later attempt must survive an earlier flood" + ); + assert!( + log.contains("verification-detail"), + "the synthesized step explaining the failure must survive too" + ); + assert!( + log.len() < 4 * 1024 * 1024, + "4MiB of first-attempt output must not reach the file, got {} bytes", + log.len() + ); +} + +/// A step Buzz synthesizes — a failed prerequisite, or post-install +/// verification — reaches the log as well as the UI. `record_step` is the only +/// path that guarantees this, which is why callers use it instead of +/// `steps.push`. +#[test] +fn test_recording_a_synthesized_step_logs_it_and_keeps_it_for_the_ui() { + let h = harness(); + let mut steps = Vec::new(); + + h.reporter + .record_step(&mut steps, step("verify", false, "still-not-usable")); + + assert_eq!(steps.len(), 1, "the UI must still receive the step"); + assert!(h.log_contents().contains("still-not-usable")); +} + +// ── one file per run ───────────────────────────────────────────────────────── + +/// A run opens with a header naming the runtime and the environment the run +/// happened in, so a file holding one run of several steps is identifiable as +/// that run rather than a stream of records — and a failure report says which +/// app version and OS produced it without a second round trip to the user. +#[test] +fn test_a_run_opens_with_a_header_naming_the_runtime_app_version_and_os() { + let h = harness(); + let log = h.log_contents(); + + assert!( + log.starts_with(&format!( + "=== install run runtime=goose app={TEST_APP_VERSION} os={} started=", + std::env::consts::OS + )), + "got: {log}" + ); +} + +/// A new run does not append to the previous run's file: it starts a fresh one +/// and keeps the previous as `.1`. Reading a log has to mean reading one run — +/// records accumulated across runs are indistinguishable from retries within +/// one. +#[test] +fn test_a_new_run_starts_a_fresh_file_and_keeps_the_previous_as_dot_one() { + let first = harness(); + first + .reporter + .record_attempt(1, outcome("cli", false, "previous-run-output")); + let previous = first.log.clone(); + let dir = first._dir; + drop(first.reporter); + + let second = harness_at(Some(dir)); + second + .reporter + .record_attempt(1, outcome("cli", false, "current-run-output")); + + let log = second.log_contents(); + assert!(log.contains("current-run-output"), "got: {log}"); + assert!( + !log.contains("previous-run-output"), + "the new run's file must not carry the previous run's records: {log}" + ); + let rotated = std::fs::read_to_string(previous.with_extension("log.1")).expect("read .1"); + assert!( + rotated.contains("previous-run-output"), + "the previous run must remain readable as .1: {rotated}" + ); +} + +/// Each executed attempt records how long it ran. A 15-minute ceiling is only +/// diagnosable if the file says which attempt consumed the time. +#[test] +fn test_an_executed_attempt_records_its_own_duration() { + let h = harness(); + + h.reporter.start_attempt(); + h.reporter.record_attempt(1, outcome("cli", true, "done")); + h.reporter + .record_step(&mut Vec::new(), step("verify", true, "")); + + let log = h.log_contents(); + assert!( + log.contains("attempt=1") && log.contains("elapsed=0."), + "an executed attempt must carry its duration: {log}" + ); + assert!( + log.contains("attempt=- ") && log.contains("elapsed=-"), + "a synthesized step never ran, so it has no duration: {log}" + ); +} + +// ── the log pointer ────────────────────────────────────────────────────────── + +/// The path is available as soon as the run's session opens, because the file +/// exists from that moment — the header is already in it. A failure before any +/// step ran still points the user at a real file. +#[test] +fn test_log_path_is_available_from_the_start_of_the_run() { + let h = harness(); + + assert_eq!(h.reporter.log_path(), Some(h.log.display().to_string())); +} + +/// A reporter with no log — an unresolvable app-data directory — records +/// nothing and reports no path, but must not panic or fail the install. +#[test] +fn test_reporter_without_a_log_records_nothing_and_reports_no_path() { + let reporter = silent_reporter(); + let mut steps = Vec::new(); + + reporter.start_attempt(); + reporter.record_attempt(1, outcome("cli", false, "output")); + reporter.record_step(&mut steps, step("verify", false, "detail")); + + assert_eq!(reporter.log_path(), None); + assert_eq!(steps.len(), 1, "the UI path is unaffected by a missing log"); +} + +/// A log path inside a directory that no longer exists cannot open a session, so +/// the run degrades to no log rather than failing. +#[test] +fn test_an_unopenable_log_degrades_to_no_log() { + let path = PathBuf::from("/nonexistent-dir-for-test/install-goose.log"); + + assert!(InstallLog::start(&path, "goose", TEST_APP_VERSION).is_none()); +} + +// ── live output line ───────────────────────────────────────────────────────── + +/// Lines carry an install-wide monotonic sequence number, so the UI can order +/// them across steps and attempts — which a per-step retry number cannot do. +#[test] +fn test_emitted_lines_carry_their_runtime_and_a_monotonic_sequence() { + let h = harness(); + + let observer = h.reporter.line_observer().expect("an observer"); + observer("downloading"); + h.reporter.start_attempt(); + + let events = h.events(); + assert_eq!(events.len(), 2); + assert!(events.iter().all(|e| e.runtime_id == "goose")); + assert_eq!(events[0].line.as_deref(), Some("downloading")); + assert_eq!(events[0].seq, 0); + assert_eq!( + events[1].seq, 1, + "the clear signal takes the next sequence number, so it cannot be \ + mistaken for a stale event" + ); +} + +/// Starting an attempt clears the display first: the previous attempt's last +/// line is typically the failure that caused the retry, and leaving it under the +/// spinner through the backoff shows the user the past as if it were current. +#[test] +fn test_starting_an_attempt_clears_the_displayed_line() { + let h = harness(); + let observer = h.reporter.line_observer().expect("an observer"); + observer("download failed"); + + h.reporter.start_attempt(); + + assert_eq!( + h.lines(), + vec![Some("download failed".to_string()), None], + "the attempt boundary must emit a clear" + ); +} + +/// The clear is not rate-limited, and it reopens the window: a new attempt's +/// first line goes out immediately even if it arrives inside the previous +/// attempt's window. This is the case the throttle used to swallow entirely. +#[test] +fn test_a_new_attempts_first_line_is_emitted_even_inside_the_previous_window() { + let h = harness(); + let observer = h.reporter.line_observer().expect("an observer"); + observer("attempt one failed"); + + // No wait: the previous line was emitted microseconds ago, so this is well + // inside the 250ms window. + h.reporter.start_attempt(); + observer("attempt two starting"); + + assert_eq!( + h.lines(), + vec![ + Some("attempt one failed".to_string()), + None, + Some("attempt two starting".to_string()), + ] + ); +} + +/// A burst inside the window coalesces to one event, and the line it emits is +/// the *newest* — the display shows current progress, not the line that happened +/// to arrive when the window opened. +#[test] +fn test_a_burst_coalesces_to_the_newest_line_not_the_first() { + let h = harness(); + let observer = h.reporter.line_observer().expect("an observer"); + + observer("one"); + observer("two"); + observer("three"); + // Ends the attempt, which is when a held line is known to be the last. + h.reporter.record_attempt(1, outcome("cli", true, "done")); + + assert_eq!( + h.lines(), + vec![Some("one".to_string()), Some("three".to_string())], + "the held line must be the newest, and it must not be lost" + ); +} + +/// The throttle is per install, not per stream: stdout and stderr of one attempt +/// share one window, so an install printing on both does not double the event +/// rate. +#[test] +fn test_both_streams_of_one_attempt_share_the_rate_window() { + let h = harness(); + let stdout = h.reporter.line_observer().expect("an observer"); + let stderr = h.reporter.line_observer().expect("an observer"); + + stdout("progress"); + stderr("warning"); + h.reporter.record_attempt(1, outcome("cli", true, "done")); + + assert_eq!( + h.lines(), + vec![Some("progress".to_string()), Some("warning".to_string())], + "the second stream's line is held, not emitted immediately, and not lost" + ); +} + +/// Nothing listening means no observer at all, so the drain skips line +/// reassembly entirely rather than doing the work and discarding it. +#[test] +fn test_no_observer_when_nothing_is_listening() { + assert!(silent_reporter().line_observer().is_none()); +} + +// ── late-drain deactivation ────────────────────────────────────────────────── + +/// After the reporter is dropped (run settled), a drain thread that still holds +/// a cloned observer must not be able to emit. The lifecycle lock is shared by +/// reference with every `line_observer` clone, so taking the exclusive write +/// lock in `Drop` — which waits out any in-flight read guards — ensures no +/// late event can reach the listener. +/// +/// The test resets the throttle via `start_attempt` before the late emit, so +/// the late line would be emitted unconditionally without the lifecycle lock. +#[test] +fn test_a_detached_observer_cannot_emit_after_the_reporter_is_dropped() { + let h = harness(); + + // Simulate a drain thread: `line_observer` clones `Live` (owned, not + // borrowed), so the closure outlives the reporter. + let observer = h.reporter.line_observer().expect("an observer"); + observer("before-settle"); + + assert_eq!( + h.lines(), + vec![Some("before-settle".to_string())], + "a live observer must emit before the reporter drops" + ); + + // Open a fresh throttle window so the next offer would emit immediately — + // simulating the drain arriving after the 250ms rate window closed. + h.reporter.start_attempt(); + let events = Arc::clone(&h.events); + + // Drop the reporter — takes the exclusive lifecycle write lock, waits for + // any in-flight publications, then deactivates. + drop(h); + + // A late offer after drop must be blocked by the deactivated lifecycle. + observer("late-drain-after-settle"); + + let lines: Vec> = events + .lock() + .unwrap() + .iter() + .map(|e| e.line.clone()) + .collect(); + assert_eq!( + lines, + // before-settle + the clear from start_attempt, no late line + vec![Some("before-settle".to_string()), None], + "a detached observer must not emit after the reporter is dropped" + ); +} + +/// Deterministic concurrency pin: proves that reporter deactivation cannot +/// complete while a drain thread is in-flight between admission and publication. +/// +/// The lifecycle `RwLock` enforces this: drain threads hold a shared read guard +/// for the entire (check → emit) span, so `InstallReporter::drop`'s write lock +/// blocks until every admitted publication finishes. +/// +/// **How this test fails on the old atomic shape** (`dc6421ac4`): on that shape, +/// `offer` loads `active` once as a plain atomic read and then calls `publish` +/// independently. There is no shared lock, so `drop` (an atomic store) can +/// complete while the thread is between the load and the emit — the test +/// exposes this by asserting the write lock CANNOT be acquired while a reader +/// holds a read guard. On the atomic shape the `lifecycle` field does not exist, +/// so the entire serialisation contract is absent and the pin fails. +#[test] +fn test_deactivation_blocks_until_in_flight_publication_completes() { + // Build a reporter and grab a Live clone that represents a drain thread. + let h = harness(); + let live_clone = { + // Extract the `Live` from a `line_observer` closure by temporarily + // building a second observer and using the lifecycle Arc directly. + h.reporter.line_observer().expect("observer"); + // Clone the inner lifecycle from the reporter's `Live` via a + // white-box path: the test module is a child of install_report and + // can access private fields. + h.reporter + .live + .as_ref() + .expect("live exists") + .lifecycle + .clone() + }; + + // Phase 1: acquire the shared read guard (admission). + let guard = live_clone.read().expect("lifecycle read"); + assert!(*guard, "lifecycle must be active at admission"); + + // Phase 2: while the read guard is held, a write lock must be blocked. + // This is the core serialisation invariant: `Drop` cannot complete until + // every admitted reader releases its guard. + assert!( + live_clone.try_write().is_err(), + "a write lock must not be acquirable while a read guard is held — \ + Drop must block while a publication is in-flight" + ); + + // Phase 3: release the read guard (publication completed). + drop(guard); + + // Phase 4: write lock is now available, and Drop can set active=false. + let mut write = live_clone.write().expect("lifecycle write"); + *write = false; + drop(write); + + // Phase 5: a new reader after deactivation sees active=false and returns. + let guard2 = live_clone.read().expect("lifecycle read"); + assert!( + !*guard2, + "lifecycle must be inactive after deactivation — new admissions rejected" + ); +} + +/// Shared-consumer assertion: drive a potential late run-1 event AND run-2's +/// events through the same `nextInstallOutputLine`-equivalent reducer and assert +/// that run 2's output replaces — not revives — any stale run-1 state. +/// +/// The frontend reduces events into a single consumer that resets to `null` +/// when `isInstalling` goes false (i.e., when the run settles). This test +/// models that reset and verifies the full cross-run contract: +/// +/// 1. Run 1 emits normally; the late drain is silenced by the lifecycle lock +/// (no event with a high run-1 `seq` ever reaches the shared sink). +/// 2. At run-1 settlement the consumer resets to `null`, exactly as the +/// frontend hook does when `isInstalling` becomes false. +/// 3. Run 2 starts, emits its `seq=0` clear and first line. With a null-state +/// consumer the reducer accepts both immediately — even if a late run-1 +/// event HAD arrived (it didn't), the reset would have cleared its seq. +/// +/// **Why this test fails on the old atomic shape**: without the lifecycle lock, +/// `obs1("late-run-one-drain")` emits a high-seq run-1 event into the shared +/// sink. That event arrives AFTER the consumer reset (its seq is accepted from +/// a null state), leaving `{ seq: N, line: "late-run-one-drain" }` in the +/// consumer. Run 2 then emits `seq=0` and `seq=1`, both ≤ N, so they are +/// rejected and the final state stays on run 1's output. +#[test] +fn test_run2_output_replaces_stale_run1_state_through_shared_consumer() { + // One shared event sink for all events from both runs, simulating the + // permanent frontend listener that receives all `acp-install-output` events. + let all_events: Arc>> = Arc::new(Mutex::new(Vec::new())); + // Track where run 1 settles so the consumer reset can be applied at the + // correct boundary in the fold below. + let run1_settle_len: Arc> = Arc::new(Mutex::new(0)); + let runtime_id = "goose"; + + // ── Run 1 ────────────────────────────────────────────────────────────── + let dir1 = tempfile::tempdir().expect("tempdir"); + let log1 = dir1.path().join("install-goose.log"); + let emit1: EmitEvent = { + let sink = Arc::clone(&all_events); + Arc::new(move |event| sink.lock().unwrap().push(event)) + }; + let reporter1 = InstallReporter::new( + runtime_id, + InstallLog::start(&log1, runtime_id, "1.0.0"), + Some(emit1), + ); + + reporter1.start_attempt(); + let obs1 = reporter1.line_observer().expect("observer"); + obs1("run-one-output"); + reporter1.record_attempt(1, outcome("cli", true, "done")); + + // Drop reporter1 — deactivates obs1 via the exclusive lifecycle write lock, + // which blocks until any in-flight read guard (publication) has released. + drop(reporter1); + + // Record the sink length at settlement — this is the point where the + // frontend resets its consumer state to null (isInstalling = false). + *run1_settle_len.lock().unwrap() = all_events.lock().unwrap().len(); + + // A late drain from run 1 arrives after settlement. With the lifecycle + // lock this is silenced. Without the lock (old atomic shape) it would + // reach the sink with a high seq, poisoning the null-reset consumer before + // run 2 can emit its restarted seq=0. + // Reset the throttle so the offer would emit unconditionally if the lock + // were absent — this makes the mutation meaningful. + { + // We need a fresh throttle window. Simulate by directly restarting + // via a new reporter to touch the shared Live (not possible after drop), + // so instead just call offer — the throttle holds the last emit time + // from flush_pending, which was microseconds ago. Give the window time + // to expire so the offer fires immediately on the old atomic shape. + std::thread::sleep(Duration::from_millis(300)); + } + obs1("late-run-one-drain"); + + // ── Run 2 ────────────────────────────────────────────────────────────── + let dir2 = tempfile::tempdir().expect("tempdir"); + let log2 = dir2.path().join("install-goose.log"); + let emit2: EmitEvent = { + let sink = Arc::clone(&all_events); + Arc::new(move |event| sink.lock().unwrap().push(event)) + }; + let reporter2 = InstallReporter::new( + runtime_id, + InstallLog::start(&log2, runtime_id, "1.0.0"), + Some(emit2), + ); + + reporter2.start_attempt(); + let obs2 = reporter2.line_observer().expect("observer"); + obs2("run-two-first-line"); + reporter2.record_attempt(1, outcome("cli", true, "done")); + + // ── Shared-consumer fold ──────────────────────────────────────────────── + // Fold all emitted events through the nextInstallOutputLine reducer logic. + // At the run-1 settlement boundary, reset consumer to null — exactly as + // the frontend hook does when isInstalling becomes false. + struct State { + seq: u64, + line: Option, + } + let settle_at = *run1_settle_len.lock().unwrap(); + let events = all_events.lock().unwrap().clone(); + let mut consumer: Option = None; + for (i, event) in events.iter().enumerate() { + // Simulate frontend reset at run-1 settlement boundary. + if i == settle_at { + consumer = None; + } + if event.runtime_id != runtime_id { + continue; + } + if let Some(ref c) = consumer { + if event.seq <= c.seq { + continue; // reject stale / out-of-order + } + } + consumer = Some(State { + seq: event.seq, + line: event.line.clone(), + }); + } + + // The final consumer state must be run 2's first line. + // + // With the lifecycle lock: obs1("late-run-one-drain") emits nothing, + // so after the null reset only run-2 events arrive — run 2 wins cleanly. + // + // Without the lifecycle lock (old atomic shape): obs1 emits the late line + // with a high seq into the already-reset (null) consumer, consumer becomes + // { seq: N, line: "late-run-one-drain" }. Run 2's seq=0/1 are both ≤ N + // and are rejected, leaving the final state on run 1's stale output. + let final_line = consumer + .as_ref() + .and_then(|s| s.line.as_deref()) + .unwrap_or(""); + assert_eq!( + final_line, "run-two-first-line", + "run 2 must replace — not revive — stale run-1 state through the shared consumer; \ + got: {final_line:?}" + ); +} diff --git a/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs index 3155104b56..535d4c9ecb 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs @@ -1,13 +1,19 @@ use crate::managed_agents::{AcpAvailabilityStatus, InstallStepResult}; -pub(super) fn run(runtime_id: &str, steps: &mut Vec) { +use super::install_report::InstallReporter; + +pub(super) fn run( + runtime_id: &str, + steps: &mut Vec, + reporter: &InstallReporter, +) { // Observe PATH changes and binaries added after Buzz launched. crate::managed_agents::refresh_login_shell_path(); crate::managed_agents::clear_resolve_cache(); let availability = crate::managed_agents::discover_acp_runtime_availability(runtime_id); if let Some(failure) = failure(runtime_id, availability) { - steps.push(failure); + reporter.record_step(steps, failure); } } diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 142e3bac88..33ecf3cfca 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -43,6 +43,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: state.identity_storage().as_str().to_string(), lost, locked, reset_failed, @@ -334,11 +335,17 @@ pub async fn save_ncryptsec_copy( #[tauri::command] pub async fn import_identity( nsec: String, + password: Option, app_handle: tauri::AppHandle, ) -> Result { tokio::task::spawn_blocking(move || { - let trimmed = nsec.trim(); - let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; + // NIP-49 backups require a passphrase and decrypt entirely in Rust. + // Raw nsec/hex input follows the existing parser path unchanged. + let password = password.map(zeroize::Zeroizing::new); + let keys = crate::key_backup::recover_keys_from_input( + &nsec, + password.as_ref().map(|value| value.as_str()), + )?; // Serialize against persist_current_identity: hold this guard for the // full function body so a concurrent stale persist can't overwrite @@ -353,30 +360,14 @@ pub async fn import_identity( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let key_path = data_dir.join("identity.key"); - // Persist into the OS keyring first (store → read-back verify → marker → - // delete file). Falls back to the 0o600 file when the keyring is - // unavailable; returns Err only when both backends fail. - let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - - // Update in-memory keys BEFORE clearing recovery flags. The Release - // stores below pair with Acquire loads in get_identity: a reader - // observing false is guaranteed to see the updated keys. - let pubkey = keys.public_key(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; - - // Clear both recovery flags — an import is valid in either lost or - // keyring-locked state and resolves both. In the locked case the - // keyring is unreachable, so persist_imported_identity already fell - // back to identity.key; on the next Unreachable boot the file is - // loaded directly and when the keyring returns the adoption path - // picks it up. - state - .identity_lost - .store(false, std::sync::atomic::Ordering::Release); - state - .keyring_locked - .store(false, std::sync::atomic::Ordering::Release); + let (pubkey, storage) = commit_imported_identity(&state, &data_dir, keys, |keys| { + // Persist into the OS keyring first (store → read-back verify → + // marker → delete file). Falls back to the 0o600 file when the + // keyring is unavailable; returns Err only when both backends fail. + let store = + crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + })?; let pubkey_hex = pubkey.to_hex(); let display_name = truncated_display_name(&pubkey)?; @@ -386,6 +377,7 @@ pub async fn import_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, @@ -395,6 +387,69 @@ pub async fn import_identity( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Commit an imported identity: durably persist, swap in-memory keys, clear +/// recovery flags, then remove the previous identity's stale app-managed +/// backup. Caller must hold `state.identity_mutation`. +/// +/// Ordering is the contract: +/// +/// 1. `persist` runs FIRST. If it fails (`Err` from both keyring and file +/// fallback), nothing has changed — the previous identity stays live in +/// memory AND its valid canonical `identity.ncryptsec` stays on disk. +/// 2. Only after durable persistence do we swap `state.keys` and clear the +/// recovery flags. +/// 3. Stale-backup cleanup runs LAST and is deliberately best-effort: at that +/// point the import is durably committed, so reporting a cleanup failure +/// as a command `Err` would claim a half-applied import that actually +/// succeeded. The leftover blob is still passphrase-encrypted and is +/// replaced by the next backup creation; we log and move on. +fn commit_imported_identity( + state: &AppState, + data_dir: &std::path::Path, + keys: nostr::Keys, + persist: impl FnOnce(&nostr::Keys) -> Result, +) -> Result<(nostr::PublicKey, crate::app_state::IdentityStorage), String> { + // Capture the previous pubkey up front for post-commit cleanup. + let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); + + let storage = persist(&keys)?; + + // Update in-memory keys BEFORE clearing recovery flags. The Release + // stores below pair with Acquire loads in get_identity: a reader + // observing false is guaranteed to see the updated keys. + let pubkey = keys.public_key(); + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = keys; + state.set_identity_storage(storage); + } + + // Clear both recovery flags — an import is valid in either lost or + // keyring-locked state and resolves both. In the locked case the + // keyring is unreachable, so the persist step already fell back to + // identity.key; on the next Unreachable boot the file is loaded + // directly and when the keyring returns the adoption path picks it up. + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + state + .keyring_locked + .store(false, std::sync::atomic::Ordering::Release); + + // Importing a different identity invalidates the app-managed backup: it + // encrypts the previous key and must not linger mislabeled. Best-effort + // per the ordering contract above. + if let Err(e) = crate::key_backup::cleanup_stale_backup(&previous_pubkey, &pubkey, data_dir) { + eprintln!( + "buzz-desktop: import committed, but stale key backup cleanup failed: {e}; \ + the leftover identity.ncryptsec encrypts the PREVIOUS key and will be \ + replaced by the next backup creation" + ); + } + + Ok((pubkey, storage)) +} + /// Make the current ephemeral identity durable by persisting it to the OS /// keyring (or falling back to identity.key). This is called when the user /// chooses to start a new identity instead of re-importing their previous one @@ -438,11 +493,12 @@ pub async fn persist_current_identity( let key_path = data_dir.join("identity.key"); let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; + let storage = + crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - // Keys are already the live identity — only clear identity_lost. - // Release pairs with Acquire in get_identity so readers see - // consistent state. + // Keys are already the live identity. Record where the durable write + // landed before clearing identity_lost. + state.set_identity_storage(storage); state .identity_lost .store(false, std::sync::atomic::Ordering::Release); @@ -454,6 +510,7 @@ pub async fn persist_current_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index a4003329bc..cab5fababc 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -75,11 +75,11 @@ pub(super) fn prepare_persona_publication( } fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool { - use buzz_core_pkg::kind::persona_event_is_shared; + use buzz_core_pkg::kind::event_is_shared; use nostr::JsonUtil; row.and_then(|retained| nostr::Event::from_json(&retained.raw_event).ok()) - .is_some_and(|event| persona_event_is_shared(&event)) + .is_some_and(|event| event_is_shared(&event)) } /// Project each persona's catalog visibility from the active relay+owner diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index d9fe6acdb9..ee8e0d8b10 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -165,7 +165,7 @@ fn migrate_personas_in_dir_at( scoped_record.shared = existing .as_ref() .and_then(|row| nostr::Event::from_json(&row.raw_event).ok()) - .is_some_and(|event| buzz_core_pkg::kind::persona_event_is_shared(&event)); + .is_some_and(|event| buzz_core_pkg::kind::event_is_shared(&event)); let event = build_persona_event(&scoped_record) .map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))? .custom_created_at(monotonic_created_at( diff --git a/desktop/src-tauri/src/huddle/agent_tts_routing.rs b/desktop/src-tauri/src/huddle/agent_tts_routing.rs new file mode 100644 index 0000000000..2ee3ec0d41 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_tts_routing.rs @@ -0,0 +1,56 @@ +use super::HuddlePhase; + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum AgentTtsRuntimeGate { + Disabled, + Inactive, + NeedsPipeline, + Ready, +} + +pub(super) fn classify_agent_tts_runtime( + enabled: bool, + phase: &HuddlePhase, + has_pipeline: bool, +) -> AgentTtsRuntimeGate { + if !enabled { + AgentTtsRuntimeGate::Disabled + } else if !matches!(phase, HuddlePhase::Connected | HuddlePhase::Active) { + AgentTtsRuntimeGate::Inactive + } else if has_pipeline { + AgentTtsRuntimeGate::Ready + } else { + AgentTtsRuntimeGate::NeedsPipeline + } +} + +/// Maximum text length accepted for TTS synthesis. +/// ~2000 chars is 1–2 minutes of speech. Longer messages are truncated. +pub(super) const MAX_TTS_TEXT_LEN: usize = 2000; + +pub(super) fn normalize_agent_tts_text(text: String) -> String { + if text.chars().count() > MAX_TTS_TEXT_LEN { + let mut truncated: String = text.chars().take(MAX_TTS_TEXT_LEN).collect(); + truncated.push_str("... message truncated."); + truncated + } else { + text + } +} + +pub(super) async fn enqueue_agent_tts_text( + route_id: u64, + text: String, + enqueue: F, +) -> Result<(), String> +where + F: FnOnce(u64, String) -> Result<(), String> + Send + 'static, +{ + tokio::task::spawn_blocking(move || enqueue(route_id, text)) + .await + .map_err(|error| format!("TTS enqueue task failed: {error}"))? +} + +#[cfg(test)] +#[path = "agent_tts_routing_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs new file mode 100644 index 0000000000..cb550d7005 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs @@ -0,0 +1,57 @@ +use super::{ + classify_agent_tts_runtime, enqueue_agent_tts_text, normalize_agent_tts_text, + AgentTtsRuntimeGate, MAX_TTS_TEXT_LEN, +}; +use crate::huddle::HuddlePhase; + +#[tokio::test] +async fn assistant_plain_text_routes_unchanged_into_voice_pipeline_boundary() { + let (sender, receiver) = std::sync::mpsc::channel(); + let text = "A newly submitted assistant reply.".to_string(); + let route_id = 42; + + enqueue_agent_tts_text(route_id, text.clone(), move |route_id, queued| { + sender + .send((route_id, queued)) + .map_err(|error| error.to_string()) + }) + .await + .expect("route assistant text"); + + assert_eq!( + receiver.recv().expect("queued text"), + (route_id, text), + "route correlation must survive the native queue boundary" + ); +} + +#[test] +fn disabled_is_the_only_intentional_runtime_no_op() { + assert_eq!( + classify_agent_tts_runtime(false, &HuddlePhase::Connected, false), + AgentTtsRuntimeGate::Disabled + ); + assert_eq!( + classify_agent_tts_runtime(true, &HuddlePhase::Idle, false), + AgentTtsRuntimeGate::Inactive + ); + assert_eq!( + classify_agent_tts_runtime(true, &HuddlePhase::Connected, false), + AgentTtsRuntimeGate::NeedsPipeline + ); + assert_eq!( + classify_agent_tts_runtime(true, &HuddlePhase::Connected, true), + AgentTtsRuntimeGate::Ready + ); +} + +#[test] +fn assistant_text_truncation_is_unicode_safe_before_voice_routing() { + let input = "🦀".repeat(MAX_TTS_TEXT_LEN + 1); + let output = normalize_agent_tts_text(input); + assert_eq!( + output.chars().count(), + MAX_TTS_TEXT_LEN + "... message truncated.".chars().count() + ); + assert!(output.ends_with("... message truncated.")); +} diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 02c4045410..2de22f99d8 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -2,7 +2,8 @@ //! //! Mental model: //! add_agent_to_huddle → kind:9000 to ephemeral channel -//! → kind:9000 to parent channel (best-effort) +//! → preserve existing parent membership, or +//! kind:9000 to parent channel (best-effort) //! //! ACP spawning is NOT needed here: the running agent process auto-subscribes //! when it receives the kind:9000 membership notification. Huddle-specific @@ -11,7 +12,10 @@ use serde::Serialize; use uuid::Uuid; -use crate::{app_state::AppState, events, relay::submit_event}; +use crate::{ + app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles, + relay::submit_event, +}; // ── Constants ───────────────────────────────────────────────────────────────── @@ -61,8 +65,9 @@ with the next one. /// The field exists for forward compatibility with future batch-add operations /// where partial success may be meaningful. /// -/// `parent_added` reflects whether the parent-channel add succeeded; -/// `parent_error` carries the error string when it didn't. +/// `parent_added` reflects whether the parent already contained the agent or +/// the parent-channel add succeeded; `parent_error` carries the error string +/// when neither condition could be confirmed. #[derive(Debug, Serialize)] pub struct AgentAddResult { /// Always `true` — invariant guaranteed by [`add_agent_to_huddle`]. @@ -91,17 +96,33 @@ pub async fn add_agent_to_huddle( let add_eph = events::build_add_member(ephemeral_channel_id, agent_pubkey, Some("bot"))?; submit_event(add_eph, state).await?; - // 2. Add agent to parent channel — so agent has full context. - // Best-effort: capture the error but don't propagate it. - let (parent_added, parent_error) = { + // 2. Preserve any active parent membership, regardless of role. Rewriting + // an existing DM member as `bot` is both unnecessary and forbidden for + // non-admins. Otherwise add the agent so it has full context. + // Best-effort: capture a real error but don't propagate it. + let parent_channel_id_string = parent_channel_id.to_string(); + let parent_already_contains_agent = + fetch_channel_members_with_roles(&parent_channel_id_string, state) + .await + .is_ok_and(|members| contains_member(&members, agent_pubkey)); + + let (parent_added, parent_error) = if parent_already_contains_agent { + (true, None) + } else { let add_parent = events::build_add_member(parent_channel_id, agent_pubkey, Some("bot"))?; match submit_event(add_parent, state).await { Ok(_) => (true, None), Err(e) => { - eprintln!( - "buzz-desktop: add agent to parent channel failed (may already be member): {e}" - ); - (false, Some(e)) + let active_after_error = + fetch_channel_members_with_roles(&parent_channel_id_string, state) + .await + .is_ok_and(|members| contains_member(&members, agent_pubkey)); + if active_after_error { + (true, None) + } else { + eprintln!("buzz-desktop: add agent to parent channel failed: {e}"); + (false, Some(e)) + } } } }; @@ -112,3 +133,26 @@ pub async fn add_agent_to_huddle( parent_error, }) } + +fn contains_member(members: &[(String, Option)], pubkey: &str) -> bool { + members + .iter() + .any(|(member_pubkey, _)| member_pubkey.eq_ignore_ascii_case(pubkey)) +} + +#[cfg(test)] +mod tests { + use super::contains_member; + + #[test] + fn existing_parent_membership_is_preserved_regardless_of_role() { + let members = vec![ + ("agent-member".to_owned(), Some("member".to_owned())), + ("agent-bot".to_owned(), Some("bot".to_owned())), + ]; + + assert!(contains_member(&members, "AGENT-MEMBER")); + assert!(contains_member(&members, "agent-bot")); + assert!(!contains_member(&members, "missing")); + } +} diff --git a/desktop/src-tauri/src/huddle/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs index dbd09353db..34dec53094 100644 --- a/desktop/src-tauri/src/huddle/audio_output.rs +++ b/desktop/src-tauri/src/huddle/audio_output.rs @@ -39,7 +39,8 @@ fn list_audio_output_devices_blocking() -> Result, String #[tauri::command] pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Result<(), String> { let mut guard = state - .audio_output_device + .huddle_audio + .output_device .lock() .map_err(|e| e.to_string())?; *guard = if name.is_empty() { None } else { Some(name) }; @@ -50,7 +51,8 @@ pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Resu #[tauri::command] pub fn get_audio_output_device(state: State<'_, AppState>) -> Result { let guard = state - .audio_output_device + .huddle_audio + .output_device .lock() .map_err(|e| e.to_string())?; Ok(guard.clone().unwrap_or_default()) diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index a815bf2d06..7d889328cb 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -23,6 +23,7 @@ //! takes `stt_pipeline`/`tts_pipeline` out of the lock, then calls `shutdown()` //! and drops them outside the lock (thread joins can block ~200ms). +mod agent_tts_routing; pub mod agents; pub mod audio_output; pub mod jitter; @@ -37,6 +38,8 @@ pub mod state; pub mod stt; pub mod transcription; pub mod tts; +pub mod tts_settings; +mod tts_voice_registry; pub mod wire; // ── Shared utilities ────────────────────────────────────────────────────────── @@ -63,16 +66,25 @@ pub(super) fn drain_until_shutdown( pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode}; pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; +pub use tts_settings::set_tts_enabled; // ── Imports ─────────────────────────────────────────────────────────────────── -use std::sync::{atomic::Ordering, Arc}; +use std::sync::atomic::Ordering; use tauri::State; use uuid::Uuid; use crate::{app_state::AppState, events, relay::submit_event}; -use pipeline::{maybe_start_stt_pipeline, maybe_start_tts_pipeline, post_connect_setup}; +use agent_tts_routing::{ + classify_agent_tts_runtime, enqueue_agent_tts_text, normalize_agent_tts_text, + AgentTtsRuntimeGate, +}; +pub use pipeline::check_pipeline_hotstart; +use pipeline::{ + await_inflight_tts_start, maybe_start_stt_pipeline, maybe_start_tts_pipeline, + post_connect_setup, start_auto_enabled_transcription, PostConnectOutcome, +}; use relay_api::{ count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex, MAX_HUDDLE_AGENTS, @@ -186,7 +198,7 @@ pub async fn start_huddle( }; // Transition to Creating. - { + let huddle_generation = { let mut hs = state.huddle()?; if hs.phase != HuddlePhase::Idle { return Err(format!( @@ -194,9 +206,11 @@ pub async fn start_huddle( hs.phase )); } + let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Creating; hs.parent_channel_id = Some(parent_channel_id.clone()); - } + generation + }; let ephemeral_uuid = Uuid::new_v4(); let ephemeral_channel_id = ephemeral_uuid.to_string(); @@ -259,27 +273,33 @@ pub async fn start_huddle( match result { Ok(successful_agents) => { // 5. Store active state. - { + let committed = { let mut hs = state.huddle()?; - hs.phase = HuddlePhase::Connected; - hs.is_creator = true; - hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); - // Only store agents that were successfully enrolled. - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = - successful_agents.clone(); - // Include the current user + successfully enrolled agents as participants. - // Use successful_agents (not member_pubkeys) so failed enrollments - // are not reflected in the participant list. - let own_pubkey = state - .keys - .lock() - .map(|k| k.public_key().to_hex()) - .unwrap_or_default(); - let mut participants = successful_agents.clone(); - if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) { - participants.insert(0, own_pubkey); + if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { + false + } else { + hs.phase = HuddlePhase::Connected; + hs.is_creator = true; + hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = + successful_agents.clone(); + hs.maybe_auto_enable_transcription_for_agents(); + let own_pubkey = state + .keys + .lock() + .map(|k| k.public_key().to_hex()) + .unwrap_or_default(); + let mut participants = successful_agents.clone(); + if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) { + participants.insert(0, own_pubkey); + } + hs.participants = participants; + true } - hs.participants = participants; + }; + if !committed { + emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; + return Err("huddle start was superseded".to_owned()); } // 6. Notify frontend of state change. @@ -287,16 +307,30 @@ pub async fn start_huddle( // 7. Hydrate members, download models, start pipelines (incl. audio relay). // Audio relay failure is fatal — no point in a huddle without audio. - if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await { - // Rollback: audio relay failed after state was committed. - // Publish the terminal lifecycle event before archiving so - // other clients do not reconstruct a phantom active huddle. - emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; - if let Ok(mut hs) = state.huddle_state.lock() { - hs.reset_preserving_generation(); + match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { + Ok(PostConnectOutcome::Ready) => {} + Ok(PostConnectOutcome::Stale) => { + return Err("huddle start was superseded".to_owned()); + } + Err(e) => { + // Roll back only if this failed setup still owns the active + // huddle. A stale failure must not tear down its replacement. + let still_current = state + .huddle() + .map(|hs| hs.is_current_huddle(&ephemeral_channel_id, huddle_generation)) + .unwrap_or(false); + if still_current { + emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state) + .await; + if let Ok(mut hs) = state.huddle_state.lock() { + if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + hs.reset_preserving_generation(); + } + } + state.emit_huddle_state_changed(); + } + return Err(e); } - state.emit_huddle_state_changed(); - return Err(e); } Ok(HuddleJoinInfo { @@ -314,11 +348,11 @@ pub async fn start_huddle( } } } - // Reset state to Idle so the user can retry. - // Preserve session_generation so in-flight transcription tasks - // from a prior session still see a stale generation and exit. + // Reset only if this failed attempt still owns the Creating state. if let Ok(mut hs) = state.huddle_state.lock() { - hs.reset_preserving_generation(); + if hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { + hs.reset_preserving_generation(); + } } Err(e) } @@ -340,7 +374,7 @@ pub async fn join_huddle( state: State<'_, AppState>, ) -> Result { // Transition to Connecting. - { + let huddle_generation = { let mut hs = state.huddle()?; if hs.phase != HuddlePhase::Idle { return Err(format!( @@ -348,10 +382,12 @@ pub async fn join_huddle( hs.phase )); } + let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Connecting; hs.parent_channel_id = Some(parent_channel_id.clone()); hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); - } + generation + }; // Seed participant list with own pubkey as a fallback until relay responds. let own_pubkey = state @@ -360,12 +396,20 @@ pub async fn join_huddle( .map(|k| k.public_key().to_hex()) .unwrap_or_default(); - { + let committed = { let mut hs = state.huddle()?; - hs.phase = HuddlePhase::Connected; - if !own_pubkey.is_empty() { - hs.participants = vec![own_pubkey]; + if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Connecting) { + false + } else { + hs.phase = HuddlePhase::Connected; + if !own_pubkey.is_empty() { + hs.participants = vec![own_pubkey]; + } + true } + }; + if !committed { + return Err("huddle join was superseded".to_owned()); } // Notify frontend of state change. @@ -373,15 +417,25 @@ pub async fn join_huddle( // Hydrate members, download models, start pipelines (incl. audio relay). // Audio relay failure is fatal — no point in a huddle without audio. - if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await { - // Rollback: audio relay failed after state was committed. - // Reset state to Idle so the user can retry. The ephemeral channel - // has a TTL and will expire — no manual archive needed for joiners. - if let Ok(mut hs) = state.huddle_state.lock() { - hs.reset_preserving_generation(); + match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { + Ok(PostConnectOutcome::Ready) => {} + Ok(PostConnectOutcome::Stale) => { + return Err("huddle join was superseded".to_owned()); + } + Err(e) => { + // Reset only the huddle lifetime that failed. + let mut did_reset = false; + if let Ok(mut hs) = state.huddle_state.lock() { + if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + hs.reset_preserving_generation(); + did_reset = true; + } + } + if did_reset { + state.emit_huddle_state_changed(); + } + return Err(e); } - state.emit_huddle_state_changed(); - return Err(e); } Ok(HuddleJoinInfo { @@ -675,123 +729,6 @@ pub fn push_audio_pcm( } } -/// Hot-start: check if voice models just finished downloading during an active -/// huddle and start the corresponding pipelines. -/// -/// Called by the frontend on a timer or after model status changes. No-op if -/// the huddle is not active or pipelines are already running. -#[tauri::command] -pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> { - let (is_active, ephemeral_channel_id) = { - let hs = state.huddle()?; - ( - matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active), - hs.ephemeral_channel_id.clone(), - ) - }; - - if !is_active { - return Ok(()); - } - - // Detect dead pipelines: if the worker thread has exited (init failure or crash), - // clear the pipeline handle so hot-start can retry on the next cycle. - { - let mut hs = state.huddle()?; - if let Some(ref p) = hs.stt_pipeline { - if p.is_finished() { - hs.stt_pipeline = None; - } - } - if let Some(ref p) = hs.tts_pipeline { - if p.is_finished() { - hs.tts_pipeline = None; - } - } - } - // Re-read after potential cleanup. - let (has_stt, has_tts, transcription_enabled) = { - let hs = state.huddle()?; - ( - hs.stt_pipeline.is_some(), - hs.tts_pipeline.is_some(), - hs.transcription_enabled, - ) - }; - - // Check if models just became ready (one-shot flags). - let stt_ready = models::global_model_manager() - .map(|m| m.take_stt_ready()) - .unwrap_or(false); - let tts_ready = models::global_model_manager() - .map(|m| m.take_tts_ready()) - .unwrap_or(false); - - // Start TTS first (so STT can capture tts_cancel). - if !has_tts && (tts_ready || models::is_tts_ready()) { - if let Err(e) = maybe_start_tts_pipeline(&state).await { - eprintln!("buzz-desktop: TTS hotstart failed: {e}"); - } - } - - if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) { - if let Some(eph_id) = &ephemeral_channel_id { - if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await { - eprintln!("buzz-desktop: STT hotstart failed: {e}"); - } - } - } - - // Periodically refresh agent_pubkeys from relay membership. - // This catches mid-huddle agent additions/removals by other participants, - // keeping STT p-tags authoritative throughout the session. - // Throttled to every 15 s (not on every 5 s hotstart poll). - // - // NOTE: The frontend ALSO polls agent membership independently (every 10 s - // via get_huddle_agent_pubkeys). This is intentional — the two polls have - // different failure semantics: - // - Rust (here): preserves stale list on failure (STT p-tags should not - // disappear on a transient network blip). - // - React (HuddleContext.tsx): clears list on failure (TTS authorization - // must fail-closed — never speak from a stale agent list). - // - // On Ok: always replace (even with empty — agents may have been removed). - // On Err: preserve the existing list (transient failure shouldn't zero it). - if let Some(eph_id) = &ephemeral_channel_id { - let should_refresh = { - let hs = state.huddle()?; - match hs.last_agent_refresh { - None => true, - Some(t) => t.elapsed() >= std::time::Duration::from_secs(15), - } - }; - if should_refresh { - // Fetch agents (for STT p-tags) and all members (for participant list). - // Sequential — tokio::join! requires the `macros` feature. - // Only update the throttle timestamp when at least one fetch succeeds, - // so transient failures retry immediately on the next poll cycle. - // Fetch both lists before acquiring the lock — no lock held across await. - let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state) - .await - .ok(); - let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok(); - - if fresh_agents.is_some() || fresh_members.is_some() { - let mut hs = state.huddle()?; - if let Some(agents) = fresh_agents { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - if let Some(members) = fresh_members { - hs.participants = members; - } - hs.last_agent_refresh = Some(std::time::Instant::now()); - } - } - } - - Ok(()) -} - /// Trigger a background download of voice models (Parakeet STT + Pocket TTS). /// /// Returns immediately — downloads run in tokio background tasks. @@ -817,91 +754,90 @@ pub fn get_model_status(_state: State<'_, AppState>) -> Result) -> Result<(), String> { - let old_pipeline = { - let mut hs = state.huddle()?; - hs.tts_enabled = enabled; - if !enabled { - hs.tts_pipeline.take() // Take out of lock. - } else { - None - } - }; - // Shut down outside the lock — thread join happens here. - if let Some(ref pipeline) = old_pipeline { - pipeline.shutdown(); - } - drop(old_pipeline); - - if enabled { - // Re-start TTS pipeline if models are available and huddle is active. - let phase = { - let hs = state.huddle()?; - hs.phase.clone() - }; - if matches!(phase, HuddlePhase::Connected | HuddlePhase::Active) { - if let Err(e) = maybe_start_tts_pipeline(&state).await { - eprintln!("buzz-desktop: TTS pipeline restart failed: {e}"); - } - } - } - - Ok(()) -} - /// Speak an agent message via TTS. /// -/// Maximum text length accepted for TTS synthesis. -/// ~2000 chars ≈ 1–2 minutes of speech. Longer messages are truncated. -const MAX_TTS_TEXT_LEN: usize = 2000; - -/// Called by the WebView when it receives an incoming agent kind:9 message. +/// Called by the WebView when it receives an eligible live agent message. /// Lazily starts the TTS pipeline if models are ready but the pipeline hasn't /// been created yet (e.g. models finished downloading after huddle started). /// -/// No-op if TTS is disabled or models aren't ready. +/// Disabled is the only intentional no-op. Enabled-but-unavailable speech +/// returns an error so the caller cannot mistake a dropped message for success. #[tauri::command] -pub async fn speak_agent_message(text: String, state: State<'_, AppState>) -> Result<(), String> { +pub async fn speak_agent_message( + text: String, + route_id: u64, + state: State<'_, AppState>, +) -> Result<(), String> { + eprintln!("buzz-desktop: tts stage=invoke status=started route_id={route_id}"); // Truncate oversized messages — agents shouldn't monologue in a voice huddle. // Use char count (not byte length) to avoid panicking on multi-byte UTF-8. - let text = if text.chars().count() > MAX_TTS_TEXT_LEN { - let mut truncated: String = text.chars().take(MAX_TTS_TEXT_LEN).collect(); - truncated.push_str("... message truncated."); - truncated - } else { - text - }; + let text = normalize_agent_tts_text(text); let needs_pipeline = { - let hs = state.huddle()?; - hs.tts_enabled - && hs.tts_pipeline.is_none() - && matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) + let mut hs = state.huddle()?; + if hs + .tts_pipeline + .as_ref() + .is_some_and(|pipeline| pipeline.is_finished()) + { + hs.tts_pipeline = None; + } + match classify_agent_tts_runtime(hs.tts_enabled, &hs.phase, hs.tts_pipeline.is_some()) { + AgentTtsRuntimeGate::Disabled => { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=disabled route_id={route_id}" + ); + return Ok(()); + } + AgentTtsRuntimeGate::Inactive => { + eprintln!( + "buzz-desktop: tts stage=invoke status=failed reason=inactive_huddle route_id={route_id}" + ); + return Err( + "Agent text to speech is unavailable outside an active huddle".to_string(), + ); + } + AgentTtsRuntimeGate::NeedsPipeline => true, + AgentTtsRuntimeGate::Ready => false, + } }; // Lazy-start: models may have finished downloading after the huddle began. if needs_pipeline { - if let Err(e) = maybe_start_tts_pipeline(&state).await { - eprintln!("buzz-desktop: TTS lazy-start failed: {e}"); - } + maybe_start_tts_pipeline(&state).await.inspect_err(|_| { + eprintln!( + "buzz-desktop: tts stage=invoke status=failed reason=startup_failed route_id={route_id}" + ); + })?; + await_inflight_tts_start(&state).await.inspect_err(|_| { + eprintln!( + "buzz-desktop: tts stage=invoke status=failed reason=startup_timeout route_id={route_id}" + ); + })?; } - let hs = state.huddle()?; - if hs.tts_enabled { - if let Some(ref pipeline) = hs.tts_pipeline { - pipeline.speak(text)?; - } - } - Ok(()) + let sender = { + let hs = state.huddle()?; + hs.tts_pipeline + .as_ref() + .map(|pipeline| pipeline.text_sender()) + }; + let Some(sender) = sender else { + eprintln!( + "buzz-desktop: tts stage=invoke status=failed reason=unavailable route_id={route_id}" + ); + return Err("Agent text to speech is enabled but its audio pipeline is unavailable".into()); + }; + enqueue_agent_tts_text(route_id, text, move |route_id, text| { + sender + .send(route_id, text) + .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}")) + }) + .await + .inspect(|_| eprintln!("buzz-desktop: tts stage=queue status=accepted route_id={route_id}")) + .inspect_err(|_| { + eprintln!("buzz-desktop: tts stage=queue status=failed reason=closed route_id={route_id}") + }) } /// Add an agent to the active huddle. @@ -924,7 +860,7 @@ pub async fn add_agent_to_huddle( ) -> Result { validate_pubkey_hex(&agent_pubkey)?; - let (eph_id, parent_id) = { + let (eph_id, parent_id, huddle_generation) = { let hs = state.huddle()?; if !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) { return Err("no active huddle".to_string()); @@ -948,7 +884,7 @@ pub async fn add_agent_to_huddle( .clone() .ok_or("no ephemeral channel")?; let parent = hs.parent_channel_id.clone().ok_or("no parent channel")?; - (eph, parent) + (eph, parent, hs.huddle_generation) }; let eph_uuid = Uuid::parse_str(&eph_id).map_err(|e| e.to_string())?; @@ -957,29 +893,30 @@ pub async fn add_agent_to_huddle( // Returns Err only if the ephemeral add fails — parent failure is in the result. let result = agents::add_agent_to_huddle(eph_uuid, parent_uuid, &agent_pubkey, &state).await?; - // Ephemeral add succeeded — safe to register for p-tagging. - // Clone the Arc first so we can drop the outer HuddleState lock before - // acquiring the inner pubkeys lock (avoids the E0597 borrow-checker error). - { - let agent_pubkeys_arc = { - let hs = state.huddle()?; - Arc::clone(&hs.agent_pubkeys) - }; - let mut pubkeys = agent_pubkeys_arc.lock().unwrap_or_else(|e| e.into_inner()); + // Ephemeral add succeeded — register it only if this is still the huddle + // that initiated the relay operation. + let transcription_auto_enabled = { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(&eph_id, huddle_generation) { + return Ok(result); + } + let mut pubkeys = hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); if !pubkeys.contains(&agent_pubkey) { pubkeys.push(agent_pubkey.clone()); } - } + drop(pubkeys); + if !hs.participants.contains(&agent_pubkey) { + hs.participants.push(agent_pubkey.clone()); + } + hs.maybe_auto_enable_transcription_for_agents() + }; // No guidelines re-post needed — the agent sees the original kind:48106 // guidelines via EOSE replay when it subscribes to the ephemeral channel. - - // Also add the agent to the visible participants list. - { - let mut hs = state.huddle()?; - if !hs.participants.contains(&agent_pubkey) { - hs.participants.push(agent_pubkey); - } + if transcription_auto_enabled { + start_auto_enabled_transcription(&state, &eph_id).await; + } else { + state.emit_huddle_state_changed(); } Ok(result) diff --git a/desktop/src-tauri/src/huddle/models.rs b/desktop/src-tauri/src/huddle/models.rs index 169ddf66c0..f9f7065769 100644 --- a/desktop/src-tauri/src/huddle/models.rs +++ b/desktop/src-tauri/src/huddle/models.rs @@ -24,6 +24,14 @@ use std::sync::{Arc, Mutex, OnceLock}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use super::pocket::{ + april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION, +}; +use super::tts_voice_registry::POCKET_VOICES; + +#[path = "models_voice_upgrade.rs"] +mod voice_upgrade; + // ── Integrity verification ──────────────────────────────────────────────────── // // All model artifacts are verified against pinned SHA-256 hashes before @@ -38,19 +46,15 @@ use sha2::{Digest, Sha256}; /// Computed from a known-good download. Update when upgrading model versions. const STT_ARCHIVE_SHA256: &str = "17f945007b52ccd8b7200ffc7c5652e9e8e961dfdf479cefcabd06cf5703630b"; -/// HuggingFace base URL for the sherpa-onnx Pocket TTS fp32 repackage. -/// -/// Pinned to commit 96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3 -/// (2026-02-10) for reproducible downloads. -/// -/// fp32 (not int8): a direct same-runtime A/B (k2-fsa/sherpa-onnx#3172) -/// found the ONNX int8 quantization audibly degraded Pocket TTS output and -/// that fp32 "significantly improved quality even at 1 step". The runtime -/// bundle grows from ~189 MB to ~473 MB; encoder, text conditioner, both -/// JSON tables, and LICENSE are byte-identical between the two repos — only -/// the three quantized sessions (lm_main, lm_flow, decoder) change. -const POCKET_HF_BASE: &str = - "https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26/resolve/96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3"; +fn pocket_artifact_url(filename: &str) -> String { + format!( + "https://huggingface.co/{APRIL_MODEL_ID}/resolve/{APRIL_MODEL_REVISION}/onnx/{APRIL_BUNDLE_ID}/{filename}" + ) +} + +fn pocket_license_url() -> String { + format!("https://huggingface.co/{APRIL_MODEL_ID}/resolve/{APRIL_MODEL_REVISION}/onnx/LICENSE") +} /// Reference voice WAV: "Mary (f, conversation)" from the Kyutai TTS demo /// voice set — VCTK speaker p333, ai-coustics-enhanced. Pinned to @@ -64,20 +68,19 @@ const POCKET_HF_BASE: &str = const POCKET_REFERENCE_WAV_URL: &str = "https://huggingface.co/kyutai/tts-voices/resolve/323332d33f997de8394f24a193e1a76df720e01a/vctk/p333_023_enhanced.wav"; -/// SHA-256 hashes for individual Pocket TTS model files. -/// Computed from known-good pinned downloads. Update when upgrading model versions. -#[rustfmt::skip] -const TTS_FILE_HASHES: &[(&str, &str)] = &[ - ("decoder.onnx", "f267880fde6c58b17b0a8f3647eaf8dcfad321f833f32d583ebc2fb2d1a15f10"), - ("encoder.onnx", "e8f2f6d301ffb96e398b138a7dc6d3038622d236044636b73d920bab85890260"), - ("lm_flow.onnx", "79c013a554a54e63319c33c0cc8830cbbedc9b7e448ae7e26f7923ae11f9873e"), - ("lm_main.onnx", "255d1a9263c5abdf36034abfc19c11d21cc5f40f0f87d8361288e972cbd5c578"), - ("text_conditioner.onnx", "0b84e837d7bfaf2c896627b03e3f080320309f37f4fc7df7698c644f7ba5e6b1"), - ("vocab.json", "6fb646346cf931016f70c4921aab0900ce7a304b893cb02135c74e294abfea01"), - ("token_scores.json", "5be2f278caf9b9800741f0fd82bff677f4943ec764c356f907213434b622d958"), - ("LICENSE", "fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6"), - ("reference_sample.wav", "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f"), -]; +const TTS_LICENSE_ARTIFACT: PocketModelArtifact = PocketModelArtifact { + filename: "LICENSE", + sha256: "fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6", + size_bytes: 18_655, + quantized: false, +}; + +const TTS_REFERENCE_ARTIFACT: PocketModelArtifact = PocketModelArtifact { + filename: "reference_sample.wav", + sha256: "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", + size_bytes: 639_084, + quantized: false, +}; // ── Model versioning ────────────────────────────────────────────────────────── // @@ -92,15 +95,8 @@ const TTS_FILE_HASHES: &[(&str, &str)] = &[ /// honest (each version tag identifies one specific set of model bytes). const STT_MODEL_VERSION: &str = "2"; -/// Model manifest version for Pocket TTS. Increment when upgrading model files. -/// Bumped "1" → "2" when the bundled reference voice changed from KevinAHM's -/// anonymous 16 kHz sample to Mary (VCTK p333, 32 kHz, ai-coustics-enhanced) -/// from kyutai/tts-voices. The hash mismatch on `reference_sample.wav` would -/// fail readiness on its own, but the manifest bump makes the re-download -/// reason explicit and skips the failing-then-re-fetching transient state. -/// Bumped "2" → "3" for the int8 → fp32 model swap (see `POCKET_HF_BASE`): -/// existing int8 installs must re-download the suffixless fp32 sessions. -const TTS_MODEL_VERSION: &str = "3"; +/// Identifies the April INT8 asset set plus the official VCTK presets. +const TTS_MODEL_VERSION: &str = "5"; /// Filename for the version manifest written alongside model files. const MANIFEST_FILENAME: &str = ".buzz-model-manifest"; @@ -110,9 +106,9 @@ const MANIFEST_FILENAME: &str = ".buzz-model-manifest"; /// Maximum expected STT archive size (200 MB — actual is ~100 MB). const MAX_STT_DOWNLOAD_BYTES: u64 = 200 * 1024 * 1024; -/// Maximum expected Pocket TTS file size (400 MB per file — largest is -/// `lm_main.onnx` at ~303 MB fp32). -const MAX_TTS_FILE_BYTES: u64 = 400 * 1024 * 1024; +/// Maximum expected Pocket TTS file size. The largest pinned INT8 artifact is +/// `flow_lm_main_int8.onnx` at 76,341,079 bytes. +const MAX_TTS_FILE_BYTES: u64 = 100 * 1024 * 1024; /// NVIDIA Parakeet TDT-CTC 110M (English, int8) — packaged for sherpa-onnx by /// k2-fsa. Single ONNX file (CTC head) + tokens.txt. Avg WER ~7.5% across @@ -168,50 +164,29 @@ const TTS_MODEL_DIR_NAME: &str = "pocket-tts"; /// Attribution sidecar written next to the Pocket TTS model files. const TTS_LICENSE_FILE_NAME: &str = "MODEL_LICENSE.txt"; -/// CC-BY-4.0 §3(a)(1) attribution block for Pocket TTS, its ONNX packaging, -/// and the bundled reference voice WAV. -const TTS_LICENSE_TEXT: &str = "\ -Pocket TTS -© Kyutai. - -Licensed under the Creative Commons Attribution 4.0 International License -(CC-BY-4.0). License text: https://creativecommons.org/licenses/by/4.0/ - -Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts -Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926). -Mimi neural codec by Kyutai is bundled as part of the model. - -ONNX export by KevinAHM: https://huggingface.co/KevinAHM/pocket-tts-onnx -Sherpa-onnx repackage by csukuangfj / k2-fsa: -https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26 - -Bundled reference voice (reference_sample.wav): -\"Mary (f, conversation)\" preset from the Kyutai TTS demo voice catalogue -(https://kyutai.org/tts), distributed via -https://huggingface.co/kyutai/tts-voices as `vctk/p333_023_enhanced.wav`. -Original recording from the Voice Cloning Toolkit (VCTK) corpus, speaker p333: -https://datashare.ed.ac.uk/handle/10283/3443 (CC-BY-4.0). -Recording enhancement (denoise/dereverb) by ai-coustics: -https://ai-coustics.com/ - -Buzz ships all ONNX/model artifacts and the reference voice WAV unmodified, -renamed only by placement in the local model directory. - -Provided \"AS IS\", without warranty of any kind, express or implied. See the -license text for full warranty disclaimer. -"; - /// All files that must be present for Pocket TTS to be considered ready. const TTS_EXPECTED_FILES: &[&str] = &[ - "decoder.onnx", - "encoder.onnx", - "lm_flow.onnx", - "lm_main.onnx", + "bundle.json", + "bos_before_voice.npy", + "flow_lm_main_int8.onnx", + "flow_lm_flow_int8.onnx", + "mimi_decoder_int8.onnx", + "mimi_encoder.onnx", "text_conditioner.onnx", - "vocab.json", - "token_scores.json", + "tokenizer.model", "LICENSE", "reference_sample.wav", + "anna.wav", + "vera.wav", + "fantine.wav", + "charles.wav", + "paul.wav", + "eponine.wav", + "azelma.wav", + "george.wav", + "jane.wav", + "michael.wav", + "eve.wav", TTS_LICENSE_FILE_NAME, ]; @@ -404,6 +379,7 @@ struct ModelSlot { dir_name: &'static str, // subdir under ~/.buzz/models/ expected_files: &'static [&'static str], // files required for "ready" version: &'static str, // manifest version; increment to force re-download + expected_size: fn(&str) -> Option, status: Arc>, just_ready: Arc, // fires once when download completes } @@ -418,11 +394,17 @@ impl ModelSlot { dir_name, expected_files, version, + expected_size: |_| None, status: Arc::new(Mutex::new(ModelStatus::NotDownloaded)), just_ready: Arc::new(AtomicBool::new(false)), } } + fn with_expected_sizes(mut self, expected_size: fn(&str) -> Option) -> Self { + self.expected_size = expected_size; + self + } + fn model_dir(&self, models_dir: &Path) -> PathBuf { models_dir.join(self.dir_name) } @@ -432,7 +414,17 @@ impl ModelSlot { std::fs::read_to_string(dir.join(MANIFEST_FILENAME)) .map(|v| v.trim() == self.version) .unwrap_or(false) - && self.expected_files.iter().all(|f| dir.join(f).is_file()) + && self.expected_files.iter().all(|filename| { + let path = dir.join(filename); + path.is_file() + && (self.expected_size)(filename) + .map(|expected| { + path.metadata() + .map(|metadata| metadata.len() == expected) + .unwrap_or(false) + }) + .unwrap_or(true) + }) } fn dir_if_ready(&self, models_dir: &Path) -> Option { @@ -453,6 +445,39 @@ impl ModelSlot { self.just_ready.swap(false, Ordering::AcqRel) } + /// Recover or clean up the backup left by an interrupted atomic install. + fn recover_interrupted_install(&self, models_dir: &Path) { + let final_dir = self.model_dir(models_dir); + let backup_dir = final_dir.with_extension("old"); + if !backup_dir.exists() { + return; + } + if self.is_ready(models_dir) { + if let Err(error) = std::fs::remove_dir_all(&backup_dir) { + eprintln!( + "buzz-desktop: could not remove stale {} backup: {error}", + self.dir_name + ); + } + return; + } + if final_dir.exists() { + if let Err(error) = std::fs::remove_dir_all(&final_dir) { + eprintln!( + "buzz-desktop: could not remove incomplete {} install: {error}", + self.dir_name + ); + return; + } + } + if let Err(error) = std::fs::rename(&backup_dir, &final_dir) { + eprintln!( + "buzz-desktop: could not restore interrupted {} install: {error}", + self.dir_name + ); + } + } + /// Spawn a background download task if not already ready or downloading. fn start_download( &self, @@ -511,6 +536,9 @@ impl ModelSlot { )); } + std::fs::write(source_dir.join(MANIFEST_FILENAME), self.version) + .map_err(|e| format!("write model manifest: {e}"))?; + let final_dir = self.model_dir(models_dir); let backup_dir = final_dir.with_extension("old"); @@ -529,8 +557,6 @@ impl ModelSlot { return Err(format!("install new model: {e}")); } - std::fs::write(final_dir.join(MANIFEST_FILENAME), self.version) - .map_err(|e| format!("write model manifest: {e}"))?; let _ = tokio::fs::remove_dir_all(&backup_dir).await; if let Some(extra) = temp_cleanup { let _ = tokio::fs::remove_dir_all(extra).await; @@ -542,6 +568,25 @@ impl ModelSlot { } } +fn tts_expected_size(filename: &str) -> Option { + april_model_info() + .artifacts + .iter() + .find(|artifact| artifact.filename == filename) + .map(|artifact| artifact.size_bytes) + .or_else(|| { + [TTS_LICENSE_ARTIFACT, TTS_REFERENCE_ARTIFACT] + .iter() + .find(|artifact| artifact.filename == filename) + .map(|artifact| artifact.size_bytes) + }) +} + +fn tts_model_slot() -> ModelSlot { + ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION) + .with_expected_sizes(tts_expected_size) +} + // ── ModelManager ────────────────────────────────────────────────────────────── /// Manages download and location of STT/TTS model files. @@ -561,11 +606,13 @@ impl ModelManager { /// Returns `None` if the home directory cannot be resolved. pub fn new() -> Option { let models_dir = dirs::home_dir()?.join(".buzz").join("models"); - Some(Self { + let manager = Self { models_dir, stt: ModelSlot::new(STT_MODEL_DIR_NAME, STT_EXPECTED_FILES, STT_MODEL_VERSION), - tts: ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION), - }) + tts: tts_model_slot(), + }; + manager.tts.recover_interrupted_install(&manager.models_dir); + Some(manager) } // ── STT accessors ──────────────────────────────────────────────────────── @@ -638,8 +685,11 @@ impl ModelManager { } } - /// Start a background Pocket TTS download (~189 MB). No-op if already ready or downloading. + /// Start a background Pocket TTS download. No-op if already ready or downloading. pub fn start_tts_download(&self, http_client: reqwest::Client) { + if let Err(error) = voice_upgrade::install_vctk_presets_into_v4_model(&self.models_dir) { + eprintln!("buzz-desktop: could not upgrade existing Pocket voices in place: {error}"); + } let manager = self.clone(); self.tts.start_download( &self.models_dir, @@ -754,10 +804,10 @@ impl ModelManager { /// Download and verify the Pocket TTS model files from HuggingFace. /// /// Downloads files into `~/.buzz/models/pocket-tts/`: - /// - five ONNX sessions (Pocket TTS + Mimi codec) - /// - `vocab.json` / `token_scores.json` for sherpa-onnx text conditioning + /// - five ONNX sessions selected by the April INT8 bundle + /// - bundle metadata, SentencePiece tokenizer, and learned voice BOS /// - upstream `LICENSE` plus Buzz's `MODEL_LICENSE.txt` attribution sidecar - /// - `reference_sample.wav` as the bundled default voice + /// - `reference_sample.wav` plus the embedded official VCTK presets /// /// Files are written to a temp directory first, then moved atomically. async fn download_tts_model(&self, http_client: reqwest::Client) -> Result<(), String> { @@ -768,24 +818,18 @@ impl ModelManager { let temp_dir = self.models_dir.join("pocket-tts.tmp"); fresh_temp_dir(&temp_dir).await?; - let model_files = [ - "decoder.onnx", - "encoder.onnx", - "lm_flow.onnx", - "lm_main.onnx", - "text_conditioner.onnx", - "vocab.json", - "token_scores.json", - "LICENSE", - ]; - let mut downloads: Vec<(String, &'static str)> = model_files + let mut downloads: Vec<(String, PocketModelArtifact)> = april_model_info() + .artifacts .iter() - .map(|filename| (format!("{POCKET_HF_BASE}/{filename}"), *filename)) + .copied() + .map(|artifact| (pocket_artifact_url(artifact.filename), artifact)) .collect(); - downloads.push((POCKET_REFERENCE_WAV_URL.to_string(), "reference_sample.wav")); + downloads.push((pocket_license_url(), TTS_LICENSE_ARTIFACT)); + downloads.push((POCKET_REFERENCE_WAV_URL.to_string(), TTS_REFERENCE_ARTIFACT)); let total_files = downloads.len() as u32; - for (i, (url, filename)) in downloads.iter().enumerate() { + for (i, (url, artifact)) in downloads.iter().enumerate() { + let filename = artifact.filename; eprintln!("buzz-desktop: downloading Pocket TTS {filename} from {url}"); let response = fetch_url(&http_client, url, filename) @@ -822,16 +866,19 @@ impl ModelManager { })?; eprintln!("buzz-desktop: downloaded {bytes} bytes ({filename}), wrote to disk"); - let expected = TTS_FILE_HASHES - .iter() - .find(|(n, _)| *n == *filename) - .map(|(_, hash)| *hash) - .ok_or_else(|| format!("missing expected hash for Pocket TTS file: {filename}"))?; + if bytes != artifact.size_bytes { + let _ = tokio::fs::remove_dir_all(&temp_dir).await; + return Err(format!( + "Pocket TTS {filename} size check failed: expected {} bytes, got {bytes}", + artifact.size_bytes + )); + } let actual = sha256_file(&dest).await?; - if actual != expected { + if actual != artifact.sha256 { let _ = tokio::fs::remove_dir_all(&temp_dir).await; return Err(format!( - "Pocket TTS {filename} integrity check failed: expected {expected}, got {actual}" + "Pocket TTS {filename} integrity check failed: expected {}, got {actual}", + artifact.sha256 )); } @@ -842,9 +889,20 @@ impl ModelManager { }); } - tokio::fs::write(temp_dir.join(TTS_LICENSE_FILE_NAME), TTS_LICENSE_TEXT) - .await - .map_err(|e| format!("write TTS model license sidecar: {e}"))?; + tokio::fs::write( + temp_dir.join(TTS_LICENSE_FILE_NAME), + voice_upgrade::TTS_LICENSE_TEXT, + ) + .await + .map_err(|e| format!("write TTS model license sidecar: {e}"))?; + for voice in POCKET_VOICES { + let Some(bytes) = voice.bytes else { + continue; + }; + tokio::fs::write(temp_dir.join(voice.reference_file), bytes) + .await + .map_err(|e| format!("install bundled {} voice: {e}", voice.display_name))?; + } self.tts.set_status(ModelStatus::Downloading { progress_percent: 90, @@ -931,24 +989,5 @@ pub fn is_tts_ready() -> bool { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tts_readiness_requires_license_sidecar() { - let temp = tempfile::tempdir().expect("tempdir"); - let slot = ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION); - let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); - std::fs::create_dir_all(&model_dir).expect("create model dir"); - - for file in TTS_EXPECTED_FILES { - std::fs::write(model_dir.join(file), b"test").expect("write expected file"); - } - std::fs::write(model_dir.join(MANIFEST_FILENAME), TTS_MODEL_VERSION).expect("manifest"); - - assert!(slot.is_ready(temp.path())); - - std::fs::remove_file(model_dir.join(TTS_LICENSE_FILE_NAME)).expect("remove sidecar"); - assert!(!slot.is_ready(temp.path())); - } -} +#[path = "models_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/models_tests.rs b/desktop/src-tauri/src/huddle/models_tests.rs new file mode 100644 index 0000000000..699ffbe459 --- /dev/null +++ b/desktop/src-tauri/src/huddle/models_tests.rs @@ -0,0 +1,146 @@ +use super::*; + +fn create_ready_model_dir(root: &Path) -> PathBuf { + let model_dir = root.join(TTS_MODEL_DIR_NAME); + std::fs::create_dir_all(&model_dir).expect("create model dir"); + for file in TTS_EXPECTED_FILES { + let path = model_dir.join(file); + let handle = std::fs::File::create(path).expect("create expected file"); + if let Some(size) = tts_expected_size(file) { + handle.set_len(size).expect("size expected file"); + } else { + std::fs::write(model_dir.join(file), b"test").expect("write expected file"); + } + } + std::fs::write(model_dir.join(MANIFEST_FILENAME), TTS_MODEL_VERSION).expect("manifest"); + model_dir +} + +#[test] +fn expected_files_match_april_int8_metadata() { + let mut expected = april_model_info() + .artifacts + .iter() + .map(|artifact| artifact.filename) + .chain([TTS_LICENSE_ARTIFACT.filename, TTS_LICENSE_FILE_NAME]) + .chain(POCKET_VOICES.iter().map(|voice| voice.reference_file)) + .collect::>(); + expected.sort_unstable(); + let mut actual = TTS_EXPECTED_FILES.to_vec(); + actual.sort_unstable(); + + assert_eq!(actual, expected); + assert!(!actual.contains(&"flow_lm_main.onnx")); + assert!(!actual.contains(&"flow_lm_flow.onnx")); + assert!(!actual.contains(&"mimi_decoder.onnx")); + assert!(!actual.contains(&"marius.wav")); +} + +#[test] +fn tts_readiness_requires_license_sidecar() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = create_ready_model_dir(temp.path()); + + assert!(slot.is_ready(temp.path())); + + std::fs::remove_file(model_dir.join(TTS_LICENSE_FILE_NAME)).expect("remove sidecar"); + assert!(!slot.is_ready(temp.path())); +} + +#[test] +fn tts_readiness_rejects_truncated_pinned_artifact() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = create_ready_model_dir(temp.path()); + let artifact = april_model_info().artifacts[0]; + + std::fs::OpenOptions::new() + .write(true) + .open(model_dir.join(artifact.filename)) + .expect("open artifact") + .set_len(artifact.size_bytes - 1) + .expect("truncate artifact"); + + assert!(!slot.is_ready(temp.path())); +} + +#[test] +fn january_cache_is_not_ready_for_april_int8() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); + std::fs::create_dir_all(&model_dir).expect("create model dir"); + for file in [ + "decoder.onnx", + "encoder.onnx", + "lm_flow.onnx", + "lm_main.onnx", + "text_conditioner.onnx", + "vocab.json", + "token_scores.json", + "LICENSE", + "reference_sample.wav", + TTS_LICENSE_FILE_NAME, + ] { + std::fs::write(model_dir.join(file), b"january").expect("write January file"); + } + std::fs::write(model_dir.join(MANIFEST_FILENAME), "3").expect("manifest"); + + assert!(!slot.is_ready(temp.path())); +} + +#[test] +fn interrupted_install_restores_backup_when_destination_is_missing() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let backup_dir = temp.path().join("pocket-tts.old"); + std::fs::create_dir_all(&backup_dir).expect("create backup"); + std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel"); + + slot.recover_interrupted_install(temp.path()); + + assert_eq!( + std::fs::read(temp.path().join(TTS_MODEL_DIR_NAME).join("sentinel")) + .expect("restored sentinel"), + b"previous" + ); + assert!(!backup_dir.exists()); +} + +#[test] +fn interrupted_install_replaces_incomplete_destination_with_backup() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); + let backup_dir = temp.path().join("pocket-tts.old"); + std::fs::create_dir_all(&model_dir).expect("create incomplete destination"); + std::fs::write(model_dir.join("incomplete"), b"april").expect("write incomplete file"); + std::fs::create_dir_all(&backup_dir).expect("create backup"); + std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel"); + + slot.recover_interrupted_install(temp.path()); + + assert_eq!( + std::fs::read(model_dir.join("sentinel")).expect("restored sentinel"), + b"previous" + ); + assert!(!model_dir.join("incomplete").exists()); + assert!(!backup_dir.exists()); +} + +#[test] +fn ready_destination_removes_stale_backup() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = create_ready_model_dir(temp.path()); + let backup_dir = temp.path().join("pocket-tts.old"); + std::fs::create_dir_all(&backup_dir).expect("create backup"); + std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel"); + + slot.recover_interrupted_install(temp.path()); + + assert!(slot.is_ready(temp.path())); + assert!(model_dir.exists()); + assert!(!backup_dir.exists()); +} diff --git a/desktop/src-tauri/src/huddle/models_voice_upgrade.rs b/desktop/src-tauri/src/huddle/models_voice_upgrade.rs new file mode 100644 index 0000000000..5233e02615 --- /dev/null +++ b/desktop/src-tauri/src/huddle/models_voice_upgrade.rs @@ -0,0 +1,128 @@ +use super::*; +use crate::huddle::tts_voice_registry::POCKET_VOICES; + +const PRESET_VOICE_TTS_MODEL_VERSION: &str = "4"; + +/// Attribution written beside every installed Pocket model and voice asset. +pub(super) const TTS_LICENSE_TEXT: &str = "\ +Pocket TTS +© Kyutai. + +Licensed under the Creative Commons Attribution 4.0 International License +(CC-BY-4.0). License text: https://creativecommons.org/licenses/by/4.0/ + +Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts +Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926). +Mimi neural codec by Kyutai is bundled as part of the model. + +April 2026 ONNX export by KevinAHM: +https://huggingface.co/KevinAHM/pocket-tts-onnx +Pinned revision: 58a6d00cf13d239b6748cb0769f35c580a8f606c + +Bundled English VCTK presets: Anna (p228), Vera (p229), Fantine (p244), +Charles (p254), Paul (p259), Eponine (p262), Azelma (p303), George (p315), +Mary (p333), Jane (p339), Michael (p360), and Eve (p361). These exact, +ai-coustics-enhanced WAVs come from Kyutai's tts-voices repository at revision +323332d33f997de8394f24a193e1a76df720e01a. +Source: https://huggingface.co/kyutai/tts-voices/tree/323332d33f997de8394f24a193e1a76df720e01a/vctk +Original recordings: Voice Cloning Toolkit (VCTK) corpus, +https://datashare.ed.ac.uk/handle/10283/3443 (CC-BY-4.0). +Enhancement (denoise/dereverb): ai-coustics, https://ai-coustics.com/ + +Buzz ships the ONNX/model artifacts and voice WAVs unmodified, renamed only +by placement in the local model directory. + +Provided \"AS IS\", without warranty of any kind, express or implied. See the +license text for full warranty disclaimer. +"; + +fn is_embedded_voice_file(filename: &str) -> bool { + POCKET_VOICES + .iter() + .any(|voice| voice.bytes.is_some() && voice.reference_file == filename) +} + +/// Add the official VCTK presets to an otherwise-ready v4 install. +/// +/// Model artifacts and Mary already exist in v4. The manifest is written last, +/// so interruption leaves v4 intact and the next launch retries. +pub(super) fn install_vctk_presets_into_v4_model(models_dir: &Path) -> Result<(), String> { + let model_dir = models_dir.join(TTS_MODEL_DIR_NAME); + let manifest_path = model_dir.join(MANIFEST_FILENAME); + let version = match std::fs::read_to_string(&manifest_path) { + Ok(version) => version, + Err(_) => return Ok(()), + }; + if version.trim() != PRESET_VOICE_TTS_MODEL_VERSION { + return Ok(()); + } + if !TTS_EXPECTED_FILES + .iter() + .filter(|filename| !is_embedded_voice_file(filename)) + .all(|filename| model_dir.join(filename).is_file()) + { + return Ok(()); + } + + for voice in POCKET_VOICES { + let Some(bytes) = voice.bytes else { + continue; + }; + std::fs::write(model_dir.join(voice.reference_file), bytes) + .map_err(|error| format!("write bundled {} voice: {error}", voice.display_name))?; + } + let retired_marius = model_dir.join("marius.wav"); + if retired_marius.is_file() { + std::fs::remove_file(retired_marius) + .map_err(|error| format!("remove retired Marius voice: {error}"))?; + } + std::fs::write(model_dir.join(TTS_LICENSE_FILE_NAME), TTS_LICENSE_TEXT) + .map_err(|error| format!("update Pocket voice notice: {error}"))?; + std::fs::write(manifest_path, TTS_MODEL_VERSION) + .map_err(|error| format!("update Pocket model manifest: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v4_install_adds_presets_without_redownloading_models() { + let temp = tempfile::tempdir().expect("tempdir"); + let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); + std::fs::create_dir_all(&model_dir).expect("create model dir"); + for file in TTS_EXPECTED_FILES + .iter() + .filter(|filename| !is_embedded_voice_file(filename)) + { + std::fs::write(model_dir.join(file), b"existing").expect("write prior file"); + } + std::fs::write( + model_dir.join(MANIFEST_FILENAME), + PRESET_VOICE_TTS_MODEL_VERSION, + ) + .expect("write prior manifest"); + std::fs::write(model_dir.join("marius.wav"), b"retired").expect("write retired voice"); + + install_vctk_presets_into_v4_model(temp.path()).expect("in-place upgrade"); + + for voice in POCKET_VOICES { + if let Some(bytes) = voice.bytes { + assert_eq!( + std::fs::read(model_dir.join(voice.reference_file)) + .expect("bundled voice installed"), + bytes + ); + } + } + assert_eq!( + std::fs::read_to_string(model_dir.join(MANIFEST_FILENAME)).expect("updated manifest"), + TTS_MODEL_VERSION + ); + assert!(!model_dir.join("marius.wav").exists()); + assert!( + ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION) + .is_ready(temp.path()) + ); + } +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 6a4cf26201..c4749d0c1b 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -3,12 +3,16 @@ //! Handles starting, hot-starting, and spawning transcription tasks for //! the voice pipelines. Extracted from mod.rs to keep the command layer thin. -use std::sync::{ - atomic::{AtomicU64, Ordering}, - Arc, Mutex, +use std::{ + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + time::Duration, }; use nostr::JsonUtil; +use tauri::State; use uuid::Uuid; use crate::app_state::AppState; @@ -16,59 +20,228 @@ use crate::events; use super::models; use super::relay_api::{self, fetch_channel_members, parse_channel_uuid}; -use super::state::{HuddlePhase, VoiceInputMode}; +use super::state::{HuddlePhase, HuddleState, VoiceInputMode}; use super::stt; use super::tts; +pub(crate) enum PostConnectOutcome { + Ready, + Stale, +} + +/// Hot-start: check if voice models just finished downloading during an active +/// huddle and start the corresponding pipelines. +/// +/// Called by the frontend on a timer or after model status changes. No-op if +/// the huddle is not active or pipelines are already running. +#[tauri::command] +pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> { + let (is_active, ephemeral_channel_id, huddle_generation) = { + let hs = state.huddle()?; + ( + matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active), + hs.ephemeral_channel_id.clone(), + hs.huddle_generation, + ) + }; + + if !is_active { + return Ok(()); + } + + // Detect dead pipelines: if the worker thread has exited (init failure or crash), + // clear the pipeline handle so hot-start can retry on the next cycle. + { + let mut hs = state.huddle()?; + if let Some(ref p) = hs.stt_pipeline { + if p.is_finished() { + hs.stt_pipeline = None; + } + } + if let Some(ref p) = hs.tts_pipeline { + if p.is_finished() { + hs.tts_pipeline = None; + } + } + } + // Re-read after potential cleanup. + let (has_stt, has_tts, transcription_enabled) = { + let hs = state.huddle()?; + ( + hs.stt_pipeline.is_some(), + hs.tts_pipeline.is_some(), + hs.transcription_enabled, + ) + }; + + // Check if models just became ready (one-shot flags). + let stt_ready = models::global_model_manager() + .map(|m| m.take_stt_ready()) + .unwrap_or(false); + let tts_ready = models::global_model_manager() + .map(|m| m.take_tts_ready()) + .unwrap_or(false); + + // Start TTS first (so STT can capture tts_cancel). + if !has_tts && (tts_ready || models::is_tts_ready()) { + if let Err(e) = maybe_start_tts_pipeline(&state).await { + eprintln!("buzz-desktop: TTS hotstart failed: {e}"); + } + } + if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) { + if let Some(eph_id) = &ephemeral_channel_id { + if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await { + eprintln!("buzz-desktop: STT hotstart failed: {e}"); + } + } + } + + // Periodically refresh agent membership from the relay. + // This catches mid-huddle additions/removals by other participants, keeps + // STT p-tags authoritative, and auto-enables transcription when the first + // agent appears unless the user has already chosen a transcription state. + // Throttled independently from the more frequent hotstart poll. + // + // NOTE: The frontend ALSO polls agent membership independently via + // get_huddle_agent_pubkeys. This is intentional — the two polls have + // different failure semantics: + // - Rust (here): preserves stale list on failure (STT p-tags should not + // disappear on a transient network blip). + // - React (HuddleContext.tsx): clears list on failure (TTS authorization + // must fail-closed — never speak from a stale agent list). + // + // On Ok: always replace (even with empty — agents may have been removed). + // On Err: preserve the existing list (transient failure shouldn't zero it). + if let Some(eph_id) = &ephemeral_channel_id { + let should_refresh = { + let hs = state.huddle()?; + match hs.last_agent_refresh { + None => true, + Some(t) => t.elapsed() >= std::time::Duration::from_secs(15), + } + }; + if should_refresh { + // Fetch agents (for STT p-tags) before all members (for participant + // list) so relay membership queries remain ordered. + // Only update the throttle timestamp when at least one fetch succeeds, + // so transient failures retry immediately on the next poll cycle. + // Fetch both lists before acquiring the lock — no lock held across await. + let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state) + .await + .ok(); + let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok(); + let transcription_auto_enabled = if fresh_agents.is_some() || fresh_members.is_some() { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(eph_id, huddle_generation) { + return Ok(()); + } + if let Some(agents) = fresh_agents { + *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; + } + if let Some(members) = fresh_members { + hs.participants = members; + } + hs.last_agent_refresh = Some(std::time::Instant::now()); + hs.maybe_auto_enable_transcription_for_agents() + } else { + false + }; + if transcription_auto_enabled { + start_auto_enabled_transcription(&state, eph_id).await; + } + } + } + + Ok(()) +} + pub(crate) async fn post_connect_setup( state: &AppState, ephemeral_channel_id: &str, -) -> Result<(), String> { + huddle_generation: u64, +) -> Result { + { + let hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(PostConnectOutcome::Stale); + } + } + // Hydrate agent pubkeys and participants from relay in parallel // (authoritative — overrides local guesses). let (agents_result, all_members_result) = tokio::join!( fetch_channel_members(ephemeral_channel_id, Some("bot"), state), fetch_channel_members(ephemeral_channel_id, None, state), ); - if let Ok(agents) = agents_result { - let hs = state.huddle()?; - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - - if let Ok(all_members) = all_members_result { - if !all_members.is_empty() { - let mut hs = state.huddle()?; - hs.participants = all_members; + let transcription_auto_enabled = { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(PostConnectOutcome::Stale); } + if let Ok(agents) = agents_result { + *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; + } + if let Ok(all_members) = all_members_result { + if !all_members.is_empty() { + hs.participants = all_members; + } + } + hs.maybe_auto_enable_transcription_for_agents() + }; + + if transcription_auto_enabled { + state.emit_huddle_state_changed(); } - // Prepare TTS for agent voice. STT is transcript-specific and starts only - // when transcription is explicitly enabled. + // Prepare voice models. Agent presence may have auto-enabled transcription; + // explicit user choices remain authoritative. if let Some(mgr) = models::global_model_manager() { mgr.start_tts_download(state.http_client.clone()); + if state.huddle()?.transcription_enabled { + mgr.start_stt_download(state.http_client.clone()); + } } // Connect audio relay WebSocket (Opus encode/decode pipeline). // This is the core audio path — failure is fatal for the huddle. let parent_id = { let hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(PostConnectOutcome::Stale); + } hs.parent_channel_id.clone() }; - let (cancel, pcm_tx) = - relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await?; + let audio_result = + relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await; { let mut hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + if let Ok((cancel, _)) = audio_result { + cancel.cancel(); + } + return Ok(PostConnectOutcome::Stale); + } + let (cancel, pcm_tx) = audio_result?; hs.audio_ws_cancel = Some(cancel); hs.audio_relay_pcm_tx = Some(pcm_tx); } - // Start TTS immediately. STT/transcript posting is opt-in and starts only - // after the user explicitly enables transcription. + // Start TTS immediately, then STT when transcription is enabled either by + // the user or by authoritative agent membership. + if !state + .huddle()? + .is_current_huddle(ephemeral_channel_id, huddle_generation) + { + return Ok(PostConnectOutcome::Stale); + } if let Err(e) = maybe_start_tts_pipeline(state).await { eprintln!("buzz-desktop: TTS pipeline failed to start: {e}"); } + if let Err(e) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await { + eprintln!("buzz-desktop: STT pipeline failed to start: {e}"); + } - Ok(()) + Ok(PostConnectOutcome::Ready) } /// Attempt to start the STT pipeline if models are present. @@ -83,12 +256,16 @@ pub(crate) async fn maybe_start_stt_pipeline( state: &AppState, ephemeral_channel_id: &str, ) -> Result { - { + let huddle_generation = { let hs = state.huddle()?; - if !hs.transcription_enabled { + if !hs.transcription_enabled + || !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) + || hs.ephemeral_channel_id.as_deref() != Some(ephemeral_channel_id) + { return Ok(false); } - } + hs.huddle_generation + }; if !models::is_stt_ready() { return Ok(false); // Models not downloaded yet — voice-only mode. @@ -97,21 +274,29 @@ pub(crate) async fn maybe_start_stt_pipeline( let channel_uuid = parse_channel_uuid(ephemeral_channel_id)?; - // Atomically claim the construction slot (mirrors tts_starting pattern). - { - let hs = state.huddle()?; - if hs.stt_starting.swap(true, Ordering::AcqRel) { - return Ok(false); // Another caller is already constructing. - } - } - - // Grab shared flags, agent pubkeys, and session generation from HuddleState. + // Atomically claim construction and grab shared state under one lock. // If replacing an existing pipeline, bump generation first so the old // transcription task's next POST sees a stale generation and exits. // Take the old pipeline OUT of the lock before dropping — Drop joins // the worker thread (~200ms) and must not block under the mutex. - let (tts_active, tts_cancel, agent_pubkeys_arc, session_gen, ptt_active_for_stt, old_stt) = { + let ( + tts_active, + tts_cancel, + agent_pubkeys_arc, + session_gen, + expected_generation, + stt_starting, + ptt_active_for_stt, + old_stt, + ) = { let mut hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(false); + } + if hs.stt_starting.swap(true, Ordering::AcqRel) { + return Ok(false); + } + let stt_starting = Arc::clone(&hs.stt_starting); // Invalidate any existing transcription task before replacing the pipeline. if hs.stt_pipeline.is_some() { hs.session_generation.fetch_add(1, Ordering::Release); @@ -130,6 +315,8 @@ pub(crate) async fn maybe_start_stt_pipeline( Some(Arc::clone(&hs.tts_cancel)), Arc::clone(&hs.agent_pubkeys), Arc::clone(&hs.session_generation), + hs.session_generation.load(Ordering::Acquire), + stt_starting, ptt, old, ) @@ -144,13 +331,11 @@ pub(crate) async fn maybe_start_stt_pipeline( let (pipeline, text_rx) = match constructed { Ok(Ok(p)) => p, Ok(Err(e)) => { - let hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); + stt_starting.store(false, Ordering::Release); return Err(e); } Err(e) => { - let hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); + stt_starting.store(false, Ordering::Release); return Err(format!("spawn_blocking failed: {e}")); } }; @@ -158,10 +343,14 @@ pub(crate) async fn maybe_start_stt_pipeline( { let mut hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); + stt_starting.store(false, Ordering::Release); // Phase check: huddle may have been torn down during construction. if !hs.transcription_enabled - || !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) + || !hs.is_current_transcription_generation( + ephemeral_channel_id, + huddle_generation, + expected_generation, + ) { return Ok(false); } @@ -172,6 +361,17 @@ pub(crate) async fn maybe_start_stt_pipeline( Ok(true) } +/// Start STT after agent presence automatically enables transcription. +pub(crate) async fn start_auto_enabled_transcription(state: &AppState, ephemeral_channel_id: &str) { + if let Some(manager) = models::global_model_manager() { + manager.start_stt_download(state.http_client.clone()); + } + if let Err(error) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await { + eprintln!("buzz-desktop: auto-enabled STT failed to start: {error}"); + } + state.emit_huddle_state_changed(); +} + /// Attempt to start the TTS pipeline if TTS models are present and TTS is enabled. /// /// Returns `Ok(true)` if the pipeline was started, `Ok(false)` if preconditions @@ -195,7 +395,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result Result Result<(), String> { + let starting = { + let huddle = state.huddle()?; + Arc::clone(&huddle.tts_starting) + }; + tokio::time::timeout(Duration::from_secs(15), async { + while starting.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(10)).await; } - hs.tts_pipeline = Some(pipeline); + }) + .await + .map_err(|_| "TTS pipeline startup did not finish before timeout".to_string())?; + // The owner clears the sentinel while holding the huddle lock, before it + // publishes. Reacquiring that lock ensures publication is visible before + // the losing caller looks up the sender. + drop(state.huddle()?); + Ok(()) +} + +struct TtsStartingGuard(Arc); + +impl Drop for TtsStartingGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); } +} +/// Publish a constructed TTS pipeline against the latest settings. +/// +/// Construction happens outside locks and can overlap a voice change or OFF +/// transition. Holding the huddle lock while re-reading settings gives either +/// transition a safe ordering: it updates the installed pipeline afterward, +/// or this finalizer observes the new setting before publishing. +fn finalize_tts_pipeline_start( + state: &AppState, + publish: impl FnOnce(&str, &mut HuddleState), +) -> Result { + let mut huddle = state.huddle()?; + huddle.tts_starting.store(false, Ordering::Release); + if !huddle.tts_enabled + || !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) + || huddle.tts_pipeline.is_some() + { + return Ok(false); + } + let voice = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}")) + .map(|settings| { + super::tts_settings::pocket_voice_name(&settings.voice_preferences).to_string() + })?; + publish(&voice, &mut huddle); Ok(true) } +fn should_reselect_constructed_voice(constructed_voice: &str, latest_voice: &str) -> bool { + constructed_voice != latest_voice +} + /// Sign an STT transcript event and produce the guarded POST body. /// /// Factored out of the transcription loop so egress boundary 5 (huddle STT) @@ -373,3 +651,148 @@ pub(crate) fn spawn_transcription_task( } }); } + +#[cfg(test)] +mod tts_start_race_tests { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Barrier, Mutex, + }; + use std::time::Duration; + + use crate::app_state::build_app_state; + + use super::{ + await_inflight_tts_start, finalize_tts_pipeline_start, should_reselect_constructed_voice, + HuddlePhase, + }; + + #[tokio::test] + async fn a_losing_starter_observes_publication_before_resuming() { + let state = Arc::new(build_app_state()); + { + let mut huddle = state.huddle().expect("huddle state"); + huddle.phase = HuddlePhase::Active; + huddle.tts_enabled = true; + huddle.tts_starting.store(true, Ordering::Release); + } + let published = Arc::new(AtomicBool::new(false)); + let owner_state = Arc::clone(&state); + let owner_published = Arc::clone(&published); + let owner = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + finalize_tts_pipeline_start(&owner_state, |_, _| { + owner_published.store(true, Ordering::Release); + }) + }); + + await_inflight_tts_start(&state) + .await + .expect("wait for pipeline owner"); + assert!(published.load(Ordering::Acquire)); + assert!(owner.join().expect("pipeline owner").expect("finalize")); + } + + #[test] + fn constructor_fallback_survives_unchanged_preference_at_publication() { + let selected_voice = Mutex::new(super::super::pocket::DEFAULT_VOICE.to_string()); + let constructed_voice = "eve"; + let latest_voice = "eve"; + + if should_reselect_constructed_voice(constructed_voice, latest_voice) { + *selected_voice.lock().expect("selected voice") = latest_voice.to_string(); + } + + assert_eq!( + selected_voice.lock().expect("selected voice").as_str(), + super::super::pocket::DEFAULT_VOICE + ); + } + + #[test] + fn construction_reconciles_a_voice_selected_while_starting() { + let state = Arc::new(build_app_state()); + { + let mut huddle = state.huddle().expect("huddle state"); + huddle.phase = HuddlePhase::Active; + huddle.tts_enabled = true; + huddle.tts_starting.store(true, Ordering::Release); + } + + let constructed = Arc::new(Barrier::new(2)); + let publish = Arc::new(Barrier::new(2)); + let selected_voice = Arc::new(Mutex::new(None)); + let worker_state = Arc::clone(&state); + let worker_constructed = Arc::clone(&constructed); + let worker_publish = Arc::clone(&publish); + let worker_voice = Arc::clone(&selected_voice); + let worker = std::thread::spawn(move || { + worker_constructed.wait(); + worker_publish.wait(); + finalize_tts_pipeline_start(&worker_state, |voice, _| { + *worker_voice.lock().expect("selected voice") = Some(voice.to_string()); + }) + }); + + constructed.wait(); + assert!(state + .huddle() + .expect("huddle state") + .tts_starting + .load(Ordering::Acquire)); + state + .huddle_audio + .tts + .lock() + .expect("text-to-speech settings") + .voice_preferences = vec!["pocket:eve".to_string()]; + publish.wait(); + + assert!(worker.join().expect("starter thread").expect("finalize")); + assert_eq!( + *selected_voice.lock().expect("selected voice"), + Some("eve".to_string()) + ); + } + + #[test] + fn construction_is_discarded_when_disabled_while_starting() { + let state = Arc::new(build_app_state()); + { + let mut huddle = state.huddle().expect("huddle state"); + huddle.phase = HuddlePhase::Active; + huddle.tts_enabled = true; + huddle.tts_starting.store(true, Ordering::Release); + } + + let constructed = Arc::new(Barrier::new(2)); + let publish = Arc::new(Barrier::new(2)); + let did_publish = Arc::new(Mutex::new(false)); + let worker_state = Arc::clone(&state); + let worker_constructed = Arc::clone(&constructed); + let worker_publish = Arc::clone(&publish); + let worker_did_publish = Arc::clone(&did_publish); + let worker = std::thread::spawn(move || { + worker_constructed.wait(); + worker_publish.wait(); + finalize_tts_pipeline_start(&worker_state, |_, _| { + *worker_did_publish.lock().expect("publish flag") = true; + }) + }); + + constructed.wait(); + { + let mut huddle = state.huddle().expect("huddle state"); + huddle.tts_enabled = false; + } + publish.wait(); + + assert!(!worker.join().expect("starter thread").expect("finalize")); + assert!(!*did_publish.lock().expect("publish flag")); + assert!(!state + .huddle() + .expect("huddle state") + .tts_starting + .load(Ordering::Acquire)); + } +} diff --git a/desktop/src-tauri/src/huddle/pocket.rs b/desktop/src-tauri/src/huddle/pocket.rs index ee1faf928a..fd407103d1 100644 --- a/desktop/src-tauri/src/huddle/pocket.rs +++ b/desktop/src-tauri/src/huddle/pocket.rs @@ -1,654 +1,4 @@ -//! Pocket TTS engine wrapper around sherpa-onnx's `OfflineTts`. -//! -//! Pocket TTS is a small (~473 MB fp32 ONNX) zero-shot voice-cloning TTS -//! model from Kyutai. It runs quickly on CPU via sherpa-onnx, replacing the -//! previous Kokoro-82M engine that also required an espeak-free but -//! lexicon-heavy G2P pipeline (Misaki + CMUdict). -//! -//! Full-precision fp32 sessions, not the ~189 MB int8 quantization we -//! originally shipped: a direct same-runtime A/B (k2-fsa/sherpa-onnx#3172) -//! found the int8 ONNX export audibly degraded output quality, and fp32 -//! "significantly improved quality even at 1 step". -//! -//! ## Attribution -//! -//! - **Model**: Kyutai *Pocket TTS* — Charles, Roebel, et al., 2026. -//! arXiv:2509.06926. Original repository: . -//! Licensed CC-BY-4.0. -//! - **Mimi neural codec**: Kyutai, bundled in the same release. CC-BY-4.0. -//! - **ONNX export**: KevinAHM — -//! . CC-BY-4.0. -//! - **sherpa-onnx repackage**: csukuangfj / k2-fsa — -//! . -//! Repackages KevinAHM's export with the file layout sherpa-onnx's -//! `OfflineTtsPocketModelConfig` expects. CC-BY-4.0. -//! - **Reference voice WAV** (`reference_sample.wav`): the "Mary -//! (f, conversation)" preset from the Kyutai TTS demo -//! (), which maps to `vctk/p333_023_enhanced.wav` -//! in . CC-BY-4.0, base recording -//! from the VCTK corpus, enhanced by ai-coustics. -//! -//! Buzz ships these files unmodified; see the on-disk `MODEL_LICENSE.txt` -//! sidecar written by `huddle::models` during install for the canonical -//! CC-BY-4.0 §3(a)(1) attribution block. -//! -//! ## Engine-module contract (see `huddle::tts`) -//! -//! `pocket.rs` exposes a fixed surface used by `tts.rs`. Mirroring this -//! contract is what lets the TTS pipeline stay engine-agnostic: -//! -//! - `SAMPLE_RATE: u32` — engine output sample rate in Hz. -//! - `DEFAULT_VOICE: &str` — default voice name (without extension). -//! - `VOICE_FILE_EXT: &str` — extension for per-voice files on disk. -//! - `load_text_to_speech(model_dir)` → `Result` -//! - `load_voice_style(path)` → `Result` -//! - `Engine::synth_chunk(&self, text, lang, &VoiceStyle, steps)` -//! → `Result, String>` -//! -//! `lang` and `steps` are accepted for API compatibility with the previous -//! Kokoro engine but are unused — Pocket TTS does its own language ID from -//! the input text and is not a diffusion model (consistency LM, one step). -//! There is no speed knob: sherpa-onnx's `GenerationConfig.speed` is only -//! read by some model families (vits), never by the Pocket impl -//! (`offline-tts-pocket-impl.h` — zero references), and upstream pocket-tts -//! has no speed parameter either. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use sherpa_onnx::{GenerationConfig, OfflineTts, OfflineTtsConfig, Wave}; - -// ── Engine-module contract: public consts ───────────────────────────────────── - -/// Pocket TTS emits 24 kHz mono PCM. Matches the previous Kokoro output rate, -/// so the rodio sink and inter-sentence silence buffer in `tts.rs` remain valid. -pub const SAMPLE_RATE: u32 = 24_000; - -/// Name (without extension) of the bundled reference voice. The model directory -/// is expected to contain `.` after install. -pub const DEFAULT_VOICE: &str = "reference_sample"; - -/// Voice files for Pocket TTS are reference audio (WAV). Distinct from the -/// Kokoro `.bin` style vectors — the model conditions on raw waveform samples, -/// not a precomputed embedding, so the extension change is honest. -pub const VOICE_FILE_EXT: &str = "wav"; - -// ── Tuning ──────────────────────────────────────────────────────────────────── - -/// Single-threaded ONNX execution for predictable CPU contention with the STT -/// pipeline. Matches `STT_NUM_THREADS` in `stt.rs`; raise only if a benchmark -/// argues for it. -const TTS_NUM_THREADS: i32 = 1; - -/// LRU cache size for cloned voice embeddings inside the sherpa-onnx engine. -/// We bind to one voice per pipeline today, but the upstream example uses 16 -/// and the cost is negligible — keep room for future multi-voice support. -const VOICE_EMBEDDING_CACHE_CAPACITY: i32 = 16; - -/// Pocket TTS is a consistency-based LM. Generation quality saturates at one -/// denoising step — the upstream `GenerationConfig` default of 5 multiplies -/// synthesis time by ~5× with no audible benefit on this model. -const SYNTH_NUM_STEPS: i32 = 1; - -/// Leave the generated audio's silences untouched (1.0 is the identity). -/// -/// sherpa-onnx's `ScaleSilence` (`offline-tts.cc`) is *not* pre/post padding -/// control: it finds every interior silence run ≥ 0.2 s (|s| ≤ 0.01) and -/// multiplies its length by this factor. The previous value of 0.0 — set -/// under the mistaken belief it disabled lead-in/lead-out padding — deleted -/// every natural pause inside an utterance: clause breaks, breaths, the gap -/// after a comma. Words slammed together and endings cut abruptly. The -/// reference Pocket TTS pipeline does not post-process silence at all; -/// 1.0 restores parity. -const SYNTH_SILENCE_SCALE: f32 = 1.0; - -/// sherpa-onnx upstream default for `max_frames` (LM steps), in -/// `offline-tts-pocket-impl.h:Generate`. 500 steps ≈ 40 s of audio at the -/// Mimi 12.5 Hz frame rate. Referenced only by the regression test below; -/// production code path never raises (or even reads) this value — we just -/// leave sherpa-onnx's own default in place by not setting the override. -#[cfg(test)] -const SHERPA_ONNX_MAX_FRAMES_DEFAULT: i32 = 500; - -/// Tight `max_frames` we ask for on short, padded prompts to bound the -/// original "monster breathing" runaway. 100 LM steps ≈ 8 s of audio — -/// roomy for any one-to-four-word utterance the user is likely to elicit -/// while still well short of the 40 s upstream default. Chosen with slack so -/// we never *truncate* a legitimate short reply. -const SHORT_PROMPT_MAX_FRAMES: i32 = 100; - -/// Word-count threshold (inclusive) below which we pad the prompt with -/// leading spaces and cap `max_frames` tighter than the upstream default. -/// Matches upstream `pocket_tts.models.tts_model.prepare_text_prompt`. Above -/// this threshold we leave sherpa-onnx's own defaults in place — overriding -/// them caused the "first 'yep' is just static" regression seen on -/// 2026-05-18, where dropping `frames_after_eos` below the upstream default -/// of 3 clipped the leading audio of multi-clause sentences. -const SHORT_PROMPT_WORD_THRESHOLD: usize = 4; - -/// Number of leading spaces prepended to short prompts. The upstream Python -/// uses exactly 8 — keep parity rather than tuning blindly. -/// -/// This is upstream's *only* mitigation for the FlowLM cold-start smear on -/// short utterances (kyutai-labs/pocket-tts #91, #70): the autoregressive -/// generation has a 2–3 step "settle" period where the first phoneme can be -/// smeared. A previous revision added a sacrificial `". . "` prefix plus an -/// amplitude-threshold trim to strip the rendered prefix from the output — -/// but the trim's absolute threshold (0.02 against raw peaks of ~0.076) sat -/// in soft-onset territory and could eat real word starts, and its tuning -/// was calibrated against `silence_scale = 0.0` audio. Deleted in favour of -/// upstream parity: accept the occasional smeared first syllable rather -/// than risk trimming real speech. -const SHORT_PROMPT_PAD_SPACES: usize = 8; - -/// sherpa-onnx's documented `frames_after_eos` default. We deliberately do -/// *not* override this knob — the previous attempt to bump it for short -/// inputs and lower it for long inputs lowered it below the upstream default -/// of 3, which clipped the leading audio of multi-clause sentences (the -/// "first 'yep' is static" regression). The constant exists only for the -/// regression test below. Source: `offline-tts-pocket-impl.h:Generate`. -#[cfg(test)] -const SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT: i32 = 3; - -// ── ONNX file names (five Pocket TTS sessions plus two JSON tables) ─────────── - -const FILE_LM_MAIN: &str = "lm_main.onnx"; -const FILE_LM_FLOW: &str = "lm_flow.onnx"; -const FILE_ENCODER: &str = "encoder.onnx"; -const FILE_DECODER: &str = "decoder.onnx"; -const FILE_TEXT_COND: &str = "text_conditioner.onnx"; -const FILE_VOCAB: &str = "vocab.json"; -const FILE_TOKEN_SCORES: &str = "token_scores.json"; - -// ── Voice style ─────────────────────────────────────────────────────────────── - -/// Loaded reference voice — normalised f32 PCM samples plus their sample rate. -/// -/// Pocket TTS takes a reference waveform per generation call (not a -/// precomputed style embedding), so we keep the samples in memory and clone -/// the small `Vec` into each `GenerationConfig` rather than re-reading the -/// WAV from disk on every sentence. -#[derive(Debug, Clone)] -pub struct VoiceStyle { - samples: Vec, - sample_rate: i32, -} - -/// Load a reference voice WAV from disk. -/// -/// Accepts any sample rate sherpa-onnx's `Wave::read` can decode — Pocket TTS -/// resamples internally using `reference_sample_rate`. The bundled -/// `reference_sample.wav` ("Mary" — VCTK p333, enhanced) is 32 kHz mono. -pub fn load_voice_style(path: &Path) -> Result { - let path_str = path - .to_str() - .ok_or_else(|| format!("voice path is not valid UTF-8: {}", path.display()))?; - let wave = Wave::read(path_str) - .ok_or_else(|| format!("could not read voice WAV at {}", path.display()))?; - let samples = wave.samples().to_vec(); - if samples.is_empty() { - return Err(format!("voice WAV is empty: {}", path.display())); - } - Ok(VoiceStyle { - samples, - sample_rate: wave.sample_rate(), - }) -} - -// ── Engine ──────────────────────────────────────────────────────────────────── - -/// Pocket TTS engine handle. Cheap to construct (one `OfflineTts::create` -/// call). Owned by the TTS worker thread for the lifetime of a huddle session. -/// -/// `OfflineTts` does not implement `Debug`, so we don't derive it here — the -/// pipeline only needs to move the engine into the worker thread and call -/// `synth_chunk` on it, never to print it. -pub struct PocketTts { - inner: OfflineTts, -} - -/// Build the Pocket TTS engine from the model directory installed by -/// `huddle::models`. Returns `Err` if any expected ONNX or JSON file is -/// missing — readiness is normally enforced by `is_tts_ready` upstream, but -/// the check is repeated here so a manually-modified model dir produces a -/// clear error string instead of an opaque sherpa-onnx `None`. -pub fn load_text_to_speech(model_dir: &str) -> Result { - let dir = PathBuf::from(model_dir); - for name in [ - FILE_LM_MAIN, - FILE_LM_FLOW, - FILE_ENCODER, - FILE_DECODER, - FILE_TEXT_COND, - FILE_VOCAB, - FILE_TOKEN_SCORES, - ] { - let p = dir.join(name); - if !p.is_file() { - return Err(format!("missing Pocket TTS file: {}", p.display())); - } - } - - let to_str = |name: &str| -> String { dir.join(name).to_string_lossy().into_owned() }; - - // Build the config by mutating defaults — mirrors `stt.rs` and stays - // resilient if sherpa-onnx adds unrelated model-family fields. - let mut cfg = OfflineTtsConfig::default(); - cfg.model.pocket.lm_main = Some(to_str(FILE_LM_MAIN)); - cfg.model.pocket.lm_flow = Some(to_str(FILE_LM_FLOW)); - cfg.model.pocket.encoder = Some(to_str(FILE_ENCODER)); - cfg.model.pocket.decoder = Some(to_str(FILE_DECODER)); - cfg.model.pocket.text_conditioner = Some(to_str(FILE_TEXT_COND)); - cfg.model.pocket.vocab_json = Some(to_str(FILE_VOCAB)); - cfg.model.pocket.token_scores_json = Some(to_str(FILE_TOKEN_SCORES)); - cfg.model.pocket.voice_embedding_cache_capacity = VOICE_EMBEDDING_CACHE_CAPACITY; - cfg.model.num_threads = TTS_NUM_THREADS; - // Explicit — defaults are not part of the API contract, and noisy debug - // logging in release builds would be expensive on every synthesized chunk. - cfg.model.debug = false; - - let inner = OfflineTts::create(&cfg) - .ok_or_else(|| "OfflineTts::create returned None for Pocket TTS".to_string())?; - Ok(PocketTts { inner }) -} - -// ── Prompt preparation ──────────────────────────────────────────────────────── - -/// Result of [`prepare_pocket_prompt`]: a synthesizer-ready prompt plus the -/// per-call generation overrides derived from the original text. -/// -/// `None` for either override means "leave sherpa-onnx's documented default -/// in place". The pipeline only sets `max_frames` (and only for short -/// padded inputs) so it can bound the original "monster breathing" runaway -/// without disturbing the rest of the LM sampling envelope. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct PreparedPrompt { - /// Text to hand to `OfflineTts::generate_with_config`. Capitalized, - /// punctuation-terminated, and (for short inputs) left-padded with - /// spaces — upstream's mitigation for the FlowLM cold-start smear. - pub text: String, - /// Value to pass via `GenerationConfig.extra["max_frames"]`, or `None` to - /// keep the upstream default of 500 LM steps. We only override on short - /// padded prompts where we have a tight expectation on output length. - pub max_frames: Option, -} - -/// Mirror of the *text-preparation* half of upstream -/// `pocket_tts.models.tts_model.prepare_text_prompt`. Sherpa-onnx's C++ -/// Pocket TTS impl does not run these preparation steps, so short / -/// unpunctuated / lowercase inputs can trigger up to 40 s of runaway -/// generation when the EOS logit never crosses its threshold. We replicate -/// the upstream Python recipe here: -/// -/// 1. Collapse interior whitespace (already done by `preprocess_for_tts`, but -/// cheap to re-check after sentence splitting). -/// 2. Capitalize the first letter. -/// 3. Append `.` if the text doesn't end in punctuation. -/// 4. If fewer than five words, prepend `SHORT_PROMPT_PAD_SPACES` spaces -/// (upstream's cold-start mitigation — see the constant's docstring) and -/// return a tight [`SHORT_PROMPT_MAX_FRAMES`] cap so the LM can't run -/// away if EOS still doesn't fire. -/// -/// We do **not** override `frames_after_eos` — sherpa-onnx's default of 3 -/// is what we want. An earlier version set it to 1 on long inputs, which -/// clipped the leading audio of multi-clause sentences ("first 'yep' is -/// just static" regression). Tests `prepare_prompt_never_lowers_frames_…` -/// lock this in. -/// -/// Returns `None` only if the input is empty after trimming — caller should -/// skip synthesis in that case. -pub(crate) fn prepare_pocket_prompt(input: &str) -> Option { - let trimmed = input.trim(); - if trimmed.is_empty() { - return None; - } - - // Collapse stray double-spaces / embedded newlines that may slip past - // `preprocess_for_tts` when sentences are spliced back together. - let mut cleaned = String::with_capacity(trimmed.len()); - let mut last_was_space = false; - for ch in trimmed.chars() { - let is_ws = ch.is_whitespace(); - if is_ws { - if !last_was_space { - cleaned.push(' '); - } - last_was_space = true; - } else { - cleaned.push(ch); - last_was_space = false; - } - } - - // Capitalize first character. Uses `to_uppercase` (multi-codepoint safe). - let first = cleaned.chars().next().expect("cleaned non-empty above"); - if first.is_lowercase() { - let upper: String = first.to_uppercase().collect(); - let mut iter = cleaned.chars(); - iter.next(); - cleaned = upper + iter.as_str(); - } - - // Ensure terminal punctuation. Anything not in `.!?;:,` gets a period. - // The upstream Python only checks `isalnum` → period, but for our agent - // text we already may end in `!` `?` `.` etc. — treat any of those as OK. - let last = cleaned - .chars() - .next_back() - .expect("cleaned non-empty above"); - if !matches!(last, '.' | '!' | '?' | ';' | ':' | ',') { - cleaned.push('.'); - } - - // Word count of the *cleaned but not padded* text — padding is whitespace - // only and would just lie to the threshold check below. - let word_count = cleaned.split_whitespace().count(); - - let (final_text, max_frames) = if word_count <= SHORT_PROMPT_WORD_THRESHOLD { - let mut padded = String::with_capacity(cleaned.len() + SHORT_PROMPT_PAD_SPACES); - for _ in 0..SHORT_PROMPT_PAD_SPACES { - padded.push(' '); - } - padded.push_str(&cleaned); - (padded, Some(SHORT_PROMPT_MAX_FRAMES)) - } else { - // For everything ≥5 words, fall back to upstream defaults. Overriding - // these is what caused the "first 'yep' is static" regression — the - // upstream LM has been tuned for `frames_after_eos = 3` and - // `max_frames = 500`, and there's no clear win in second-guessing. - (cleaned, None) - }; - - Some(PreparedPrompt { - text: final_text, - max_frames, - }) -} - -/// Build the `GenerationConfig.extra` HashMap from a [`PreparedPrompt`]. -/// -/// Centralised so the regression test below can assert that we **never** -/// emit a `frames_after_eos` override — the previous attempt to override -/// that knob (setting it to 1 for ≥5-word inputs) clipped the leading -/// audio of multi-clause sentences (the "first 'yep' is static" bug on -/// 2026-05-18). The upstream sherpa-onnx default of 3 is what we want, and -/// the right way to keep it is to not set it at all. -fn build_generation_extra(prepared: &PreparedPrompt) -> Option> { - prepared.max_frames.map(|mf| { - let mut h: HashMap = HashMap::with_capacity(1); - h.insert("max_frames".to_string(), serde_json::Value::from(mf)); - h - }) -} - -impl PocketTts { - /// Synthesise `text` with the given reference voice. - /// - /// `_lang` and `_steps` are accepted for API compatibility with the - /// previous Kokoro engine. Pocket TTS infers language from the input text - /// directly and is a one-step consistency model. Returns an empty buffer - /// for whitespace-only input. - pub fn synth_chunk( - &self, - text: &str, - _lang: &str, - style: &VoiceStyle, - _steps: usize, - ) -> Result, String> { - // Mirror upstream pocket-tts prompt prep — without this short or - // unpunctuated inputs can cause the LM's EOS logit to never trip, - // producing up to 40 s of "monster breathing" garbage on the first - // utterance. See `prepare_pocket_prompt` for the full recipe. - let prepared = match prepare_pocket_prompt(text) { - Some(p) => p, - None => return Ok(Vec::new()), - }; - - // Per-call generation hints sherpa-onnx forwards to - // `offline-tts-pocket-impl.h`. We only override `max_frames`, and - // only for short padded prompts where we have a tight expectation - // on output length — that bounds the original runaway without - // disturbing the rest of the LM sampling envelope. See - // `prepare_pocket_prompt` docs for the regression history. - let extra = build_generation_extra(&prepared); - - let cfg = GenerationConfig { - num_steps: SYNTH_NUM_STEPS, - silence_scale: SYNTH_SILENCE_SCALE, - reference_audio: Some(style.samples.clone()), - reference_sample_rate: style.sample_rate, - extra, - // `speed` stays at its default: the Pocket impl never reads it - // (see the engine-contract note in the module docs). - ..Default::default() - }; - - // No progress callback — synthesis is fast enough that returning the - // whole buffer at once keeps the lookahead pipelining in `tts.rs` - // simple. `None:: bool>` pins the callback type for the - // `generate_with_config` generic parameter. - let audio = self - .inner - .generate_with_config(&prepared.text, &cfg, None:: bool>) - .ok_or_else(|| { - format!( - "Pocket TTS synthesis failed for text ({} chars)", - prepared.text.len() - ) - })?; - - let sample_rate = audio.sample_rate(); - if sample_rate != SAMPLE_RATE as i32 { - eprintln!( - "buzz-desktop: Pocket TTS returned unexpected sample rate {sample_rate}Hz \ - (expected {SAMPLE_RATE}Hz); playback speed may be wrong" - ); - } - - Ok(audio.samples().to_vec()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── prepare_pocket_prompt ──────────────────────────────────────────────── - - #[test] - fn prepare_prompt_returns_none_for_empty_input() { - assert!(prepare_pocket_prompt("").is_none()); - assert!(prepare_pocket_prompt(" ").is_none()); - assert!(prepare_pocket_prompt("\n\t ").is_none()); - } - - /// Helper: the exact leading sequence prepended to every short prompt — - /// 8 spaces of padding (upstream's cold-start mitigation). - /// Centralising this keeps the assertions readable. - fn short_prefix() -> String { - " ".repeat(SHORT_PROMPT_PAD_SPACES) - } - - #[test] - fn prepare_prompt_pads_and_capitalizes_one_word() { - // The "yep" case Tyler hit in production — bare lowercase one-word - // utterance with no punctuation. Must be padded with the short-prompt - // space pad, capitalized, terminated, with a tight `max_frames` cap - // to bound runaway gen. - let out = prepare_pocket_prompt("yep").expect("non-empty"); - assert_eq!(out.text, format!("{}Yep.", short_prefix())); - assert_eq!(out.max_frames, Some(SHORT_PROMPT_MAX_FRAMES)); - const { - assert!( - SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT, - "short cap must be tighter than the upstream default" - ); - } - } - - #[test] - fn prepare_prompt_preserves_existing_punctuation() { - let out = prepare_pocket_prompt("yes!").expect("non-empty"); - assert_eq!(out.text, format!("{}Yes!", short_prefix())); // exclamation kept - let out = prepare_pocket_prompt("really?").expect("non-empty"); - assert_eq!(out.text, format!("{}Really?", short_prefix())); - } - - #[test] - fn prepare_prompt_threshold_is_inclusive_at_four_words() { - // 4 words = short (padded + tight max_frames); 5 words = long - // (no padding, no overrides — upstream defaults stand). - let four = prepare_pocket_prompt("one two three four").expect("non-empty"); - assert_eq!( - four.text, - format!("{}One two three four.", short_prefix()), - "four-word input should get exactly the space pad" - ); - assert_eq!(four.max_frames, Some(SHORT_PROMPT_MAX_FRAMES)); - - let five = prepare_pocket_prompt("one two three four five").expect("non-empty"); - assert!( - !five.text.starts_with(' '), - "five-word input should NOT be padded" - ); - assert_eq!( - five.max_frames, None, - "long inputs must leave sherpa-onnx's max_frames default in place" - ); - } - - #[test] - fn prepare_prompt_does_not_pad_long_text() { - let long = "This is a longer sentence that the model should handle just fine."; - let out = prepare_pocket_prompt(long).expect("non-empty"); - assert!(!out.text.starts_with(' ')); - assert_eq!(out.max_frames, None); - assert!(out.text.ends_with('.')); - } - - #[test] - fn prepare_prompt_collapses_whitespace() { - let out = prepare_pocket_prompt("Hello world\n\nfriend").expect("non-empty"); - // 3 words → short → padded. Interior whitespace collapsed. - assert_eq!(out.text, format!("{}Hello world friend.", short_prefix())); - } - - #[test] - fn prepare_prompt_does_not_double_capitalize_already_uppercase() { - let out = prepare_pocket_prompt("HELLO there").expect("non-empty"); - assert_eq!(out.text, format!("{}HELLO there.", short_prefix())); - } - - #[test] - fn prepare_prompt_handles_non_ascii_first_letter() { - // Cyrillic lowercase 'д' → uppercase 'Д'. Must not panic / produce - // mojibake. - let out = prepare_pocket_prompt("дa").expect("non-empty"); - assert!(out.text.contains("Дa.")); - } - - /// REGRESSION GUARD: short prompts must receive *only* whitespace - /// padding — no sacrificial text. A previous revision prepended a - /// `". . "` cold-start absorber and trimmed the rendered audio back out - /// with an amplitude threshold that could eat soft word onsets. If - /// non-whitespace ever reappears in the pad, the synth output will - /// contain audio for text the user never wrote. - #[test] - fn prepare_prompt_pad_is_whitespace_only() { - let out = prepare_pocket_prompt("I'm happy.").expect("non-empty"); - let pad_len = out.text.len() - "I'm happy.".len(); - assert!( - out.text[..pad_len].chars().all(|c| c == ' '), - "short-prompt pad must be spaces only, got {:?}", - &out.text[..pad_len] - ); - assert_eq!(out.text, format!("{}I'm happy.", short_prefix())); - } - - // ── build_generation_extra ─────────────────────────────────────────────── - // - // These tests pin down a behaviour we've now regressed twice on: - // 1) Not padding/punctuating short inputs → 40 s of "monster breathing" - // (pre-773a2a1). - // 2) Setting `frames_after_eos = 1` on long inputs → clipped leading - // audio of multi-clause sentences, e.g. "Yep, I can hear you. …" - // came out as a static burst (the 773a2a1 regression Tyler hit on - // 2026-05-18 ~14:30 UTC). - // - // The contract we enforce going forward: we **only** override - // `max_frames`, and only for ≤4-word inputs. Every other knob is left - // at sherpa-onnx's documented default (notably `frames_after_eos = 3`). - - #[test] - fn build_extra_short_prompt_sets_only_max_frames() { - let prepared = prepare_pocket_prompt("yep").expect("non-empty"); - let extra = build_generation_extra(&prepared).expect("short prompts get extra"); - // Exactly one key — `max_frames` — and nothing else. - assert_eq!(extra.len(), 1, "extra has unexpected keys: {extra:?}"); - assert_eq!( - extra.get("max_frames"), - Some(&serde_json::Value::from(SHORT_PROMPT_MAX_FRAMES)) - ); - assert!( - !extra.contains_key("frames_after_eos"), - "frames_after_eos must never be set — upstream default of {SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT} is what we want" - ); - } - - #[test] - fn build_extra_long_prompt_is_none() { - // ≥5 words: no extras at all. This is the key fix for the "first - // 'yep' in 'Yep, I can hear you. …' is static" regression — we - // were previously forcing `frames_after_eos = 1` on this path. - let prepared = prepare_pocket_prompt("Yep, I can hear you.").expect("non-empty"); - assert_eq!( - build_generation_extra(&prepared), - None, - "long prompts must not override any LM knob" - ); - } - - #[test] - fn build_extra_never_lowers_frames_after_eos_for_any_word_count() { - // Sweep a range of prompt lengths and assert the `extra` map (when - // present) never carries a `frames_after_eos` override that's lower - // than the upstream sherpa-onnx default. Implemented as a structural - // check — we just never set the key — but worth a property test in - // case someone reintroduces the override in the future. - let prompts: &[&str] = &[ - "hi", - "hi there", - "yes please", - "one two three four", - "one two three four five", - "a slightly longer reply, hopefully fine", - "This is a multi-clause sentence. It has two parts.", - "really really really really really long prompt with lots of words just to be sure", - ]; - for &p in prompts { - let prepared = prepare_pocket_prompt(p).expect("non-empty"); - if let Some(extra) = build_generation_extra(&prepared) { - if let Some(v) = extra.get("frames_after_eos") { - let n = v.as_i64().expect("frames_after_eos should be int"); - assert!( - n >= SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT as i64, - "prompt {p:?} set frames_after_eos={n}, below upstream default of {SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT}" - ); - } - } - } - } - - #[test] - fn short_prompt_max_frames_is_below_upstream_default() { - // Sanity: the override only ever *lowers* the cap, never raises it. - const { - assert!(SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT); - } - // …and is still large enough for a one-to-four-word reply. At Mimi's - // 12.5 Hz frame rate, 100 frames = 8 s, which is roomy. - const { - assert!(SHORT_PROMPT_MAX_FRAMES >= 50, "would risk truncation"); - } - } -} +pub use buzz_voice_pkg::pocket::*; +pub(crate) use buzz_voice_pkg::{ + april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION, +}; diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index eb3fea92d5..3f2aa76a56 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -164,7 +164,8 @@ pub(crate) async fn connect_audio_relay( let cancel_clone = cancel.clone(); let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::>(50); let output_device_name = state - .audio_output_device + .huddle_audio + .output_device .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 876c2d688b..37eb3533f6 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::sync::{ - atomic::{AtomicBool, AtomicU64}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Mutex, }; @@ -80,6 +80,15 @@ pub struct HuddleState { pub tts_enabled: bool, /// Whether STT transcript posting is enabled for this huddle. pub transcription_enabled: bool, + /// Whether the user has explicitly used the transcription control in this + /// huddle. Agent presence may auto-enable transcription only while this is + /// false, so membership refreshes never undo an explicit user choice. + /// + /// This is backend-only session state: keeping it in `HuddleState` makes it + /// survive frontend remounts and audio reconnects, while huddle teardown + /// resets it for the next session. + #[serde(skip)] + pub transcription_user_controlled: bool, /// Shared flag: true while TTS is playing audio. /// Shared with the STT pipeline for barge-in / echo gating. #[serde(skip)] @@ -103,6 +112,10 @@ pub struct HuddleState { /// Used to throttle the refresh in check_pipeline_hotstart to every 15 s. #[serde(skip)] pub last_agent_refresh: Option, + /// Monotonic identity for a local huddle lifetime. Unlike transcript + /// generation, this changes only when a new start/join attempt begins. + #[serde(skip)] + pub huddle_generation: u64, /// Session generation — incremented on every teardown. The transcription /// task captures this at spawn time and checks before each POST. If the /// generation has changed, the task silently drops the transcript. @@ -157,11 +170,13 @@ impl Clone for HuddleState { is_creator: self.is_creator, tts_enabled: self.tts_enabled, transcription_enabled: self.transcription_enabled, + transcription_user_controlled: self.transcription_user_controlled, tts_active: Arc::clone(&self.tts_active), tts_cancel: Arc::clone(&self.tts_cancel), tts_starting: Arc::clone(&self.tts_starting), stt_starting: Arc::clone(&self.stt_starting), last_agent_refresh: self.last_agent_refresh, + huddle_generation: self.huddle_generation, session_generation: Arc::clone(&self.session_generation), voice_input_mode: self.voice_input_mode.clone(), ptt_active: Arc::clone(&self.ptt_active), @@ -184,11 +199,13 @@ impl Default for HuddleState { is_creator: false, tts_enabled: true, transcription_enabled: false, + transcription_user_controlled: false, tts_active: Arc::new(AtomicBool::new(false)), tts_cancel: Arc::new(AtomicBool::new(false)), tts_starting: Arc::new(AtomicBool::new(false)), stt_starting: Arc::new(AtomicBool::new(false)), last_agent_refresh: None, + huddle_generation: 0, session_generation: Arc::new(AtomicU64::new(0)), voice_input_mode: VoiceInputMode::default(), ptt_active: Arc::new(AtomicBool::new(false)), @@ -197,13 +214,247 @@ impl Default for HuddleState { } impl HuddleState { + /// Begin a new local huddle lifetime and return its identity. + pub(crate) fn begin_huddle_lifetime(&mut self) -> u64 { + self.huddle_generation = self.huddle_generation.wrapping_add(1); + self.huddle_generation + } + + pub(crate) fn owns_huddle_lifetime(&self, huddle_generation: u64, phase: HuddlePhase) -> bool { + self.huddle_generation == huddle_generation && self.phase == phase + } + + /// Whether an async result still belongs to the active huddle that + /// initiated it. The channel id is the huddle-session identity; transcript + /// generation changes within the same huddle must not invalidate it. + pub(crate) fn is_current_huddle( + &self, + ephemeral_channel_id: &str, + huddle_generation: u64, + ) -> bool { + matches!(self.phase, HuddlePhase::Connected | HuddlePhase::Active) + && self.ephemeral_channel_id.as_deref() == Some(ephemeral_channel_id) + && self.huddle_generation == huddle_generation + } + + /// Whether an STT construction still belongs to the current transcript + /// generation within the active huddle. + pub(crate) fn is_current_transcription_generation( + &self, + ephemeral_channel_id: &str, + huddle_generation: u64, + session_generation: u64, + ) -> bool { + self.is_current_huddle(ephemeral_channel_id, huddle_generation) + && self.session_generation.load(Ordering::Acquire) == session_generation + } + + /// Invalidate in-flight transcription work and give the next constructor a + /// fresh sentinel that stale constructors cannot clear. + pub(crate) fn invalidate_transcription_pipeline(&mut self) { + self.session_generation.fetch_add(1, Ordering::Release); + self.stt_starting = Arc::new(AtomicBool::new(false)); + } + + /// Record an explicit transcription choice made through the existing user + /// control. Later agent membership refreshes must preserve this choice. + pub(crate) fn set_transcription_enabled_by_user(&mut self, enabled: bool) { + self.transcription_enabled = enabled; + self.transcription_user_controlled = true; + } + + /// Enable transcription when an agent is present and the user has not + /// explicitly chosen a transcription state for this huddle. + /// + /// Returns true only for the transition from disabled to enabled, allowing + /// callers to start models/pipelines and emit state exactly once. Removing + /// the last agent deliberately leaves the current state unchanged. + pub(crate) fn maybe_auto_enable_transcription_for_agents(&mut self) -> bool { + let has_agent = !self + .agent_pubkeys + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_empty(); + if has_agent && !self.transcription_user_controlled && !self.transcription_enabled { + self.transcription_enabled = true; + return true; + } + false + } + /// Reset to default state while preserving the session generation counter. /// Used by start_huddle rollback, join_huddle rollback, and teardown_huddle /// to invalidate in-flight transcription tasks without losing the generation. pub(crate) fn reset_preserving_generation(&mut self) { let gen = Arc::clone(&self.session_generation); + let huddle_generation = self.huddle_generation; + let tts_enabled = self.tts_enabled; *self = Self::default(); self.session_generation = gen; + self.huddle_generation = huddle_generation; + self.tts_enabled = tts_enabled; + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use super::HuddleState; + + fn set_agents(state: &HuddleState, agents: &[&str]) { + *state + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) = + agents.iter().map(|agent| (*agent).to_owned()).collect(); + } + + #[test] + fn first_agent_auto_enables_transcription_once() { + let mut state = HuddleState::default(); + set_agents(&state, &["agent"]); + + assert!(state.maybe_auto_enable_transcription_for_agents()); + assert!(state.transcription_enabled); + assert!(!state.maybe_auto_enable_transcription_for_agents()); + } + + #[test] + fn explicit_user_disable_is_not_undone_by_agent_presence() { + let mut state = HuddleState::default(); + set_agents(&state, &["agent"]); + assert!(state.maybe_auto_enable_transcription_for_agents()); + + state.set_transcription_enabled_by_user(false); + + assert!(!state.maybe_auto_enable_transcription_for_agents()); + assert!(!state.transcription_enabled); + } + + #[test] + fn last_agent_leaving_preserves_current_transcription_state() { + let mut state = HuddleState::default(); + set_agents(&state, &["agent"]); + assert!(state.maybe_auto_enable_transcription_for_agents()); + + set_agents(&state, &[]); + + assert!(!state.maybe_auto_enable_transcription_for_agents()); + assert!(state.transcription_enabled); + } + + #[test] + fn clone_preserves_user_control_across_frontend_state_reads() { + let mut state = HuddleState::default(); + state.set_transcription_enabled_by_user(false); + + let mut clone = state.clone(); + set_agents(&clone, &["agent"]); + + assert!(clone.transcription_user_controlled); + assert!(!clone.maybe_auto_enable_transcription_for_agents()); + } + + #[test] + fn stale_huddle_identity_is_rejected_after_replacement() { + let mut state = HuddleState { + phase: super::HuddlePhase::Active, + ephemeral_channel_id: Some("huddle-a".to_owned()), + ..HuddleState::default() + }; + let huddle_generation = state.begin_huddle_lifetime(); + let generation = state.session_generation.load(Ordering::Acquire); + assert!(state.is_current_huddle("huddle-a", huddle_generation)); + assert!(state.is_current_transcription_generation( + "huddle-a", + huddle_generation, + generation + )); + + state.session_generation.fetch_add(1, Ordering::Release); + assert!(state.is_current_huddle("huddle-a", huddle_generation)); + assert!(!state.is_current_transcription_generation( + "huddle-a", + huddle_generation, + generation + )); + + state.ephemeral_channel_id = Some("huddle-b".to_owned()); + + assert!(!state.is_current_huddle("huddle-a", huddle_generation)); + } + + #[test] + fn same_channel_rejoin_gets_a_new_huddle_lifetime() { + let mut state = HuddleState { + phase: super::HuddlePhase::Active, + ephemeral_channel_id: Some("huddle".to_owned()), + ..HuddleState::default() + }; + let first_generation = state.begin_huddle_lifetime(); + state.reset_preserving_generation(); + state.phase = super::HuddlePhase::Active; + state.ephemeral_channel_id = Some("huddle".to_owned()); + let second_generation = state.begin_huddle_lifetime(); + + assert_ne!(first_generation, second_generation); + assert!(!state.is_current_huddle("huddle", first_generation)); + assert!(state.is_current_huddle("huddle", second_generation)); + } + + #[test] + fn superseded_create_cannot_commit_or_reset_replacement_lifetime() { + let mut state = HuddleState::default(); + let first_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Creating; + assert!(state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Creating)); + + state.reset_preserving_generation(); + let replacement_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Creating; + + assert!(!state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Creating)); + assert!(state.owns_huddle_lifetime(replacement_generation, super::HuddlePhase::Creating)); + } + + #[test] + fn superseded_join_cannot_commit_replacement_lifetime() { + let mut state = HuddleState::default(); + let first_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Connecting; + + state.reset_preserving_generation(); + let replacement_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Connecting; + + assert!(!state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Connecting)); + assert!(state.owns_huddle_lifetime(replacement_generation, super::HuddlePhase::Connecting)); + } + + #[test] + fn teardown_preserves_installation_global_tts_preference() { + let mut state = HuddleState { + tts_enabled: false, + phase: super::HuddlePhase::Active, + ..HuddleState::default() + }; + state.reset_preserving_generation(); + assert!(!state.tts_enabled); + assert_eq!(state.phase, super::HuddlePhase::Idle); + } + + #[test] + fn stale_constructor_cannot_clear_replacement_sentinel() { + let mut state = HuddleState::default(); + let stale_sentinel = std::sync::Arc::clone(&state.stt_starting); + stale_sentinel.store(true, Ordering::Release); + + state.invalidate_transcription_pipeline(); + state.stt_starting.store(true, Ordering::Release); + stale_sentinel.store(false, Ordering::Release); + + assert!(state.stt_starting.load(Ordering::Acquire)); } } diff --git a/desktop/src-tauri/src/huddle/transcription.rs b/desktop/src-tauri/src/huddle/transcription.rs index 0d752c1de7..5962f57cf4 100644 --- a/desktop/src-tauri/src/huddle/transcription.rs +++ b/desktop/src-tauri/src/huddle/transcription.rs @@ -1,5 +1,3 @@ -use std::sync::atomic::Ordering; - use tauri::State; use crate::app_state::AppState; @@ -15,10 +13,12 @@ use super::{models, pipeline::maybe_start_stt_pipeline}; pub async fn start_stt_pipeline(state: State<'_, AppState>) -> Result<(), String> { let ephemeral_channel_id = { let mut hs = state.huddle()?; - hs.transcription_enabled = true; - hs.ephemeral_channel_id + let ephemeral_channel_id = hs + .ephemeral_channel_id .clone() - .ok_or("no active huddle — start or join a huddle first")? + .ok_or("no active huddle — start or join a huddle first")?; + hs.set_transcription_enabled_by_user(true); + ephemeral_channel_id }; match maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await { @@ -41,14 +41,17 @@ pub async fn set_huddle_transcription_enabled( ) -> Result<(), String> { let (ephemeral_channel_id, old_stt) = { let mut hs = state.huddle()?; - hs.transcription_enabled = enabled; + let ephemeral_channel_id = hs + .ephemeral_channel_id + .clone() + .ok_or("no active huddle — start or join a huddle first")?; + hs.set_transcription_enabled_by_user(enabled); if enabled { - (hs.ephemeral_channel_id.clone(), None) + (ephemeral_channel_id, None) } else { - hs.session_generation.fetch_add(1, Ordering::Release); - hs.stt_starting.store(false, Ordering::Release); - (hs.ephemeral_channel_id.clone(), hs.stt_pipeline.take()) + hs.invalidate_transcription_pipeline(); + (ephemeral_channel_id, hs.stt_pipeline.take()) } }; @@ -58,12 +61,10 @@ pub async fn set_huddle_transcription_enabled( drop(old_stt); if enabled { - let eph_id = - ephemeral_channel_id.ok_or("no active huddle — start or join a huddle first")?; if let Some(manager) = models::global_model_manager() { manager.start_stt_download(state.http_client.clone()); } - if let Err(e) = maybe_start_stt_pipeline(&state, &eph_id).await { + if let Err(e) = maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await { eprintln!("buzz-desktop: STT transcript start failed: {e}"); } } diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 63a435cd8e..c03589f9fe 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -35,10 +35,11 @@ //! can gate microphone input while the agent is speaking. use std::{ + collections::VecDeque, num::NonZero, path::PathBuf, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{self, SyncSender}, Arc, Mutex, MutexGuard, PoisonError, }, @@ -46,9 +47,21 @@ use std::{ time::Duration, }; -use super::pocket::{load_text_to_speech, load_voice_style, SAMPLE_RATE, VOICE_FILE_EXT}; +use super::pocket::{ + load_text_to_speech, load_voice_style, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, +}; use super::preprocessing::{preprocess_for_tts, split_sentences}; +#[path = "tts_voice_transition.rs"] +mod voice_transition; +use voice_transition::*; +#[path = "tts_startup.rs"] +mod startup; +use startup::await_worker_startup; +#[path = "tts_audio.rs"] +mod audio; +use audio::*; + // ── Constants ───────────────────────────────────────────────────────────────── /// Maximum number of queued text items. @@ -56,15 +69,15 @@ use super::preprocessing::{preprocess_for_tts, split_sentences}; /// TTS can play it. Excess items are dropped with a warning. const TEXT_QUEUE_DEPTH: usize = 8; -/// How long the worker waits on the text channel before checking the shutdown flag. +/// How long the worker waits before checking the shutdown flag. const RECV_TIMEOUT: Duration = Duration::from_millis(100); - /// Poll interval of the barge-in monitor thread. Bounds flag-to-silence /// latency: a cancel is noticed within one tick, and rodio's internal /// `periodic_access` wrapper stops the in-flight source within a further /// ~5 ms — so playing audio dies ~15 ms after the flag is set, even while /// the worker is blocked inside `synth_chunk`. const MONITOR_TICK: Duration = Duration::from_millis(10); +const AUDIO_PRIME_TIMEOUT: Duration = Duration::from_secs(2); /// Pocket TTS is a one-step consistency model, not diffusion. Kept for API compat. const SYNTH_STEPS: usize = 1; @@ -73,9 +86,8 @@ const SYNTH_STEPS: usize = 1; /// /// Applied only at the *end* of each synthesised sentence to eliminate the /// click that would otherwise occur when a non-zero waveform terminates -/// abruptly. **No fade-in is applied** — see `apply_fade_out` for the -/// rationale and `examples/pocket_onset_probe.rs` for the measurement that -/// motivated removing the leading fade. +/// abruptly. **No fade-in is applied** — see `apply_fade_out` for why preserving +/// the leading waveform is important. const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize; /// Length of the zero-sample cushion prepended before each synthesized @@ -101,19 +113,17 @@ const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize; /// names chunk stitching as the reliability lever). Our previous /// sentence-per-call path created ~2–4× more seams than upstream. /// -/// We don't ship the SentencePiece tokenizer, so 50 tokens is approximated -/// with a character budget. The bundled 4k-entry vocab averages ~4 chars per -/// token, but usage-weighted English text leans on short common tokens, so -/// the effective ratio is ~2–4 chars/token and 200 chars ≈ 60–100 tokens — -/// modestly above upstream's 50, deliberately: erring large means fewer -/// seams, and even ~100 tokens is far below the model's 500-LM-step (~40 s) -/// ceiling. Do not shrink this budget to chase an exact 50-token match. +/// This character budget performs only coarse sentence packing. The April +/// engine applies its SentencePiece tokenizer afterward and refines every +/// result at the bundle's exact 50-token boundary. const MAX_CHUNK_CHARS: usize = 200; /// Silence inserted between sentences by the TTS pipeline (seconds). /// Injected as a silent buffer between each synthesized sentence chunk. const INTER_SENTENCE_SILENCE: f32 = 0.1; +type WorkerControlState = (Arc, Arc, WorkerCancelSignals); + // ── Public pipeline handle ──────────────────────────────────────────────────── /// Handle to the running TTS pipeline. @@ -122,7 +132,7 @@ const INTER_SENTENCE_SILENCE: f32 = 0.1; #[derive(Debug)] pub struct TtsPipeline { /// Send preprocessed text into the pipeline. - text_tx: SyncSender, + text_tx: SyncSender, /// `true` while the agent is speaking. Shared with the STT pipeline for gating. #[allow(dead_code)] pub tts_active: Arc, @@ -132,38 +142,25 @@ pub struct TtsPipeline { /// Kept alive here so the Arc isn't dropped — the worker holds a clone. #[allow(dead_code)] cancel: Arc, - /// Voice name (e.g. "reference_sample"). Stored for future voice-switching support. - #[allow(dead_code)] - voice: String, + /// Internal cancellation used only for voice changes. Kept separate so a + /// concurrent human barge-in always clears every queued message. + voice_cancel: Arc, + /// Selected manifest voice. The worker reloads only the lightweight style + /// when this changes; the warmed Pocket engine and audio player stay alive. + voice: Arc>, + /// Tags messages so a voice change drops only pre-change queue entries. + voice_generation: Arc, + /// Completed after the worker drains pre-change text and installs the new style. + voice_change_ack: VoiceChangeAck, /// Worker thread handle — taken on drop to join cleanly. thread: Option>, } impl TtsPipeline { - /// Spawn the TTS pipeline thread using the default voice. - /// - /// `model_dir` must contain the Pocket TTS files declared by `huddle::models` - /// (the five ONNX sessions, the two JSON tables, and `.wav`). - /// - /// `tts_active` is set to `true` while audio is playing and `false` when idle. - /// Pass the same `Arc` to the STT pipeline to gate microphone input. + /// Spawn the TTS pipeline thread with a manifest-backed voice name. /// - /// `cancel` is the shared barge-in flag from `HuddleState.tts_cancel`. Pass the - /// same `Arc` to the STT pipeline so both sides reference the same flag for the - /// entire huddle session — no stale references after pipeline restarts. - pub fn new( - model_dir: PathBuf, - tts_active: Arc, - cancel: Arc, - output_device: Option, - ) -> Result { - use super::pocket::DEFAULT_VOICE; - Self::new_with_voice(model_dir, tts_active, cancel, DEFAULT_VOICE, output_device) - } - - /// Spawn the TTS pipeline thread with a specific voice name. Today only the - /// bundled default voice (see `pocket::DEFAULT_VOICE`) is shipped; other - /// names will surface a clear error from `load_voice_style`. + /// `cancel` is shared with STT for barge-in. The same handle survives voice + /// changes so the warmed Pocket engine is retained. pub fn new_with_voice( model_dir: PathBuf, tts_active: Arc, @@ -171,37 +168,56 @@ impl TtsPipeline { voice: &str, output_device: Option, ) -> Result { - let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); + let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); let shutdown = Arc::new(AtomicBool::new(false)); // cancel is passed in from HuddleState.tts_cancel — shared with STT for barge-in. let shutdown_worker = Arc::clone(&shutdown); let cancel_worker = Arc::clone(&cancel); + let voice_cancel = Arc::new(AtomicBool::new(false)); + let worker_voice_cancel = Arc::clone(&voice_cancel); let tts_active_worker = Arc::clone(&tts_active); - let voice_name = voice.to_string(); + let voice = Arc::new(Mutex::new(voice.to_string())); + let voice_worker = Arc::clone(&voice); + let voice_generation = Arc::new(AtomicU64::new(1)); + let worker_voice_generation = Arc::clone(&voice_generation); + let voice_change_ack = Arc::new(Mutex::new(None)); + let worker_voice_change_ack = Arc::clone(&voice_change_ack); let model_dir_worker = model_dir.clone(); + let (startup_tx, startup_rx) = mpsc::sync_channel(1); let handle = thread::Builder::new() .name("tts-worker".into()) .spawn(move || { tts_worker( model_dir_worker, - voice_name, + ( + voice_worker, + worker_voice_generation, + worker_voice_change_ack, + ), text_rx, - tts_active_worker, - shutdown_worker, - cancel_worker, + ( + tts_active_worker, + shutdown_worker, + (cancel_worker, worker_voice_cancel), + ), output_device, + startup_tx, ) }) .map_err(|e| format!("failed to spawn tts-worker thread: {e}"))?; + let handle = await_worker_startup(handle, startup_rx)?; Ok(Self { text_tx, tts_active, shutdown, cancel, - voice: voice.to_string(), + voice_cancel, + voice, + voice_generation, + voice_change_ack, thread: Some(handle), }) } @@ -211,14 +227,59 @@ 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> { - self.text_tx.try_send(text).map_err(|e| { - eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}"); - format!("TTS queue full, dropping: {e}") - }) + self.text_tx + .try_send(QueuedText { + generation: self.voice_generation.load(Ordering::Acquire), + route_id: 0, + text, + }) + .map_err(|e| { + eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}"); + format!("TTS queue full, dropping: {e}") + }) + } + + /// Clone the bounded queue sender so callers can apply backpressure without + /// holding the huddle mutex. Disabling TTS drops the receiver and unblocks + /// any waiting sender while the shared cancellation flag stops playback. + pub(crate) fn text_sender(&self) -> TtsTextSender { + TtsTextSender { + text_tx: self.text_tx.clone(), + generation: self.voice_generation.load(Ordering::Acquire), + } + } + + /// Select a bundled Pocket voice for subsequent speech. + /// + /// Current playback and queued text are cancelled immediately so content + /// cannot continue in the old voice. The worker keeps its warmed inference + /// engine and reloads only the reference style before the next utterance. + pub fn select_voice(&self, voice: &str) -> Option> { + let acknowledged = begin_voice_change( + &self.voice, + &self.voice_generation, + &self.voice_cancel, + &self.voice_change_ack, + voice, + ); + if acknowledged.is_some() { + eprintln!("buzz-desktop: tts stage=cancellation reason=voice_switch route_id=0"); + } + acknowledged + } + + /// Reconcile the voice of a pipeline that has not been published yet. + /// + /// No caller can enqueue text before publication, so raising the shared + /// cancellation flag here would create a race that could discard the first + /// message queued immediately after installation. + pub(crate) fn select_voice_before_publish(&self, voice: &str) { + *self.voice.lock().unwrap_or_else(|error| error.into_inner()) = voice.to_string(); } /// Signal the worker thread to stop. pub fn shutdown(&self) { + eprintln!("buzz-desktop: tts stage=cancellation reason=shutdown route_id=0"); self.shutdown.store(true, Ordering::Release); } @@ -244,40 +305,52 @@ impl Drop for TtsPipeline { fn tts_worker( model_dir: PathBuf, - voice_name: String, - text_rx: mpsc::Receiver, - tts_active: Arc, - shutdown: Arc, - cancel: Arc, + voice_state: WorkerVoiceState, + text_rx: mpsc::Receiver, + control_state: WorkerControlState, output_device: Option, + startup_tx: mpsc::SyncSender>, ) { + let (selected_voice, voice_generation, voice_change_ack) = voice_state; + let (tts_active, shutdown, cancel_signals) = control_state; + let (cancel, voice_cancel) = cancel_signals; // ── 1. Initialise TTS engine ────────────────────────────────────────────── let model_dir_str = model_dir.to_string_lossy().to_string(); let engine = match load_text_to_speech(&model_dir_str) { Ok(e) => e, Err(e) => { - eprintln!( - "buzz-desktop: TTS engine init failed (model_dir={}): {e}. TTS disabled.", - model_dir.display() - ); - drain_until_shutdown(text_rx, &shutdown); + let error = format!("TTS engine initialization failed: {e}"); + eprintln!("buzz-desktop: tts stage=startup status=failed reason=engine_load"); + let _ = startup_tx.send(Err(error)); return; } }; // ── 2. Load voice style ─────────────────────────────────────────────────── - let voice_path = model_dir.join(format!("{voice_name}.{VOICE_FILE_EXT}")); - let style = match load_voice_style(&voice_path) { + let requested_voice = selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + let mut voice_name = DEFAULT_VOICE.to_string(); + let fallback_path = model_dir.join(format!("{DEFAULT_VOICE}.{VOICE_FILE_EXT}")); + let mut style = match load_voice_style(&fallback_path) { Ok(s) => s, Err(e) => { - eprintln!( - "buzz-desktop: TTS voice style load failed ({voice_name}): {e}. TTS disabled." - ); - drain_until_shutdown(text_rx, &shutdown); + let error = format!("TTS voice style initialization failed: {e}"); + eprintln!("buzz-desktop: tts stage=startup status=failed reason=fallback_voice_style"); + let _ = startup_tx.send(Err(error)); return; } }; + if requested_voice != DEFAULT_VOICE + && !reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style) + { + let _ = startup_tx.send(Err( + "TTS selected voice and Mary fallback are unavailable".to_string() + )); + return; + } // ── 2b. Warmup inference ───────────────────────────────────────────────── // The first ONNX inference on any session is significantly slower than @@ -285,15 +358,10 @@ fn tts_worker( // pool allocation, and graph-specific caches. Run a short dummy synthesis // and discard the output so the first real utterance runs at warm-session speed. { - let t = std::time::Instant::now(); match engine.synth_chunk("warmup", "en", &style, SYNTH_STEPS) { - Ok(_) => eprintln!( - "buzz-desktop: TTS warmup completed in {:.0}ms", - t.elapsed().as_millis() - ), - Err(e) => eprintln!( - "buzz-desktop: TTS warmup failed after {:.0}ms: {e} — first utterance may be slow", - t.elapsed().as_millis() + Ok(_) => eprintln!("buzz-desktop: tts stage=warmup status=ready"), + Err(_) => eprintln!( + "buzz-desktop: tts stage=warmup status=failed reason=inference first_utterance_may_be_slow=true" ), } } @@ -306,8 +374,9 @@ fn tts_worker( { Ok(h) => h, Err(e) => { - eprintln!("buzz-desktop: TTS audio output failed: {e}. TTS disabled."); - drain_until_shutdown(text_rx, &shutdown); + let error = format!("TTS audio output initialization failed: {e}"); + eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_open"); + let _ = startup_tx.send(Err(error)); return; } }; @@ -315,14 +384,14 @@ fn tts_worker( let channels = match NonZero::new(1u16) { Some(c) => c, None => { - eprintln!("buzz-desktop: TTS channel count invariant violated"); + let _ = startup_tx.send(Err("TTS channel count invariant violated".to_string())); return; } }; let rate = match NonZero::new(SAMPLE_RATE) { Some(r) => r, None => { - eprintln!("buzz-desktop: TTS sample rate invariant violated"); + let _ = startup_tx.send(Err("TTS sample rate invariant violated".to_string())); return; } }; @@ -346,10 +415,22 @@ fn tts_worker( player.append(SamplesBuffer::new(channels, rate, silence)); // Wait for the silent buffer to drain — this ensures the output stream // is fully initialized before the first real utterance. + let deadline = std::time::Instant::now() + AUDIO_PRIME_TIMEOUT; while !player.empty() { + if std::time::Instant::now() >= deadline { + eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_prime"); + let _ = startup_tx.send(Err( + "TTS audio output did not become ready before timeout".to_string(), + )); + return; + } thread::sleep(Duration::from_millis(10)); } } + if startup_tx.send(Ok(())).is_err() { + return; + } + eprintln!("buzz-desktop: tts stage=startup status=ready"); // ── 3b. Barge-in monitor thread ─────────────────────────────────────────── // @@ -377,6 +458,7 @@ fn tts_worker( let monitor = { let player = Arc::clone(&player); let cancel = Arc::clone(&cancel); + let voice_cancel = Arc::clone(&voice_cancel); let tts_active = Arc::clone(&tts_active); let stop = Arc::clone(&monitor_stop); let player_ops = Arc::clone(&player_ops); @@ -384,12 +466,12 @@ fn tts_worker( .name("tts-barge-in-monitor".into()) .spawn(move || { while !stop.load(Ordering::Acquire) { - if cancel.load(Ordering::Acquire) { + if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { let _ops = lock_player_ops(&player_ops); // Re-check under the lock: the worker may have // consumed this cancel (and appended fresh audio) // between the load above and the lock acquisition. - if cancel.load(Ordering::Acquire) { + if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { // clear() pauses the persistent player; play() // un-pauses (see handle_cancel_or_shutdown). // Idempotent — safe to repeat every tick until @@ -423,13 +505,45 @@ fn tts_worker( // idle branch below uses it to decide when to drop `tts_active` and to // arm a fresh lead-in cushion for the next utterance. let mut first_append = true; + let mut last_route_id = 0; + let mut deferred_text = VecDeque::new(); + let append_audio = |prepared: PreparedModelAudio, route_id: u64| { + let _ops = lock_player_ops(&player_ops); + 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; + } + player.append(SamplesBuffer::new(channels, rate, prepared.buffer)); + eprintln!( + "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={} sample_count={}", + prepared.chunk_index, prepared.sample_count + ); + // Set this only after append so STT remains open during synthesis. + tts_active.store(true, Ordering::Release); + true + }; loop { + let mut no_current_text = None; if handle_cancel_or_shutdown( - &cancel, + (&cancel, &voice_cancel), &shutdown, &tts_active, - &text_rx, + (&text_rx, &mut deferred_text, &mut no_current_text), + &voice_change_ack, + None, Some((&player, &player_ops)), ) { if shutdown.load(Ordering::Acquire) { @@ -441,28 +555,47 @@ fn tts_worker( continue; } - let raw_text = match text_rx.recv_timeout(RECV_TIMEOUT) { - Ok(t) => t, - Err(mpsc::RecvTimeoutError::Timeout) => { - // Nothing queued. If playback has also finished, the agent - // has gone quiet — release the mic gate and reset the - // lead-in so the next utterance gets a fresh cushion. - if player.empty() && !first_append { - tts_active.store(false, Ordering::Release); - first_append = true; + // Voice changes cancel the old utterance/queue and are observed here, + // before receiving subsequent text. A bad bundled asset falls back to + // Mary without discarding the already-warmed Pocket engine. + let voice_ready = + reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style); + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + if !voice_ready { + continue; + } + + let mut queued_text = Some(match deferred_text.pop_front() { + Some(text) => text, + None => match text_rx.recv_timeout(RECV_TIMEOUT) { + Ok(text) => text, + Err(mpsc::RecvTimeoutError::Timeout) => { + // Nothing queued. If playback has also finished, the agent + // has gone quiet — release the mic gate and reset the + // lead-in so the next utterance gets a fresh cushion. + if player.empty() && !first_append { + tts_active.store(false, Ordering::Release); + eprintln!( + "buzz-desktop: tts stage=player status=drained route_id={last_route_id}" + ); + first_append = true; + } + continue; } - continue; - } - Err(mpsc::RecvTimeoutError::Disconnected) => break, - }; + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }, + }); // Check cancel again after unblocking — a cancel may have arrived // while we were waiting. + let pending_route_id = queued_text.as_ref().map(|queued| queued.route_id); if handle_cancel_or_shutdown( - &cancel, + (&cancel, &voice_cancel), &shutdown, &tts_active, - &text_rx, + (&text_rx, &mut deferred_text, &mut queued_text), + &voice_change_ack, + pending_route_id, Some((&player, &player_ops)), ) { if shutdown.load(Ordering::Acquire) { @@ -471,6 +604,30 @@ fn tts_worker( first_append = true; continue; } + let Some(queued_text) = queued_text else { + continue; + }; + if queued_text.generation < voice_generation.load(Ordering::Acquire) { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=voice_switch route_id={}", + queued_text.route_id + ); + continue; + } + let raw_text = queued_text.text; + let route_id = queued_text.route_id; + eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); + + // The selected voice can change while this worker is blocked in + // recv_timeout. Reconcile again after receipt so the first message + // queued after an unpublished pipeline is installed cannot use the + // voice captured when construction began. + if !reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style) { + eprintln!( + "buzz-desktop: tts stage=synthesis status=failed reason=voice_unavailable route_id={route_id}" + ); + continue; + } // If playback already drained while we were waiting for this item, // the agent is silent — release the mic gate BEFORE preprocessing/ @@ -482,37 +639,53 @@ fn tts_worker( // stays set across items.) if player.empty() && !first_append { tts_active.store(false, Ordering::Release); + eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}"); first_append = true; } // Preprocess text. let text = preprocess_for_tts(&raw_text); if text.is_empty() { + eprintln!( + "buzz-desktop: tts stage=synthesis status=empty reason=preprocess route_id={route_id}" + ); continue; } // Split into sentences, then group into synthesis chunks: the first // sentence stays alone (fast time-to-first-audio), the rest pack - // greedily up to MAX_CHUNK_CHARS. Each chunk is one `generate()` - // call; playback of chunk N overlaps synthesis of chunk N+1 - // (lookahead pipelining). Grouping matches upstream's ~50-token - // chunking and halves the exposed prosody seams on multi-sentence - // replies — see MAX_CHUNK_CHARS. + // greedily up to MAX_CHUNK_CHARS. Playback of each model unit overlaps + // synthesis of the next one. The Pocket engine applies its exact + // 50-token split; keeping those units within one playback chunk avoids + // adding fades and pauses at token-only boundaries. let sentences: Vec = split_sentences(&text) .into_iter() .filter(|s| !s.trim().is_empty()) .collect(); let chunks = group_sentences_into_chunks(&sentences, MAX_CHUNK_CHARS); + if chunks.is_empty() { + eprintln!( + "buzz-desktop: tts stage=synthesis status=empty reason=no_chunks route_id={route_id}" + ); + continue; + } - for chunk in &chunks { + let mut synthesis_outcome = "completed"; + let mut appended_audio = false; + let mut model_unit_index = 0_usize; + 'playback_chunks: for chunk in &chunks { + let mut no_current_text = None; if handle_cancel_or_shutdown( - &cancel, + (&cancel, &voice_cancel), &shutdown, &tts_active, - &text_rx, + (&text_rx, &mut deferred_text, &mut no_current_text), + &voice_change_ack, + Some(route_id), Some((&player, &player_ops)), ) { first_append = true; + synthesis_outcome = "cancelled"; break; } @@ -521,53 +694,113 @@ fn tts_worker( continue; } - match engine.synth_chunk(text, "en", &style, SYNTH_STEPS) { - Ok(samples) if !samples.is_empty() => { - let mut audio = clamp_to_full_scale(samples); - // Fade-out only — fading-in would attenuate the consonant - // onset (see `apply_fade_out` docstring + the - // 2026-05-18 "first little sound is missing" regression). - apply_fade_out(&mut audio); - - // Build one contiguous buffer per synthesized sentence: - // lead-in cushion + audio + trailing gap. Keeping this as - // a single rodio source preserves the original queue/drain - // semantics (one append per sentence) while still giving - // every chunk a quiet device warm-up window. - let buf = - build_sentence_append_buffer(&mut first_append, audio, silence_buf_len); - - // Check-and-append under `player_ops`, serialized with - // the monitor: a barge-in may have arrived during - // synthesis (the blocking window the monitor thread - // exists for). Don't append the now-stale sentence — the - // human interrupted; speaking it anyway would talk over - // them. Holding the lock for the check + append means the - // monitor can never clear between our check passing and - // the buffer landing. The flag is deliberately NOT - // consumed here: the loop-top handle_cancel_or_shutdown - // does the full consume (drain queue, reset lead-in) on - // the next iteration. - let _ops = lock_player_ops(&player_ops); - if cancel.load(Ordering::Acquire) { - // Nothing appended; the loop-top consume re-arms - // `first_append` (the flag is still set — the worker - // is its only consumer). + let model_chunks = match engine.split_text_into_chunks(text) { + Ok(model_chunks) => model_chunks, + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=failed reason=chunking route_id={route_id}" + ); + synthesis_outcome = "failed"; + break 'playback_chunks; + } + }; + if model_chunks.is_empty() { + eprintln!( + "buzz-desktop: tts stage=synthesis status=empty reason=no_chunks route_id={route_id}" + ); + continue; + } + let mut playback_audio = PlaybackChunkAudio::new(); + for model_chunk in &model_chunks { + let chunk_index = model_unit_index; + model_unit_index += 1; + let mut no_current_text = None; + if handle_cancel_or_shutdown( + (&cancel, &voice_cancel), + &shutdown, + &tts_active, + (&text_rx, &mut deferred_text, &mut no_current_text), + &voice_change_ack, + Some(route_id), + Some((&player, &player_ops)), + ) { + first_append = true; + synthesis_outcome = "cancelled"; + break 'playback_chunks; + } + + let synthesis = engine.synth_chunk(model_chunk, "en", &style, SYNTH_STEPS); + 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}" + ); + // The monitor already stopped any queued playback. Discard + // synthesis that completed after cancellation so stale audio + // never reaches the player, while keeping buzz-voice's + // extracted April engine API unchanged. + first_append = true; + synthesis_outcome = "cancelled"; + break 'playback_chunks; + } + match synthesis { + Ok(samples) if !samples.is_empty() => { + if let Some(prepared) = playback_audio.push( + samples, + chunk_index, + &mut first_append, + silence_buf_len, + player.empty(), + ) { + if !append_audio(prepared, route_id) { + first_append = true; + synthesis_outcome = "cancelled"; + break 'playback_chunks; + } + appended_audio = true; + last_route_id = route_id; + } + } + Ok(_) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=empty route_id={route_id} chunk_index={chunk_index}" + ); + } + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=failed reason=inference route_id={route_id} chunk_index={chunk_index}" + ); + synthesis_outcome = "failed"; break; } - player.append(SamplesBuffer::new(channels, rate, buf)); - // NOTE: tts_active is set AFTER player.append(), not - // before. Setting it before synthesis would cause STT to - // discard user speech during the synthesis window as - // "echo" even though no audio is actually playing yet. - // See crossfire review C3. - tts_active.store(true, Ordering::Release); } - Ok(_) => {} - Err(e) => { - eprintln!("buzz-desktop: TTS synth failed: {e}"); + } + if let Some(prepared) = + playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) + { + if !append_audio(prepared, route_id) { + first_append = true; + synthesis_outcome = "cancelled"; + break 'playback_chunks; } + appended_audio = true; + last_route_id = route_id; } + if synthesis_outcome == "failed" { + break 'playback_chunks; + } + } + if synthesis_outcome == "completed" && appended_audio { + eprintln!("buzz-desktop: tts stage=synthesis status=completed route_id={route_id}"); } if shutdown.load(Ordering::Acquire) { @@ -582,6 +815,7 @@ fn tts_worker( let _ = handle.join(); } + finish_voice_change_ack(&voice_change_ack); tts_active.store(false, Ordering::Release); } @@ -595,13 +829,21 @@ fn tts_worker( /// it is serialized with the monitor's stale-branch re-check (see the monitor /// block in `tts_worker`). fn handle_cancel_or_shutdown( - cancel: &AtomicBool, + cancel_signals: CancelSignals<'_>, shutdown: &AtomicBool, tts_active: &AtomicBool, - text_rx: &mpsc::Receiver, + text_state: CancelTextState<'_>, + voice_change_ack: &VoiceChangeAck, + active_route_id: Option, player: Option<(&rodio::Player, &Mutex<()>)>, ) -> bool { + let (cancel, voice_cancel) = cancel_signals; + let (text_rx, deferred_text, current_text) = text_state; if shutdown.load(Ordering::Acquire) { + eprintln!( + "buzz-desktop: tts stage=cancellation reason=shutdown route_id={}", + active_route_id.unwrap_or(0) + ); if let Some((p, ops)) = player { let _ops = lock_player_ops(ops); p.clear(); @@ -609,7 +851,29 @@ fn handle_cancel_or_shutdown( tts_active.store(false, Ordering::Release); return true; } - if cancel.load(Ordering::Acquire) { + if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { + // Serialize with begin_voice_change so the generation boundary and + // cancel consumption are observed as one transition. + let pending_voice_change = voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()); + // Consume at the serialization point. A later barge-in remains true + // for the next pass instead of being overwritten after queue cleanup. + let barge_in = cancel.swap(false, Ordering::AcqRel); + voice_cancel.store(false, Ordering::Release); + eprintln!( + "buzz-desktop: tts stage=cancellation reason={} route_id={}", + if barge_in { "barge_in" } else { "voice_switch" }, + active_route_id.unwrap_or(0) + ); + let preserve_generation = (!barge_in) + .then(|| { + pending_voice_change + .as_ref() + .map(|pending| pending.generation) + }) + .flatten(); + retain_cancelled_text(deferred_text, current_text, text_rx, preserve_generation); if let Some((p, ops)) = player { let _ops = lock_player_ops(ops); // `Player::clear()` removes queued sources AND pauses the player @@ -622,11 +886,6 @@ fn handle_cancel_or_shutdown( // Consume the flag under the lock: once released with // `cancel == false`, the monitor's stale branch no-ops instead // of clearing the fresh post-cancel utterance. - while text_rx.try_recv().is_ok() {} - cancel.store(false, Ordering::Release); - } else { - while text_rx.try_recv().is_ok() {} - cancel.store(false, Ordering::Release); } tts_active.store(false, Ordering::Release); return true; @@ -644,135 +903,11 @@ fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { ops.lock().unwrap_or_else(PoisonError::into_inner) } -/// Hard-clamp samples to ±1.0 full scale. -/// -/// No gain is applied: Pocket TTS already emits speech-level audio -/// (peaks 0.4–0.97, RMS ≈ −20 dBFS across varied sentences — measured by -/// `examples/pocket_clip_probe`), matching the kyutai reference pipeline, -/// which applies no output scaling. Two earlier gain stages were both -/// regressions against that baseline: per-sentence peak normalization caused -/// level pumping between sentences, and the fixed 9.3× gain that replaced it -/// was calibrated on a single anomalously-quiet bench utterance (peak 0.076) -/// and clipped 13–34% of samples on real speech ("blown out", 2026-06-12). -/// The clamp alone remains as the safety net against outlier transients. -fn clamp_to_full_scale(samples: Vec) -> Vec { - samples.into_iter().map(|s| s.clamp(-1.0, 1.0)).collect() -} - -/// Apply a short linear fade-out at the *end* of `samples`. -/// -/// Uses `FADE_OUT_SAMPLES` (8 ms) or half the buffer length, whichever is -/// smaller. Eliminates the click that occurs when a non-zero waveform -/// terminates abruptly at a sentence boundary. -/// -/// # Why no fade-in -/// -/// An earlier revision (pre 2026-05) symmetrically faded *in* over the same -/// 8 ms window. That swallowed the leading consonant attack on every -/// sentence — Pocket TTS produces real audio energy inside the first -/// millisecond (RMS ≈ 0.02, peak ≈ 0.03 measured across four prompts in -/// `examples/pocket_onset_probe.rs`), and a linear 0→1 ramp over 192 samples -/// scales those onset samples by ≤50 % for the first ~4 ms. The result was -/// the "first little sound or two is missing" regression heard on -/// 2026-05-18. -/// -/// The first sample of Pocket output measures ≈ 0.0018 (≈ −54 dBFS) — well -/// below the threshold at which a DC-jump would be audible as a click — so -/// no fade-in is needed. The OS audio device gets its quiet ramp-up window -/// from `SENTENCE_LEAD_IN_SAMPLES` instead, inserted as pure silence before -/// each sentence buffer. -fn apply_fade_out(samples: &mut [f32]) { - let len = samples.len(); - let fade = FADE_OUT_SAMPLES.min(len / 2); - for i in 0..fade { - samples[len - 1 - i] *= i as f32 / fade as f32; - } -} - -/// Build the single buffer appended to the rodio `Player` for one synthesised -/// sentence. -/// -/// Every sentence chunk gets a short lead-in pad immediately before its audio. -/// This matters for chunks that start with soft first phonemes (`I'm`, `I've`): -/// the synthesized buffer can begin with speech within the first millisecond, -/// so the playback layer must provide the device/mixer cushion. -/// To keep the audible gap unchanged, the trailing silence after this chunk is -/// shortened by the same amount (`silence_buf_len - SENTENCE_LEAD_IN_SAMPLES`): -/// sentence N contributes 80 ms of post-speech silence and sentence N+1 -/// contributes the remaining 20 ms of pre-speech cushion. -/// -/// The lead-in, audio, and trailing silence are concatenated into one -/// `SamplesBuffer` before appending. This keeps rodio's queue shape at one -/// tracked source per synthesized sentence, avoiding source-boundary/drain -/// regressions from enqueueing the lead-in, audio, and tail as separate sounds. -/// -/// `first_append` is flipped on the first call after the player goes idle. -/// The worker uses it in the idle branch of the main loop to distinguish -/// "never queued anything since last drain" from "drained after speaking", -/// which controls when `tts_active` is released and the lead-in re-armed. -fn build_sentence_append_buffer( - first_append: &mut bool, - audio: Vec, - silence_buf_len: usize, -) -> Vec { - if *first_append { - *first_append = false; - } - - let trailing_silence_len = silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES); - let mut buf = Vec::with_capacity(SENTENCE_LEAD_IN_SAMPLES + audio.len() + trailing_silence_len); - buf.extend(std::iter::repeat_n(0.0_f32, SENTENCE_LEAD_IN_SAMPLES)); - buf.extend(audio); - buf.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len)); - buf -} - -/// Group sentences into synthesis chunks. -/// -/// The first sentence always stands alone — it is what the listener hears -/// first, and synthesizing it by itself keeps time-to-first-audio at the -/// single-sentence cost. Subsequent sentences pack greedily: a sentence -/// joins the current chunk while the combined length stays within -/// `max_chars`; otherwise it starts a new chunk. A single sentence longer -/// than `max_chars` becomes its own chunk unsplit — Pocket TTS handles long -/// single sentences fine (the ceiling is the 500-LM-step default), it's the -/// *seams* we're minimizing. -/// -/// Sentences within a chunk are joined with a single space; sentence-ending -/// punctuation is preserved by `split_sentences`, so the model sees natural -/// multi-sentence prose — the same shape upstream's ~50-token chunker feeds it. -fn group_sentences_into_chunks(sentences: &[String], max_chars: usize) -> Vec { - let mut chunks: Vec = Vec::new(); - for (i, sentence) in sentences.iter().enumerate() { - let sentence = sentence.trim(); - if sentence.is_empty() { - continue; - } - if i == 0 || chunks.is_empty() { - chunks.push(sentence.to_string()); - continue; - } - // Never merge into the first chunk — it's the latency-critical one. - let can_merge = chunks.len() > 1 - && chunks - .last() - .is_some_and(|c| c.len() + 1 + sentence.len() <= max_chars); - if can_merge { - let last = chunks.last_mut().expect("non-empty checked above"); - last.push(' '); - last.push_str(sentence); - } else { - chunks.push(sentence.to_string()); - } - } - chunks -} - -// drain_until_shutdown lives in super (huddle/mod.rs) — shared with stt.rs. -use super::drain_until_shutdown; - // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] #[path = "tts_tests.rs"] mod tests; +#[cfg(test)] +#[path = "tts_voice_selection_tests.rs"] +mod voice_selection_tests; diff --git a/desktop/src-tauri/src/huddle/tts_audio.rs b/desktop/src-tauri/src/huddle/tts_audio.rs new file mode 100644 index 0000000000..58300b7497 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_audio.rs @@ -0,0 +1,235 @@ +use super::{FADE_OUT_SAMPLES, SENTENCE_LEAD_IN_SAMPLES}; + +pub(super) struct PreparedModelAudio { + pub(super) buffer: Vec, + pub(super) sample_count: usize, + pub(super) chunk_index: usize, +} + +/// Holds one synthesized model unit so playback-boundary decoration is based +/// on the first and last unit that actually produced audio. +pub(super) struct PlaybackChunkAudio { + pending: Option<(Vec, usize)>, + appended: bool, +} + +impl PlaybackChunkAudio { + pub(super) fn new() -> Self { + Self { + pending: None, + appended: false, + } + } + + pub(super) fn push( + &mut self, + samples: Vec, + chunk_index: usize, + first_append: &mut bool, + silence_buf_len: usize, + playback_idle: bool, + ) -> Option { + if samples.is_empty() { + return None; + } + let previous = self.pending.replace((samples, chunk_index))?; + let prepared = prepare_model_audio( + previous, + first_append, + silence_buf_len, + !self.appended || playback_idle, + false, + ); + self.appended = true; + Some(prepared) + } + + pub(super) fn finish( + &mut self, + first_append: &mut bool, + silence_buf_len: usize, + playback_idle: bool, + ) -> Option { + let pending = self.pending.take()?; + Some(prepare_model_audio( + pending, + first_append, + silence_buf_len, + !self.appended || playback_idle, + true, + )) + } +} + +fn prepare_model_audio( + (samples, chunk_index): (Vec, usize), + first_append: &mut bool, + silence_buf_len: usize, + starts_playback_chunk: bool, + ends_playback_chunk: bool, +) -> PreparedModelAudio { + let sample_count = samples.len(); + let mut audio = clamp_to_full_scale(samples); + if ends_playback_chunk { + apply_fade_out(&mut audio); + } + PreparedModelAudio { + buffer: build_sentence_append_buffer( + first_append, + audio, + silence_buf_len, + starts_playback_chunk, + ends_playback_chunk, + ), + sample_count, + chunk_index, + } +} + +/// Hard-clamp samples to ±1.0 full scale. +pub(super) fn clamp_to_full_scale(samples: Vec) -> Vec { + samples.into_iter().map(|s| s.clamp(-1.0, 1.0)).collect() +} + +/// Apply a short linear fade-out to avoid a discontinuity at playback boundaries. +pub(super) fn apply_fade_out(samples: &mut [f32]) { + let len = samples.len(); + let fade = FADE_OUT_SAMPLES.min(len / 2); + for i in 0..fade { + samples[len - 1 - i] *= i as f32 / fade as f32; + } +} + +pub(super) fn build_sentence_append_buffer( + first_append: &mut bool, + audio: Vec, + silence_buf_len: usize, + starts_playback_chunk: bool, + ends_playback_chunk: bool, +) -> Vec { + if *first_append { + *first_append = false; + } + + let lead_in_len = if starts_playback_chunk { + SENTENCE_LEAD_IN_SAMPLES + } else { + 0 + }; + let trailing_silence_len = if ends_playback_chunk { + silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES) + } else { + 0 + }; + let mut buffer = Vec::with_capacity(lead_in_len + audio.len() + trailing_silence_len); + buffer.extend(std::iter::repeat_n(0.0_f32, lead_in_len)); + buffer.extend(audio); + buffer.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len)); + buffer +} + +pub(super) fn group_sentences_into_chunks(sentences: &[String], max_chars: usize) -> Vec { + let mut chunks: Vec = Vec::new(); + for (index, sentence) in sentences.iter().enumerate() { + let sentence = sentence.trim(); + if sentence.is_empty() { + continue; + } + if index == 0 || chunks.is_empty() { + chunks.push(sentence.to_string()); + continue; + } + let can_merge = chunks.len() > 1 + && chunks + .last() + .is_some_and(|chunk| chunk.len() + 1 + sentence.len() <= max_chars); + if can_merge { + if let Some(last) = chunks.last_mut() { + last.push(' '); + last.push_str(sentence); + } + } else { + chunks.push(sentence.to_string()); + } + } + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multi_unit_audio_decorates_only_outer_playback_boundaries() { + let mut chunk = PlaybackChunkAudio::new(); + let mut first_append = true; + let silence = SENTENCE_LEAD_IN_SAMPLES + 100; + + assert!(chunk + .push(vec![0.4; 16], 0, &mut first_append, silence, false) + .is_none()); + let first = chunk + .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .expect("first ready model unit"); + assert_eq!(first.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); + assert!(first.buffer[..SENTENCE_LEAD_IN_SAMPLES] + .iter() + .all(|sample| *sample == 0.0)); + assert_eq!(first.buffer[SENTENCE_LEAD_IN_SAMPLES], 0.4); + + let last = chunk + .finish(&mut first_append, silence, false) + .expect("last ready model unit"); + assert_eq!(last.buffer.len(), 16 + 100); + assert_eq!(last.buffer.last(), Some(&0.0)); + } + + #[test] + fn empty_edge_units_do_not_steal_lead_in_or_trailing_boundary() { + let mut chunk = PlaybackChunkAudio::new(); + let mut first_append = true; + let silence = SENTENCE_LEAD_IN_SAMPLES + 100; + + assert!(chunk + .push(Vec::new(), 0, &mut first_append, silence, false) + .is_none()); + assert!(chunk + .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .is_none()); + assert!(chunk + .push(Vec::new(), 2, &mut first_append, silence, false) + .is_none()); + + let only = chunk + .finish(&mut first_append, silence, false) + .expect("only audible model unit"); + assert_eq!(only.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16 + 100); + assert!(only.buffer[..SENTENCE_LEAD_IN_SAMPLES] + .iter() + .all(|sample| *sample == 0.0)); + assert_eq!(only.buffer.last(), Some(&0.0)); + } + + #[test] + fn playback_underrun_rearms_the_onset_cushion() { + let mut chunk = PlaybackChunkAudio::new(); + let mut first_append = true; + let silence = SENTENCE_LEAD_IN_SAMPLES + 100; + + assert!(chunk + .push(vec![0.4; 16], 0, &mut first_append, silence, false) + .is_none()); + let first = chunk + .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .expect("first model unit"); + assert_eq!(first.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); + + let after_underrun = chunk + .push(vec![0.6; 16], 2, &mut first_append, silence, true) + .expect("model unit after underrun"); + assert_eq!(after_underrun.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); + assert!(after_underrun.buffer[..SENTENCE_LEAD_IN_SAMPLES] + .iter() + .all(|sample| *sample == 0.0)); + } +} diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs new file mode 100644 index 0000000000..a3e931dffe --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -0,0 +1,831 @@ +//! Installation-global text-to-speech preferences and the local voice registry. +//! +//! Voice keys are backend-qualified (`pocket:mary`, `siri:aaron`) and +//! preferences are ordered. A client resolves the first compatible entry for +//! its one active playback backend. The same [`VoicePreferences`] value can be +//! embedded in installation-global settings or future agent identity without a +//! schema change. Availability is intentionally client-local. + +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, State}; + +use crate::{app_state::AppState, managed_agents::storage::atomic_write_json_restricted}; + +use super::{ + models, + pocket::DEFAULT_VOICE, + tts_voice_registry::{source_url, MARY_VOICE_KEY, POCKET_VOICES}, + HuddlePhase, HuddleState, +}; + +const SETTINGS_FILE: &str = "tts-settings.json"; +const CURRENT_VERSION: u32 = 1; +const VOICE_CHANGE_ACK_TIMEOUT: Duration = Duration::from_secs(5); +pub const POCKET_BACKEND_ID: &str = "pocket"; + +type VoiceChangeWait = ( + Arc, + tokio::sync::oneshot::Receiver<()>, +); + +const VOICE_AVAILABILITY_BUNDLED: &str = "bundled"; +const VOICE_AVAILABILITY_INSTALLED: &str = "installed"; + +/// Installation-global huddle audio and speech preferences. +#[derive(Default)] +pub struct HuddleAudioSettingsState { + pub tts: Mutex, + pub tts_load_error: Mutex>, + pub tts_transition: tokio::sync::Mutex<()>, + /// Selected huddle output device. `None` uses the system default. + pub output_device: Mutex>, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VoiceRegistryEntry { + /// Stable identity, never derived from or merged by the display name. + /// + /// Built-ins use `backend:slug`. Future imports use + /// `pocket:imported:` so two clips with the same + /// editable label remain distinct. + pub key: String, + pub display_name: String, + pub backend: String, + pub backend_name: String, + /// Client-local state: bundled, installed, downloadable, or unavailable. + pub availability: String, + pub fallback_key: Option, + pub reference_file: Option, + pub provenance: VoiceProvenance, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VoiceProvenance { + pub source: String, + pub content_hash: Option, + pub license: Option, + pub source_url: Option, +} + +/// Ordered, backend-qualified preferences shared by global and agent settings. +/// +/// Unknown but well-formed keys remain persisted because a different client +/// may have that backend installed. Resolution is always local. +pub type VoicePreferences = Vec; + +/// Cross-backend registry for voices known to this client. +/// +/// V1 contains Pocket entries only. Siri, Kokoro, imported voices, and +/// per-agent assignment can add entries or reuse the preference type without +/// changing the registry/settings boundary. +pub fn voice_registry() -> Vec { + POCKET_VOICES + .iter() + .map(|voice| VoiceRegistryEntry { + key: voice.key.to_string(), + display_name: voice.display_name.to_string(), + backend: POCKET_BACKEND_ID.to_string(), + backend_name: "Pocket TTS".to_string(), + availability: VOICE_AVAILABILITY_BUNDLED.to_string(), + fallback_key: (voice.key != MARY_VOICE_KEY).then(|| MARY_VOICE_KEY.to_string()), + reference_file: Some(voice.reference_file.to_string()), + provenance: VoiceProvenance { + source: "bundled".to_string(), + content_hash: Some(voice.sha256.to_string()), + license: Some("CC-BY-4.0".to_string()), + source_url: Some(source_url(voice)), + }, + }) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TtsSettings { + pub version: u32, + pub agent_text_to_speech: bool, + pub voice_preferences: VoicePreferences, +} + +impl Default for TtsSettings { + fn default() -> Self { + Self { + version: CURRENT_VERSION, + agent_text_to_speech: true, + voice_preferences: vec![MARY_VOICE_KEY.to_string()], + } + } +} + +pub fn voice_by_key(key: &str) -> Option { + voice_registry().into_iter().find(|voice| voice.key == key) +} + +fn is_qualified_voice_key(key: &str) -> bool { + key.split_once(':') + .is_some_and(|(backend, voice)| !backend.is_empty() && !voice.is_empty()) +} + +fn is_locally_available(availability: &str) -> bool { + matches!( + availability, + VOICE_AVAILABILITY_BUNDLED | VOICE_AVAILABILITY_INSTALLED + ) +} + +pub fn resolve_voice_for_backend( + preferences: &[String], + backend: &str, +) -> Result { + let registry = voice_registry(); + preferences + .iter() + .filter_map(|key| registry.iter().find(|voice| voice.key == *key)) + .find(|voice| voice.backend == backend && is_locally_available(voice.availability.as_str())) + .or_else(|| { + registry.iter().find(|voice| { + voice.backend == backend + && voice.fallback_key.is_none() + && is_locally_available(voice.availability.as_str()) + }) + }) + .cloned() + .ok_or_else(|| format!("No locally available fallback voice for backend {backend}")) +} + +pub fn pocket_voice_name(preferences: &[String]) -> String { + resolve_voice_for_backend(preferences, POCKET_BACKEND_ID) + .ok() + .and_then(|voice| voice.reference_file) + .and_then(|file| file.strip_suffix(".wav").map(str::to_string)) + .unwrap_or_else(|| DEFAULT_VOICE.to_string()) +} + +pub(crate) fn settings_path(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|dir| dir.join(SETTINGS_FILE)) + .map_err(|error| format!("could not locate Buzz settings storage: {error}")) +} + +pub(crate) fn load_from_path(path: &Path) -> Result { + if !path.exists() { + return Ok(TtsSettings::default()); + } + let bytes = std::fs::read(path) + .map_err(|error| format!("could not read text-to-speech settings: {error}"))?; + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("text-to-speech settings are not valid JSON: {error}"))?; + + // Unversioned settings are incompatible with the V1 schema. Use + // deterministic V1 defaults rather than interpreting ambiguous fields. + if value.get("version").is_none() { + return Ok(TtsSettings::default()); + } + + let version = value + .get("version") + .and_then(serde_json::Value::as_u64) + .ok_or("text-to-speech settings version is invalid")?; + if version > u64::from(CURRENT_VERSION) { + return Err(format!( + "text-to-speech settings version {version} is newer than this Buzz build supports" + )); + } + + // Legacy V1 settings may contain one bare Pocket `voiceId`. Preserve the + // toggle and qualify it into the ordered cross-backend preference schema. + if value.get("voicePreferences").is_none() { + let legacy_voice = value + .get("voiceId") + .or_else(|| value.get("voice_id")) + .and_then(serde_json::Value::as_str) + .unwrap_or("mary"); + let voice_key = if is_qualified_voice_key(legacy_voice) { + legacy_voice.to_string() + } else { + format!("{POCKET_BACKEND_ID}:{legacy_voice}") + }; + return Ok(TtsSettings { + version: CURRENT_VERSION, + agent_text_to_speech: value + .get("agentTextToSpeech") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + voice_preferences: vec![voice_key], + }); + } + + let mut settings: TtsSettings = serde_json::from_value(value) + .map_err(|error| format!("text-to-speech settings are invalid: {error}"))?; + settings.version = CURRENT_VERSION; + if settings.voice_preferences.is_empty() + || settings + .voice_preferences + .iter() + .any(|key| !is_qualified_voice_key(key)) + { + settings.voice_preferences = TtsSettings::default().voice_preferences; + } + Ok(settings) +} + +pub(crate) fn save_to_path(path: &Path, settings: &TtsSettings) -> Result<(), String> { + if settings.voice_preferences.is_empty() { + return Err("At least one voice preference is required".to_string()); + } + if let Some(key) = settings + .voice_preferences + .iter() + .find(|key| !is_qualified_voice_key(key)) + { + return Err(format!( + "Voice preference keys must be backend-qualified: {key}" + )); + } + let payload = serde_json::to_vec_pretty(settings) + .map_err(|error| format!("could not encode text-to-speech settings: {error}"))?; + atomic_write_json_restricted(path, &payload) + .map_err(|error| format!("could not save text-to-speech settings: {error}")) +} + +pub fn load_for_app(app: &AppHandle) -> (TtsSettings, Option) { + let result = settings_path(app).and_then(|path| load_from_path(&path)); + match result { + Ok(settings) => (settings, None), + Err(error) => { + eprintln!("buzz-desktop: {error}; preserving the file and using Mary for this session"); + (TtsSettings::default(), Some(error)) + } + } +} + +#[tauri::command] +pub fn get_tts_settings(state: State<'_, AppState>) -> Result { + if let Some(error) = state + .huddle_audio + .tts_load_error + .lock() + .map_err(|lock_error| format!("text-to-speech settings lock poisoned: {lock_error}"))? + .clone() + { + return Err(format!( + "Voice settings could not be loaded and were left unchanged: {error}" + )); + } + state + .huddle_audio + .tts + .lock() + .map(|settings| settings.clone()) + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}")) +} + +#[tauri::command] +pub fn list_voice_registry() -> Vec { + voice_registry() +} + +fn ensure_settings_writable(state: &AppState) -> Result<(), String> { + if let Some(error) = state + .huddle_audio + .tts_load_error + .lock() + .map_err(|lock_error| format!("text-to-speech settings lock poisoned: {lock_error}"))? + .as_ref() + { + return Err(format!( + "Voice settings were not saved because the existing file could not be loaded: {error}" + )); + } + Ok(()) +} + +fn cancel_huddle_speech( + huddle: &mut super::HuddleState, +) -> Option> { + huddle.tts_enabled = false; + huddle + .tts_cancel + .store(true, std::sync::atomic::Ordering::Release); + huddle.tts_pipeline.take() +} + +fn disable_tts_runtime(state: &AppState) -> Result<(), String> { + let old_pipeline = { + let mut huddle = state.huddle()?; + cancel_huddle_speech(&mut huddle) + }; + if let Some(ref pipeline) = old_pipeline { + pipeline.shutdown(); + } + drop(old_pipeline); + state.emit_huddle_state_changed(); + Ok(()) +} + +fn commit_effective_off(state: &AppState) -> Result<(), String> { + state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .agent_text_to_speech = false; + Ok(()) +} + +fn enable_tts_runtime(huddle: &mut HuddleState, voice: &str) -> Option { + huddle.tts_enabled = true; + // OFF removes the pipeline. Clear a prior cancellation only when enabling + // a fresh pipeline; an idempotent ON write must not erase a voice + // transition that the existing worker still needs to drain. + prepare_enable_cancel(&huddle.tts_cancel, huddle.tts_pipeline.is_some()); + huddle.tts_pipeline.as_ref().and_then(|pipeline| { + pipeline + .select_voice(voice) + .map(|acknowledged| (Arc::clone(pipeline), acknowledged)) + }) +} + +fn prepare_enable_cancel(cancel: &std::sync::atomic::AtomicBool, has_pipeline: bool) { + if !has_pipeline { + cancel.store(false, std::sync::atomic::Ordering::Release); + } +} + +async fn apply_tts_settings( + settings: TtsSettings, + app: &AppHandle, + state: &AppState, +) -> Result, String> { + if settings.version != CURRENT_VERSION { + return Err(format!( + "Unsupported text-to-speech settings version: {}", + settings.version + )); + } + + // OFF is safety-sensitive: stop current and queued speech before any disk + // I/O, and never resume it merely because persistence fails. + if !settings.agent_text_to_speech { + disable_tts_runtime(state)?; + commit_effective_off(state)?; + } + + ensure_settings_writable(state)?; + save_to_path(&settings_path(app)?, &settings)?; + + *state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? = + settings.clone(); + + let mut voice_change_wait = None; + if settings.agent_text_to_speech { + let (active, voice_change_ack) = { + let mut huddle = state.huddle()?; + let voice_change_ack = + enable_tts_runtime(&mut huddle, &pocket_voice_name(&settings.voice_preferences)); + ( + matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active), + voice_change_ack, + ) + }; + voice_change_wait = voice_change_ack; + if active { + if let Err(error) = super::pipeline::maybe_start_tts_pipeline(state).await { + eprintln!("buzz-desktop: could not hot-start text to speech: {error}"); + } + } + state.emit_huddle_state_changed(); + } + Ok(voice_change_wait) +} + +fn current_settings(state: &AppState) -> Result { + state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}")) + .map(|settings| settings.clone()) +} + +async fn finish_voice_change(voice_change: Option) -> Result<(), String> { + let Some((pipeline, acknowledged)) = voice_change else { + return Ok(()); + }; + wait_for_voice_change_ack(acknowledged, VOICE_CHANGE_ACK_TIMEOUT, || { + pipeline.is_finished() + }) + .await +} + +async fn wait_for_voice_change_ack( + mut acknowledged: tokio::sync::oneshot::Receiver<()>, + timeout: Duration, + mut worker_is_finished: impl FnMut() -> bool, +) -> Result<(), String> { + let deadline = tokio::time::sleep(timeout); + tokio::pin!(deadline); + loop { + tokio::select! { + _ = &mut acknowledged => return Ok(()), + _ = &mut deadline => { + return Err( + "Pocket TTS is still finishing the previous voice. Turn Agent text to speech off and try again." + .to_string(), + ); + } + _ = tokio::time::sleep(Duration::from_millis(25)) => { + if worker_is_finished() { + return Ok(()); + } + } + } + } +} + +/// Compatibility command for the huddle speaker button. It updates the same +/// installation-global preference as Settings; there is no per-huddle override. +#[tauri::command] +pub async fn set_tts_enabled( + enabled: bool, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let transition = state.huddle_audio.tts_transition.lock().await; + let mut settings = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .clone(); + settings.agent_text_to_speech = enabled; + let voice_change = apply_tts_settings(settings, &app, &state).await?; + drop(transition); + finish_voice_change(voice_change).await?; + current_settings(&state) +} + +fn settings_with_pocket_voice( + mut settings: TtsSettings, + voice_key: &str, +) -> Result { + let voice = voice_by_key(voice_key).ok_or_else(|| format!("Unknown voice: {voice_key}"))?; + if voice.backend != POCKET_BACKEND_ID || !is_locally_available(&voice.availability) { + return Err("The selected Pocket voice is not available on this device".to_string()); + } + let first_pocket_index = settings + .voice_preferences + .iter() + .position(|key| key.starts_with("pocket:")); + settings + .voice_preferences + .retain(|key| !key.starts_with("pocket:")); + let insert_at = first_pocket_index + .unwrap_or(settings.voice_preferences.len()) + .min(settings.voice_preferences.len()); + settings + .voice_preferences + .insert(insert_at, voice_key.to_string()); + Ok(settings) +} + +#[tauri::command] +pub async fn set_pocket_voice( + voice_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let transition = state.huddle_audio.tts_transition.lock().await; + let settings = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .clone(); + let settings = settings_with_pocket_voice(settings, &voice_key)?; + let voice_change = apply_tts_settings(settings, &app, &state).await?; + drop(transition); + if let Err(error) = finish_voice_change(voice_change).await { + // The preference is already durable. Report the delayed live + // transition diagnostically without telling the UI that saving failed; + // the next pipeline start resolves the persisted voice normally. + eprintln!( + "buzz-desktop: tts stage=voice_switch status=delayed reason=ack_timeout error={error}" + ); + } + current_settings(&state) +} + +#[tauri::command] +pub async fn preview_pocket_voice( + voice_key: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let voice = voice_by_key(&voice_key).ok_or_else(|| format!("Unknown voice: {voice_key}"))?; + if voice.backend != POCKET_BACKEND_ID { + return Err("Only Pocket voices can be previewed in this build".to_string()); + } + if !models::is_tts_ready() { + return Err("Voice files are still downloading. Try preview again shortly.".to_string()); + } + let model_dir = models::tts_model_dir().ok_or("Pocket voice files are unavailable")?; + let output_device = state + .huddle_audio + .output_device + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + let voice_name = voice + .reference_file + .and_then(|file| file.strip_suffix(".wav").map(str::to_string)) + .ok_or_else(|| format!("Voice {voice_key} has no local Pocket reference file"))?; + tokio::task::spawn_blocking(move || { + let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let pipeline = super::tts::TtsPipeline::new_with_voice( + model_dir, + active.clone(), + cancel, + &voice_name, + output_device, + )?; + pipeline.speak("Hello! This is how I’ll read agent responses.".to_string())?; + let started = std::time::Instant::now(); + let mut heard_audio = false; + while started.elapsed() < std::time::Duration::from_secs(30) { + let is_active = active.load(std::sync::atomic::Ordering::Acquire); + heard_audio |= is_active; + if heard_audio && !is_active { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + Err("Voice preview timed out. Check your audio output and try again.".to_string()) + }) + .await + .map_err(|error| format!("Voice preview task failed: {error}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + const EVE_VOICE_KEY: &str = "pocket:eve"; + + #[tokio::test] + async fn stalled_voice_change_returns_an_actionable_error() { + let (_keep_pending, acknowledged) = tokio::sync::oneshot::channel(); + + let error = wait_for_voice_change_ack(acknowledged, Duration::from_millis(1), || false) + .await + .expect_err("stalled worker should time out"); + + assert!(error.contains("Turn Agent text to speech off")); + } + + #[test] + fn idempotent_enable_preserves_an_existing_pipeline_cancel() { + let cancel = std::sync::atomic::AtomicBool::new(true); + prepare_enable_cancel(&cancel, true); + assert!(cancel.load(std::sync::atomic::Ordering::Acquire)); + prepare_enable_cancel(&cancel, false); + assert!(!cancel.load(std::sync::atomic::Ordering::Acquire)); + } + + #[test] + fn defaults_are_backwards_compatible_and_use_mary() { + assert_eq!( + TtsSettings::default(), + TtsSettings { + version: 1, + agent_text_to_speech: true, + voice_preferences: vec!["pocket:mary".to_string()], + } + ); + } + + #[test] + fn registry_has_all_official_english_vctk_presets() { + assert_eq!( + voice_registry() + .iter() + .map(|voice| { + ( + voice.key.as_str(), + voice.display_name.as_str(), + voice.reference_file.as_deref(), + ) + }) + .collect::>(), + vec![ + ("pocket:anna", "Anna", Some("anna.wav")), + ("pocket:vera", "Vera", Some("vera.wav")), + ("pocket:fantine", "Fantine", Some("fantine.wav")), + ("pocket:charles", "Charles", Some("charles.wav")), + ("pocket:paul", "Paul", Some("paul.wav")), + ("pocket:eponine", "Eponine", Some("eponine.wav")), + ("pocket:azelma", "Azelma", Some("azelma.wav")), + ("pocket:george", "George", Some("george.wav")), + ("pocket:mary", "Mary", Some("reference_sample.wav")), + ("pocket:jane", "Jane", Some("jane.wav")), + ("pocket:michael", "Michael", Some("michael.wav")), + ("pocket:eve", "Eve", Some("eve.wav")), + ] + ); + } + + #[test] + fn local_backend_resolution_uses_first_compatible_preference() { + let preferences = vec![ + "siri:aaron".to_string(), + EVE_VOICE_KEY.to_string(), + MARY_VOICE_KEY.to_string(), + "kokoro:af_heart".to_string(), + ]; + assert_eq!( + resolve_voice_for_backend(&preferences, POCKET_BACKEND_ID) + .expect("Pocket fallback") + .key, + EVE_VOICE_KEY + ); + } + + #[test] + fn unsupported_or_missing_preferences_fall_back_to_backend_default() { + let preferences = vec![ + "siri:aaron".to_string(), + "pocket:imported:deadbeef".to_string(), + ]; + assert_eq!( + resolve_voice_for_backend(&preferences, POCKET_BACKEND_ID) + .expect("Pocket fallback") + .key, + MARY_VOICE_KEY + ); + } + + #[test] + fn identity_is_qualified_key_not_display_label() { + assert!(is_qualified_voice_key("pocket:imported:audio-content-hash")); + assert_ne!(MARY_VOICE_KEY, EVE_VOICE_KEY); + let mut registry = voice_registry(); + registry[0].display_name = "Jim".to_string(); + registry[1].display_name = "Jim".to_string(); + assert_eq!(registry[0].display_name, registry[1].display_name); + assert_ne!(registry[0].key, registry[1].key); + assert_eq!( + registry + .iter() + .map(|voice| voice.key.as_str()) + .collect::>() + .len(), + registry.len() + ); + } + + #[test] + fn bundled_vctk_assets_match_the_registry_manifest() { + for voice in POCKET_VOICES { + let Some(bytes) = voice.bytes else { + continue; + }; + assert_eq!(&bytes[0..4], b"RIFF", "{}", voice.display_name); + assert_eq!(&bytes[8..12], b"WAVE", "{}", voice.display_name); + assert_eq!( + hex::encode(::digest(bytes)), + voice.sha256, + "{}", + voice.display_name + ); + } + } + + #[test] + fn migrates_unversioned_experiment_settings_to_v1_defaults() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write(&path, r#"{"voice":"legacy-experiment"}"#).expect("fixture write"); + assert_eq!( + load_from_path(&path).expect("migration"), + TtsSettings::default() + ); + } + + #[test] + fn migrates_bare_pocket_voice_id_to_qualified_preferences() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write( + &path, + r#"{"version":1,"agentTextToSpeech":false,"voiceId":"eve"}"#, + ) + .expect("fixture write"); + assert_eq!( + load_from_path(&path).expect("migration"), + TtsSettings { + version: 1, + agent_text_to_speech: false, + voice_preferences: vec![EVE_VOICE_KEY.to_string()], + } + ); + } + + #[test] + fn unknown_qualified_preferences_are_preserved_for_other_clients() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write( + &path, + r#"{"version":1,"agentTextToSpeech":false,"voicePreferences":["siri:aaron","pocket:imported:abc123"]}"#, + ) + .expect("fixture write"); + let settings = load_from_path(&path).expect("load"); + assert!(!settings.agent_text_to_speech); + assert_eq!( + settings.voice_preferences, + vec!["siri:aaron", "pocket:imported:abc123"] + ); + } + + #[test] + fn rejects_future_schema_versions_clearly() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write( + &path, + r#"{"version":99,"agentTextToSpeech":true,"voicePreferences":["pocket:mary"]}"#, + ) + .expect("fixture write"); + assert!(load_from_path(&path) + .expect_err("future version should fail") + .contains("newer than this Buzz build supports")); + } + + #[test] + fn disabling_cancels_runtime_before_persistence_can_fail() { + let mut huddle = super::super::HuddleState { + tts_enabled: true, + ..super::super::HuddleState::default() + }; + assert!(!huddle.tts_cancel.load(std::sync::atomic::Ordering::Acquire)); + assert!(cancel_huddle_speech(&mut huddle).is_none()); + assert!(!huddle.tts_enabled); + assert!(huddle.tts_cancel.load(std::sync::atomic::Ordering::Acquire)); + } + + #[test] + fn pocket_voice_update_preserves_the_latest_toggle_and_other_backends() { + let current = TtsSettings { + agent_text_to_speech: false, + voice_preferences: vec!["siri:aaron".to_string(), MARY_VOICE_KEY.to_string()], + ..TtsSettings::default() + }; + let updated = settings_with_pocket_voice(current, EVE_VOICE_KEY).expect("available voice"); + assert!(!updated.agent_text_to_speech); + assert_eq!(updated.voice_preferences, vec!["siri:aaron", EVE_VOICE_KEY]); + } + + #[test] + fn failed_off_persistence_cannot_be_undone_by_a_later_voice_update() { + let state = crate::app_state::build_app_state(); + commit_effective_off(&state).expect("commit effective OFF state"); + + // This models the next command after the OFF save fails: it must merge + // from effective memory state, not the stale last-persisted ON value. + let current = state.huddle_audio.tts.lock().expect("settings").clone(); + let voice_update = + settings_with_pocket_voice(current, EVE_VOICE_KEY).expect("available voice"); + assert!(!voice_update.agent_text_to_speech); + } + + #[test] + fn failed_disabled_voice_save_does_not_change_the_remembered_voice() { + let state = crate::app_state::build_app_state(); + state + .huddle_audio + .tts + .lock() + .expect("settings") + .agent_text_to_speech = false; + let current = state.huddle_audio.tts.lock().expect("settings").clone(); + let unsaved = settings_with_pocket_voice(current, EVE_VOICE_KEY).expect("available voice"); + + // This is the only pre-persistence mutation for an OFF candidate. + commit_effective_off(&state).expect("commit effective OFF state"); + let remembered = state.huddle_audio.tts.lock().expect("settings").clone(); + assert_eq!(remembered.voice_preferences, vec![MARY_VOICE_KEY]); + assert_eq!(unsaved.voice_preferences, vec![EVE_VOICE_KEY]); + } +} diff --git a/desktop/src-tauri/src/huddle/tts_startup.rs b/desktop/src-tauri/src/huddle/tts_startup.rs new file mode 100644 index 0000000000..2cb50401a9 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_startup.rs @@ -0,0 +1,24 @@ +use std::{sync::mpsc, thread}; + +pub(super) fn await_worker_startup( + handle: thread::JoinHandle<()>, + startup_rx: mpsc::Receiver>, +) -> Result, String> { + match startup_rx.recv() { + Ok(Ok(())) => Ok(handle), + Ok(Err(error)) => { + let _ = handle.join(); + Err(error) + } + Err(error) => { + let _ = handle.join(); + Err(format!( + "TTS worker exited before reporting readiness: {error}" + )) + } + } +} + +#[cfg(test)] +#[path = "tts_startup_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/tts_startup_tests.rs b/desktop/src-tauri/src/huddle/tts_startup_tests.rs new file mode 100644 index 0000000000..cf688c2db8 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_startup_tests.rs @@ -0,0 +1,44 @@ +use super::*; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +#[test] +fn startup_failure_is_returned_after_worker_exit() { + let (tx, rx) = mpsc::sync_channel(1); + let exited = Arc::new(AtomicBool::new(false)); + let exited_worker = Arc::clone(&exited); + let handle = std::thread::spawn(move || { + tx.send(Err("output unavailable".to_string())) + .expect("startup receiver"); + exited_worker.store(true, Ordering::Release); + }); + + assert_eq!( + await_worker_startup(handle, rx).expect_err("startup must fail"), + "output unavailable" + ); + assert!(exited.load(Ordering::Acquire)); +} + +#[test] +fn worker_exit_before_readiness_is_a_startup_error() { + let (tx, rx) = mpsc::sync_channel::>(1); + let handle = std::thread::spawn(move || drop(tx)); + + assert!(await_worker_startup(handle, rx) + .expect_err("closed startup channel must fail") + .contains("before reporting readiness")); +} + +#[test] +fn ready_ack_precedes_pipeline_publication_boundary() { + let (tx, rx) = mpsc::sync_channel(1); + let handle = std::thread::spawn(move || { + tx.send(Ok(())).expect("startup receiver"); + }); + + let handle = await_worker_startup(handle, rx).expect("ready worker"); + handle.join().expect("worker exits"); +} diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 7887f8bbdb..1908b096b1 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -9,6 +9,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; +#[path = "tts_tests/token_split.rs"] +mod token_split; + // ── Remote interrupt tracker ────────────────────────────────────────────── // // Models the per-peer frame counting logic in the recv task of @@ -785,16 +788,6 @@ fn apply_fade_out_single_sample() { assert_eq!(samples[0], 1.0); } -/// Sanity-check the per-sentence cushion length: 20 ms at 24 kHz must -/// land at exactly 480 samples. This is a const computation, so the -/// real value of this test is documenting *why* 20 ms was chosen — it -/// covers a typical CoreAudio buffer turnover (256–1024 samples) -/// without being audible as user-facing latency. -#[test] -fn sentence_lead_in_is_sane() { - assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); -} - // ── build_sentence_append_buffer tests ─────────────────────────────────── /// REGRESSION: every chunk needs an onset cushion; synthesized chunks @@ -812,6 +805,8 @@ fn lead_in_pad_is_present_for_every_sentence_chunk() { &mut first, vec![0.5_f32; SENTENCE_AUDIO_LEN], SILENCE_BUF_LEN, + true, + true, ); assert_eq!(buf.len(), SENTENCE_AUDIO_LEN + SILENCE_BUF_LEN); @@ -840,11 +835,11 @@ fn lead_in_pad_is_present_for_every_sentence_chunk() { #[test] fn build_sentence_append_buffer_flips_first_append() { let mut first = true; - let _ = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let _ = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert!(!first, "first call must flip the flag"); // Subsequent call: still has a per-sentence lead-in, flag stays false. - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); assert!(!first); } @@ -853,7 +848,7 @@ fn build_sentence_append_buffer_flips_first_append() { #[test] fn first_sentence_leading_silence_is_exactly_lead_in() { let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5); } @@ -863,8 +858,10 @@ fn first_sentence_leading_silence_is_exactly_lead_in() { fn sentence_gap_budget_is_preserved() { let mut first = true; let silence_buf_len = 2400; - let first_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len); - let second_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len); + let first_buf = + build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, true); + let second_buf = + build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, true); let first_tail = &first_buf[SENTENCE_LEAD_IN_SAMPLES + 100..]; let second_lead = &second_buf[..SENTENCE_LEAD_IN_SAMPLES]; @@ -877,7 +874,7 @@ fn sentence_gap_budget_is_preserved() { #[test] fn sentence_append_buffer_is_one_contiguous_source() { let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert_eq!(buf.len(), 2400 + 100); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); @@ -949,9 +946,8 @@ fn chunk_grouping_packs_up_to_budget_then_spills() { assert_eq!(chunks[2], d); } -/// A single sentence longer than the budget is passed through unsplit — -/// long single sentences are fine (the LM cap bounds runaway); only seams -/// are being minimized. +/// A single sentence longer than the coarse budget is passed through here; +/// the loaded April engine subsequently enforces its exact 50-token limit. #[test] fn chunk_grouping_oversized_sentence_passes_through() { let long = "word ".repeat(60).trim_end().to_string() + "."; diff --git a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs new file mode 100644 index 0000000000..b9249c9afc --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs @@ -0,0 +1,24 @@ +use super::*; + +/// The onset cushion covers 20 ms at the production sample rate. +#[test] +fn sentence_lead_in_is_sane() { + assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); +} + +/// Model-token splits remain contiguous: only the playback chunk as a whole +/// receives its onset cushion and trailing sentence gap. +#[test] +fn token_split_units_do_not_add_sentence_boundary_padding() { + let mut first = true; + let silence_buf_len = 2400; + let first_unit = + build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, false); + let last_unit = + build_sentence_append_buffer(&mut first, vec![0.25; 100], silence_buf_len, false, true); + + assert_eq!(first_unit.len(), SENTENCE_LEAD_IN_SAMPLES + 100); + assert_eq!(first_unit.last(), Some(&0.5)); + assert_eq!(last_unit.first(), Some(&0.25)); + assert_eq!(first_unit.len() + last_unit.len(), 200 + silence_buf_len); +} diff --git a/desktop/src-tauri/src/huddle/tts_voice_registry.rs b/desktop/src-tauri/src/huddle/tts_voice_registry.rs new file mode 100644 index 0000000000..bdfbd7677b --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_voice_registry.rs @@ -0,0 +1,129 @@ +//! Built-in Pocket voice identities and immutable asset metadata. +//! +//! Stable keys identify audio, not display labels. Future imported voices use +//! `pocket:imported:` and may share editable labels. + +pub(super) const MARY_VOICE_KEY: &str = "pocket:mary"; +pub(super) const VCTK_REVISION: &str = "323332d33f997de8394f24a193e1a76df720e01a"; + +pub(super) struct PocketVoiceSpec { + pub key: &'static str, + pub display_name: &'static str, + pub reference_file: &'static str, + pub upstream_file: &'static str, + pub sha256: &'static str, + pub bytes: Option<&'static [u8]>, +} + +macro_rules! bundled_voice { + ($key:literal, $name:literal, $file:literal, $upstream:literal, $hash:literal) => { + PocketVoiceSpec { + key: $key, + display_name: $name, + reference_file: concat!($file, ".wav"), + upstream_file: concat!("vctk/", $upstream), + sha256: $hash, + bytes: Some(include_bytes!(concat!( + "../../resources/pocket-voices/", + $file, + ".wav" + ))), + } + }; +} + +/// Official English Pocket presets, in the order published by Kyutai. +pub(super) static POCKET_VOICES: &[PocketVoiceSpec] = &[ + bundled_voice!( + "pocket:anna", + "Anna", + "anna", + "p228_023_enhanced.wav", + "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856" + ), + bundled_voice!( + "pocket:vera", + "Vera", + "vera", + "p229_023_enhanced.wav", + "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b" + ), + bundled_voice!( + "pocket:fantine", + "Fantine", + "fantine", + "p244_023_enhanced.wav", + "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b" + ), + bundled_voice!( + "pocket:charles", + "Charles", + "charles", + "p254_023_enhanced.wav", + "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756" + ), + bundled_voice!( + "pocket:paul", + "Paul", + "paul", + "p259_023_enhanced.wav", + "7aba504fe0b3b16478b69eb27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b" + ), + bundled_voice!( + "pocket:eponine", + "Eponine", + "eponine", + "p262_023_enhanced.wav", + "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b" + ), + bundled_voice!( + "pocket:azelma", + "Azelma", + "azelma", + "p303_023_enhanced.wav", + "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026" + ), + bundled_voice!( + "pocket:george", + "George", + "george", + "p315_023_enhanced.wav", + "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae" + ), + PocketVoiceSpec { + key: MARY_VOICE_KEY, + display_name: "Mary", + reference_file: "reference_sample.wav", + upstream_file: "vctk/p333_023_enhanced.wav", + sha256: "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", + bytes: None, + }, + bundled_voice!( + "pocket:jane", + "Jane", + "jane", + "p339_023_enhanced.wav", + "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a" + ), + bundled_voice!( + "pocket:michael", + "Michael", + "michael", + "p360_023_enhanced.wav", + "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad" + ), + bundled_voice!( + "pocket:eve", + "Eve", + "eve", + "p361_023_enhanced.wav", + "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd" + ), +]; + +pub(super) fn source_url(voice: &PocketVoiceSpec) -> String { + format!( + "https://huggingface.co/kyutai/tts-voices/blob/{VCTK_REVISION}/{}", + voice.upstream_file + ) +} diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs new file mode 100644 index 0000000000..45662c9921 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -0,0 +1,385 @@ +use super::*; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +fn inert_pipeline(cancel: Arc) -> TtsPipeline { + let (text_tx, text_rx) = std::sync::mpsc::sync_channel(TEXT_QUEUE_DEPTH); + let shutdown = Arc::new(AtomicBool::new(false)); + let worker_shutdown = Arc::clone(&shutdown); + let thread = std::thread::spawn(move || { + while !worker_shutdown.load(Ordering::Acquire) { + let _ = text_rx.recv_timeout(RECV_TIMEOUT); + } + }); + TtsPipeline { + text_tx, + tts_active: Arc::new(AtomicBool::new(false)), + shutdown, + cancel, + 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)), + voice_change_ack: Arc::new(std::sync::Mutex::new(None)), + thread: Some(thread), + } +} + +#[test] +fn selecting_a_voice_raises_only_the_internal_cancel_and_retains_the_engine_handle() { + let cancel = Arc::new(AtomicBool::new(false)); + let pipeline = inert_pipeline(Arc::clone(&cancel)); + + let _acknowledged = pipeline.select_voice("eve"); + + assert!(!cancel.load(Ordering::Acquire)); + assert!(pipeline.voice_cancel.load(Ordering::Acquire)); + assert_eq!( + pipeline + .voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_str(), + "eve" + ); +} + +#[test] +fn reconciling_an_unpublished_pipeline_does_not_cancel_its_first_message() { + let cancel = Arc::new(AtomicBool::new(false)); + let pipeline = inert_pipeline(Arc::clone(&cancel)); + + pipeline.select_voice_before_publish("eve"); + + assert!(!cancel.load(Ordering::Acquire)); + assert_eq!( + pipeline + .voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_str(), + "eve" + ); +} + +#[test] +fn received_text_reconciles_a_voice_changed_while_the_worker_was_waiting() { + let model_dir = tempfile::tempdir().expect("temp model dir"); + let bundled_voice = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/pocket-voices/eve.wav"); + std::fs::copy( + &bundled_voice, + model_dir.path().join("reference_sample.wav"), + ) + .expect("Mary test voice"); + std::fs::copy(&bundled_voice, model_dir.path().join("eve.wav")).expect("Eve test voice"); + + let selected_voice = Arc::new(std::sync::Mutex::new("reference_sample".to_string())); + let mut style = + load_voice_style(&model_dir.path().join("reference_sample.wav")).expect("initial style"); + let waiting = Arc::new(std::sync::Barrier::new(2)); + let (text_tx, text_rx) = std::sync::mpsc::channel(); + let worker_voice = Arc::clone(&selected_voice); + let worker_waiting = Arc::clone(&waiting); + let worker_model_dir = model_dir.path().to_path_buf(); + let worker = std::thread::spawn(move || { + let mut voice_name = "reference_sample".to_string(); + worker_waiting.wait(); + let text = text_rx.recv().expect("first queued text"); + assert!(reconcile_selected_voice( + &worker_model_dir, + &worker_voice, + &mut voice_name, + &mut style, + )); + (text, voice_name) + }); + + waiting.wait(); + *selected_voice.lock().expect("selected voice") = "eve".to_string(); + text_tx + .send("first message".to_string()) + .expect("queue first message"); + + assert_eq!( + worker.join().expect("worker"), + ("first message".to_string(), "eve".to_string()) + ); +} + +#[test] +fn corrupt_selected_voice_falls_back_to_mary() { + let model_dir = tempfile::tempdir().expect("temp model dir"); + let bundled_voice = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/pocket-voices/eve.wav"); + std::fs::copy(bundled_voice, model_dir.path().join("reference_sample.wav")) + .expect("Mary test voice"); + std::fs::write(model_dir.path().join("eve.wav"), b"not a wave") + .expect("corrupt selected voice"); + + let selected_voice = std::sync::Mutex::new("eve".to_string()); + let mut voice_name = "reference_sample".to_string(); + let mut style = + load_voice_style(&model_dir.path().join("reference_sample.wav")).expect("Mary style"); + + assert!(reconcile_selected_voice( + model_dir.path(), + &selected_voice, + &mut voice_name, + &mut style, + )); + assert_eq!(voice_name, DEFAULT_VOICE); + assert_eq!( + selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_str(), + DEFAULT_VOICE + ); +} + +#[test] +fn an_in_hand_post_change_message_survives_cancellation() { + let selected_voice = Arc::new(std::sync::Mutex::new("reference_sample".to_string())); + let voice_generation = AtomicU64::new(1); + let barge_in = AtomicBool::new(false); + let voice_cancel = Arc::new(AtomicBool::new(false)); + let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); + let (text_tx, text_rx) = std::sync::mpsc::sync_channel(1); + let mut acknowledged = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "eve", + ) + .expect("voice changed"); + assert!(voice_cancel.load(Ordering::Acquire)); + assert!(matches!( + acknowledged.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + assert!(matches!( + acknowledged.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + text_tx + .send(QueuedText { + generation: voice_generation.load(Ordering::Acquire), + route_id: 1, + text: "new message".to_string(), + }) + .expect("new message"); + let mut current_text = Some(text_rx.recv().expect("in-hand new message")); + + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + let mut deferred_text = VecDeque::from([ + QueuedText { + generation: 1, + route_id: 2, + text: "old message".to_string(), + }, + QueuedText { + generation: voice_generation.load(Ordering::Acquire), + route_id: 3, + text: "later new message".to_string(), + }, + ]); + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + acknowledged.blocking_recv().expect("voice change ack"); + + assert_eq!( + deferred_text + .pop_front() + .expect("preserved post-change message") + .text, + "new message" + ); + assert_eq!( + deferred_text + .pop_front() + .expect("later post-change message") + .text, + "later new message" + ); + assert!(text_rx.try_recv().is_err()); +} + +#[test] +fn superseding_voice_change_removes_earlier_deferred_messages() { + let selected_voice = std::sync::Mutex::new("reference_sample".to_string()); + let voice_generation = AtomicU64::new(1); + let barge_in = AtomicBool::new(false); + let voice_cancel = AtomicBool::new(false); + let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); + let (_text_tx, text_rx) = std::sync::mpsc::channel(); + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + let mut deferred_text = VecDeque::new(); + let mut current_text = None; + + let first = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "eve", + ) + .expect("first voice change"); + deferred_text.push_back(QueuedText { + generation: voice_generation.load(Ordering::Acquire), + route_id: 4, + text: "message for Eve".to_string(), + }); + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + first.blocking_recv().expect("first acknowledgement"); + + let _second = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "reference_sample", + ) + .expect("second voice change"); + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + + assert!(deferred_text.is_empty()); +} + +#[test] +fn barge_in_clears_deferred_voice_change_messages() { + let barge_in = AtomicBool::new(true); + let voice_cancel = AtomicBool::new(false); + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + 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 { + generation: 2, + route_id: 5, + text: "deferred message".to_string(), + }]); + let mut current_text = None; + + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + + assert!(deferred_text.is_empty()); +} + +#[test] +fn barge_in_during_a_voice_change_clears_post_change_messages() { + let selected_voice = std::sync::Mutex::new("reference_sample".to_string()); + let voice_generation = AtomicU64::new(1); + let barge_in = AtomicBool::new(false); + let voice_cancel = AtomicBool::new(false); + let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); + let (_text_tx, text_rx) = std::sync::mpsc::channel(); + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + let mut deferred_text = VecDeque::new(); + let mut current_text = None; + + let _acknowledged = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "eve", + ) + .expect("voice change"); + deferred_text.push_back(QueuedText { + generation: voice_generation.load(Ordering::Acquire), + route_id: 6, + text: "post-change message".to_string(), + }); + barge_in.store(true, Ordering::Release); + + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + assert!(deferred_text.is_empty()); +} + +#[test] +fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() { + let selected_voice = std::sync::Mutex::new("reference_sample".to_string()); + let voice_generation = Arc::new(AtomicU64::new(1)); + let barge_in = AtomicBool::new(false); + let voice_cancel = AtomicBool::new(false); + let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); + let (text_tx, text_rx) = std::sync::mpsc::sync_channel(1); + let old_sender = TtsTextSender { + text_tx, + generation: voice_generation.load(Ordering::Acquire), + }; + let shutdown = AtomicBool::new(false); + let active = AtomicBool::new(true); + let mut deferred_text = VecDeque::new(); + let mut current_text = None; + + let _acknowledged = begin_voice_change( + &selected_voice, + &voice_generation, + &voice_cancel, + &voice_change_ack, + "eve", + ) + .expect("voice change"); + assert!(handle_cancel_or_shutdown( + (&barge_in, &voice_cancel), + &shutdown, + &active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + None, + )); + old_sender + .send(7, "late old message".to_string()) + .expect("late send"); + let late = text_rx.recv().expect("late queued text"); + + assert!(late.generation < voice_generation.load(Ordering::Acquire)); +} diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs new file mode 100644 index 0000000000..833d3b1c41 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -0,0 +1,197 @@ +use std::{ + collections::VecDeque, + path::Path, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{self, SyncSender}, + Arc, Mutex, + }, +}; + +use crate::huddle::pocket::{load_voice_style, VoiceStyle, DEFAULT_VOICE, VOICE_FILE_EXT}; + +#[derive(Debug)] +pub(super) struct PendingVoiceChange { + pub(super) generation: u64, + acknowledged: tokio::sync::oneshot::Sender<()>, +} + +pub(super) type VoiceChangeAck = Arc>>; +pub(super) type WorkerVoiceState = (Arc>, Arc, VoiceChangeAck); +pub(super) type WorkerCancelSignals = (Arc, Arc); +pub(super) type CancelTextState<'a> = ( + &'a mpsc::Receiver, + &'a mut VecDeque, + &'a mut Option, +); +pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool); + +#[derive(Debug)] +pub(super) struct QueuedText { + pub(super) generation: u64, + pub(super) route_id: u64, + pub(super) text: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct TtsTextSender { + pub(super) text_tx: SyncSender, + pub(super) generation: u64, +} + +impl TtsTextSender { + pub(crate) fn send(&self, route_id: u64, text: String) -> Result<(), String> { + self.text_tx + .send(QueuedText { + generation: self.generation, + route_id, + text, + }) + .map_err(|error| error.to_string()) + } +} + +pub(super) fn begin_voice_change( + selected_voice: &Mutex, + voice_generation: &AtomicU64, + voice_cancel: &AtomicBool, + voice_change_ack: &VoiceChangeAck, + voice: &str, +) -> Option> { + let mut pending_ack = voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()); + let mut selected = selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()); + if selected.as_str() == voice { + return None; + } + + let (sender, receiver) = tokio::sync::oneshot::channel(); + voice_cancel.store(true, Ordering::Release); + let generation = voice_generation.fetch_add(1, Ordering::AcqRel) + 1; + if let Some(superseded) = pending_ack.replace(PendingVoiceChange { + generation, + acknowledged: sender, + }) { + let _ = superseded.acknowledged.send(()); + } + *selected = voice.to_string(); + Some(receiver) +} + +pub(super) fn acknowledge_voice_change( + voice_change_ack: &VoiceChangeAck, + voice_cancel: &AtomicBool, +) { + let mut pending_ack = voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()); + if voice_cancel.load(Ordering::Acquire) { + return; + } + if let Some(pending) = pending_ack.take() { + let _ = pending.acknowledged.send(()); + } +} + +pub(super) fn finish_voice_change_ack(voice_change_ack: &VoiceChangeAck) { + if let Some(pending) = voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + { + let _ = pending.acknowledged.send(()); + } +} + +pub(super) fn reconcile_selected_voice( + model_dir: &Path, + selected_voice: &Mutex, + voice_name: &mut String, + style: &mut VoiceStyle, +) -> bool { + let requested_voice = selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + if requested_voice == *voice_name { + return true; + } + + let requested_path = model_dir.join(format!("{requested_voice}.{VOICE_FILE_EXT}")); + match load_voice_style(&requested_path) { + Ok(requested_style) => { + *style = requested_style; + *voice_name = requested_voice; + true + } + Err(_) => { + eprintln!("buzz-desktop: tts stage=voice_switch status=fallback reason=voice_style"); + let fallback_path = model_dir.join(format!("{DEFAULT_VOICE}.{VOICE_FILE_EXT}")); + match load_voice_style(&fallback_path) { + Ok(fallback_style) => { + *style = fallback_style; + *voice_name = DEFAULT_VOICE.to_string(); + *selected_voice + .lock() + .unwrap_or_else(|lock_error| lock_error.into_inner()) = + DEFAULT_VOICE.to_string(); + true + } + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=voice_switch status=failed reason=fallback_voice_style" + ); + false + } + } + } + } +} + +pub(super) fn retain_cancelled_text( + deferred_text: &mut VecDeque, + current_text: &mut Option, + text_rx: &mpsc::Receiver, + preserve_generation: Option, +) { + if let Some(generation) = preserve_generation { + deferred_text.retain(|text| { + let preserve = text.generation >= generation; + if !preserve { + log_cancelled_route(text.route_id, "voice_switch"); + } + preserve + }); + if let Some(text) = current_text.take() { + if text.generation >= generation { + deferred_text.push_front(text); + } else { + log_cancelled_route(text.route_id, "voice_switch"); + } + } + while let Ok(text) = text_rx.try_recv() { + if text.generation >= generation { + deferred_text.push_back(text); + } else { + log_cancelled_route(text.route_id, "voice_switch"); + } + } + } else { + for text in deferred_text.drain(..) { + log_cancelled_route(text.route_id, "barge_in"); + } + if let Some(text) = current_text.take() { + log_cancelled_route(text.route_id, "barge_in"); + } + while let Ok(text) = text_rx.try_recv() { + log_cancelled_route(text.route_id, "barge_in"); + } + } +} + +fn log_cancelled_route(route_id: u64, reason: &str) { + eprintln!("buzz-desktop: tts stage=queue status=dropped reason={reason} route_id={route_id}"); +} diff --git a/desktop/src-tauri/src/identity_storage.rs b/desktop/src-tauri/src/identity_storage.rs new file mode 100644 index 0000000000..b39c1a0331 --- /dev/null +++ b/desktop/src-tauri/src/identity_storage.rs @@ -0,0 +1,62 @@ +use nostr::Keys; + +use crate::app_state::AppState; + +/// Durable location of the active human identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum IdentityStorage { + Ephemeral = 0, + SystemKeyring = 1, + LocalFile = 2, + Environment = 3, +} + +impl IdentityStorage { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Ephemeral => "ephemeral", + Self::SystemKeyring => "system-keyring", + Self::LocalFile => "local-file", + Self::Environment => "environment", + } + } + + fn from_u8(value: u8) -> Self { + match value { + 1 => Self::SystemKeyring, + 2 => Self::LocalFile, + 3 => Self::Environment, + _ => Self::Ephemeral, + } + } +} + +impl AppState { + pub(crate) fn identity_storage(&self) -> IdentityStorage { + IdentityStorage::from_u8( + self.identity_storage + .load(std::sync::atomic::Ordering::Acquire), + ) + } + + pub(crate) fn set_identity_storage(&self, storage: IdentityStorage) { + self.identity_storage + .store(storage as u8, std::sync::atomic::Ordering::Release); + } +} + +/// Recovery state produced by identity resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RecoveryState { + None, + Lost, + KeyringLocked, +} + +/// Identity and persistence metadata produced by startup resolution. +pub(crate) struct ResolvedIdentity { + pub(crate) keys: Keys, + pub(crate) recovery: RecoveryState, + pub(crate) storage: IdentityStorage, +} diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index 6396911aef..f97bf95a67 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -13,6 +13,10 @@ use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; use nostr::{FromBech32, Keys, ToBech32}; +/// Bech32 prefix of NIP-49 encrypted secret keys. Import routing is +/// case-insensitive because bech32 permits all-uppercase encodings. +pub const NCRYPTSEC_HRP: &str = "ncryptsec1"; + /// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB). /// The blob self-describes its cost, so this can be raised later without /// breaking existing backups. @@ -108,6 +112,27 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { Ok(Keys::new(secret_key)) } +/// Recover identity keys from either an encrypted NIP-49 backup or the raw +/// nsec/hex formats accepted before encrypted imports were added. +pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result { + let trimmed = input.trim(); + let is_ncryptsec = trimmed + .get(..NCRYPTSEC_HRP.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(NCRYPTSEC_HRP)); + + if is_ncryptsec { + let password = password.ok_or_else(|| "key backup requires a password".to_string())?; + decrypt_ncryptsec(trimmed, password) + } else { + Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}")) + } +} + +/// Path of the canonical app-managed backup file. +pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf { + data_dir.join(BACKUP_FILE_NAME) +} + /// Atomically write `ncryptsec` to `path` with owner-only permissions, then /// reread and byte-compare. Same crash-safety pattern as /// `app_state::save_key_file`. @@ -140,6 +165,28 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), Ok(()) } +/// Delete the app-managed backup if present. Missing files are already clean. +pub fn delete_backup_file(data_dir: &std::path::Path) -> Result<(), String> { + let path = backup_file_path(data_dir); + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("delete stale backup file: {e}")), + } +} + +/// Remove the app-managed backup only when an import changes identities. +pub fn cleanup_stale_backup( + previous: &nostr::PublicKey, + new: &nostr::PublicKey, + data_dir: &std::path::Path, +) -> Result<(), String> { + if previous != new { + delete_backup_file(data_dir)?; + } + Ok(()) +} + /// Generate a passphrase of `word_count` EFF short-wordlist words joined by /// `separator`, using OS entropy. /// diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index e5892ad99e..b9713201e1 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -79,12 +79,59 @@ fn verify_backup_blob_catches_pubkey_mismatch() { assert!(err.contains("does not match identity"), "{err}"); } +// ── Import key recovery ─────────────────────────────────────────────────────── + +#[test] +fn recover_keys_ncryptsec_happy_path() { + let keys = recover_keys_from_input(&format!(" {SPEC_NCRYPTSEC}\n"), Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); +} + +#[test] +fn recover_keys_ncryptsec_requires_password() { + let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err(); + assert_eq!(err, "key backup requires a password"); +} + +#[test] +fn recover_keys_ncryptsec_wrong_password() { + let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err(); + assert_eq!(err, "wrong backup password or damaged key backup"); +} + +#[test] +fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() { + let upper = SPEC_NCRYPTSEC.to_ascii_uppercase(); + assert_eq!( + recover_keys_from_input(&upper, None).unwrap_err(), + "key backup requires a password" + ); + let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); + + let mut mixed = SPEC_NCRYPTSEC.to_string(); + mixed.replace_range(0..1, "N"); + let err = recover_keys_from_input(&mixed, Some("nostr")).unwrap_err(); + assert!(err.contains("invalid ncryptsec"), "{err}"); +} + +#[test] +fn recover_keys_raw_nsec_path_unchanged() { + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + let recovered = recover_keys_from_input(&nsec, Some("ignored")).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + let recovered = recover_keys_from_input(&nsec, None).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + assert!(recover_keys_from_input("garbage", None).is_err()); +} + // ── File lifecycle ──────────────────────────────────────────────────────────── #[test] fn write_backup_file_persists_0600_and_verifies() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(BACKUP_FILE_NAME); + let path = backup_file_path(dir.path()); write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); let on_disk = std::fs::read_to_string(&path).unwrap(); @@ -101,7 +148,7 @@ fn write_backup_file_persists_0600_and_verifies() { #[test] fn write_backup_file_overwrites_atomically() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(BACKUP_FILE_NAME); + let path = backup_file_path(dir.path()); write_backup_file(&path, "ncryptsec1old").unwrap(); write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); @@ -113,6 +160,34 @@ fn write_backup_file_overwrites_atomically() { assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); } +#[test] +fn delete_backup_file_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + delete_backup_file(dir.path()).unwrap(); + let path = backup_file_path(dir.path()); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + delete_backup_file(dir.path()).unwrap(); + assert!(!path.exists()); +} + +#[test] +fn cleanup_stale_backup_removes_only_on_identity_change() { + let dir = tempfile::tempdir().unwrap(); + let path = backup_file_path(dir.path()); + let a = Keys::generate().public_key(); + let b = Keys::generate().public_key(); + + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + cleanup_stale_backup(&a, &a, dir.path()).unwrap(); + assert!(path.exists(), "same identity must keep the backup"); + + cleanup_stale_backup(&a, &b, dir.path()).unwrap(); + assert!( + !path.exists(), + "identity change must remove the stale backup" + ); +} + #[test] fn generated_passphrase_respects_word_count_and_separator() { let words: std::collections::HashSet<&str> = diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ca3047843a..c8b9d3547b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod egress_guard; mod event_sync; mod events; mod huddle; +mod identity_storage; mod key_backup; mod linux_media; mod managed_agents; @@ -468,6 +469,18 @@ pub fn run() { *guard = Some(app_handle.clone()); } + let (tts_settings, tts_settings_load_error) = + huddle::tts_settings::load_for_app(&app_handle); + if let Ok(mut guard) = state.huddle_audio.tts.lock() { + *guard = tts_settings.clone(); + } + if let Ok(mut guard) = state.huddle_audio.tts_load_error.lock() { + *guard = tts_settings_load_error; + } + if let Ok(mut huddle) = state.huddle_state.lock() { + huddle.tts_enabled = tts_settings.agent_text_to_speech; + } + // Bring up the runtime-owned shared-compute coordinator before // saved agents are restored. Its lifetime is tied to the app, not // a UI mount; it publishes discovery and reconciles membership for @@ -878,6 +891,10 @@ pub fn run() { download_voice_models, get_model_status, set_tts_enabled, + huddle::tts_settings::get_tts_settings, + huddle::tts_settings::list_voice_registry, + huddle::tts_settings::set_pocket_voice, + huddle::tts_settings::preview_pocket_voice, speak_agent_message, add_agent_to_huddle, check_pipeline_hotstart, diff --git a/desktop/src-tauri/src/linux_media.rs b/desktop/src-tauri/src/linux_media.rs index e05c75967d..240e2f8a77 100644 --- a/desktop/src-tauri/src/linux_media.rs +++ b/desktop/src-tauri/src/linux_media.rs @@ -21,36 +21,23 @@ //! Buzz's AppImage pins `GDK_BACKEND=x11` (see [`crate::webkit_rendering`]), //! which is the backend WebKitGTK media capture is reliable on. -// FORK-LOCAL PATCH (adrienlacombe/buzz): `PROD_ORIGIN`, `DEV_ORIGIN` and -// `is_trusted_media_origin` are reachable only from the `cfg(target_os = "linux")` -// `enable_media_capture` and from the tests. In the *lib* target on macOS or -// Windows nothing calls them, so `clippy -- -D warnings` fails with three -// dead_code errors — which breaks `just desktop-tauri-clippy`, and with it the -// pre-push hook, for anyone developing on a Mac. Upstream's CI only runs that -// lint on Linux, so it never sees this. -// -// Deliberately an `allow` and not a `cfg`: the items must stay compiled on every -// platform, because `mod tests` below unit-tests the origin check everywhere (see -// the doc comment on `is_trusted_media_origin`). Gating them to Linux would fix -// the lint by breaking those tests on the other two platforms. -// -// One module-level attribute rather than three item-level ones, to keep this to a -// single hunk at the top of the file where an upstream merge is least likely to -// touch it. Belongs upstream in block/buzz; carried here until it lands there. -#![cfg_attr(not(target_os = "linux"), allow(dead_code))] - /// The origin Tauri serves the packaged app from on Linux. +/// Consumed only by linux-gated [`enable_media_capture`]; kept compiling on all +/// platforms so the unit tests run everywhere. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] const PROD_ORIGIN: &str = "tauri://localhost"; /// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort` /// 1420 in `vite.config.ts`). Only trusted in debug builds. #[cfg(debug_assertions)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] const DEV_ORIGIN: &str = "http://localhost:1420"; /// Whether `uri` (the webview's current document URI) is a trusted app origin /// allowed to use mic/camera. Matches the origin exactly or as a path prefix so /// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip /// through. Pure and platform-independent so it can be unit-tested everywhere. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn is_trusted_media_origin(uri: &str) -> bool { fn matches(uri: &str, origin: &str) -> bool { uri == origin diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 2a72af92d7..5debae41cb 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -286,7 +286,7 @@ fn redact_secrets(s: &str) -> String { /// (would match every short token in normal log output). Entries are /// applied in decreasing length order so superstrings get scrubbed before /// substrings — protects against partial overlap leaks. -fn redact_secrets_with(s: &str, extras: &[&str]) -> String { +pub(crate) fn redact_secrets_with(s: &str, extras: &[&str]) -> String { let mut result = s.to_string(); // Extras: longest first to avoid partial-overlap leaks. We use @@ -305,9 +305,23 @@ fn redact_secrets_with(s: &str, extras: &[&str]) -> String { // Then prefix-based scrubbing. This loop *can* re-scan because each // replacement shortens the buffer past the matched prefix — the - // replacement marker `[REDACTED]` does not contain `nsec1` or - // `sprt_tok_`, so progress is guaranteed. - for prefix in &["nsec1", "sprt_tok_"] { + // replacement marker `[REDACTED]` contains none of these prefixes, so + // progress is guaranteed. Any prefix added here must preserve that. + // + // GitHub tokens are recognised by shape as well as by variable name: a + // token reaches output from outside our environment too — embedded in a + // git remote URL an installer echoes, say — where no name-based rule can + // see it. + for prefix in &[ + "nsec1", + "sprt_tok_", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", + "github_pat_", + ] { while let Some(pos) = result.find(prefix) { let end = result[pos..] .find(|c: char| c.is_whitespace() || c == '"' || c == '\'') @@ -563,6 +577,29 @@ mod tests { assert!(r.contains("42")); } + /// GitHub tokens are recognised by shape, so one that never passed through + /// our environment — embedded in a remote URL an installer echoes — is + /// still scrubbed. The scan runs to the next whitespace or quote, so the + /// rest of the URL goes with it; over-redaction is the safe direction. + #[test] + fn redact_secrets_with_scrubs_github_token_prefixes() { + for token in [ + "ghp_abcdefghij0123456789", + "gho_abcdefghij0123456789", + "ghu_abcdefghij0123456789", + "ghs_abcdefghij0123456789", + "ghr_abcdefghij0123456789", + "github_pat_abcdefghij0123456789", + ] { + let r = + redact_secrets_with(&format!("cloning https://{token}@github.com/o/r now"), &[]); + assert!(!r.contains(token), "leaked {token}: {r}"); + assert!(r.contains("[REDACTED]"), "got: {r}"); + assert!(r.contains("cloning"), "scan must stop at whitespace: {r}"); + assert!(r.ends_with(" now"), "scan must stop at whitespace: {r}"); + } + } + #[test] fn redact_secrets_with_extras_terminates_when_value_substring_of_marker() { // Regression: an earlier impl used `while let Some(pos) = find(value)` diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index c8a85be34a..8d1b8a5013 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -10,8 +10,11 @@ use crate::managed_agents::{ HarnessSource, }; +mod presets; mod runtime_metadata; +use presets::{preset_catalog_entry, PRESET_HARNESSES}; +pub(crate) use presets::{preset_harness_definitions, preset_harness_ids}; pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -1436,225 +1439,6 @@ pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option, -} - -/// Build the catalog entry for one preset harness through an injectable -/// resolver — the seam the preset loop consumes and tests bind. -/// -/// Availability consumes only the adapter-missing arm of the builtin -/// predicate: adapter presence alone decides `Available` (exactly today's -/// behavior — an `amp-acp` without `amp` stays selectable), and -/// `underlying_cli` is consulted only when the adapter is absent, to -/// distinguish `AdapterMissing` (vendor CLI present) from `NotInstalled` -/// (neither found). See the `underlying_cli` field doc for why the full -/// `classify_runtime` predicate is deliberately not used here. -fn preset_catalog_entry( - def: &PresetHarness, - resolve: impl Fn(&str) -> Option, -) -> AcpRuntimeCatalogEntry { - let (availability, command, binary_path) = match resolve(def.command) { - Some(path) => ( - AcpAvailabilityStatus::Available, - Some(def.command.to_string()), - Some(path.display().to_string()), - ), - None => { - let underlying_cli_found = def - .underlying_cli - .map(|cli| resolve(cli).is_some()) - .unwrap_or(false); - if underlying_cli_found { - (AcpAvailabilityStatus::AdapterMissing, None, None) - } else { - (AcpAvailabilityStatus::NotInstalled, None, None) - } - } - }; - let underlying_cli_path = def - .underlying_cli - .and_then(resolve) - .map(|p| p.display().to_string()); - - let default_args = normalize_agent_args( - def.command, - def.args.iter().map(|s| s.to_string()).collect(), - ); - - AcpRuntimeCatalogEntry { - id: def.id.to_string(), - label: def.label.to_string(), - // No remote URL — all preset icons are bundled assets. - avatar_url: String::new(), - availability, - command, - binary_path, - default_args, - mcp_command: None, - model_env_var: None, - provider_env_var: None, - thinking_env_var: None, - install_hint: def.install_hint.to_string(), - install_instructions_url: def.install_instructions_url.to_string(), - can_auto_install: false, - // Kept false even for adapter presets: presets carry one flat - // install_hint (the adapter's), so the requiresExternalCli - // "CLI is missing" wording would pair the wrong noun with it. - // The builtin path, with per-availability hints, is the only - // consumer of the true case. - requires_external_cli: false, - underlying_cli_path, - node_required: false, - auth_status: AuthStatus::NotApplicable, - login_hint: None, - source: HarnessSource::Preset, - // Preset entries have static, non-editable env; definition_env is empty. - definition_env: Default::default(), - } -} - -const PRESET_HARNESSES: &[PresetHarness] = &[ - PresetHarness { - id: "cursor", - label: "Cursor", - command: "cursor-agent", - args: &["acp"], - install_instructions_url: "https://cursor.com/downloads", - install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.", - underlying_cli: None, - }, - PresetHarness { - id: "omp", - label: "Oh My Pi", - command: "omp", - args: &["acp"], - install_instructions_url: "https://github.com/can1357/oh-my-pi", - install_hint: "Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).", - underlying_cli: None, - }, - PresetHarness { - id: "grok", - label: "Grok Build", - command: "grok", - args: &["agent", "--always-approve", "stdio"], - install_instructions_url: "https://build.x.ai/docs", - install_hint: "Buzz talks to Grok Build through its CLI's agent stdio mode.", - underlying_cli: None, - }, - PresetHarness { - id: "opencode", - label: "OpenCode", - command: "opencode", - args: &["acp"], - install_instructions_url: "https://opencode.ai/docs", - install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).", - underlying_cli: None, - }, - PresetHarness { - id: "kimi", - label: "Kimi Code", - command: "kimi", - args: &["acp"], - install_instructions_url: "https://kimi.ai/download", - install_hint: "Buzz talks to Kimi Code through its CLI's ACP mode (kimi acp).", - underlying_cli: None, - }, - PresetHarness { - id: "amp", - label: "Amp", - command: "amp-acp", - args: &[], - install_instructions_url: "https://github.com/tao12345666333/amp-acp", - install_hint: "Buzz talks to the Amp CLI through the amp-acp adapter. Follow the setup guide to install the adapter so the amp-acp command is on your PATH.", - underlying_cli: Some("amp"), - }, - PresetHarness { - id: "hermes", - label: "Hermes Agent", - command: "hermes-acp", - args: &[], - install_instructions_url: "https://hermes-agent.nousresearch.com", - install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.", - underlying_cli: None, - }, - PresetHarness { - id: "openclaw", - label: "OpenClaw", - command: "openclaw", - args: &["acp"], - install_instructions_url: "https://docs.openclaw.ai/start/getting-started", - install_hint: "Buzz talks to OpenClaw through its ACP mode (openclaw acp), which relies on the OpenClaw Gateway daemon. Follow the setup guide to install both.\n\n\ - ⚠️ Execution-locus note: `openclaw acp` runs tools inside the \ - OpenClaw Gateway daemon, not in the Desktop process. \ - Desktop-injected BUZZ_* env vars are visible to the `openclaw` \ - harness process itself, but do NOT automatically reach the \ - Gateway's execution environment. If your tools or agent logic \ - needs BUZZ_* credentials at execution time, set them on the \ - Gateway's own environment separately.", - underlying_cli: None, - }, -]; - -/// Return the static preset harness definitions as `HarnessDefinition` values. -/// -/// Used by `warm_harness_registry_from_dir` to seed the loaded-harness registry -/// at startup before the frontend triggers a full discovery run. -pub(crate) fn preset_harness_definitions( -) -> Vec { - PRESET_HARNESSES - .iter() - .map( - |p| crate::managed_agents::custom_harnesses::HarnessDefinition { - id: p.id.to_string(), - label: p.label.to_string(), - command: p.command.to_string(), - args: p.args.iter().map(|s| s.to_string()).collect(), - env: std::collections::BTreeMap::new(), - install_instructions_url: p.install_instructions_url.to_string(), - install_hint: p.install_hint.to_string(), - }, - ) - .collect() -} - -/// Return the static slice of preset harness IDs. -/// -/// Used by `check_id_collision` in `custom_harnesses` to derive the reserved-ID -/// set from the single source of truth (`PRESET_HARNESSES`) rather than a -/// hand-maintained copy. Adding a preset automatically reserves its ID. -pub(crate) fn preset_harness_ids() -> &'static [&'static str] { - // `PRESET_HARNESSES` is `'static`; we project its `id` fields. - // Computed once via OnceLock to avoid repeated allocations on hot paths. - use std::sync::OnceLock; - static IDS: OnceLock> = OnceLock::new(); - IDS.get_or_init(|| PRESET_HARNESSES.iter().map(|p| p.id).collect()) - .as_slice() -} - /// Discover all ACP runtimes, optionally merging user-defined custom harnesses /// from `custom_harnesses_dir`. /// diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs new file mode 100644 index 0000000000..72c4657dc7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -0,0 +1,335 @@ +use std::path::PathBuf; +use std::sync::OnceLock; + +use crate::managed_agents::{ + AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, HarnessSource, +}; + +use super::normalize_agent_args; + +/// Static data for a well-known tier-2 ACP harness. +pub(super) struct PresetHarness { + pub(super) id: &'static str, + label: &'static str, + command: &'static str, + args: &'static [&'static str], + install_instructions_url: &'static str, + install_hint: &'static str, + /// Vendor CLI the ACP command wraps, when the preset is an adapter. + /// + /// Consulted only when the adapter is absent, so `AdapterMissing` replaces + /// `NotInstalled` when the CLI is present but the adapter is not. `None` + /// when the command is itself the vendor CLI. + underlying_cli: Option<&'static str>, +} + +/// Build one preset catalog entry through an injectable command resolver. +pub(super) fn preset_catalog_entry( + def: &PresetHarness, + resolve: impl Fn(&str) -> Option, +) -> AcpRuntimeCatalogEntry { + let (availability, command, binary_path) = match resolve(def.command) { + Some(path) => ( + AcpAvailabilityStatus::Available, + Some(def.command.to_string()), + Some(path.display().to_string()), + ), + None => { + let underlying_cli_found = def + .underlying_cli + .map(|cli| resolve(cli).is_some()) + .unwrap_or(false); + if underlying_cli_found { + (AcpAvailabilityStatus::AdapterMissing, None, None) + } else { + (AcpAvailabilityStatus::NotInstalled, None, None) + } + } + }; + let underlying_cli_path = def + .underlying_cli + .and_then(resolve) + .map(|path| path.display().to_string()); + + AcpRuntimeCatalogEntry { + id: def.id.to_string(), + label: def.label.to_string(), + // No remote URL — all preset icons are bundled assets. + avatar_url: String::new(), + availability, + command, + binary_path, + default_args: normalize_agent_args( + def.command, + def.args.iter().map(|arg| arg.to_string()).collect(), + ), + mcp_command: None, + model_env_var: None, + provider_env_var: None, + thinking_env_var: None, + install_hint: def.install_hint.to_string(), + install_instructions_url: def.install_instructions_url.to_string(), + can_auto_install: false, + // Presets carry one flat install hint, so builtin external-CLI copy + // would name the wrong missing component for adapter presets. + requires_external_cli: false, + underlying_cli_path, + node_required: false, + auth_status: AuthStatus::NotApplicable, + login_hint: None, + source: HarnessSource::Preset, + definition_env: Default::default(), + } +} + +pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ + PresetHarness { + id: "devin", + label: "Devin", + command: "devin", + args: &["acp"], + install_instructions_url: "https://docs.devin.ai/cli", + install_hint: "Buzz talks to Devin through the official Devin CLI's ACP mode (devin acp).", + underlying_cli: None, + }, + PresetHarness { + id: "cursor", + label: "Cursor", + command: "cursor-agent", + args: &["acp"], + install_instructions_url: "https://cursor.com/downloads", + install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.", + underlying_cli: None, + }, + PresetHarness { + id: "omp", + label: "Oh My Pi", + command: "omp", + args: &["acp"], + install_instructions_url: "https://github.com/can1357/oh-my-pi", + install_hint: "Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).", + underlying_cli: None, + }, + PresetHarness { + id: "grok", + label: "Grok Build", + command: "grok", + args: &["agent", "--always-approve", "stdio"], + install_instructions_url: "https://build.x.ai/docs", + install_hint: "Buzz talks to Grok Build through its CLI's agent stdio mode.", + underlying_cli: None, + }, + PresetHarness { + id: "opencode", + label: "OpenCode", + command: "opencode", + args: &["acp"], + install_instructions_url: "https://opencode.ai/docs", + install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).", + underlying_cli: None, + }, + PresetHarness { + id: "kimi", + label: "Kimi Code", + command: "kimi", + args: &["acp"], + install_instructions_url: "https://kimi.ai/download", + install_hint: "Buzz talks to Kimi Code through its CLI's ACP mode (kimi acp).", + underlying_cli: None, + }, + PresetHarness { + id: "amp", + label: "Amp", + command: "amp-acp", + args: &[], + install_instructions_url: "https://github.com/tao12345666333/amp-acp", + install_hint: "Buzz talks to the Amp CLI through the amp-acp adapter. Follow the setup guide to install the adapter so the amp-acp command is on your PATH.", + underlying_cli: Some("amp"), + }, + PresetHarness { + id: "hermes", + label: "Hermes Agent", + command: "hermes-acp", + args: &[], + install_instructions_url: "https://hermes-agent.nousresearch.com", + install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.", + underlying_cli: None, + }, + PresetHarness { + id: "openclaw", + label: "OpenClaw", + command: "openclaw", + args: &["acp"], + install_instructions_url: "https://docs.openclaw.ai/start/getting-started", + install_hint: "Buzz talks to OpenClaw through its ACP mode (openclaw acp), which relies on the OpenClaw Gateway daemon. Follow the setup guide to install both.\n\n\ + ⚠️ Execution-locus note: `openclaw acp` runs tools inside the \ + OpenClaw Gateway daemon, not in the Desktop process. \ + Desktop-injected BUZZ_* env vars are visible to the `openclaw` \ + harness process itself, but do NOT automatically reach the \ + Gateway's execution environment. If your tools or agent logic \ + needs BUZZ_* credentials at execution time, set them on the \ + Gateway's own environment separately.", + underlying_cli: None, + }, +]; + +/// Return preset definitions for the spawn/readiness registry. +pub(crate) fn preset_harness_definitions( +) -> Vec { + PRESET_HARNESSES + .iter() + .map( + |preset| crate::managed_agents::custom_harnesses::HarnessDefinition { + id: preset.id.to_string(), + label: preset.label.to_string(), + command: preset.command.to_string(), + args: preset.args.iter().map(|arg| arg.to_string()).collect(), + env: Default::default(), + install_instructions_url: preset.install_instructions_url.to_string(), + install_hint: preset.install_hint.to_string(), + }, + ) + .collect() +} + +/// Return preset IDs from the catalog's single source of truth. +pub(crate) fn preset_harness_ids() -> &'static [&'static str] { + static IDS: OnceLock> = OnceLock::new(); + IDS.get_or_init(|| PRESET_HARNESSES.iter().map(|preset| preset.id).collect()) + .as_slice() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::managed_agents::{AcpAvailabilityStatus, AuthStatus, HarnessSource}; + + use super::{preset_catalog_entry, PresetHarness, PRESET_HARNESSES}; + + /// Amp-shaped preset: an ACP adapter wrapping a separately installed CLI. + const ADAPTER_PRESET: PresetHarness = PresetHarness { + id: "amp-test", + label: "Amp Test", + command: "amp-acp", + args: &[], + install_instructions_url: "https://example.com/install", + install_hint: "Install the amp-acp npm adapter.", + underlying_cli: Some("amp"), + }; + + #[test] + fn devin_preset_uses_official_native_acp_invocation() { + let preset = PRESET_HARNESSES + .iter() + .find(|preset| preset.id == "devin") + .expect("Devin preset should be present"); + + assert_eq!(preset.label, "Devin"); + assert_eq!(preset.command, "devin"); + assert_eq!(preset.args, &["acp"]); + assert_eq!(preset.underlying_cli, None); + assert_eq!(preset.install_instructions_url, "https://docs.devin.ai/cli"); + + let entry = preset_catalog_entry(preset, |command| { + (command == "devin").then(|| PathBuf::from("/usr/local/bin/devin")) + }); + assert_eq!(entry.availability, AcpAvailabilityStatus::Available); + assert_eq!(entry.command.as_deref(), Some("devin")); + assert_eq!(entry.default_args, vec!["acp"]); + assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/devin")); + assert_eq!(entry.auth_status, AuthStatus::NotApplicable); + assert_eq!(entry.source, HarnessSource::Preset); + + let missing_entry = preset_catalog_entry(preset, |_| None); + assert_eq!( + missing_entry.availability, + AcpAvailabilityStatus::NotInstalled + ); + assert!(missing_entry.command.is_none()); + assert_eq!(missing_entry.default_args, vec!["acp"]); + } + + #[test] + fn devin_preset_is_exposed_in_the_runtime_catalog() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + + // Discovery touches process-global command-resolution and the loaded + // harness registry. Serialize with the other discovery 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) + .into_iter() + .find(|entry| entry.id == "devin") + .expect("Devin preset should appear in the runtime catalog"); + + assert_eq!(entry.label, "Devin"); + assert_eq!(entry.default_args, vec!["acp"]); + assert_eq!(entry.install_instructions_url, "https://docs.devin.ai/cli"); + assert_eq!(entry.source, HarnessSource::Preset); + } + + #[test] + fn adapter_missing_when_underlying_cli_present() { + let entry = preset_catalog_entry(&ADAPTER_PRESET, |command| { + (command == "amp").then(|| PathBuf::from("/usr/local/bin/amp")) + }); + assert_eq!(entry.availability, AcpAvailabilityStatus::AdapterMissing); + assert!(entry.command.is_none()); + assert!(entry.binary_path.is_none()); + assert_eq!( + entry.underlying_cli_path.as_deref(), + Some("/usr/local/bin/amp") + ); + assert!(!entry.requires_external_cli); + assert_eq!(entry.install_hint, "Install the amp-acp npm adapter."); + } + + #[test] + fn not_installed_when_adapter_and_cli_are_missing() { + let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None); + assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled); + assert!(entry.underlying_cli_path.is_none()); + assert!(!entry.requires_external_cli); + } + + #[test] + fn available_when_adapter_and_cli_are_present() { + let entry = preset_catalog_entry(&ADAPTER_PRESET, |command| match command { + "amp-acp" => Some(PathBuf::from("/usr/local/bin/amp-acp")), + "amp" => Some(PathBuf::from("/usr/local/bin/amp")), + _ => None, + }); + assert_eq!(entry.availability, AcpAvailabilityStatus::Available); + assert_eq!(entry.command.as_deref(), Some("amp-acp")); + assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp")); + assert_eq!( + entry.underlying_cli_path.as_deref(), + Some("/usr/local/bin/amp") + ); + } + + #[test] + fn adapter_presence_is_enough_for_availability() { + let entry = preset_catalog_entry(&ADAPTER_PRESET, |command| { + (command == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp")) + }); + assert_eq!(entry.availability, AcpAvailabilityStatus::Available); + assert_eq!(entry.command.as_deref(), Some("amp-acp")); + assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp")); + assert!(entry.underlying_cli_path.is_none()); + } + + #[test] + fn preset_without_underlying_cli_stays_simple() { + let preset = PresetHarness { + underlying_cli: None, + ..ADAPTER_PRESET + }; + let entry = preset_catalog_entry(&preset, |_| None); + assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled); + assert!(!entry.requires_external_cli); + assert!(entry.underlying_cli_path.is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 1b587dca0e..6fe6a77521 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -6,9 +6,9 @@ use super::{ 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, preset_catalog_entry, probe_codex_acp_version, record_agent_command, - refresh_login_shell_path, try_record_agent_command, PresetHarness, BUZZ_AGENT_AVATAR_URL, - CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + 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; @@ -187,90 +187,6 @@ fn classifies_cli_missing_when_adapter_found_but_cli_absent() { assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); } -/// Amp-shaped preset: an ACP adapter (`amp-acp`) wrapping a separately -/// installed vendor CLI (`amp`). -const ADAPTER_PRESET: PresetHarness = PresetHarness { - id: "amp-test", - label: "Amp Test", - command: "amp-acp", - args: &[], - install_instructions_url: "https://example.com/install", - install_hint: "Install the amp-acp npm adapter.", - underlying_cli: Some("amp"), -}; - -#[test] -fn preset_entry_adapter_missing_when_underlying_cli_present() { - // Vendor CLI resolves, adapter does not — the state Tyler's Amp - // hand-test hit. Must NOT degrade to the misleading NotInstalled. - let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| { - (cmd == "amp").then(|| PathBuf::from("/usr/local/bin/amp")) - }); - assert_eq!(entry.availability, AcpAvailabilityStatus::AdapterMissing); - assert!(entry.command.is_none()); - assert!(entry.binary_path.is_none()); - assert_eq!( - entry.underlying_cli_path.as_deref(), - Some("/usr/local/bin/amp") - ); - assert!(!entry.requires_external_cli); - assert_eq!(entry.install_hint, "Install the amp-acp npm adapter."); -} - -#[test] -fn preset_entry_not_installed_when_both_missing() { - let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None); - assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled); - assert!(entry.underlying_cli_path.is_none()); - assert!(!entry.requires_external_cli); -} - -#[test] -fn preset_entry_available_when_adapter_and_cli_present() { - let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| match cmd { - "amp-acp" => Some(PathBuf::from("/usr/local/bin/amp-acp")), - "amp" => Some(PathBuf::from("/usr/local/bin/amp")), - _ => None, - }); - assert_eq!(entry.availability, AcpAvailabilityStatus::Available); - assert_eq!(entry.command.as_deref(), Some("amp-acp")); - assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp")); - assert_eq!( - entry.underlying_cli_path.as_deref(), - Some("/usr/local/bin/amp") - ); -} - -#[test] -fn preset_entry_stays_available_when_adapter_present_but_cli_absent() { - // Wren's regression guard: today an `amp-acp` install without `amp` - // is Available and selectable. Feeding underlying_cli through the - // FULL classify_runtime predicate would flip this to CliMissing - // (unselectable, with backwards install copy) — the adapter-missing - // arm is the only one presets consume. - let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| { - (cmd == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp")) - }); - assert_eq!(entry.availability, AcpAvailabilityStatus::Available); - assert_eq!(entry.command.as_deref(), Some("amp-acp")); - assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp")); - assert!(entry.underlying_cli_path.is_none()); -} - -#[test] -fn preset_entry_without_underlying_cli_stays_simple() { - // Most presets: the command IS the vendor CLI. No external-CLI flag, - // absent command means plain NotInstalled. - let preset = PresetHarness { - underlying_cli: None, - ..ADAPTER_PRESET - }; - let entry = preset_catalog_entry(&preset, |_| None); - assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled); - assert!(!entry.requires_external_cli); - assert!(entry.underlying_cli_path.is_none()); -} - fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { id: id.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index ea61a811db..6afc18a501 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; -use buzz_core_pkg::kind::{persona_event_is_shared, KIND_PERSONA}; +use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; @@ -192,7 +192,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result std::collections::BTreeMap { let mut env = std::collections::BTreeMap::new(); - for key in ["BUZZ_AGENT_MAX_OUTPUT_TOKENS", "BUZZ_AGENT_THINKING_EFFORT"] { + for key in [ + "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + "BUZZ_AGENT_THINKING_EFFORT", + // Must be copied forward for the user's value to survive: this map is + // written onto the command *after* the layered user env, so a key absent + // here is re-defaulted by `apply_relay_mesh_env` below and an explicit + // `BUZZ_AGENT_REQUIRE_REPLY=0` would be silently overridden back to `1`. + "BUZZ_AGENT_REQUIRE_REPLY", + ] { if let Some(value) = effective_env.get(key) { env.insert(key.to_string(), value.clone()); } @@ -145,6 +160,78 @@ mod tests { ); } + #[test] + fn native_provider_enables_reply_guard_by_default() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("1"), + "mesh agents opt into the reply guard automatically" + ); + } + + #[test] + fn native_provider_preserves_explicit_reply_guard_opt_out() { + let mut env = BTreeMap::from([("BUZZ_AGENT_REQUIRE_REPLY".to_string(), "0".to_string())]); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("0"), + "an explicit opt-out is a user decision, not a value to re-default" + ); + } + + #[test] + fn non_mesh_provider_leaves_reply_guard_unset() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env(&mut env, Some("anthropic"), Some("claude-haiku-4.5")); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY"), + None, + "the guard stays opt-in everywhere except mesh" + ); + assert!(env.is_empty(), "non-mesh providers get no mesh env at all"); + } + + /// The spawn path writes this map onto the command *after* the layered user + /// env, so an explicit opt-out only survives if it is copied forward. Without + /// the copy-forward, `apply_relay_mesh_env` re-defaults it to `1` here and + /// silently overrides the user at spawn while readiness still shows `0`. + #[test] + fn process_env_preserves_explicit_reply_guard_opt_out() { + let effective_env = + BTreeMap::from([("BUZZ_AGENT_REQUIRE_REPLY".to_string(), "0".to_string())]); + + let env = relay_mesh_process_env(&effective_env, "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("0") + ); + } + + #[test] + fn process_env_enables_reply_guard_when_user_is_silent() { + let env = relay_mesh_process_env(&BTreeMap::new(), "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("1") + ); + } + #[test] fn process_env_seeds_controls_without_restoring_unrelated_credentials() { let effective_env = BTreeMap::from([ diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index f6f89ed898..652bb9b9ea 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -52,6 +52,34 @@ fn managed_agents_logs_dir(app: &AppHandle) -> Result { Ok(dir) } +/// Install-log path for `runtime_id`, alongside the agent logs. +pub fn install_log_path(app: &AppHandle, runtime_id: &str) -> Result { + Ok(managed_agents_logs_dir(app)?.join(install_log_filename(runtime_id)?)) +} + +/// Filename for a runtime's install log, or an error for an id that must not +/// become one. +/// +/// The id is validated rather than trusted: ids reach this from user-defined +/// custom harnesses as well as the catalog, and a `../` or a separator in one +/// would place the log outside the logs directory. Rejecting beats sanitizing — +/// a rejected id means no log, while a rewritten one could collide with another +/// runtime's. +fn install_log_filename(runtime_id: &str) -> Result { + if runtime_id.is_empty() || !runtime_id.chars().all(is_safe_id_char) { + return Err(format!( + "unsafe runtime id for a log filename: {runtime_id}" + )); + } + Ok(format!("install-{runtime_id}.log")) +} + +/// Characters allowed in a runtime id used as a filename. Excludes `/`, `\`, +/// `:` and `.`, so no id can traverse or escape the logs directory. +fn is_safe_id_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' +} + pub fn managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result { Ok(managed_agents_logs_dir(app)?.join(format!("{pubkey}.log"))) } @@ -632,6 +660,62 @@ pub(crate) fn open_log_file(path: &Path) -> Result { .map_err(|error| format!("failed to open log file {}: {error}", path.display())) } +/// Start a new install-log session at `path`: keep the previous run as +/// `.1` and return a freshly created, empty current file. +/// +/// Rotating per *run* rather than by size is what bounds this file. A run +/// writes one record per executed attempt, each capped by the log-scale +/// capture, so one run's file is bounded by steps × attempts × cap and the +/// history on disk is bounded at two runs. Size-triggered rotation could not +/// promise either: it never replaced an existing `.1`, and on Windows — +/// where rename does not replace its destination — it stopped working +/// altogether once `.1` existed, leaving the current file to grow. +/// +/// The old `.1` is therefore *removed* before the rename rather than renamed +/// over. Every step is best-effort: a rotation that fails must not cost the +/// user the install, so the session continues with a truncated current file. +pub(crate) fn start_install_log_session(path: &Path) -> Result { + if path.exists() { + let mut previous = path.as_os_str().to_owned(); + previous.push(".1"); + let previous = PathBuf::from(previous); + let _ = fs::remove_file(&previous); + let _ = fs::rename(path, &previous); + } + open_install_log(path, /* truncate */ true) +} + +/// Open an install log for appending one more record to the current session. +pub(crate) fn open_install_log_file(path: &Path) -> Result { + open_install_log(path, /* truncate */ false) +} + +/// Open an install log owner-only. +/// +/// The mode is set *in the create* rather than chmod'd afterwards, so the file +/// is never briefly group/world-readable. Install output can carry registry +/// tokens and proxy credentials echoed by a failing installer, so the window +/// matters even though it is short. An existing file's mode is left as-is — +/// `OpenOptions::mode` only applies on creation, and silently re-tightening a +/// file the user relaxed is not this function's call to make. +fn open_install_log(path: &Path, truncate: bool) -> Result { + let mut options = OpenOptions::new(); + options.create(true); + if truncate { + options.write(true).truncate(true); + } else { + options.append(true); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .map_err(|error| format!("failed to open log file {}: {error}", path.display())) +} + pub(crate) fn append_log_marker(path: &Path, message: &str) -> Result<(), String> { let mut file = open_log_file(path)?; writeln!(file, "{message}").map_err(|error| format!("failed to write log marker: {error}")) diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 73567bb915..9943c6b3ac 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -698,3 +698,135 @@ fn try_delete_agent_key_returns_result() { // team_snapshot::tests::rollback_aggregates_multiple_errors. let _: fn(&str) -> Result<(), String> = super::try_delete_agent_key; } + +// ── install logs ───────────────────────────────────────────────────────────── + +/// Install output can carry registry tokens and proxy credentials a failing +/// installer echoed, and the file is written unattended. `0o600` must come from +/// the create itself: a post-write `chmod` leaves a window where the umask +/// decides, and a crash inside it leaves the log readable to other local users. +#[cfg(unix)] +#[test] +fn install_log_is_created_owner_only_without_post_write_chmod() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("install-goose.log"); + + let mut file = super::open_install_log_file(&path).expect("open install log"); + file.write_all(b"npm ERR!\n").expect("write"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "install logs must be owner-only"); +} + +/// A run starts a new current file and keeps the previous run as `.1`, so the +/// two runs are never mixed and the history on disk stays bounded at two. +#[test] +fn install_log_session_keeps_the_previous_run_as_dot_one() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("install-goose.log"); + + let mut first = super::start_install_log_session(&path).expect("first session"); + first.write_all(b"run-one\n").expect("write"); + let mut second = super::start_install_log_session(&path).expect("second session"); + second.write_all(b"run-two\n").expect("write"); + + assert_eq!( + std::fs::read_to_string(&path).expect("read current"), + "run-two\n", + "the current file must hold only the newest run" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("install-goose.log.1")).expect("read .1"), + "run-one\n", + "the previous run must be preserved as .1" + ); +} + +/// The third run must still rotate when `.1` already exists. Windows `rename` +/// does not replace its destination, so a rename-only rotation silently stops +/// working here and leaves the current file to grow across every later run — +/// the old `.1` is removed first precisely so this cannot happen. Runs on the +/// Windows target too: this is the path that fails there. +#[test] +fn install_log_session_replaces_an_existing_dot_one() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("install-goose.log"); + let rotated = dir.path().join("install-goose.log.1"); + // Seed the state a rename-only rotation cannot get out of: both files exist. + std::fs::write(&path, b"previous-run\n").expect("seed current"); + std::fs::write(&rotated, b"ancient-run\n").expect("seed .1"); + + let mut file = super::start_install_log_session(&path).expect("session"); + file.write_all(b"fresh-run\n").expect("write"); + + assert_eq!( + std::fs::read_to_string(&path).expect("read current"), + "fresh-run\n", + "the current file must restart even when .1 was already present" + ); + assert_eq!( + std::fs::read_to_string(&rotated).expect("read .1"), + "previous-run\n", + ".1 must be replaced by the run that just ended, not kept" + ); +} + +/// Records written after the session starts append to it — a run's later +/// records must not erase its earlier ones. +#[test] +fn install_log_appends_within_a_session() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("install-goose.log"); + + let mut session = super::start_install_log_session(&path).expect("session"); + session.write_all(b"header\n").expect("write"); + for record in ["first\n", "second\n"] { + let mut file = super::open_install_log_file(&path).expect("open install log"); + file.write_all(record.as_bytes()).expect("write"); + } + + assert_eq!( + std::fs::read_to_string(&path).expect("read back"), + "header\nfirst\nsecond\n" + ); +} + +/// A runtime id becomes part of a filename. Ids reach this from user-defined +/// custom harnesses as well as the catalog, so anything that could traverse or +/// escape the logs directory is rejected rather than sanitized — a rejected id +/// simply means no log, while a silently rewritten one could collide with +/// another runtime's log. +#[test] +fn install_log_filename_rejects_ids_that_would_escape_the_logs_dir() { + for id in [ + "../../etc/passwd", + "goose/../../evil", + "sub/dir", + "back\\slash", + "with.dot", + "", + ] { + assert!( + super::install_log_filename(id).is_err(), + "id {id:?} must not be accepted as a filename component" + ); + } +} + +/// Ordinary catalog and custom-harness ids are accepted — the guard must not +/// reject the ids it exists to serve. +#[test] +fn install_log_filename_accepts_ordinary_runtime_ids() { + for id in ["goose", "claude-code", "buzz_agent", "codex2"] { + assert_eq!( + super::install_log_filename(id).expect("id must be usable in a log filename"), + format!("install-{id}.log") + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3d8e0ed02b..fcd8b13fc9 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -587,7 +587,8 @@ pub struct ManagedAgentLogResponse { pub enum AcpAvailabilityStatus { Available, AdapterMissing, - /// Adapter binary is present but is from the deprecated package (< 1.0). Reinstall required. + /// Adapter binary is present but unsupported — either the deprecated + /// package or a version below the supported floor. Reinstall required. AdapterOutdated, CliMissing, NotInstalled, @@ -701,6 +702,10 @@ pub struct InstallRuntimeResult { /// Number of agents whose stop succeeded but respawn failed. /// Mirrors `GlobalAgentConfigSaveResult.failed_restart_count`. pub failed_restart_count: u32, + /// Install log file for this run, when one was written. The UI surfaces it + /// on failure so a user can read the full retry history instead of only the + /// last step's truncated output. `None` when no log could be opened. + pub log_path: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 1d9747bc20..3f04d3d7a1 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Deserializer, Serialize}; pub struct IdentityInfo { pub pubkey: String, pub display_name: String, + /// Durable location of the active identity key. + pub storage: String, /// True when the app booted with an ephemeral key because the OS keyring /// was empty despite a prior successful migration (key was externally /// deleted). The frontend routes to the nsec re-import step when true. diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index d2e35e6839..18ddd80eb8 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -463,6 +463,26 @@ mod tests { assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once"); } + // ── NIP-49: the boot wipe destroys the app-managed key backup ───────────── + + #[test] + fn test_wipe_removes_app_managed_key_backup() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let backup = crate::key_backup::backup_file_path(&app_data); + std::fs::write(&backup, b"encrypted-backup-bytes").unwrap(); + + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let outcome = run_boot_reset_with_keychain(make_ctx(&app_data, &kc, false)); + + assert!(outcome.completed); + assert!( + !backup.exists(), + "sign-out wipe must destroy the app-managed key backup" + ); + } + // ── Test 3: keychain failure keeps sentinel ──────────────────────────────── #[test] diff --git a/desktop/src/app/AppHuddleBar.tsx b/desktop/src/app/AppHuddleBar.tsx new file mode 100644 index 0000000000..9fa12d513f --- /dev/null +++ b/desktop/src/app/AppHuddleBar.tsx @@ -0,0 +1,25 @@ +import type * as React from "react"; + +import { HuddleBar } from "@/features/huddle"; + +import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; + +type AppHuddleBarProps = Pick< + React.ComponentProps, + "onOpenThread" | "onVisibilityChange" +>; + +export function AppHuddleBar({ + onOpenThread, + onVisibilityChange, +}: AppHuddleBarProps) { + return ( + + + + ); +} diff --git a/desktop/src/app/AppProfilePanelProvider.tsx b/desktop/src/app/AppProfilePanelProvider.tsx new file mode 100644 index 0000000000..213acec498 --- /dev/null +++ b/desktop/src/app/AppProfilePanelProvider.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; + +export function AppProfilePanelProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const { goProfile } = useAppNavigation(); + const handleOpenProfilePanel = React.useCallback( + (pubkey: string) => { + void goProfile(pubkey); + }, + [goProfile], + ); + + return ( + + {children} + + ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b856434e61..4eb0a42bbe 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -63,7 +63,8 @@ import { type SettingsSection, isSettingsSection, } from "@/features/settings/ui/SettingsPanels"; -import { HuddleBar, HuddleProvider } from "@/features/huddle"; +import { HuddleProvider } from "@/features/huddle"; +import { AppHuddleBar } from "@/app/AppHuddleBar"; import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; @@ -97,7 +98,7 @@ import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; - +import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -160,7 +161,6 @@ export function AppShell() { ? locationSearchSection : DEFAULT_SETTINGS_SECTION; const startupReady = useDeferredStartup(); - const identityQuery = useIdentityQuery(); const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( identityQuery.data?.pubkey, @@ -303,7 +303,6 @@ export function AppShell() { ? (channels.find((channel) => channel.id === targetChannelId) ?? null) : null; }, [channels, managedChannelId, selectedChannelId]); - const { handleChannelNotification, handleDmNotification, @@ -518,7 +517,6 @@ export function AppShell() { }, [applyAgents, applyCanvas, createChannelMutation, goChannel], ); - const handleCreateForum = React.useCallback( async ({ description, @@ -586,7 +584,6 @@ export function AppShell() { }, [goHome, hideDmMutation, selectedChannelId], ); - const handleOpenSettings = React.useCallback( (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => { setIsChannelManagementOpen(false); @@ -594,12 +591,10 @@ export function AppShell() { }, [goSettings], ); - const handleCloseSettings = React.useCallback( () => closeSettings(), [closeSettings], ); - // Section switches rewrite the settings entry rather than stacking one // history entry per section, so back always exits settings in one step. const handleSettingsSectionChange = React.useCallback( @@ -620,11 +615,8 @@ export function AppShell() { unreadChannelIds, unreadChannelNotificationCount, }); + // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); - const handleOpenNewDm = React.useCallback( - () => void goNewMessage(), - [goNewMessage], - ); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], @@ -657,7 +649,7 @@ export function AppShell() { if (key === "k" && event.shiftKey) { event.preventDefault(); - handleOpenNewDm(); + void goNewMessage(); return; } @@ -686,9 +678,9 @@ export function AppShell() { }; }, [ handleOpenBrowseChannels, - handleOpenNewDm, handleOpenCreateChannel, handleOpenSearch, + goNewMessage, goHome, settingsOpen, ]); @@ -770,216 +762,224 @@ export function AppShell() { /> ) : null} - {!settingsOpen ? ( - - ) : null} - {settingsOpen ? ( -
- - + {!settingsOpen ? ( + + ) : null} + {settingsOpen ? ( +
+ + + +
+ ) : ( +
+ { + const id = communitiesHook.addCommunity({ + ...community, + pubkey: + community.pubkey ?? + identityQuery.data?.pubkey, + }); + handleSwitchCommunity(id); + }} + onAddCommunityOpenChange={ + addCommunityDialog.onOpenChange } - notificationSettings={notificationSettings.settings} - onClose={handleCloseSettings} - onSectionChange={handleSettingsSectionChange} - onSetDesktopNotificationsEnabled={ - notificationSettings.setDesktopEnabled + onNewMessage={goNewMessage} + onBackgroundClick={requestFocusedThreadClose} + onCreateChannelOpenChange={setIsCreateChannelOpen} + onOpenAddCommunity={addCommunityDialog.openDialog} + onSendFeedback={() => setIsSendFeedbackOpen(true)} + onUpdateCommunity={communitiesHook.updateCommunity} + onRemoveCommunity={(id) => + void handleRemoveCommunity(id) } - onSetHomeBadgeEnabled={ - notificationSettings.setHomeBadgeEnabled + onSwitchCommunity={handleSwitchCommunity} + onCreateAgent={() => requestOpenCreateAgent()} + selfPresenceStatus={presenceSession.currentStatus} + communities={communitiesHook.communities} + onCreateChannel={handleCreateChannel} + onCreateForum={handleCreateForum} + onHideDm={handleHideDm} + onMarkAllChannelsRead={markAllChannelsRead} + onMarkChannelRead={markChannelRead} + onMarkChannelUnread={markChannelUnread} + onBrowseChannels={handleOpenBrowseChannels} + onOpenDm={async ({ pubkeys }) => { + const directMessage = + await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); + }} + onSelectAgents={() => void goAgents()} + onSelectChannel={(channelId) => + void goChannel(channelId) } - onSetSlotAlertsEnabled={ - notificationSettings.setSlotAlertsEnabled + onOpenSearchResult={handleOpenSearchResult} + searchChannels={channels} + searchFocusRequest={searchFocusRequest} + onSelectHome={() => void goHome()} + onSelectProjects={() => void goProjects()} + onSelectPulse={() => void goPulse()} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => void goWorkflows()} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) } - onSetNotifyWhileViewing={ - notificationSettings.setNotifyWhileViewing + onSetUserStatus={(text, emoji) => + setUserStatusMutation.mutate({ text, emoji }) } - onSetAllSlotAlertsEnabled={ - notificationSettings.setAllSlotAlertsEnabled + onClearUserStatus={() => + setUserStatusMutation.mutate({ + text: "", + emoji: "", + }) } - onSetSoundForSlot={ - notificationSettings.setSoundForSlot + profile={profileQuery.data} + selfUserStatus={ + deferredPubkey + ? (selfStatusQuery.data?.[ + deferredPubkey.toLowerCase() + ] ?? undefined) + : undefined } - section={settingsSection} + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + unreadChannelCounts={unreadChannelCounts} + mutedChannelIds={mutedChannelIds} + onMuteChannel={muteChannel} + onUnmuteChannel={unmuteChannel} + starredChannelIds={starredChannelIds} + onStarChannel={starChannel} + onUnstarChannel={unstarChannel} /> - -
- ) : ( -
- { - const id = communitiesHook.addCommunity({ - ...community, - pubkey: - community.pubkey ?? identityQuery.data?.pubkey, - }); - handleSwitchCommunity(id); - }} - onAddCommunityOpenChange={ - addCommunityDialog.onOpenChange - } - onNewMessage={handleOpenNewDm} - onBackgroundClick={requestFocusedThreadClose} - onCreateChannelOpenChange={setIsCreateChannelOpen} - onOpenAddCommunity={addCommunityDialog.openDialog} - onSendFeedback={() => setIsSendFeedbackOpen(true)} - onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={(id) => - void handleRemoveCommunity(id) - } - onSwitchCommunity={handleSwitchCommunity} - onCreateAgent={() => requestOpenCreateAgent()} - selfPresenceStatus={presenceSession.currentStatus} - communities={communitiesHook.communities} - onCreateChannel={handleCreateChannel} - onCreateForum={handleCreateForum} - onHideDm={handleHideDm} - onMarkAllChannelsRead={markAllChannelsRead} - onMarkChannelRead={markChannelRead} - onMarkChannelUnread={markChannelUnread} - onBrowseChannels={handleOpenBrowseChannels} - onOpenDm={async ({ pubkeys }) => { - const directMessage = - await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} - onSelectAgents={() => void goAgents()} - onSelectChannel={(channelId) => - void goChannel(channelId) - } - onOpenSearchResult={handleOpenSearchResult} - searchChannels={channels} - searchFocusRequest={searchFocusRequest} - onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} - onSelectPulse={() => void goPulse()} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => void goWorkflows()} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - onSetUserStatus={(text, emoji) => - setUserStatusMutation.mutate({ text, emoji }) - } - onClearUserStatus={() => - setUserStatusMutation.mutate({ - text: "", - emoji: "", - }) - } - profile={profileQuery.data} - selfUserStatus={ - deferredPubkey - ? (selfStatusQuery.data?.[ - deferredPubkey.toLowerCase() - ] ?? undefined) - : undefined + + + + + + + + +
+ )} + + + { + setIsChannelManagementOpen(open); + if (!open) { + setManagedChannelId(null); } - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - unreadChannelCounts={unreadChannelCounts} - mutedChannelIds={mutedChannelIds} - onMuteChannel={muteChannel} - onUnmuteChannel={unmuteChannel} - starredChannelIds={starredChannelIds} - onStarChannel={starChannel} - onUnstarChannel={unstarChannel} - /> - - - - - - - - -
- )} - - - { - setIsChannelManagementOpen(open); - if (!open) { + }} + onDeleteActiveChannel={() => { + setIsChannelManagementOpen(false); setManagedChannelId(null); - } - }} - onDeleteActiveChannel={() => { - setIsChannelManagementOpen(false); - setManagedChannelId(null); - void goHome({ replace: true }); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - /> - + void goHome({ replace: true }); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> + +
- { void goChannel(channelId, { messageId, diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index f928970610..d19ac03120 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -79,6 +79,18 @@ export function useAppNavigation() { [commitNavigation], ); + const goProfile = React.useCallback( + (pubkey: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/pulse", + search: { profile: pubkey }, + }, + behavior, + ), + [commitNavigation], + ); + const goProjects = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -303,6 +315,7 @@ export function useAppNavigation() { goProject, goProjects, goPulse, + goProfile, goSettings, goWorkflow, goWorkflows, diff --git a/desktop/src/features/agents/lib/useInstallOutputLine.test.mjs b/desktop/src/features/agents/lib/useInstallOutputLine.test.mjs new file mode 100644 index 0000000000..d65583e00d --- /dev/null +++ b/desktop/src/features/agents/lib/useInstallOutputLine.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { nextInstallOutputLine } from "./useInstallOutputLine.ts"; + +function event(runtimeId, seq, line) { + return { runtime_id: runtimeId, seq, line }; +} + +test("nextInstallOutputLine: adopts the first line for the watched runtime", () => { + assert.deepEqual( + nextInstallOutputLine(null, event("goose", 0, "downloading"), "goose"), + { seq: 0, line: "downloading" }, + ); +}); + +test("nextInstallOutputLine: a later line replaces the current one", () => { + const current = { seq: 4, line: "downloading" }; + + assert.deepEqual( + nextInstallOutputLine(current, event("goose", 5, "unpacking"), "goose"), + { seq: 5, line: "unpacking" }, + ); +}); + +test("nextInstallOutputLine: ignores a line from another runtime", () => { + const current = { seq: 1, line: "downloading" }; + + assert.equal( + nextInstallOutputLine(current, event("codex", 2, "other work"), "goose"), + current, + ); +}); + +test("nextInstallOutputLine: ignores an out-of-order line", () => { + const current = { seq: 7, line: "retrying" }; + + assert.equal( + nextInstallOutputLine(current, event("goose", 6, "stale line"), "goose"), + current, + ); +}); + +test("nextInstallOutputLine: ignores a replay of the current sequence number", () => { + const current = { seq: 7, line: "retrying" }; + + assert.equal( + nextInstallOutputLine(current, event("goose", 7, "duplicate"), "goose"), + current, + ); +}); + +test("nextInstallOutputLine: a null line clears the display", () => { + const current = { seq: 3, line: "download failed" }; + + assert.deepEqual( + nextInstallOutputLine(current, event("goose", 4, null), "goose"), + { seq: 4, line: null }, + ); +}); + +test("nextInstallOutputLine: a later step's first line is adopted after a higher attempt", () => { + // The seq is install-wide: step 2 attempt 1 always follows step 1 attempt 2, + // which is exactly what an attempt-keyed comparison got wrong. + const current = { seq: 9, line: "step one, attempt two" }; + + assert.deepEqual( + nextInstallOutputLine( + current, + event("goose", 10, "step two, attempt one"), + "goose", + ), + { seq: 10, line: "step two, attempt one" }, + ); +}); + +test("nextInstallOutputLine: a first event mid-install is adopted", () => { + assert.deepEqual( + nextInstallOutputLine(null, event("goose", 42, "downloading"), "goose"), + { seq: 42, line: "downloading" }, + ); +}); diff --git a/desktop/src/features/agents/lib/useInstallOutputLine.ts b/desktop/src/features/agents/lib/useInstallOutputLine.ts new file mode 100644 index 0000000000..9f50843fc7 --- /dev/null +++ b/desktop/src/features/agents/lib/useInstallOutputLine.ts @@ -0,0 +1,108 @@ +import * as React from "react"; +import { listen } from "@tauri-apps/api/event"; + +/** Mirror of the Rust `InstallOutputEvent` payload (install_report.rs). */ +export type InstallOutputEvent = { + runtime_id: string; + /** Monotonic across the whole install, not per step or per attempt. */ + seq: number; + /** Null is the start signal: clear the displayed line now. */ + line: string | null; +}; + +/** The line being shown, and the sequence number that produced it. */ +export type InstallOutputState = { + seq: number; + line: string | null; +}; + +/** + * Fold one event into the displayed line. + * + * Events from another runtime are ignored — every install card listens to the + * same channel. So is an out-of-order event: emission is monotonic in `seq`, so + * a lower one has already been superseded. That matters at a retry boundary, + * where a line emitted just as the next attempt starts would otherwise sit + * under the spinner showing the failure the user already had. + * + * The ordering key is the install-wide `seq` rather than the attempt number, + * which restarts at 1 for every step: keyed on attempt, a step that succeeded on + * attempt 2 would make the next step's attempt-1 output look stale and freeze + * the display for the rest of the install. + */ +export function nextInstallOutputLine( + current: InstallOutputState | null, + event: InstallOutputEvent, + runtimeId: string, +): InstallOutputState | null { + if (event.runtime_id !== runtimeId) return current; + if (current && event.seq <= current.seq) return current; + return { seq: event.seq, line: event.line }; +} + +/** + * The install command's most recent output line for `runtimeId`, or null when + * nothing is being shown — the install is not running, nothing has printed yet, + * or the backend cleared the line because a new attempt is starting. + * + * An install runs for up to 15 minutes with no other feedback than a spinner; + * this turns that wait into observable progress. The backend throttles + * emission, so this re-renders a few times a second at most. + * + * Pass `isInstalling` so the line clears when the install settles — a finished + * install must not leave its last line under a fresh Install button. + */ +export function useInstallOutputLine( + runtimeId: string, + isInstalling: boolean, +): string | null { + const [state, setState] = React.useState(null); + + // Subscribed for this runtime's whole lifetime, not just while installing. + // The install command is invoked from the click handler, so the backend can + // emit the attempt-start clear and the first line before React commits + // `isInstalling` — and there is no replay, so a subscription that waited for + // that commit would lose those events permanently. A fast command's entire + // output is exactly what fits in that window. + React.useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + (async () => { + try { + const stop = await listen( + "acp-install-output", + (event) => { + if (cancelled) return; + setState((current) => + nextInstallOutputLine(current, event.payload, runtimeId), + ); + }, + ); + if (cancelled) { + stop(); + } else { + unlisten = stop; + } + } catch { + // Event system unavailable (web/e2e) — the spinner shows alone. + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [runtimeId]); + + // `seq` is monotonic within one install and restarts at 0 for the next, so + // state must not outlive the run that produced it: a retained higher `seq` + // would make every event of the following install look superseded. Settling + // is the run boundary, so it is where the ordering key resets. + React.useEffect(() => { + if (!isInstalling) setState(null); + }, [isInstalling]); + + // Events that arrive after the install settles — a drain flushing its last + // line — must not reappear under a fresh Install button, so the line is + // reported only while the install is running. + return (isInstalling ? state?.line : null) ?? null; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index a907f87245..641b81490b 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -35,10 +35,12 @@ import { AuxiliaryPanelHeader, AuxiliaryPanelHeaderActions, AuxiliaryPanelHeaderGroup, - AuxiliaryPanelHeaderTitleBlock, } from "@/shared/layout/AuxiliaryPanel"; import { Button } from "@/shared/ui/button"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { DropdownMenu, DropdownMenuContent, @@ -237,6 +239,15 @@ export function AgentSessionThreadPanel({ ? `#${scopeChannelName}` : "1 channel" : "All channels"; + const agentProfile = profiles?.[normalizePubkey(agent.pubkey)] ?? null; + const agentLabel = resolveUserLabel({ + pubkey: agent.pubkey, + fallbackName: agent.name, + profiles, + preferResolvedSelfLabel: true, + }); + const viewLabel = showRawFeed ? "Raw ACP activity" : "Activity"; + const headerScopeLabel = `${viewLabel} · ${scopeLabel}`; const animateActivity = useTranscriptAnimationEnabled(); const showTimestamps = useTranscriptTimestampsEnabled(); async function handleInterruptTurn() { @@ -417,19 +428,39 @@ export function AgentSessionThreadPanel({ backButtonTestId="agent-session-back" onBack={onBack} > - - {/* Scope label: makes channel-targeted vs all-channels state obvious - (an all-channels pane can look "wrong" without it). */} - - {scopeLabel} - +
+

+ {agentLabel} +

+
+

+ {headerScopeLabel} +

+ + + {lastUpdatedLabel} + +
+
{agentHeaderActions} diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c87617ce9b..7b9bf2b79f 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -11,9 +11,15 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { + getDmHuddleMemberPubkeys, + hasOtherDmParticipant, +} from "@/features/channels/lib/dmHuddleMembers"; import { canStartHuddleInChannel } from "@/features/channels/lib/huddleAvailability"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { Channel } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; @@ -64,6 +70,41 @@ export function ChannelMembersBar({ const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); const members = membersQuery.data ?? []; + const dmProfilesQuery = useUsersBatchQuery( + channel.channelType === "dm" ? channel.participantPubkeys : [], + { enabled: channel.channelType === "dm" }, + ); + const huddleAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set( + mergeChannelKnownAgentPubkeys( + membersQuery.data, + managedAgentsQuery.data, + relayAgentsQuery.data, + ), + ); + for (const [pubkey, profile] of Object.entries( + dmProfilesQuery.data?.profiles ?? {}, + )) { + if (profile.isAgent) pubkeys.add(normalizePubkey(pubkey)); + } + return pubkeys; + }, [ + dmProfilesQuery.data?.profiles, + managedAgentsQuery.data, + membersQuery.data, + relayAgentsQuery.data, + ]); + const huddleMemberPubkeys = React.useMemo( + () => getDmHuddleMemberPubkeys(channel, huddleAgentPubkeys, currentPubkey), + [channel, currentPubkey, huddleAgentPubkeys], + ); + const huddleMemberPubkeysPending = + hasOtherDmParticipant(channel, currentPubkey) && + (membersQuery.isPending || + managedAgentsQuery.isPending || + relayAgentsQuery.isPending || + dmProfilesQuery.isPending || + dmProfilesQuery.isPlaceholderData); const memberCount = membersQuery.data?.length ?? channel.memberCount; const providers = React.useMemo( () => @@ -117,7 +158,7 @@ export function ChannelMembersBar({ try { await startHuddle( channel.id, - [], + [...huddleMemberPubkeys], buildHuddleChannelName({ channel, currentPubkey, @@ -133,7 +174,9 @@ export function ChannelMembersBar({ } }} renderMode={variant === "compact" ? "menu-item" : "button"} - startDisabled={!canStartHuddle || isStartingHuddle} + startDisabled={ + !canStartHuddle || isStartingHuddle || huddleMemberPubkeysPending + } /> ); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 7b750daa4f..b4d33b4776 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -48,6 +48,7 @@ import { channelWindowThreadSummaries, type ChannelWindowThreadSummary, } from "@/features/messages/lib/channelWindowStore"; +import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; import { getThreadReference } from "@/features/messages/lib/threading"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { @@ -483,6 +484,9 @@ export function ChannelScreen({ timelineMessages.find((message) => message.id === editTargetId) ?? null, [editTargetId, timelineMessages], ); + // Event id awaiting the empty-edit "Delete message?" confirmation (non-null + // while the dialog is open); see handleEditSave. + const [emptyDeleteId, setEmptyDeleteId] = React.useState(null); const { handleCancelEdit, handleCancelThreadReply, @@ -506,6 +510,7 @@ export function ChannelScreen({ markRevealedRepliesRead, openThreadHeadId: effectiveOpenThreadHeadId, onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId, + onRequestEmptyEditDelete: setEmptyDeleteId, sendMessageMutation, setExpandedThreadReplyIds, setEditTargetId, @@ -802,6 +807,19 @@ export function ChannelScreen({ open={welcomeAgentCreate.isOpen} sendError={welcomeAgentCreate.error} /> + { + if (emptyDeleteId) { + setEditTargetId(null); + void handleDelete({ id: emptyDeleteId }); + } + setEmptyDeleteId(null); + }} + onOpenChange={(open) => { + if (!open) setEmptyDeleteId(null); + }} + open={emptyDeleteId !== null} + />
1; + const activeDmParticipant = activeDmHeaderParticipants[0] ?? null; const showJoinButton = activeChannel !== null && !activeChannel.isMember && @@ -113,6 +115,25 @@ export function ChannelScreenHeader({ + ) : activeDmParticipant ? ( + + + ) : (