diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc7415a1074..6bb4ddfba88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: token: '' @@ -78,6 +78,10 @@ jobs: run: scripts/test-release-ref-contract.sh - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh + - name: OSS desktop promotion contract + run: | + scripts/test-oss-desktop-promotion.sh + scripts/test-oss-desktop-promotion-behavior.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh @@ -317,6 +321,9 @@ jobs: if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' permissions: contents: read + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -341,6 +348,13 @@ jobs: . desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} + # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; + # the bounded PR 5224 trial writes only to its isolated merge-ref scope. + - name: Set up sccache + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. + with: + version: v0.16.0 - name: Install cargo-nextest if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 @@ -348,6 +362,8 @@ jobs: tool: cargo-nextest@0.9.136 - name: Build relay artifacts if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + env: + RUSTC_WRAPPER: sccache run: | cargo build --profile ci -p buzz-relay -p git-credential-nostr cargo nextest archive \ @@ -359,7 +375,9 @@ jobs: --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Save relay artifacts cache - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + # PR-scoped exact-source entries cannot warm main or other PRs and churn + # the shared cache pool. sccache provides read-only PR reuse instead. + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | diff --git a/.github/workflows/promote-oss-desktop-release.yml b/.github/workflows/promote-oss-desktop-release.yml new file mode 100644 index 00000000000..f73bbd032b1 --- /dev/null +++ b/.github/workflows/promote-oss-desktop-release.yml @@ -0,0 +1,45 @@ +name: Promote OSS Desktop Auto-Update +run-name: Promote desktop-v${{ inputs.version }} to auto-update + +on: + workflow_dispatch: + inputs: + version: + description: Stable desktop version to promote (X.Y.Z) + required: true + type: string + +concurrency: + group: oss-desktop-auto-update-promotion + cancel-in-progress: false + +permissions: + contents: read + +jobs: + promote: + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Require the reviewed workflow from main + env: + DISPATCH_REF: ${{ github.ref }} + run: | + if [ "$DISPATCH_REF" != "refs/heads/main" ]; then + echo "::error::OSS desktop promotion must be dispatched from main, not $DISPATCH_REF" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Validate and promote exact release manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + run: scripts/promote-oss-desktop-release.sh "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d9798850fa..42710238b56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -906,6 +906,7 @@ jobs: [ "${#TRIPLES[@]}" -ge 3 ] || { echo "::error::too few platforms (${#TRIPLES[@]})"; exit 1; } bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json + cp latest.json staged/updater-manifest.json - name: Create or verify versioned draft run: | @@ -945,7 +946,3 @@ jobs: - name: Publish complete versioned release if: env.already_published != 'true' run: gh release edit "desktop-v${VERSION}" --draft=false - - - name: Upload latest.json to rolling release last - if: ${{ !contains(needs.setup.outputs.version, '-') }} - run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 843ac0dd7d7..2c06a06c6a2 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.8", - "base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f", - "previous_tag": "desktop-v0.5.7", - "previous_base_sha": "74b913cff8512c015dc6f1a7473b253fa803f954", - "previous_merge_sha": "13c9e900c84cac1e2c8eeb7551bd1510ecb544d3", - "tag": "desktop-v0.5.8", - "commit_count": 4 + "version": "0.5.14", + "base_sha": "1b3dbcaaea882eeea90359c1db02e306d2f4f50a", + "previous_tag": "desktop-v0.5.13", + "previous_base_sha": "09768100ec3420f0aa7cd278bd00fe0baab5de8d", + "previous_merge_sha": "51beba603886d34e751349d12b33c0c5aeb92c28", + "tag": "desktop-v0.5.14", + "commit_count": 1 } diff --git a/AGENTS.md b/AGENTS.md index 2d3939bbb36..d66ecbdc748 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,25 @@ code style, PR process, architecture), see [CONTRIBUTING.md](CONTRIBUTING.md). --- +## Product Contract + +Before planning or reviewing a non-trivial change: + +1. Read [VISION.md](VISION.md). +2. Read the `VISION_*.md` documents relevant to the affected product surface. +3. Read the applicable guidance in [TESTING.md](TESTING.md) and any + package-local `TESTING.md`. +4. Check that the proposed design advances, or at least does not contradict, + that product intent. Call out any intentional tension explicitly. + +Implementation describes the product today; the vision documents describe the +product it is becoming. A locally correct change can still be wrong if it works +against that direction. Scale validation to the change's risk and exercise the +real workflow for user-visible or integration behavior when practical; green CI +and runtime evidence answer different questions. + +--- + ## Ecosystem Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment: @@ -90,8 +109,9 @@ See CONTRIBUTING.md for full setup details and dependency requirements. ## Quality Gates -Run `just ci` before every PR — it runs `fmt` + `clippy` + desktop lint + -unit tests + builds. Clippy passing does not mean fmt passes; run both. +Run `just ci` before every PR — it runs repository-wide formatting, lint, +and static checks; Rust, Tauri, desktop, and mobile tests; and desktop and web +builds. Clippy passing does not mean fmt passes; run both. Run `just test` for integration tests if you touched `buzz-relay`, `buzz-db`, or `buzz-auth` — these require a running Postgres and Redis. @@ -185,7 +205,7 @@ or invoke with the full path. thread. To read the linked thread: ```bash -buzz messages thread --channel --event --format compact +buzz --format compact messages thread --channel --event ``` Extract `channel` and `id` from the URL query parameters. The optional @@ -216,7 +236,9 @@ E2E tests live in `crates/buzz-test-client/tests/`: - `e2e_media_extended.rs` — extended media scenarios - `e2e_nostr_interop.rs` — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps) -Desktop E2E: `cd desktop && pnpm exec playwright test` +Desktop E2E: `cd desktop && pnpm test:e2e:smoke` for mock-bridge smoke +coverage, or `pnpm test:e2e:integration` for relay-backed coverage. These +scripts build the required E2E bridge before running Playwright. See [TESTING.md](TESTING.md) for the full multi-agent E2E guide. @@ -427,11 +449,10 @@ description. See [PR #803](https://github.com/block/buzz/pull/803). 1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `buzz-core/src/kind.rs`. 2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters. -3. **`messages search` must include `--kinds`** — an open-ended search (no kinds) hits the relay p-gate and returns 403. Pass at least `--kinds 9,45001,45003` to scope the query. +3. **`messages search` chooses its own supported kinds** — do not add a `--kinds` option; the current command does not accept one. This differs from raw relay filters, which still need explicit kinds. 4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command. 5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly. -6. **Desktop Tauri fmt fails in worktrees and blocks commits** — the pre-commit hook runs `just desktop-tauri-fmt`, which fails in git worktrees because `cargo fmt` resolves workspace paths relative to the worktree root. Run `just desktop-tauri-fmt` from the main checkout to apply the fix, then re-stage and commit. CI is unaffected. -7. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing. +6. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing. --- @@ -492,27 +513,12 @@ class instances, cached promises) survive across remounts. Every community-scope singleton needs a reset function wired into `resetCommunityState()` in `desktop/src/features/communities/useCommunityInit.ts`. -Current singletons that are reset on relay boundary changes (same-relay -reconnects preserve pending avatar verification work): -- `relayClient.disconnect()` — WebSocket teardown + promise rejection -- `resetRateLimitGate()` — clears any active rate-limit window from the old relay -- `clearAllDrafts()` — message draft cache -- `resetAgentObserverStore()` — agent observer relay store -- `resetActiveAgentTurnsStore()` — active agent turn timers -- `resetAgentWorkingSignal()` — agent working indicator signal -- `resetAvatarProfileSync()` — pending verified-avatar profile writes -- `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts -- `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state -- `resetMediaCaches()` — proxy port and relay origin caches -- `resetVideoPlayerState()` — video player singleton -- `resetRenderScopedReactionHydration()` — reaction hydration cache -- `clearSearchHitEventCache()` — search result event cache -- `clearMarkdownNodeCache()` — markdown parse-node cache -- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events) - -**If you add a new module-level cache, Map, or class instance that holds -community-scoped data, you must add its reset to `resetCommunityState()`.** -Failure to do so causes data from the old community to leak into the new one. +`resetCommunityState()` is the canonical inventory of community-scoped +singletons. **If you add a new module-level cache, Map, or class instance that +holds community-scoped data, add its reset there in the same change.** Failure +to do so causes data from the old community to leak into the new one. Avoid +duplicating its complete reset list here; the implementation is the source of +truth. Key files: - `desktop/src/app/App.tsx` — community key, init gate, remount boundary @@ -537,9 +543,23 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks. - **NEVER use `StatefulWidget`** — favor Riverpod for state and always use `HookConsumerWidget` or `ConsumerWidget` with `flutter_hooks` for local state. -- **NEVER run `flutter run`, `flutter build`, `flutter clean`, or - `flutter upgrade`** — only `flutter test`, `flutter analyze`, and - `dart format` are safe for agents to run. +- Agents may build and run the Flutter app when it materially helps implement, + debug, or validate mobile changes. Prefer the smallest relevant command and + reuse an already-running simulator/emulator and the app's configured staging + or production community when that is sufficient. Do not start or rebuild + local relay services unless the task specifically requires relay-side or + isolated integration behavior. +- For iOS runtime validation, prefer `just mobile-dev`; it applies the + worktree-specific debug identity and runs `flutter run`. Direct `flutter run` + or IDE workflows are also allowed. Use `just mobile-build-android` only when + an APK build is relevant to the task. +- Do not rebuild, reinstall, or relaunch merely for ceremony. Preserve Flutter's + incremental build cache and use hot reload/restart where appropriate. Use + `flutter clean` only when stale build artifacts are a credible cause. Run + `flutter upgrade` only when the task explicitly requires a toolchain change. +- For user-visible or integration changes, exercise the affected workflow in a + real app when practical and report the device/simulator, connected community, + and workflow actually tested. - **Do NOT use `print()`** — use `debugPrint()` or structured logging. - Prefer `context.colors` and `context.textTheme` (via theme extensions) over raw `Theme.of(context)` calls. @@ -565,12 +585,16 @@ flutter test Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests). -To run the app locally (starts Docker, relay, iOS simulator automatically): +To run the app locally with a worktree-specific debug identity and a +started or reused iOS Simulator: ```bash just mobile-dev ``` +This runs `flutter run` against the app's configured community; it does not +start Docker or local relay services. + When run from a git worktree, `just mobile-dev` (and `just mobile-build-android`) give the debug build a per-worktree app identifier (keyed to the worktree directory name) and a branch-labelled app name via diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca95250749..9248c6fbd77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,168 @@ # Changelog +## v0.5.14 + +### Desktop and shared changes + +- None + +### Other repository changes + +- ci(release): remove desktop smoke gate ([#5914](https://github.com/block/buzz/pull/5914)) ([`1b3dbcaaea882eeea90359c1db02e306d2f4f50a`](https://github.com/block/buzz/commit/1b3dbcaaea882eeea90359c1db02e306d2f4f50a)) + +[Compare desktop-v0.5.13...desktop-v0.5.14](https://github.com/block/buzz/compare/desktop-v0.5.13...desktop-v0.5.14) + +## v0.5.13 + +### Desktop and shared changes + +- fix(desktop): restore the agent trading-card mint button ([#5900](https://github.com/block/buzz/pull/5900)) ([`263c9bf76c18f0cde6cec9fb43d22f8895319380`](https://github.com/block/buzz/commit/263c9bf76c18f0cde6cec9fb43d22f8895319380)) +- Projects v3: unify sharing, discussions, and issue ownership ([#5792](https://github.com/block/buzz/pull/5792)) ([`122a8b8988869f0b1a7c056a76f7d16bfb0f6fdd`](https://github.com/block/buzz/commit/122a8b8988869f0b1a7c056a76f7d16bfb0f6fdd)) + +### Other repository changes + +- fix(ci): read Playwright version without nested shell quoting ([#5910](https://github.com/block/buzz/pull/5910)) ([`09768100ec3420f0aa7cd278bd00fe0baab5de8d`](https://github.com/block/buzz/commit/09768100ec3420f0aa7cd278bd00fe0baab5de8d)) +- fix(mobile): unwrap batched observer telemetry ([#5805](https://github.com/block/buzz/pull/5805)) ([`0bb7c60f824a05ac4d8c8569ee1e74d200069b45`](https://github.com/block/buzz/commit/0bb7c60f824a05ac4d8c8569ee1e74d200069b45)) + +[Compare desktop-v0.5.12...desktop-v0.5.13](https://github.com/block/buzz/compare/desktop-v0.5.12...desktop-v0.5.13) + +## v0.5.12 + +### Desktop and shared changes + +- perf(desktop): update active turns incrementally ([#5897](https://github.com/block/buzz/pull/5897)) ([`757779bb1ef22cc4a1c233344baa0946d907e5a6`](https://github.com/block/buzz/commit/757779bb1ef22cc4a1c233344baa0946d907e5a6)) +- fix(link-previews): send while previews finish in background ([#5697](https://github.com/block/buzz/pull/5697)) ([`f086eb6544fd9f450832ea22de74b5418d1f85a1`](https://github.com/block/buzz/commit/f086eb6544fd9f450832ea22de74b5418d1f85a1)) +- fix(desktop): cut steady-state relay traffic from polls and read-state echo ([#5879](https://github.com/block/buzz/pull/5879)) ([`01f76ec9719ebdacce3f6e67d545692a90e10b06`](https://github.com/block/buzz/commit/01f76ec9719ebdacce3f6e67d545692a90e10b06)) +- fix(desktop): support channel message path links ([#5889](https://github.com/block/buzz/pull/5889)) ([`207154706c87cbf207f2a2abbc096d17737b091a`](https://github.com/block/buzz/commit/207154706c87cbf207f2a2abbc096d17737b091a)) +- test(desktop): await channel E2E bridge readiness ([#5886](https://github.com/block/buzz/pull/5886)) ([`dbee2914ad806c7f038389eb95c7513f5df4e0d2`](https://github.com/block/buzz/commit/dbee2914ad806c7f038389eb95c7513f5df4e0d2)) +- fix(link-preview): refetch a link when it re-enters the composer ([#5510](https://github.com/block/buzz/pull/5510)) ([`fd0ab47a1b5526d7496b5a6d731f3c8d7e4dbe9f`](https://github.com/block/buzz/commit/fd0ab47a1b5526d7496b5a6d731f3c8d7e4dbe9f)) +- feat(desktop-messages): render compact Buzz permalink chips ([#5638](https://github.com/block/buzz/pull/5638)) ([`5acb930821ba56b5f4d1b487bffd237dd3ebe76a`](https://github.com/block/buzz/commit/5acb930821ba56b5f4d1b487bffd237dd3ebe76a)) +- Fix video comment effect wrapping ([#5748](https://github.com/block/buzz/pull/5748)) ([`17d2147ecadaef5891da598cf8f5257f7787992b`](https://github.com/block/buzz/commit/17d2147ecadaef5891da598cf8f5257f7787992b)) +- feat(desktop): one relative date ladder across chat and the Inbox ([#3769](https://github.com/block/buzz/pull/3769)) ([`caa64b5e8f584a740e331887a5dd1cda32bcb958`](https://github.com/block/buzz/commit/caa64b5e8f584a740e331887a5dd1cda32bcb958)) +- fix(desktop): amortize observer journal eviction with a low-water mark ([#5808](https://github.com/block/buzz/pull/5808)) ([`17977814d38a841ed475b318a5dfd4bc8405d049`](https://github.com/block/buzz/commit/17977814d38a841ed475b318a5dfd4bc8405d049)) +- Unify agent profile content ([#5788](https://github.com/block/buzz/pull/5788)) ([`34a7f2fb917cff8afd86bb59f6abcfa4cb8981d5`](https://github.com/block/buzz/commit/34a7f2fb917cff8afd86bb59f6abcfa4cb8981d5)) +- Standardize settings section layout ([#5855](https://github.com/block/buzz/pull/5855)) ([`43e53fc3491ecbd1def14ede3fb8c9e2d44e84d8`](https://github.com/block/buzz/commit/43e53fc3491ecbd1def14ede3fb8c9e2d44e84d8)) +- fix(desktop): share one timer across same-interval useNow consumers ([#5861](https://github.com/block/buzz/pull/5861)) ([`8b8445f5ef3338c58825194ebc008b98111a0962`](https://github.com/block/buzz/commit/8b8445f5ef3338c58825194ebc008b98111a0962)) +- Clarify immediate spoken huddle replies ([#5863](https://github.com/block/buzz/pull/5863)) ([`ea0960f8d0221de18d7d3504607594035519f33f`](https://github.com/block/buzz/commit/ea0960f8d0221de18d7d3504607594035519f33f)) +- Scope desktop presence subscriptions to active demand ([#5830](https://github.com/block/buzz/pull/5830)) ([`df9e773a13f17a270fd6531fc74948b8059d58c3`](https://github.com/block/buzz/commit/df9e773a13f17a270fd6531fc74948b8059d58c3)) +- fix(huddle): stop 20 Hz speaker-level churn from re-rendering the whole app ([#5825](https://github.com/block/buzz/pull/5825)) ([`57435628961d25bd24689cee82f1373e7a074040`](https://github.com/block/buzz/commit/57435628961d25bd24689cee82f1373e7a074040)) +- fix(desktop): match compact link preview thumbnail corners to card shell ([#5711](https://github.com/block/buzz/pull/5711)) ([`eedcd886a04833a78c18f49931abe68792518f97`](https://github.com/block/buzz/commit/eedcd886a04833a78c18f49931abe68792518f97)) +- feat(huddle): cut voice-turn time-to-first-audio from ~1.0 s to ~0.35 s (env-gated latency levers) ([#5671](https://github.com/block/buzz/pull/5671)) ([`068a83b09712703c71923fb22601dffd96554c91`](https://github.com/block/buzz/commit/068a83b09712703c71923fb22601dffd96554c91)) +- Speed up initial direct messages ([#5658](https://github.com/block/buzz/pull/5658)) ([`c8da06c5e9e92b2441927cdb4619318b4328c2bd`](https://github.com/block/buzz/commit/c8da06c5e9e92b2441927cdb4619318b4328c2bd)) +- Polish glass Huddle tray behavior ([#5590](https://github.com/block/buzz/pull/5590)) ([`0571f5455b1b2aeea7334082f0df9d1f19b22f7d`](https://github.com/block/buzz/commit/0571f5455b1b2aeea7334082f0df9d1f19b22f7d)) +- test: add deterministic desktop release smoke ([#5699](https://github.com/block/buzz/pull/5699)) ([`76f114a252866f17003520db0a11a8b6f5b3da0c`](https://github.com/block/buzz/commit/76f114a252866f17003520db0a11a8b6f5b3da0c)) +- feat(desktop): add Inbox message delete action ([#5779](https://github.com/block/buzz/pull/5779)) ([`514195b1d58d1a8679bfc8c63a2b410b6a227489`](https://github.com/block/buzz/commit/514195b1d58d1a8679bfc8c63a2b410b6a227489)) +- fix(desktop): enforce agent mention authorization at send boundaries ([#5681](https://github.com/block/buzz/pull/5681)) ([`bcf353c969b91991c22d0715aa2d7a618d630e1d`](https://github.com/block/buzz/commit/bcf353c969b91991c22d0715aa2d7a618d630e1d)) +- fix(desktop): route compact preview geometry fixture through media proxy ([#5799](https://github.com/block/buzz/pull/5799)) ([`b269e8df7e6ed3e1910b6f6eeef08fa4b89778bd`](https://github.com/block/buzz/commit/b269e8df7e6ed3e1910b6f6eeef08fa4b89778bd)) +- Make workflow run history authoritative in Desktop ([#5780](https://github.com/block/buzz/pull/5780)) ([`2693e0db1fc4980a551c2492031812dc4bad985f`](https://github.com/block/buzz/commit/2693e0db1fc4980a551c2492031812dc4bad985f)) +- fix(desktop): more compact "compact" link previews ([#5629](https://github.com/block/buzz/pull/5629)) ([`45f4b91a36145f2ce642548c34f699f1b529bcf5`](https://github.com/block/buzz/commit/45f4b91a36145f2ce642548c34f699f1b529bcf5)) +- Harden shared agent instruction review ([#4220](https://github.com/block/buzz/pull/4220)) ([`a96af89526f7181543e7651100a944aa8e21812b`](https://github.com/block/buzz/commit/a96af89526f7181543e7651100a944aa8e21812b)) + +### Other repository changes + +- feat(mobile-messages): render compact Buzz permalink chips ([#5639](https://github.com/block/buzz/pull/5639)) ([`5ddf23d700abdd96622de2d39750c56509a7561f`](https://github.com/block/buzz/commit/5ddf23d700abdd96622de2d39750c56509a7561f)) +- Teach agents to inherit Buzz product intent ([#5875](https://github.com/block/buzz/pull/5875)) ([`1d51081b8abf4d3f9ec7fc676207f967a843e860`](https://github.com/block/buzz/commit/1d51081b8abf4d3f9ec7fc676207f967a843e860)) +- Polish mobile profiles, DMs, and sheets ([#5401](https://github.com/block/buzz/pull/5401)) ([`b30f1f61299f6f559777f797be27f193a6a4f0b3`](https://github.com/block/buzz/commit/b30f1f61299f6f559777f797be27f193a6a4f0b3)) +- Fix channel list scroll interruption ([#5815](https://github.com/block/buzz/pull/5815)) ([`0f61f24ad659abf44a7a4fcde6a0a2cbcf78f13b`](https://github.com/block/buzz/commit/0f61f24ad659abf44a7a4fcde6a0a2cbcf78f13b)) +- fix(channels): return complete member rosters ([#5765](https://github.com/block/buzz/pull/5765)) ([`e0940927ff381f6a353c637732c7a81886f9639d`](https://github.com/block/buzz/commit/e0940927ff381f6a353c637732c7a81886f9639d)) +- Fix mobile composer input regressions ([#5594](https://github.com/block/buzz/pull/5594)) ([`98d3d77b426f1107c98b7826d0224624ea774385`](https://github.com/block/buzz/commit/98d3d77b426f1107c98b7826d0224624ea774385)) +- Add mobile community invites ([#5641](https://github.com/block/buzz/pull/5641)) ([`8abc2baf0b71844fc4ff7222aab5027c862b7d1f`](https://github.com/block/buzz/commit/8abc2baf0b71844fc4ff7222aab5027c862b7d1f)) + +[Compare desktop-v0.5.11...desktop-v0.5.12](https://github.com/block/buzz/compare/desktop-v0.5.11...desktop-v0.5.12) + +## v0.5.11 + +### Desktop and shared changes + +- perf(desktop): persist channel snapshot hash ([#5684](https://github.com/block/buzz/pull/5684)) ([`c86443c5997c96c42829ce200e73e6e6efe52d96`](https://github.com/block/buzz/commit/c86443c5997c96c42829ce200e73e6e6efe52d96)) +- fix(agent): raise output limit and allow 3 recoveries ([#5475](https://github.com/block/buzz/pull/5475)) ([`72d56e7bd3a94fa3ee20b5a50bd1b868a9329d9c`](https://github.com/block/buzz/commit/72d56e7bd3a94fa3ee20b5a50bd1b868a9329d9c)) +- fix(desktop): defer foreground resume work ([#5696](https://github.com/block/buzz/pull/5696)) ([`59f613c404958d8ac99525b4aaaf26843257de31`](https://github.com/block/buzz/commit/59f613c404958d8ac99525b4aaaf26843257de31)) +- perf(desktop): coalesce thread-activity localStorage writes ([#5693](https://github.com/block/buzz/pull/5693)) ([`c6c6e7eca70d6b526c43af925e596e8616b19fb8`](https://github.com/block/buzz/commit/c6c6e7eca70d6b526c43af925e596e8616b19fb8)) +- Batch observer-store publications per relay envelope ([#5680](https://github.com/block/buzz/pull/5680)) ([`c3b0ccf383fe4ee936abbe6b9c9134b5728cc2b5`](https://github.com/block/buzz/commit/c3b0ccf383fe4ee936abbe6b9c9134b5728cc2b5)) +- feat(buzz-acp): idle re-sleep for woken lazy pools ([#5682](https://github.com/block/buzz/pull/5682)) ([`dc2dbfe0f570abb818d3f3da8a71ea235555ed27`](https://github.com/block/buzz/commit/dc2dbfe0f570abb818d3f3da8a71ea235555ed27)) +- fix(desktop): preserve agent mention separator after send ([#5623](https://github.com/block/buzz/pull/5623)) ([`a8e5c89e23b85ee93306f2c3c11d8fe6300cd360`](https://github.com/block/buzz/commit/a8e5c89e23b85ee93306f2c3c11d8fe6300cd360)) +- fix(link-previews): proxy sent preview media ([#5627](https://github.com/block/buzz/pull/5627)) ([`884ed8a5d35dfba3892fc40437f39e08856dec7d`](https://github.com/block/buzz/commit/884ed8a5d35dfba3892fc40437f39e08856dec7d)) +- feat(deletion): add durable whole-community deletion ([#4425](https://github.com/block/buzz/pull/4425)) ([`8a2c9af2dbe0cf315e77f43a4560d3572da5e554`](https://github.com/block/buzz/commit/8a2c9af2dbe0cf315e77f43a4560d3572da5e554)) +- fix(desktop): preserve live channel timelines ([#5662](https://github.com/block/buzz/pull/5662)) ([`63d14a0e95c8d5ae19f3f80123027729ec209bb2`](https://github.com/block/buzz/commit/63d14a0e95c8d5ae19f3f80123027729ec209bb2)) +- Refine channel settings and profile panels ([#5574](https://github.com/block/buzz/pull/5574)) ([`63f961c7e4818a1d29f1185002c123e486bd4a19`](https://github.com/block/buzz/commit/63f961c7e4818a1d29f1185002c123e486bd4a19)) +- fix(deps): bump webbrowser to 1.2.4 for RUSTSEC-2026-0257 ([#5659](https://github.com/block/buzz/pull/5659)) ([`c966b862fe8b9018c68c384b1680ca0173d0128c`](https://github.com/block/buzz/commit/c966b862fe8b9018c68c384b1680ca0173d0128c)) +- fix(desktop): launch Databricks OAuth from passive model discovery ([#5607](https://github.com/block/buzz/pull/5607)) ([`1ff98fa685fdb7133dbc18437d23dcdeeb42ce6e`](https://github.com/block/buzz/commit/1ff98fa685fdb7133dbc18437d23dcdeeb42ce6e)) + +### Other repository changes + +- feat(acp): report standard adapter usage ([#4950](https://github.com/block/buzz/pull/4950)) ([`4749bc7be3cdb78c2db4ce4864775ba7ab60b4cc`](https://github.com/block/buzz/commit/4749bc7be3cdb78c2db4ce4864775ba7ab60b4cc)) +- fix(mobile): settle hydrated threads on latest reply ([#4702](https://github.com/block/buzz/pull/4702)) ([`7634fe74563ea7f3c86fb6017a0ad647a9934477`](https://github.com/block/buzz/commit/7634fe74563ea7f3c86fb6017a0ad647a9934477)) +- feat(acp): deliver channel description in prompt [Context] ([#4552](https://github.com/block/buzz/pull/4552)) ([`6e0631f6b5d2139e4e080bf94e27ecee8a3d4d74`](https://github.com/block/buzz/commit/6e0631f6b5d2139e4e080bf94e27ecee8a3d4d74)) + +[Compare desktop-v0.5.10...desktop-v0.5.11](https://github.com/block/buzz/compare/desktop-v0.5.10...desktop-v0.5.11) + +## v0.5.10 + +### Desktop and shared changes + +- fix(desktop): remove 0.5.9+ perf regressions, speed up get_channels ([#5599](https://github.com/block/buzz/pull/5599)) ([`f35930104bcbdb1332ff13735214ecb9fce1fc7b`](https://github.com/block/buzz/commit/f35930104bcbdb1332ff13735214ecb9fce1fc7b)) +- perf(desktop): coalesce read state localStorage persistence ([#5591](https://github.com/block/buzz/pull/5591)) ([`9203bf60eea44875cafb36410252f8705ce54e2d`](https://github.com/block/buzz/commit/9203bf60eea44875cafb36410252f8705ce54e2d)) +- fix(desktop): bound initial timeline retention ([#5603](https://github.com/block/buzz/pull/5603)) ([`d9dc76c0aa7ab8a96b2ecf89325eef6b1536039d`](https://github.com/block/buzz/commit/d9dc76c0aa7ab8a96b2ecf89325eef6b1536039d)) +- Improve desktop search scoping ([#5306](https://github.com/block/buzz/pull/5306)) ([`cf03bd7c37cb3918afd4fe2a561360d01e11b68e`](https://github.com/block/buzz/commit/cf03bd7c37cb3918afd4fe2a561360d01e11b68e)) +- Add glass appearance and cohesive settings ([#5478](https://github.com/block/buzz/pull/5478)) ([`cd2aa5c12d1c802ea9d93c30809f3625c49e9bd4`](https://github.com/block/buzz/commit/cd2aa5c12d1c802ea9d93c30809f3625c49e9bd4)) +- Add Send to channel for thread messages ([#5305](https://github.com/block/buzz/pull/5305)) ([`b0795a10ea0f63f2382f4028a1adc2bc3e039d79`](https://github.com/block/buzz/commit/b0795a10ea0f63f2382f4028a1adc2bc3e039d79)) +- Fix macOS attachment picker lifecycle and allow inert HTML downloads ([#5569](https://github.com/block/buzz/pull/5569)) ([`bba3e06386b8a0ca22e9867dc81aac1ca2b1b737`](https://github.com/block/buzz/commit/bba3e06386b8a0ca22e9867dc81aac1ca2b1b737)) +- fix(desktop): preserve fresh channel timelines ([#5577](https://github.com/block/buzz/pull/5577)) ([`d3ec831e0cecbff347d55a236e34b27d79961503`](https://github.com/block/buzz/commit/d3ec831e0cecbff347d55a236e34b27d79961503)) +- fix(desktop): suppress fresh focus-return refetches for channels and home-feed ([#5535](https://github.com/block/buzz/pull/5535)) ([`49357244945c2f4b8432eb8b5cebbba5b1c30a08`](https://github.com/block/buzz/commit/49357244945c2f4b8432eb8b5cebbba5b1c30a08)) +- chore: mesh upgrade, clean up legacy special case code, simplify model selection for mesh ([#5289](https://github.com/block/buzz/pull/5289)) ([`240cdd3ea17a8f4d521c8398a929294210bd1e1a`](https://github.com/block/buzz/commit/240cdd3ea17a8f4d521c8398a929294210bd1e1a)) +- fix(desktop): preserve theme when opening communities ([#5266](https://github.com/block/buzz/pull/5266)) ([`83ca595adadae32238197d9c34a5895a34950968`](https://github.com/block/buzz/commit/83ca595adadae32238197d9c34a5895a34950968)) +- fix(link-preview): resolve YouTube videos through oEmbed ([#5520](https://github.com/block/buzz/pull/5520)) ([`7eb8cc5a5f03c454a84f2b5c4369819ba6d4d11b`](https://github.com/block/buzz/commit/7eb8cc5a5f03c454a84f2b5c4369819ba6d4d11b)) +- fix(buzz-agent): harden Databricks OAuth token cache and callback ([#5534](https://github.com/block/buzz/pull/5534)) ([`5e4d0fe92508fc5e0c812ff3edbe8877d86b8ec6`](https://github.com/block/buzz/commit/5e4d0fe92508fc5e0c812ff3edbe8877d86b8ec6)) +- fix(link-preview): reliably render previews sent right after they resolve ([#5245](https://github.com/block/buzz/pull/5245)) ([`be48ce98bd163899197b79a82ad5b2bcf0bc9b54`](https://github.com/block/buzz/commit/be48ce98bd163899197b79a82ad5b2bcf0bc9b54)) +- fix(link-preview): restore Buzz entity link cards ([#5494](https://github.com/block/buzz/pull/5494)) ([`7e6e9c547fa97abff6929cf2702b956586eec9bc`](https://github.com/block/buzz/commit/7e6e9c547fa97abff6929cf2702b956586eec9bc)) + +### Other repository changes + +- fix(relay): stop panicking the ingest worker on reactions to project events ([#5294](https://github.com/block/buzz/pull/5294)) ([`16b7ae7ce623a57be1461adee3b8fce4115b3c3a`](https://github.com/block/buzz/commit/16b7ae7ce623a57be1461adee3b8fce4115b3c3a)) +- fix(relay): log event kind on the HTTP bridge /events line ([#5291](https://github.com/block/buzz/pull/5291)) ([`e8153f8f27f5a35f56b2a578ab749190787d9e91`](https://github.com/block/buzz/commit/e8153f8f27f5a35f56b2a578ab749190787d9e91)) +- feat(tracing): add PostgreSQL tracing spans ([#3678](https://github.com/block/buzz/pull/3678)) ([`397796c5f343db4251198f44505b1afebe88223f`](https://github.com/block/buzz/commit/397796c5f343db4251198f44505b1afebe88223f)) + +[Compare desktop-v0.5.9...desktop-v0.5.10](https://github.com/block/buzz/compare/desktop-v0.5.9...desktop-v0.5.10) + +## v0.5.9 + +### Desktop and shared changes + +- Polish desktop onboarding flow ([#5310](https://github.com/block/buzz/pull/5310)) ([`3f2f32641f4093d087fd9506bfac1fa0329e8b2e`](https://github.com/block/buzz/commit/3f2f32641f4093d087fd9506bfac1fa0329e8b2e)) +- fix(desktop): quiesce renderer polling while hidden (#3677) ([#5490](https://github.com/block/buzz/pull/5490)) ([`07a3c768d619db31fee3f0590f9433cdd1213e8f`](https://github.com/block/buzz/commit/07a3c768d619db31fee3f0590f9433cdd1213e8f)) +- fix(channels): restore member invitations to private channels ([#5493](https://github.com/block/buzz/pull/5493)) ([`2777189d960fa5b1d863166f36d6e37ff8ce0819`](https://github.com/block/buzz/commit/2777189d960fa5b1d863166f36d6e37ff8ce0819)) +- fix(desktop): bound nine unbounded localStorage stores ([#5454](https://github.com/block/buzz/pull/5454)) ([`9c074bb89b290721f839bbc84fdf4701269e43a0`](https://github.com/block/buzz/commit/9c074bb89b290721f839bbc84fdf4701269e43a0)) +- feat(desktop): time-based sweep for stale localStorage caches ([#5453](https://github.com/block/buzz/pull/5453)) ([`bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead`](https://github.com/block/buzz/commit/bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead)) +- feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 ([#4000](https://github.com/block/buzz/pull/4000)) ([`5e4c05f90b062898e1827ba45cb826c6ff913741`](https://github.com/block/buzz/commit/5e4c05f90b062898e1827ba45cb826c6ff913741)) +- fix(desktop): resolve overlapping member mentions ([#5225](https://github.com/block/buzz/pull/5225)) ([`44456e200e3ca6a5d2882b58b447b80474041347`](https://github.com/block/buzz/commit/44456e200e3ca6a5d2882b58b447b80474041347)) +- chore(deps): update rust crate anyhow to v1.0.104 ([#4447](https://github.com/block/buzz/pull/4447)) ([`e1ff91ecc1269682a50c17da2c0708d1448b336f`](https://github.com/block/buzz/commit/e1ff91ecc1269682a50c17da2c0708d1448b336f)) +- fix(desktop): preserve Welcome banner dismissal ([#5406](https://github.com/block/buzz/pull/5406)) ([`97aa9e31856edb9d8abcdcb33c472027f5588890`](https://github.com/block/buzz/commit/97aa9e31856edb9d8abcdcb33c472027f5588890)) +- fix(agent): retry LLM completion on malformed 2xx JSON body ([#5351](https://github.com/block/buzz/pull/5351)) ([`5bf78671f45178f8de02ba18d3d321cbbf19cd1f`](https://github.com/block/buzz/commit/5bf78671f45178f8de02ba18d3d321cbbf19cd1f)) +- fix(desktop): welcome banner overlap and missing dismiss control ([#5330](https://github.com/block/buzz/pull/5330)) ([`f029deafae6ad3b63e13c29104f3be76122cb1df`](https://github.com/block/buzz/commit/f029deafae6ad3b63e13c29104f3be76122cb1df)) +- fix(desktop): prevent horizontal clipping in Prompt Context modal ([#5324](https://github.com/block/buzz/pull/5324)) ([`fbf89e3bed9adebc033a26b7c43362c004e816a2`](https://github.com/block/buzz/commit/fbf89e3bed9adebc033a26b7c43362c004e816a2)) +- fix(buzz-agent): recover from 400-shaped image rejections; unbound benchmark agent rounds ([#5318](https://github.com/block/buzz/pull/5318)) ([`261c46076166c6de5bb9a71fb4a0fd0b70aa1efa`](https://github.com/block/buzz/commit/261c46076166c6de5bb9a71fb4a0fd0b70aa1efa)) + +### Other repository changes + +- feat(cli): add --visibility flag to channels update ([#5119](https://github.com/block/buzz/pull/5119)) ([`f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b`](https://github.com/block/buzz/commit/f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b)) +- perf(ci): experiment with sccache for relay builds ([#5224](https://github.com/block/buzz/pull/5224)) ([`5a3b3d23226474f835a1cf41d2ecc5f53cacb070`](https://github.com/block/buzz/commit/5a3b3d23226474f835a1cf41d2ecc5f53cacb070)) +- ci(release): gate OSS desktop auto-update promotion ([#5398](https://github.com/block/buzz/pull/5398)) ([`43573d114b5bfaf7cefa75eee7e219dc05cf1cd1`](https://github.com/block/buzz/commit/43573d114b5bfaf7cefa75eee7e219dc05cf1cd1)) +- fix(release): pin desktop PR operations to block/buzz ([#5212](https://github.com/block/buzz/pull/5212)) ([`c1e20a814bf694db2af959adacb375ced27af023`](https://github.com/block/buzz/commit/c1e20a814bf694db2af959adacb375ced27af023)) +- fix(search): surface exact short profile names ([#5480](https://github.com/block/buzz/pull/5480)) ([`3c76f682c3c2dfe2cd296c277c5e63799d3424f9`](https://github.com/block/buzz/commit/3c76f682c3c2dfe2cd296c277c5e63799d3424f9)) +- Reduce repeated ACP session context ([#5423](https://github.com/block/buzz/pull/5423)) ([`563e4346da37d0fb2e9ec1c95e7f1eba79f83040`](https://github.com/block/buzz/commit/563e4346da37d0fb2e9ec1c95e7f1eba79f83040)) +- chore(deps): update react monorepo ([#4441](https://github.com/block/buzz/pull/4441)) ([`119a84897f225c1e3213a09cd149abb37dcb3abc`](https://github.com/block/buzz/commit/119a84897f225c1e3213a09cd149abb37dcb3abc)) +- ci(security): allow retired relay pool advisory ([#5404](https://github.com/block/buzz/pull/5404)) ([`d2ebaa95a7d2565fb217fdfae56bafb9509be444`](https://github.com/block/buzz/commit/d2ebaa95a7d2565fb217fdfae56bafb9509be444)) +- chore(deps): update dependency @tanstack/react-virtual to v3.14.9 ([#4439](https://github.com/block/buzz/pull/4439)) ([`c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888`](https://github.com/block/buzz/commit/c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888)) +- chore(deps): update all non-major dependencies ([#3049](https://github.com/block/buzz/pull/3049)) ([`856cdb848b0a849e33620887b145b7e598dfd95c`](https://github.com/block/buzz/commit/856cdb848b0a849e33620887b145b7e598dfd95c)) +- chore(deps): update rust crate arc-swap to v1.9.2 ([#4448](https://github.com/block/buzz/pull/4448)) ([`08de85c592106ea2ffe22ba16e3a0fc10687db54`](https://github.com/block/buzz/commit/08de85c592106ea2ffe22ba16e3a0fc10687db54)) +- chore(deps): update rust crate async-trait to v0.1.91 ([#4458](https://github.com/block/buzz/pull/4458)) ([`12b1f566480d4feddc171739097f9359d3f255c1`](https://github.com/block/buzz/commit/12b1f566480d4feddc171739097f9359d3f255c1)) +- chore(deps): update rust crate diffy to v0.5.1 ([#4466](https://github.com/block/buzz/pull/4466)) ([`d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4`](https://github.com/block/buzz/commit/d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4)) +- chore(deps): update rust crate async-compression to v0.4.43 ([#4456](https://github.com/block/buzz/pull/4456)) ([`7dd8791d0765e9f15fed3299b6948e2babbfd763`](https://github.com/block/buzz/commit/7dd8791d0765e9f15fed3299b6948e2babbfd763)) +- chore(deps): update rust crate clap to v4.6.6 ([#4465](https://github.com/block/buzz/pull/4465)) ([`e668c6bb4913e36e58d7f947dbaf982e704e9132`](https://github.com/block/buzz/commit/e668c6bb4913e36e58d7f947dbaf982e704e9132)) +- chore(release): release Buzz Relay version 0.2.1 ([#2856](https://github.com/block/buzz/pull/2856)) ([`6e5c462ac524de60d7edb46c66130fd779cc9006`](https://github.com/block/buzz/commit/6e5c462ac524de60d7edb46c66130fd779cc9006)) + +[Compare desktop-v0.5.8...desktop-v0.5.9](https://github.com/block/buzz/compare/desktop-v0.5.8...desktop-v0.5.9) + ## v0.5.8 ### Desktop and shared changes diff --git a/Cargo.lock b/Cargo.lock index f7c42e625a7..6c46beedf2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,9 +133,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "appattest" @@ -172,9 +172,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -235,9 +235,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -329,13 +329,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -352,20 +352,21 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +checksum = "2c713e1f14c7b82e32ea159af1c6e2f070cfadbdf23fb2512acce9af0a26f1a2" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", "tokio", + "tokio-happy-eyeballs", "tokio-rustls", "tokio-socks", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.28.0", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -398,12 +399,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -597,6 +592,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "beef" version = "0.5.2" @@ -609,7 +610,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "serde", "unicode-normalization", ] @@ -644,6 +645,21 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + [[package]] name = "bitcoin-io" version = "0.1.4" @@ -657,7 +673,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals", + "hex-conservative 1.2.0", "serde", ] @@ -812,7 +840,7 @@ dependencies = [ "hex", "httparse", "nix 0.31.3", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "rustls", "serde", @@ -838,6 +866,7 @@ dependencies = [ "buzz-auth", "buzz-core", "buzz-db", + "buzz-deletion", "buzz-media", "buzz-pubsub", "buzz-search", @@ -845,13 +874,14 @@ dependencies = [ "clap", "deadpool-redis", "hex", - "nostr", + "nostr 0.44.7", "rustls", "serde_json", "sqlx", "tokio", "tracing", "url", + "uuid", ] [[package]] @@ -876,6 +906,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "url", "urlencoding", "webbrowser", ] @@ -885,6 +916,7 @@ name = "buzz-audit" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-datastore-tracing", "chrono", "futures-util", "hex", @@ -904,7 +936,7 @@ version = "0.1.0" dependencies = [ "buzz-core", "hex", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "serde", "serde_json", @@ -926,7 +958,7 @@ dependencies = [ "http-body-util", "k8s-openapi", "kube", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "rustls", "serde", @@ -953,7 +985,7 @@ dependencies = [ "dirs", "hex", "infer", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", "rustls", @@ -986,7 +1018,7 @@ dependencies = [ "chrono", "hex", "hmac 0.13.0", - "nostr", + "nostr 0.44.7", "percent-encoding", "rand 0.10.1", "serde", @@ -999,16 +1031,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "buzz-datastore-tracing" +version = "0.1.0" +dependencies = [ + "opentelemetry 0.32.0", + "opentelemetry_sdk 0.32.1", + "proc-macro2", + "quote", + "syn 2.0.117", + "tokio", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + [[package]] name = "buzz-db" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-datastore-tracing", "chrono", "hex", "metrics", "metrics-util", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "serde", "serde_json", @@ -1020,6 +1068,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-deletion" +version = "0.1.0" +dependencies = [ + "anyhow", + "buzz-core", + "buzz-db", + "buzz-media", + "chrono", + "clap", + "deadpool-redis", + "hex", + "redis", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "uuid", +] + [[package]] name = "buzz-dev-mcp" version = "0.1.0" @@ -1032,7 +1102,7 @@ dependencies = [ "ignore", "image", "nix 0.31.3", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "rmcp", "rustls", @@ -1065,7 +1135,7 @@ dependencies = [ "imagesize", "infer", "mp4", - "nostr", + "nostr 0.44.7", "rust-s3", "serde", "serde_json", @@ -1104,7 +1174,7 @@ dependencies = [ "clap", "futures-util", "hex", - "nostr", + "nostr 0.44.7", "serde_json", "thiserror 2.0.18", "tokio", @@ -1133,7 +1203,7 @@ dependencies = [ "chrono", "deadpool-redis", "futures-util", - "nostr", + "nostr 0.44.7", "redis", "serde", "serde_json", @@ -1159,7 +1229,7 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "minicbor", - "nostr", + "nostr 0.44.7", "p256", "proptest", "rand 0.10.1", @@ -1191,7 +1261,9 @@ dependencies = [ "buzz-auth", "buzz-conformance", "buzz-core", + "buzz-datastore-tracing", "buzz-db", + "buzz-deletion", "buzz-media", "buzz-pubsub", "buzz-relay-mesh", @@ -1216,7 +1288,7 @@ dependencies = [ "metrics-exporter-prometheus", "metrics-util", "moka", - "nostr", + "nostr 0.44.7", "opentelemetry 0.32.0", "opentelemetry-otlp 0.32.0", "opentelemetry_sdk 0.32.1", @@ -1257,7 +1329,7 @@ dependencies = [ "hex", "hmac 0.13.0", "iroh", - "nostr", + "nostr 0.44.7", "postcard", "proptest", "redis", @@ -1275,7 +1347,7 @@ name = "buzz-sdk" version = "0.1.0" dependencies = [ "buzz-core", - "nostr", + "nostr 0.44.7", "serde", "serde_json", "thiserror 2.0.18", @@ -1287,9 +1359,11 @@ name = "buzz-search" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-datastore-tracing", "sqlx", "thiserror 2.0.18", "tokio", + "tracing", "uuid", ] @@ -1306,7 +1380,7 @@ dependencies = [ "chrono", "futures-util", "hex", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", "rust-s3", @@ -1349,13 +1423,14 @@ version = "0.1.0" dependencies = [ "buzz-core", "buzz-db", + "buzz-deletion", "chrono", "cron", "dashmap", "evalexpr", "hex", "moka", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "serde", "serde_json", @@ -1371,7 +1446,7 @@ name = "buzz-ws-client" version = "0.1.0" dependencies = [ "futures-util", - "nostr", + "nostr 0.44.7", "serde_json", "thiserror 2.0.18", "tokio", @@ -1557,9 +1632,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -1567,9 +1642,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1579,14 +1654,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1637,7 +1712,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1819,7 +1894,7 @@ dependencies = [ "anyhow", "buzz-sdk", "futures-util", - "nostr", + "nostr 0.44.7", "serde_json", "tokio", "tokio-tungstenite 0.29.0", @@ -2070,43 +2145,16 @@ dependencies = [ "phf", ] -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - [[package]] name = "ctor" -version = "0.6.3" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "ctr" version = "0.9.2" @@ -2285,7 +2333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -2448,9 +2496,9 @@ checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" [[package]] name = "diffy" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05264ab2aab4fb952fc4b0f3f6eff1ddfb4563064053a4ea174d91537584a769" +checksum = "10aec8f7f9393bd6a4f2762be0ceb012d3cbe2478987258cc9960de148561914" dependencies = [ "hashbrown 0.17.1", ] @@ -2496,7 +2544,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2557,21 +2605,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -2734,7 +2767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2833,6 +2866,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3140,7 +3183,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", + "windows-link 0.1.3", "windows-result 0.4.1", ] @@ -3219,7 +3262,7 @@ name = "git-credential-nostr" version = "0.1.0" dependencies = [ "base64 0.22.1", - "nostr", + "nostr 0.44.7", "serde_json", "zeroize", ] @@ -3232,7 +3275,7 @@ dependencies = [ "chrono", "hex", "libc", - "nostr", + "nostr 0.44.7", "serde_json", "zeroize", ] @@ -3312,6 +3355,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbag" version = "0.1.13" @@ -3370,6 +3422,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3398,45 +3460,31 @@ dependencies = [ ] [[package]] -name = "hf-hub" -version = "1.0.0-rc.1" +name = "hex-conservative" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", + "arrayvec", ] [[package]] name = "hf-xet" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" +checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" dependencies = [ + "anyhow", "async-trait", "bytes", "http", "more-asserts", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "uuid", "xet-client", @@ -4174,7 +4222,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.0", + "lru", "n0-error", "n0-future", "noq", @@ -4544,6 +4592,18 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "link-section" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4629,12 +4689,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - [[package]] name = "lru" version = "0.18.0" @@ -4781,8 +4835,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "mesh-llm-client", @@ -4791,8 +4845,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4802,13 +4856,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-llm-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -4839,8 +4893,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -4855,8 +4909,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4865,8 +4919,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "clap", @@ -4877,12 +4931,9 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ - "anyhow", - "cc", - "libc", "serde", "serde_json", "tracing", @@ -4890,8 +4941,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -4899,16 +4950,45 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "mesh-llm-native-runtime", ] +[[package]] +name = "mesh-llm-hf-hub" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43088a838cf0c6715c65f65a5ac99045fd6d6e90949a8a4183b8104ab791e96b" +dependencies = [ + "base64 0.22.1", + "bon", + "bytes", + "futures", + "getrandom 0.2.17", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + [[package]] name = "mesh-llm-host-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "argon2", @@ -4926,7 +5006,6 @@ dependencies = [ "flate2", "futures-util", "hex", - "hf-hub", "http", "http-body-util", "httparse", @@ -4941,6 +5020,7 @@ dependencies = [ "mesh-llm-config", "mesh-llm-events", "mesh-llm-guardrails", + "mesh-llm-hf-hub", "mesh-llm-identity", "mesh-llm-native-runtime", "mesh-llm-node", @@ -4953,6 +5033,7 @@ dependencies = [ "mesh-llm-types", "mesh-llm-ui", "mesh-mixture-of-agents", + "mesh-native-serving-plugin-host", "model-artifact", "model-hf", "model-package", @@ -5000,8 +5081,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "argon2", "base64 0.22.1", @@ -5022,8 +5103,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "serde", @@ -5033,8 +5114,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-types", @@ -5047,8 +5128,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5064,8 +5145,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5083,8 +5164,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "hex", @@ -5094,18 +5175,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "mesh-llm-release-footer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "hex", + "sha2 0.10.9", +] + [[package]] name = "mesh-llm-routing" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5127,8 +5217,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5142,8 +5232,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5153,8 +5243,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "chrono", @@ -5162,8 +5252,12 @@ dependencies = [ "dirs", "hex", "libc", + "libloading", "mesh-llm-build-info", "mesh-llm-gpu-bench", + "mesh-llm-native-runtime", + "mesh-llm-release-footer", + "mesh-llm-runtime-install", "reqwest 0.12.28", "semver", "serde", @@ -5176,8 +5270,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "serde", @@ -5187,13 +5281,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-mixture-of-agents" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5204,6 +5298,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "mesh-native-serving-plugin-api" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" + +[[package]] +name = "mesh-native-serving-plugin-host" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "anyhow", + "libloading", + "mesh-native-serving-plugin-api", + "skippy-server", +] + [[package]] name = "metrics" version = "0.24.6" @@ -5341,8 +5451,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5352,14 +5462,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", - "hf-hub", + "mesh-llm-hf-hub", "model-artifact", "model-ref", "serde", @@ -5370,14 +5480,14 @@ dependencies = [ [[package]] name = "model-package" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "bytes", "chrono", "futures", - "hf-hub", + "mesh-llm-hf-hub", "model-hf", "model-ref", "reqwest 0.12.28", @@ -5390,16 +5500,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "model-artifact", @@ -5777,6 +5887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", + "aws-lc-rs", "bytes", "derive_more", "enum-assoc", @@ -5817,9 +5928,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.11.1", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -5835,56 +5946,71 @@ dependencies = [ ] [[package]] -name = "nostr-database" -version = "0.44.0" +name = "nostr" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +checksum = "5dde8c76076d334409d86c2e1db3e97abe5deb8cb92744f939cbc1fa45bd69e7" dependencies = [ - "lru 0.16.4", - "nostr", - "tokio", + "base64 0.22.1", + "bech32 0.12.0", + "bip39", + "bitcoin_hashes 1.2.0", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "faster-hex", + "opaquerr", + "rand 0.10.1", + "secp256k1 0.30.0", + "serde", + "serde_json", + "unicode-normalization", + "universal-time", + "url", + "zeroize", ] [[package]] -name = "nostr-gossip" -version = "0.44.0" +name = "nostr-database" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf" dependencies = [ - "nostr", + "nostr 0.45.1", + "opaquerr", ] [[package]] -name = "nostr-relay-pool" -version = "0.44.3" +name = "nostr-gossip" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336" dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru 0.16.4", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", + "nostr 0.45.1", + "opaquerr", ] [[package]] name = "nostr-sdk" -version = "0.44.1" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +checksum = "26c86342f367bd9b173ec4a697e936e3a82d6dad5b4aa06c0d35d9b4f88a8e72" dependencies = [ "async-utility", - "nostr", + "async-wsocket", + "faster-hex", + "futures", + "lru", + "negentropy", + "nostr 0.45.1", "nostr-database", "nostr-gossip", - "nostr-relay-pool", + "opaquerr", + "rand 0.10.1", "tokio", + "tokio-stream", "tracing", + "universal-time", ] [[package]] @@ -5902,7 +6028,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6046,6 +6172,17 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -6165,10 +6302,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaquerr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" + [[package]] name = "openai-frontend" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "axum", @@ -7358,7 +7501,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7532,7 +7675,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools", "kasuari", - "lru 0.18.0", + "lru", "palette", "serde", "strum", @@ -8042,7 +8185,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8101,7 +8244,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8273,13 +8416,24 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.8.6", + "secp256k1-sys 0.10.1", +] + [[package]] name = "secp256k1" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "rand 0.9.4", "secp256k1-sys 0.11.0", ] @@ -8373,7 +8527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8577,7 +8731,6 @@ dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", - "sha2-asm", ] [[package]] @@ -8591,15 +8744,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "sha2-asm" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" -dependencies = [ - "cc", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -8743,8 +8887,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "blake3", @@ -8753,40 +8897,41 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "skippy-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "prost 0.14.3", "prost-build 0.14.3", "protoc-bin-vendored", "serde", + "skippy-tokenizer", ] [[package]] name = "skippy-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "libc", @@ -8799,8 +8944,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "ahash", "anyhow", @@ -8811,6 +8956,8 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-native-serving-plugin-api", + "model-artifact", "openai-frontend", "opentelemetry-proto 0.31.0", "serde", @@ -8820,16 +8967,25 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-tokenizer", "socket2", "tokio", "tokio-stream", "tonic", ] +[[package]] +name = "skippy-tokenizer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "serde", +] + [[package]] name = "skippy-topology" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -9380,6 +9536,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -9485,7 +9652,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9731,6 +9898,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-happy-eyeballs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8564c32dfb6f4257f8bc6edfc178a34af97520e0b7b9815500c55eb3d092f29f" +dependencies = [ + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -9799,9 +9975,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -9809,7 +9985,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.26.2", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -9849,6 +10025,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ + "aws-lc-rs", "base64 0.22.1", "bytes", "futures-core", @@ -9866,6 +10043,30 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -10167,9 +10368,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -10346,6 +10547,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -10652,15 +10859,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "core-foundation 0.10.1", "jni 0.22.4", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys", @@ -10812,7 +11019,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11359,20 +11566,18 @@ dependencies = [ [[package]] name = "xet-client" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" +checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bytes", - "clap", "crc32fast", "futures", "http", "hyper", - "lazy_static", "more-asserts", "rand 0.10.1", "redb", @@ -11386,8 +11591,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-retry", + "tokio_with_wasm", "tracing", - "tracing-subscriber", "url", "urlencoding", "web-time", @@ -11397,24 +11602,21 @@ dependencies = [ [[package]] name = "xet-core-structures" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" +checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" dependencies = [ "async-trait", "base64 0.22.1", "blake3", "bytemuck", "bytes", - "clap", "countio", - "csv", "futures", "futures-util", "getrandom 0.4.3", "heapify", "itertools", - "lazy_static", "lz4_flex", "more-asserts", "rand 0.10.1", @@ -11422,7 +11624,6 @@ dependencies = [ "safe-transmute", "serde", "static_assertions", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", @@ -11434,32 +11635,31 @@ dependencies = [ [[package]] name = "xet-data" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" +checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" dependencies = [ "anyhow", "async-trait", "bytes", "chrono", - "clap", "gearhash", "http", "itertools", - "lazy_static", "more-asserts", "rand 0.10.1", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "url", "uuid", - "walkdir", + "web-time", "xet-client", "xet-core-structures", "xet-runtime", @@ -11467,9 +11667,9 @@ dependencies = [ [[package]] name = "xet-runtime" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" +checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" dependencies = [ "anyhow", "async-trait", @@ -11483,7 +11683,6 @@ dependencies = [ "git-version", "humantime", "konst", - "lazy_static", "libc", "more-asserts", "oneshot", @@ -11497,9 +11696,11 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "tracing-appender", "tracing-subscriber", + "web-time", "whoami", "winapi", ] diff --git a/Cargo.toml b/Cargo.toml index cc1dd0f9dff..78816ff4827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/buzz-test-client", "crates/buzz-ws-client", "crates/buzz-admin", + "crates/buzz-deletion", "crates/buzz-workflow", "crates/buzz-media", "crates/buzz-cli", @@ -28,6 +29,7 @@ members = [ "crates/buzz-dev-mcp", "crates/buzz-voice", "crates/buzz-backend-kubernetes", + "crates/buzz-datastore-tracing", "examples/countdown-bot", ] exclude = ["desktop/src-tauri"] @@ -134,6 +136,7 @@ schemars = { version = "1", default-features = false } buzz-core = { path = "crates/buzz-core" } buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } +buzz-deletion = { path = "crates/buzz-deletion" } buzz-auth = { path = "crates/buzz-auth" } buzz-pubsub = { path = "crates/buzz-pubsub" } buzz-search = { path = "crates/buzz-search" } @@ -143,13 +146,14 @@ buzz-media = { path = "crates/buzz-media" } buzz-sdk = { path = "crates/buzz-sdk" } buzz-ws-client = { path = "crates/buzz-ws-client" } buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } +buzz-datastore-tracing = { path = "crates/buzz-datastore-tracing" } # CI profile — builds the relay for desktop e2e. Dependencies keep full # release optimization (warm from main's cache; they carry the runtime hot # path: tokio/sqlx/axum). Workspace crates build at opt-level 1 — enough for # stable e2e timing (PR #307 flakiness was opt-0 + debug-assertions) at -# roughly half the codegen cost. `incremental` is irrelevant in CI: -# rust-cache exports CARGO_INCREMENTAL=0 and never caches member artifacts. +# roughly half the codegen cost. Incremental stays disabled: rust-cache exports +# CARGO_INCREMENTAL=0, and the CI compiler cache requires non-incremental units. [profile.ci] inherits = "release" lto = false diff --git a/Justfile b/Justfile index 0a43249d5fc..9e471784275 100644 --- a/Justfile +++ b/Justfile @@ -276,6 +276,10 @@ desktop-e2e-smoke: desktop-e2e-integration: _ensure-migrations cd {{desktop_dir}} && pnpm test:e2e:integration +# Run the deterministic desktop correctness smoke against an isolated local relay +desktop-release-smoke: + ./scripts/run-desktop-release-smoke.sh + # Run only the e2e specs changed vs origin/main (both projects) before pushing desktop-e2e-pre-push: _ensure-migrations git fetch origin main @@ -355,7 +359,6 @@ mesh-dev-fresh: mesh-e2e-hardware: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo run -p buzz-relay --example mesh_serve_client_smoke # Three isolated node processes: trusted member joins and infers; stranger is rejected. @@ -363,14 +366,12 @@ mesh-e2e-hardware: mesh-e2e-admission: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo run -p buzz-relay --example mesh_admission_smoke # Full hardware confidence suite: routing, owner admission, and real agent inference. mesh-e2e-confidence: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo build --release -p buzz-agent -p buzz-dev-mcp cargo run -p buzz-relay --example mesh_serve_client_smoke cargo run -p buzz-relay --example mesh_admission_smoke @@ -457,9 +458,6 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations done fi cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay - if [[ -n "{{mesh}}" ]]; then - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" - fi # Docker Desktop's forwarded MinIO port can stall under the deployment # probe's 32 concurrent writers. Keep the gate enabled in local dev, using # the bounded profile already used by the relay test launcher. @@ -536,7 +534,6 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the @@ -572,7 +569,6 @@ production *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the diff --git a/RELEASING.md b/RELEASING.md index 53d58055619..8d1fad74807 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -173,10 +173,10 @@ for distributable builds or builds from an immutable release tag. `release.yml` has no manual dispatch and cannot build from `main` or another caller-selected ref. If a run for an existing immutable `desktop-v` tag fails, rerun that failed workflow from GitHub Actions -(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also -repairs `buzz-desktop-latest/latest.json` if the original run published the -versioned release but failed during that final rolling-manifest upload. Do not -recreate, move, or push the immutable tag again. +(or use `gh run rerun --failed --repo block/buzz`). A rerun +repairs the versioned draft if publication did not complete. It does not +promote that version to the auto-updater; promotion is a separate manual +action. Do not recreate, move, or push the immutable tag again. Mobile intentionally has no branch or arbitrary-ref fallback. The private Buildkite pipeline accepts only an exact candidate tag. @@ -200,8 +200,25 @@ for the rest of the private pipeline contract. Desktop publishes two GitHub releases: -1. **`desktop-v`**: the user-facing release with installers. -2. **`buzz-desktop-latest`**: the rolling auto-updater release. +1. **`desktop-v`**: the user-facing release with installers and the + exact `updater-manifest.json` promotion candidate. Publishing this release + does not expose it through in-app auto-update. +2. **`buzz-desktop-latest`**: the rolling auto-updater release. Its + `latest.json` changes only through the manual promotion workflow. + +### Promote an OSS desktop release to auto-update + +After installing and testing the published `desktop-v` artifacts, run +**Promote OSS Desktop Auto-Update** from the `main` branch and enter the exact +stable `X.Y.Z` version. The workflow validates the immutable tag and release, +the retained manifest and every referenced updater asset, and requires the +version to be newer than the currently promoted version before replacing +`buzz-desktop-latest/latest.json`. Same-version retries succeed only when the +manifest is identical; downgrades are rejected. + +Withholding promotion leaves existing clients on the previous version. If a +promoted release is bad, ship and promote a higher patch version; changing the +manifest to an older version does not downgrade clients that already updated. Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts and rollout records retain the exact tag they used. Mobile does not publish a diff --git a/TESTING.md b/TESTING.md index 7c107da5754..29d07a80de0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -155,9 +155,49 @@ buzz messages thread --channel "$CHANNEL" --event "$EVENT_ID" | jq . A successful run prints `{"event_id":"…","accepted":true,"message":""}` for the send, and the message body in the `get` output. `thread` returns `[]` -for a leaf message — populated only after a reply comes in (see §5). +for a leaf message — populated only after a reply comes in (see §6). -### 5. Going deeper +### 5. Verify a roster beyond 1,000 members + +Use the focused live-relay script when changing channel membership, discovery, +or reconciliation. It proves the three boundaries that DB-only tests cannot: +the relay-served kind 39002 includes a member at roster position 1,501, that +identity can publish a channel message, and targeted reconciliation preserves +its discoverability. + +Run this only against an isolated local database. The script inserts fixture +members directly, then drives discovery and messaging through the release CLI +and relay. Keep the release relay from step 3 running and use its configured +relay key for authoritative replacement: + +```bash +export PATH="$PWD/target/release:$PATH" +export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz_roster_e2e" +export BUZZ_RELAY_URL="http://localhost:3030" # match the relay from step 3 +export RELAY_URL="ws://localhost:3030" +export BUZZ_RELAY_PRIVATE_KEY="" + +scripts/e2e-large-channel-roster.sh +``` + +Success is directly observable as four `PASS` lines. The first and fourth +include a member count greater than 1,000 and the same late-member pubkey; the +second includes the accepted kind 9 event ID, and the third proves targeted +repair left kind 39000/39001 IDs and tags unchanged: + +```text +PASS discovery-before-republish channel= members=1502 late_pubkey= +PASS late-member-action event_id= +PASS targeted-repair-preserves-metadata-and-admin-events channel= +PASS discovery-after-republish channel= members=1502 late_pubkey= +``` + +The script refuses debug binaries and refuses a `buzz` or `buzz-admin` resolved +outside this checkout's `target/release`. It also requires the targeted admin +operation to use `BUZZ_RELAY_PRIVATE_KEY`; never substitute an ephemeral signer +for an authoritative replacement. + +### 6. Going deeper For full coverage of every CLI command (54 subcommands across 12 groups), follow [`crates/buzz-cli/TESTING.md`](crates/buzz-cli/TESTING.md). diff --git a/VISION.md b/VISION.md index 66a106bdebc..900e5a9475b 100644 --- a/VISION.md +++ b/VISION.md @@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate. | Type | Visibility | Join | Create | |------|-----------|------|--------| | **Open channels** | Searchable by all members | Self-join | Any member | -| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member | +| **Private channels** | Hidden, invite-only | Invited by member | Any member | | **DMs** | Participants only | N/A (up to 9) | Any member | | **Guests** | Scoped to specific channels | Invited | N/A | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 700d5e8dcfd..f04b8eeec0d 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -14,7 +14,9 @@ use tokio::process::{Child, ChildStdin, ChildStdout}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; use crate::observer::{ObserverContext, ObserverHandle}; -use crate::usage::{TurnUsage, UsageTracker}; +use crate::usage::{ + PromptResponseUsage, StandardAdapterKind, StandardUsageTracker, TurnUsage, UsageTracker, +}; /// Maximum allowed size of a single NDJSON line from the agent's stdout. /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. @@ -206,11 +208,12 @@ pub struct AcpClient { /// outside of a goose-native turn — the read loop's steer arm is /// disabled in that case. steer_rx: Option>, - /// Usage tracker — accumulates cumulative token counts from - /// `_goose/unstable/session/update` notifications and computes per-turn - /// deltas. Both goose and buzz-agent emit this notification; goose gates - /// on client capability advertisement, buzz-agent emits unconditionally. + /// Usage tracker for goose/buzz-agent's cumulative notification format. goose_usage: UsageTracker, + /// Per-turn prompt-response usage and Claude's optional cumulative cost. + standard_usage: StandardUsageTracker, + /// Known adapter identity for prompt-response usage mapping. + standard_adapter: Option, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -523,6 +526,14 @@ impl AcpClient { // console-subsystem child process spawned from a GUI/non-console parent. configure_no_window(&mut cmd); + let standard_adapter = + match crate::config::normalize_agent_command_identity(command).as_str() { + "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" => { + Some(StandardAdapterKind::Claude) + } + "codex" | "codex-acp" => Some(StandardAdapterKind::Codex), + _ => None, + }; let mut child = cmd.spawn()?; let stdin = child @@ -550,6 +561,8 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + standard_usage: StandardUsageTracker::default(), + standard_adapter, }) } @@ -776,6 +789,7 @@ impl AcpClient { // prompt so that any setup notifications recorded earlier are not // misattributed to this turn. self.goose_usage.begin_turn(session_id); + self.standard_usage.begin_turn(session_id); self.last_prompt_id = Some(self.next_id); let id = self.next_id; @@ -821,7 +835,7 @@ impl AcpClient { self.current_hard_deadline = None; } } - self.parse_stop_reason(&result?) + self.parse_prompt_response(session_id, &result?) } /// Send a `session/cancel` **notification** (no `id` field, no response expected). @@ -867,18 +881,24 @@ impl AcpClient { self.steering_supported } - /// Consume and return the per-turn usage record computed from the most - /// recent `_goose/unstable/session/update` notification. - /// - /// Returns `None` if no usage update arrived since the last call (i.e. - /// the harness did not emit one for this turn, or this is not a goose - /// agent). Must be called at most once per turn; subsequent calls return - /// `None` until the next `usage_update` notification is recorded. - /// - /// Intended for consumption by `publish_agent_turn_metric` in `pool.rs` to - /// publish a kind 44200 NIP-AM event. + /// Consume per-turn usage for NIP-AM publishing. Goose/buzz-agent is an + /// exclusive cumulative path; standard ACP prompt usage is used only when + /// goose emitted nothing for this turn. pub fn take_turn_usage(&mut self) -> Option { - self.goose_usage.take() + let goose_usage = self.goose_usage.take(); + let standard_usage = self.standard_usage.take(); + goose_usage.or(standard_usage) + } + + /// Notify the usage tracker that buzz-acp just spawned a new session. + /// + /// Seeds a zero baseline so the first usage notification for `session_id` + /// produces `delta_reliable: true` (turn delta == cumulative from zero). + /// Must be called only when buzz-acp created the session via `session/new`; + /// never when attaching to a pre-existing session. + pub(crate) fn notify_session_spawned(&mut self, session_id: &str) { + self.goose_usage.seed_zero_baseline(session_id); + self.standard_usage.seed_zero_baseline(session_id); } /// Install a per-turn steer request channel for goose-native @@ -1038,7 +1058,7 @@ impl AcpClient { remaining, ) .await?; - self.parse_stop_reason(&result) + self.parse_prompt_response(session_id, &result) } /// Serialize `value` as a single NDJSON line and flush to the agent's stdin. @@ -1607,7 +1627,9 @@ impl AcpClient { "steer accepted as {STEER_OUTCOME_STARTED_NEW_TURN}: \ awaited turn had ended — hard deadline not renewed" ); - crate::pool::SteerAck::Success + crate::pool::SteerAck::Success { + session_id: session_id.to_owned(), + } } Some(_) => { let renew_now = Instant::now(); @@ -1619,7 +1641,9 @@ impl AcpClient { "steer success: renewed hard deadline ({max_duration:?} from now)" ); } - crate::pool::SteerAck::Success + crate::pool::SteerAck::Success { + session_id: session_id.to_owned(), + } } None => { // Report the raw string when @@ -1817,6 +1841,10 @@ impl AcpClient { } false } + "usage_update" => { + self.handle_standard_usage_update(msg); + false + } "keepalive" => false, other => { tracing::debug!(target: "acp::update", "session/update: {other}"); @@ -1825,6 +1853,30 @@ impl AcpClient { } } + /// Record the standard ACP cumulative cost notification when emitted by + /// Claude. Unlike Goose's payload, `used`/`size` are context occupancy and + /// are intentionally not mapped to token accounting. + fn handle_standard_usage_update(&mut self, msg: &serde_json::Value) { + if self.standard_adapter != Some(StandardAdapterKind::Claude) { + return; + } + let session_id = match msg + .pointer("/params/sessionId") + .and_then(serde_json::Value::as_str) + { + Some(session_id) => session_id, + None => return, + }; + let cost = match msg + .pointer("/params/update/cost/amount") + .and_then(serde_json::Value::as_f64) + { + Some(cost) => cost, + None => return, + }; + self.standard_usage.record_cost(session_id, cost); + } + /// Parse a `_goose/unstable/session/update` notification and record the /// usage snapshot in the per-session tracker. /// @@ -1849,8 +1901,8 @@ impl AcpClient { tracing::debug!( target: "acp::usage", session_id = %notif.session_id, - input = payload.accumulated_input_tokens, - output = payload.accumulated_output_tokens, + input = ?payload.accumulated_input_tokens, + output = ?payload.accumulated_output_tokens, // A subset of `input`, logged so downstream accounting can // price it at the provider's cached rate. Always emitted, // including as 0, so a parser can tell "no cache hits" @@ -1956,6 +2008,28 @@ impl AcpClient { Ok(()) } + /// Parse a completed prompt response and retain its optional per-turn usage. + fn parse_prompt_response( + &mut self, + session_id: &str, + result: &serde_json::Value, + ) -> Result { + let stop_reason = self.parse_stop_reason(result)?; + if let Some(adapter) = self.standard_adapter { + match serde_json::from_value::(result["usage"].clone()) { + Ok(usage) => self + .standard_usage + .record_prompt_usage(session_id, usage, adapter), + Err(_) if result.get("usage").is_some() => tracing::debug!( + target: "acp::usage", + "session/prompt response contained malformed standard usage" + ), + Err(_) => {} + } + } + Ok(stop_reason) + } + /// Parse `stopReason` from a `session/prompt` result value. fn parse_stop_reason(&self, result: &serde_json::Value) -> Result { let raw = result["stopReason"].as_str().ok_or_else(|| { @@ -2885,6 +2959,30 @@ mod tests { .expect("failed to spawn test script") } + #[cfg(unix)] + async fn spawn_named_script(name: &str, script: &str) -> (AcpClient, std::path::PathBuf) { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!( + "buzz-acp-{name}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).expect("create temp adapter dir"); + let path = dir.join(name); + std::fs::write(&path, format!("#!/usr/bin/env bash\n{script}\n")) + .expect("write fake adapter"); + let mut permissions = std::fs::metadata(&path) + .expect("adapter metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("chmod fake adapter"); + let client = AcpClient::spawn(path.to_str().expect("utf8 path"), &[], &[], false) + .await + .expect("spawn named fake adapter"); + (client, dir) + } + /// Spawn a probe script whose file name carries a runtime identity (e.g. /// `hermes-acp`) and return the value of `var` as the child observed it. /// `` means the child did not receive the var. @@ -3789,7 +3887,7 @@ mod tests { .await .expect("ack oneshot must have received a SteerAck"); match ack { - crate::pool::SteerAck::Success => {} + crate::pool::SteerAck::Success { .. } => {} other => panic!("expected SteerAck::Success, got {other:?}"), } } @@ -3850,7 +3948,7 @@ mod tests { .await .expect("ack oneshot must have received a SteerAck"); match ack { - crate::pool::SteerAck::Success => {} + crate::pool::SteerAck::Success { .. } => {} other => panic!("expected SteerAck::Success, got {other:?}"), } } @@ -4034,7 +4132,7 @@ mod tests { "_session/steering must not carry expectedRunId; wrote: {written}" ); assert!( - matches!(ack, crate::pool::SteerAck::Success), + matches!(ack, crate::pool::SteerAck::Success { .. }), "injected outcome must ack Success, got {ack:?}" ); } @@ -4066,7 +4164,7 @@ mod tests { // no `outcome`) — the OutcomeRejected guard applies only to // `_session/steering`. assert!( - matches!(ack, crate::pool::SteerAck::Success), + matches!(ack, crate::pool::SteerAck::Success { .. }), "goose success result must ack Success, got {ack:?}" ); } @@ -4171,7 +4269,7 @@ mod tests { assert_eq!(result.unwrap()["done"], serde_json::json!(true)); let ack = ack_rx.await.expect("ack must be received"); assert!( - matches!(ack, crate::pool::SteerAck::Success), + matches!(ack, crate::pool::SteerAck::Success { .. }), "injected must ack Success, got {ack:?}" ); } @@ -4228,7 +4326,7 @@ mod tests { // rather than released — hence Success, not an Err. let ack = ack_rx.await.expect("ack must be received"); assert!( - matches!(ack, crate::pool::SteerAck::Success), + matches!(ack, crate::pool::SteerAck::Success { .. }), "startedNewTurn is a delivery success, got {ack:?}" ); } @@ -4261,6 +4359,254 @@ mod tests { } } + // ── Standard ACP prompt-response usage ───────────────────────────────── + + fn prompt_response_usage( + input: u64, + output: u64, + total: u64, + cached_read: Option, + cached_write: Option, + ) -> serde_json::Value { + let mut usage = serde_json::json!({ + "inputTokens": input, + "outputTokens": output, + "totalTokens": total, + }); + if let Some(cached_read) = cached_read { + usage["cachedReadTokens"] = serde_json::json!(cached_read); + } + if let Some(cached_write) = cached_write { + usage["cachedWriteTokens"] = serde_json::json!(cached_write); + } + serde_json::json!({"stopReason": "end_turn", "usage": usage}) + } + + fn standard_cost_update(session_id: &str, cost: f64) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": { + "sessionUpdate": "usage_update", + "cost": {"amount": cost, "currency": "USD"} + } + } + }) + } + + #[tokio::test] + async fn claude_prompt_response_usage_merges_with_cumulative_cost() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.notify_session_spawned("claude-session"); + client.standard_usage.begin_turn("claude-session"); + client.handle_session_update(&standard_cost_update("claude-session", 0.042)); + assert_eq!( + client + .parse_prompt_response( + "claude-session", + &prompt_response_usage(100, 20, 175, Some(30), Some(25)), + ) + .unwrap(), + StopReason::EndTurn + ); + + let usage = client.take_turn_usage().expect("prompt usage"); + assert!(usage.delta_reliable, "response tokens need no baseline"); + assert_eq!(usage.turn_input_tokens, Some(155)); + assert_eq!(usage.turn_output_tokens, Some(20)); + assert_eq!( + usage.turn_total_tokens, None, + "Claude total is adapter-derived" + ); + assert_eq!(usage.turn_cache_read_tokens, Some(30)); + assert_eq!(usage.turn_cache_write_tokens, Some(25)); + assert_eq!(usage.turn_cost_usd, Some(0.042)); + assert_eq!(usage.cumulative_cost_usd, Some(0.042)); + assert_eq!(usage.cumulative_input_tokens, None); + assert_eq!(usage.cumulative_output_tokens, None); + } + + #[tokio::test] + async fn codex_prompt_response_usage_preserves_provider_total_without_cost() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Codex); + client.standard_usage.begin_turn("codex-session"); + client.handle_session_update(&standard_cost_update("codex-session", 0.042)); + client + .parse_prompt_response( + "codex-session", + &prompt_response_usage(90, 10, 140, Some(40), None), + ) + .unwrap(); + + let usage = client.take_turn_usage().expect("prompt usage"); + assert!(usage.delta_reliable); + assert_eq!(usage.turn_input_tokens, Some(130)); + assert_eq!(usage.turn_output_tokens, Some(10)); + assert_eq!(usage.turn_total_tokens, Some(140)); + assert_eq!(usage.turn_cache_read_tokens, Some(40)); + assert_eq!(usage.turn_cache_write_tokens, None); + assert_eq!( + usage.cumulative_cost_usd, None, + "Codex cost update is ignored" + ); + assert_eq!(usage.cumulative_input_tokens, None); + assert_eq!(usage.cumulative_output_tokens, None); + } + + #[tokio::test] + async fn standard_prompt_input_overflow_fails_closed() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.standard_usage.begin_turn("overflow-session"); + client + .parse_prompt_response( + "overflow-session", + &prompt_response_usage(u64::MAX, 10, u64::MAX, Some(1), None), + ) + .unwrap(); + + assert!( + client.take_turn_usage().is_none(), + "overflow without another valid signal must not emit all-null usage" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn claude_named_adapter_wire_lifecycle_records_prompt_and_cost() { + let script = r#" + read -r REQ + ID=$(printf '%s' "$REQ" | sed -E 's/.*"id":([0-9]+).*/\1/') + echo '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"wire-session","update":{"sessionUpdate":"usage_update","cost":{"amount":0.5,"currency":"USD"}}}}' + echo '{"jsonrpc":"2.0","id":'"$ID"',"result":{"stopReason":"end_turn","usage":{"inputTokens":7,"outputTokens":3,"totalTokens":10,"cachedReadTokens":2}}}' + sleep 1 + "#; + let (mut client, dir) = spawn_named_script("claude-code", script).await; + assert_eq!(client.standard_adapter, Some(StandardAdapterKind::Claude)); + client.notify_session_spawned("wire-session"); + + let stop = client + .session_prompt_with_idle_timeout( + "wire-session", + "hello", + std::time::Duration::from_secs(2), + std::time::Duration::from_secs(5), + ) + .await + .expect("wire prompt"); + assert_eq!(stop, StopReason::EndTurn); + + let usage = client.take_turn_usage().expect("wire usage"); + assert_eq!(usage.turn_seq, 1); + assert_eq!(usage.turn_input_tokens, Some(9)); + assert_eq!(usage.turn_output_tokens, Some(3)); + assert_eq!(usage.turn_cost_usd, Some(0.5)); + assert_eq!(usage.cumulative_cost_usd, Some(0.5)); + drop(client); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn claude_cost_only_record_survives_missing_prompt_usage() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.notify_session_spawned("cost-only-session"); + client.standard_usage.begin_turn("cost-only-session"); + client.handle_session_update(&standard_cost_update("cost-only-session", 0.125)); + + let usage = client.take_turn_usage().expect("cost-only usage"); + assert_eq!(usage.turn_seq, 1); + assert!(usage.delta_reliable); + assert_eq!(usage.turn_input_tokens, None); + assert_eq!(usage.turn_cost_usd, Some(0.125)); + assert_eq!(usage.cumulative_cost_usd, Some(0.125)); + } + + #[tokio::test] + async fn attached_claude_session_does_not_invent_first_cost_delta() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.standard_usage.begin_turn("attached-session"); + client.handle_session_update(&standard_cost_update("attached-session", 1.25)); + client + .parse_prompt_response( + "attached-session", + &prompt_response_usage(10, 2, 12, None, None), + ) + .unwrap(); + + let usage = client.take_turn_usage().expect("attached usage"); + assert_eq!(usage.turn_cost_usd, None); + assert_eq!(usage.cumulative_cost_usd, Some(1.25)); + } + + #[tokio::test] + async fn standard_usage_two_prompts_preserve_both_monotonic_sequences() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.notify_session_spawned("two-prompt-session"); + + client.standard_usage.begin_turn("two-prompt-session"); + client.handle_session_update(&standard_cost_update("two-prompt-session", 0.1)); + client + .parse_prompt_response( + "two-prompt-session", + &prompt_response_usage(10, 2, 12, None, None), + ) + .unwrap(); + let initial = client.take_turn_usage().expect("initial prompt usage"); + + client.standard_usage.begin_turn("two-prompt-session"); + client.handle_session_update(&standard_cost_update("two-prompt-session", 0.25)); + client + .parse_prompt_response( + "two-prompt-session", + &prompt_response_usage(20, 3, 23, None, None), + ) + .unwrap(); + let user = client.take_turn_usage().expect("user prompt usage"); + + assert_eq!((initial.turn_seq, user.turn_seq), (1, 2)); + assert_eq!( + (initial.turn_input_tokens, user.turn_input_tokens), + (Some(10), Some(20)) + ); + assert_eq!( + (initial.turn_cost_usd, user.turn_cost_usd), + (Some(0.1), Some(0.15)) + ); + } + + #[tokio::test] + async fn goose_usage_stays_exclusive_and_drains_standard_usage() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.goose_usage.begin_turn("goose-session"); + client.standard_usage.begin_turn("goose-session"); + client.handle_goose_usage_update(&goose_usage_update_msg("goose-session", 1000, 200, None)); + client + .parse_prompt_response( + "goose-session", + &prompt_response_usage(100, 20, 120, None, None), + ) + .unwrap(); + + let usage = client.take_turn_usage().expect("goose usage"); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert_eq!( + usage.turn_input_tokens, None, + "goose first delta remains exclusive" + ); + assert!( + client.take_turn_usage().is_none(), + "standard usage was drained" + ); + } + // ── Goose usage notification integration ────────────────────────────── /// Build a `_goose/unstable/session/update` JSON-RPC notification. @@ -4306,8 +4652,8 @@ mod tests { assert_eq!(usage.session_id, "s1"); assert_eq!(usage.turn_seq, 1); assert!(!usage.delta_reliable, "first turn must be unreliable"); - assert_eq!(usage.cumulative_input_tokens, 1000); - assert_eq!(usage.cumulative_output_tokens, 200); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert_eq!(usage.cumulative_output_tokens, Some(200)); assert_eq!(usage.cumulative_cost_usd, Some(0.01)); // Second take must be None. diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 1d85221f113..83b9357171a 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -23,7 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | -| `buzz issues` | `create`, `get`, `list`, `status` | +| `buzz issues` | `create`, `get`, `list`, `status`, `assign` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -31,7 +31,9 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. -`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. +`buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. + +To assign an issue to someone, run `buzz issues assign --issue --repo-owner --repo-id --assignee --label ` after creating it. Remove an assignment with the matching `buzz issues unassign` arguments. Writing assignee names in the issue body or adding recipients with `issues create --to` is notification/presentation only — Buzz Desktop's Assignees rail and the "Assigned to me" filter read the signed assignment operations. Only operations signed by the issue author or repo owner are trusted for other people; anyone may assign or unassign themselves. ## Conversational Agent Creation @@ -47,7 +49,7 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat ### Mentions -- Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently. +- For a notifying `@mention`, use the person's **exact display name as shown in Buzz** (e.g., `@Will Pfleger`, not `@Will`, when the displayed name is `Will Pfleger`). Do not expand a short display name, infer a surname, or spend tool calls looking for a “fuller” name merely to address someone. Partial names fail silently. - Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery. - When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. - Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. @@ -81,7 +83,7 @@ All replies and delegations — including task assignments to other agents — g - For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message. - Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting. - No push notifications — poll with `buzz messages get --channel --since `. -- Address people by the name in their own message header. +- Address people using the name shown in their own message header. Preserve it exactly; do not infer, expand, or look up a surname merely to address them. - Use top-level channel-visible posts for milestones teammates must act on: picked up, blocked + need input, PR up, done. - Praise in public; correct in the work, not the person. @@ -115,6 +117,7 @@ These paths are relative to your working directory — keep exploration there. N Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions. - **Keep `core` small.** A line earns a permanent slot only if it matters across most sessions or prevents a sharp repeat mistake. Treat the 65,535-byte hard limit as a wall to stay far from, not a budget to fill — aim to keep `core` under ~10 KB (roughly your healthy baseline). +- **Turn mistakes into durable lessons.** When a mistake exposes a repeatable mechanism, record the invariant in the same session. Keep only the load-bearing rule in `core`; put detailed evidence and procedures in cold memory. If the lesson improves a shared workflow, update the team's shared guidance so others do not have to re-earn it. - **Durable detail goes to a cold `mem/` slug, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in a `mem/` slug you read on demand — not appended to `core`. - **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `mem/` slug if you need it later. - **Treat `core` as load-bearing.** Follow it unless newer explicit user instructions override it. @@ -130,13 +133,15 @@ These are guidelines, not a fixed procedure — apply judgment to the task in fr - **Plan briefly, then build.** Be opinionated about the safest concrete approach. Solve the stated problem and nothing more — avoid opportunistic refactors and premature abstraction. - **Match what's there.** Follow the surrounding code's conventions and module boundaries. Read neighboring code first. - **Attribute results to the exact state that produced them.** Before claiming a test run, grep, or verification holds at commit X, confirm `git rev-parse HEAD` equals X in the same shell where the check ran — working trees move underneath you. Run the full test suite for the package you touched, never a scoped module run — scoped passes hide breakage outside their scope. Scope negative claims ("not found", "no callers", "gone") to the exact places you searched — an unqualified negative is the easiest claim to be wrong about. -- **Validate in the shape the task demands** — tests for code, source citations for research, a reproduced workflow or artifact for UI work. If the same failure hits twice, change angle rather than retrying. +- **Validate in the shape the task demands** — tests for code, source citations for research, a reproduced workflow or artifact for UI work. CI and live workflow evidence answer different questions: for user-visible or integration behavior, exercise the real workflow when practical and scale the depth to the risk. If the same failure hits twice, change angle rather than retrying. - **Get a second opinion on risky changes.** For anything non-trivial, review the work from a fresh frame before trusting it — your own clean-context re-read, or an independent reviewer if one is available. Don't tell the reviewer what you expect them to find. - **Self-review before calling it done.** Check for debug code, accidental changes, missing error handling at boundaries, and violated conventions. - **Scale effort to risk.** A typo or config tweak just gets done. A multi-file change touching persistence, auth, or anything user-visible earns the full discipline above. ## Working in the Repo +- After selecting a repository or worktree, read its root `AGENTS.md` and any path-local `AGENTS.md` files that apply before planning or editing. The workspace-level file is team context; it does not replace repository-owned instructions. +- Treat repository-owned product, architecture, and vision documents as design constraints, not optional background. Read the relevant documents before making non-trivial plans, and surface any intentional conflict with them. - Make file changes in a worktree, not on the default branch. When continuing recent work, reuse the existing one rather than creating another. - Before committing, read the repo-local git `user.name` / `user.email`; if email is empty, stop and ask. Include the trailers the repo requires. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 35aaec188db..f9e7bf1ed8a 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -482,6 +482,13 @@ pub struct CliArgs { /// Connect and subscribe before starting the ACP/LLM subprocess pool. #[arg(long, env = "BUZZ_ACP_LAZY_POOL", default_value_t = false)] pub lazy_pool: bool, + + /// Tear the woken pool back down to the lazy empty-slot state after this + /// many seconds with no dispatched turn in flight and an empty queue, + /// releasing worker subprocesses until the next accepted event re-wakes. + /// Requires `--lazy-pool`; ignored otherwise. 0 disables idle re-sleep. + #[arg(long, env = "BUZZ_ACP_IDLE_POOL_SLEEP", default_value_t = 0)] + pub idle_pool_sleep: u64, } /// Merged NIP-01 subscription filter for a single channel. @@ -559,6 +566,10 @@ pub struct Config { pub exit_after_inactivity_secs: u64, /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives. pub lazy_pool: bool, + /// Seconds with no dispatched turn in flight and an empty queue before a + /// woken lazy pool is torn back down to the empty-slot state. 0 = disabled. + /// Only meaningful when `lazy_pool` is true. + pub idle_pool_sleep_secs: u64, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, @@ -1107,6 +1118,7 @@ impl Config { relay_observer: args.relay_observer, exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, + idle_pool_sleep_secs: args.idle_pool_sleep, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, base_prompt_content, @@ -1478,6 +1490,7 @@ mod tests { relay_observer: false, exit_after_inactivity_secs: 0, lazy_pool: false, + idle_pool_sleep_secs: 0, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -2198,6 +2211,22 @@ channels = "ALL" assert!(!CliArgs::parse_from(["buzz-acp", "--private-key", &key]).lazy_pool); } + #[test] + fn idle_pool_sleep_defaults_disabled_and_accepts_cli_value() { + let key = "0".repeat(64); + let default = CliArgs::parse_from(["buzz-acp", "--private-key", &key]); + assert_eq!(default.idle_pool_sleep, 0); + + let configured = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &key, + "--idle-pool-sleep", + "300", + ]); + assert_eq!(configured.idle_pool_sleep, 300); + } + #[test] fn lazy_pool_cli_flag_enables_deferred_startup() { let key = "0".repeat(64); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b69a1f453a5..7fd40b83db1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1071,6 +1071,7 @@ fn handle_relay_observer_control_event( pool: &mut AgentPool, observer: Option<&observer::ObserverHandle>, owner_pubkey_hex: &str, + event_publisher: RelayEventPublisher, ) { // Defense-in-depth: verify signature even though the relay already checked. if let Err(e) = buzz_core::verify_event(&event) { @@ -1116,12 +1117,162 @@ fn handle_relay_observer_control_event( Some("switch_model") => { handle_switch_model_control(&payload, pool, observer); } + Some("publish_project_owner_announcements") => { + handle_publish_project_owner_announcements_control( + &payload, + keys, + observer, + event_publisher, + ); + } _ => { tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); } } } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProjectOwnerAnnouncementControl { + request_id: String, + announcements: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProjectOwnerAnnouncementTemplate { + kind: u16, + content: String, + created_at: Option, + tags: Vec>, +} + +fn handle_publish_project_owner_announcements_control( + payload: &serde_json::Value, + keys: &nostr::Keys, + observer: Option<&observer::ObserverHandle>, + publisher: RelayEventPublisher, +) { + let Ok(control) = serde_json::from_value::(payload.clone()) + else { + tracing::warn!("project announcement control frame has an invalid payload"); + return; + }; + if Uuid::parse_str(&control.request_id).is_err() + || control.announcements.is_empty() + || control.announcements.len() > 2 + { + tracing::warn!("project announcement control frame has invalid request metadata"); + return; + } + + let keys = keys.clone(); + let observer = observer.cloned(); + tokio::spawn(async move { + let events = match build_project_owner_announcement_events(control.announcements, &keys) { + Ok(events) => events, + Err(error) => { + emit_project_owner_control_result( + observer.as_ref(), + &control.request_id, + "error", + &[], + Some(error.to_string()), + ); + return; + } + }; + let mut published_events = Vec::with_capacity(events.len()); + for event in events { + if let Err(error) = publisher.publish_event(event.clone()).await { + emit_project_owner_control_result( + observer.as_ref(), + &control.request_id, + "error", + &published_events, + Some(format!("publish project announcement: {error}")), + ); + return; + } + published_events.push(event); + } + emit_project_owner_control_result( + observer.as_ref(), + &control.request_id, + "ok", + &published_events, + None, + ); + }); +} + +fn build_project_owner_announcement_events( + announcements: Vec, + keys: &nostr::Keys, +) -> Result> { + let now = nostr::Timestamp::now().as_secs(); + announcements + .into_iter() + .map(|template| { + if !matches!(template.kind, 30_617 | 30_621) { + anyhow::bail!("unsupported project announcement kind"); + } + if !template.tags.iter().any(|tag| { + tag.first().is_some_and(|value| value == "d") + && tag.get(1).is_some_and(|value| !value.trim().is_empty()) + }) { + anyhow::bail!("project announcement is missing its address"); + } + let tags = template + .tags + .into_iter() + .map(|tag| { + nostr::Tag::parse(tag) + .map_err(|error| anyhow::anyhow!("invalid project tag: {error}")) + }) + .collect::>>()?; + let created_at = template.created_at.unwrap_or(now); + if created_at > now.saturating_add(300) { + anyhow::bail!("project announcement timestamp is too far in the future"); + } + nostr::EventBuilder::new(nostr::Kind::Custom(template.kind), template.content) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(keys) + .map_err(|error| anyhow::anyhow!("sign project announcement: {error}")) + }) + .collect() +} + +fn emit_project_owner_control_result( + observer: Option<&observer::ObserverHandle>, + request_id: &str, + status: &str, + events: &[nostr::Event], + error: Option, +) { + let Some(observer) = observer else { + return; + }; + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: None, + session_id: None, + turn_id: None, + started_at: None, + }, + serde_json::json!({ + "type": "publish_project_owner_announcements", + "requestId": request_id, + "status": status, + "events": events, + "error": error, + }), + ); +} + /// Handle a `cancel_turn` control frame: signal the in-flight task to cancel. fn handle_cancel_turn_control( payload: &serde_json::Value, @@ -1468,6 +1619,33 @@ fn inactivity_expired( !bound.is_zero() && !turn_in_flight && now.duration_since(last_activity) >= bound } +/// Whether a woken lazy pool may be torn back down to the empty-slot state. +/// +/// True only when the pool is ready, the idle bound has elapsed with no +/// dispatched turn or heartbeat in flight and no in-flight prompt tasks, no +/// work is queued, and no wake/respawn task is running. The queue and task +/// gates make teardown race-safe with enqueue/wake: an event that landed in +/// the queue (or a wake/respawn already in flight) blocks this decision, so a +/// queued batch is never stranded — the caller's next loop iteration will +/// dispatch or wake it instead. +#[allow(clippy::too_many_arguments)] +fn idle_pool_sleep_due( + pool_ready: bool, + last_activity: tokio::time::Instant, + now: tokio::time::Instant, + bound: Duration, + turn_in_flight: bool, + prompt_tasks_in_flight: bool, + work_queued: bool, + wake_or_respawn_in_flight: bool, +) -> bool { + pool_ready + && !work_queued + && !prompt_tasks_in_flight + && !wake_or_respawn_in_flight + && inactivity_expired(last_activity, now, bound, turn_in_flight) +} + #[cfg(test)] mod inactivity_tests { use super::*; @@ -1512,6 +1690,179 @@ mod inactivity_tests { } } +#[cfg(test)] +mod idle_pool_sleep_tests { + use super::*; + + // The all-clear baseline: pool ready, bound elapsed, nothing busy or + // queued. Every negative case below flips exactly one gate off this. + fn ready_after_bound() -> (tokio::time::Instant, tokio::time::Instant, Duration) { + let started = tokio::time::Instant::now(); + ( + started, + started + Duration::from_secs(61), + Duration::from_secs(60), + ) + } + + #[test] + fn sleeps_when_ready_idle_and_quiet() { + let (last, now, bound) = ready_after_bound(); + assert!(idle_pool_sleep_due( + true, last, now, bound, false, false, false, false + )); + } + + #[test] + fn zero_bound_never_sleeps() { + let (last, now, _) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, + last, + now, + Duration::ZERO, + false, + false, + false, + false + )); + } + + #[test] + fn not_ready_never_sleeps() { + // A still-sleeping (or waking) pool must not "re-sleep". + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + false, last, now, bound, false, false, false, false + )); + } + + #[test] + fn active_turn_defers_sleep() { + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, last, now, bound, true, false, false, false + )); + } + + #[test] + fn in_flight_prompt_task_defers_sleep() { + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, last, now, bound, false, true, false, false + )); + } + + #[test] + fn queued_work_at_boundary_defers_sleep() { + // Enqueue-at-teardown protection: a batch sitting in the queue blocks + // teardown so it is never stranded — the loop dispatches it instead. + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, last, now, bound, false, false, true, false + )); + } + + #[test] + fn wake_or_respawn_in_flight_defers_sleep() { + let (last, now, bound) = ready_after_bound(); + assert!(!idle_pool_sleep_due( + true, last, now, bound, false, false, false, true + )); + } + + #[test] + fn recent_activity_defers_sleep() { + // Activity 50s ago under a 60s bound: not yet idle. + let started = tokio::time::Instant::now(); + let recent = started + Duration::from_secs(50); + let now = started + Duration::from_secs(59); + assert!(!idle_pool_sleep_due( + true, + recent, + now, + Duration::from_secs(60), + false, + false, + false, + false + )); + } + + fn slot(respawn_in_flight: bool) -> SlotCircuit { + SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight, + } + } + + // The call-site signal for the `wake_or_respawn_in_flight` gate is + // `any_respawn_in_flight(&crash_history)`, NOT `!respawn_tasks.is_empty()`. + // Regression for the PR #5682 review blocker: completed respawn tasks are + // never joined from the `respawn_tasks` JoinSet (their payloads arrive + // out-of-band via `respawn_rx`), so `!is_empty()` stays true forever after + // the first refill/crash recovery and the pool could never re-sleep. The + // authoritative signal clears per-slot when the payload is received. + #[test] + fn respawn_in_flight_signal_gates_then_clears_for_sleep() { + let (last, now, bound) = ready_after_bound(); + + // A respawn in flight for any slot defers sleep. + let busy = [slot(false), slot(true), slot(false)]; + assert!(any_respawn_in_flight(&busy)); + assert!(!idle_pool_sleep_due( + true, + last, + now, + bound, + false, + false, + false, + any_respawn_in_flight(&busy), + )); + + // Once the respawn completes (payload received → flag cleared), the + // signal goes false and the otherwise-quiet pool becomes sleep-eligible + // — even though a naive `!JoinSet.is_empty()` would still be stuck true. + let quiet = [slot(false), slot(false), slot(false)]; + assert!(!any_respawn_in_flight(&quiet)); + assert!(idle_pool_sleep_due( + true, + last, + now, + bound, + false, + false, + false, + any_respawn_in_flight(&quiet), + )); + } + + // The reaper (`respawn_tasks.join_next().now_or_never()` loop) must drain + // completed handles so the JoinSet does not grow without bound and so + // `!respawn_tasks.is_empty()` cannot become a permanent busy bit if anyone + // ever reintroduces it as the gate signal. + #[tokio::test] + async fn completed_respawn_tasks_are_reaped_from_the_joinset() { + let mut respawn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); + respawn_tasks.spawn(async {}); + respawn_tasks.spawn(async {}); + // Let both tasks run to completion. + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(10)).await; + + // The reaper drains finished handles non-blockingly. + while respawn_tasks.join_next().now_or_never().flatten().is_some() {} + + assert!( + respawn_tasks.is_empty(), + "completed respawn tasks must be reaped so the set does not wedge \ + the idle-sleep gate or grow unbounded" + ); + } +} + pub fn run() -> Result<()> { config::propagate_legacy_env_vars(); tokio_main() @@ -1900,6 +2251,27 @@ async fn tokio_main() -> Result<()> { )) }; + // Idle pool re-sleep: tear a woken lazy pool back down to the empty-slot + // state after `idle_pool_sleep_bound` of quiet, releasing worker + // subprocesses. The next accepted event re-wakes it through the same lazy + // path. Only meaningful under `lazy_pool`; the tick arm additionally gates + // on `pool_ready`, so a still-sleeping pool never re-sleeps. Reuses the + // `last_activity` clock the dispatch path already maintains. + let idle_pool_sleep_bound = if config.lazy_pool { + Duration::from_secs(config.idle_pool_sleep_secs) + } else { + Duration::ZERO + }; + let mut idle_pool_sleep_reaper = if idle_pool_sleep_bound.is_zero() { + None + } else { + let interval = idle_pool_sleep_bound.min(Duration::from_secs(30)); + Some(tokio::time::interval_at( + tokio::time::Instant::now() + interval, + interval, + )) + }; + // Runs at the TOP of every loop iteration via Instant check — cannot be // starved by the biased select. Slot refill spawns background tasks so // spawn_and_init never blocks the main loop. @@ -2107,6 +2479,17 @@ async fn tokio_main() -> Result<()> { } } } + // Reap completed respawn handles from the JoinSet. Payloads are + // delivered out-of-band through `respawn_rx` (drained above), so the + // JoinSet is never joined by the normal flow — Tokio retains finished + // tasks until `join_next`, so without this the set grows on every + // refill/crash recovery and `!respawn_tasks.is_empty()` would stay true + // forever. Non-blocking (`now_or_never`), same pattern as + // `drain_ready_join_results` for `pool.join_set`. The authoritative + // in-flight signal is `any_respawn_in_flight(&crash_history)` (each + // slot's `respawn_in_flight` is cleared when its payload is received), + // not JoinSet occupancy. + while respawn_tasks.join_next().now_or_never().flatten().is_some() {} // Flush requeued events that were waiting for a live agent. Without // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. @@ -2191,7 +2574,14 @@ async fn tokio_main() -> Result<()> { match control_event { Some(event) => { if let Some(ref owner_hex) = owner_cache.pubkey { - handle_relay_observer_control_event(&config.keys, event, &mut pool, observer.as_ref(), owner_hex); + handle_relay_observer_control_event( + &config.keys, + event, + &mut pool, + observer.as_ref(), + owner_hex, + relay.event_publisher(), + ); } else { tracing::warn!("observer control frame received but no owner resolved — dropping"); } @@ -2599,6 +2989,56 @@ async fn tokio_main() -> Result<()> { } None } + _ = async { + match idle_pool_sleep_reaper.as_mut() { + Some(timer) => timer.tick().await, + None => std::future::pending().await, + } + } => { + let _ = result_rx; // end split borrow before touching pool + // A wake in flight (pool not yet ready) is covered by the + // pool_ready gate; respawn tasks and in-flight prompt tasks + // are the remaining "busy" signals. Never sleep mid-work: + // `has_undispatched_work()` (not `has_flushable_work()`) + // keeps `work_queued` true for a retry-throttled batch too, + // so a failed turn awaiting backoff is never stranded — the + // next iteration dispatches or re-wakes it. + if idle_pool_sleep_due( + pool_ready, + last_activity, + tokio::time::Instant::now(), + idle_pool_sleep_bound, + queue.has_in_flight() || heartbeat_in_flight, + !pool.join_set.is_empty(), + queue.has_undispatched_work(), + !wake_tasks.is_empty() + || any_respawn_in_flight(&crash_history), + ) { + tracing::info!( + idle_pool_sleep_seconds = config.idle_pool_sleep_secs, + "idle pool sleep bound reached — tearing pool back to lazy state" + ); + shutdown_agent_pool(&mut pool).await; + // Return to the exact pre-wake lazy state: empty slots, + // Listening lifecycle. The top-of-loop wake path re-wakes + // on the next accepted event. No second lifecycle. + pool = AgentPool::from_slots( + (0..config.agents).map(|_| None).collect(), + ); + pool_ready = false; + pool_lifecycle = PoolLifecycle::listening(); + last_activity = tokio::time::Instant::now(); + emit_runtime_lifecycle( + observer.as_ref(), + &runtime_start_nonce, + &pubkey_hex, + &config.relay_url, + "listening", + None, + ); + } + None + } _ = async { match heartbeat.as_mut() { Some(hb) => hb.tick().await, @@ -2821,7 +3261,7 @@ async fn tokio_main() -> Result<()> { // treat as PromptCompletedNeutral to avoid leaking // the withheld event in `withheld_native_steer`. let (release_withheld, drop_withheld, signal_fallback) = match &ack { - Ok(pool::SteerAck::Success) => (false, true, false), + Ok(pool::SteerAck::Success { .. }) => (false, true, false), // -32601 = method_not_found: agent does not implement the // steer extension. Fire cancel+merge so the message still // reaches the agent. @@ -2854,8 +3294,19 @@ async fn tokio_main() -> Result<()> { signal_fallback, "non-cancelling steer ack received" ); - if matches!(ack, Ok(pool::SteerAck::Success)) { + if let Ok(pool::SteerAck::Success { session_id }) = &ack { queue.extend_in_flight_deadline(channel_id, config.max_turn_duration_secs); + if !pool.record_successful_steer( + channel_id, + event_id.clone(), + session_id.clone(), + ) { + tracing::warn!( + channel = %channel_id, + event_id = %event_id, + "successful steer lost its in-flight delivery ledger" + ); + } } if drop_withheld { queue.remove_event(channel_id, &event_id); @@ -3318,6 +3769,7 @@ fn dispatch_pending( recoverable_batch, control_tx: Some(control_tx), steer_tx, + successful_steer_deliveries: HashSet::new(), }, ); dispatched_channels.push((channel_id, typing_scope)); @@ -3399,9 +3851,30 @@ fn handle_prompt_result( ) -> LoopAction { let before = pool.task_map().len(); let agent_index = result.agent.index; + let successful_steer_deliveries = pool + .task_map() + .values() + .find(|meta| meta.agent_index == agent_index) + .map(|meta| meta.successful_steer_deliveries.clone()) + .unwrap_or_default(); pool.task_map_mut() .retain(|_, meta| meta.agent_index != agent_index); debug_assert_eq!(before, pool.task_map().len() + 1); + if let PromptSource::Channel(channel_id) = &result.source { + // The task may have invalidated this session before returning. Never + // resurrect delivery state for a dead session; its replacement must + // receive fresh standing context and history. + if let Some(live_session_id) = result.agent.state.sessions.get(channel_id).cloned() { + let event_ids = successful_steer_deliveries + .into_iter() + .filter(|delivery| delivery.session_id == live_session_id) + .map(|delivery| delivery.event_id); + result + .agent + .state + .mark_channel_delivery_success(*channel_id, false, event_ids); + } + } // The hard-timeout death_message (below) must describe the batch's // *actual* fate, not just the `recently_active` eligibility flag — a @@ -3932,6 +4405,7 @@ fn dispatch_heartbeat( recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); *heartbeat_in_flight = true; @@ -3958,9 +4432,25 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("buzz messages send ... --content -")); } + #[test] + fn shared_base_prompt_teaches_repo_context_and_learning_loop() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("read its root `AGENTS.md`")); + assert!(prompt.contains("path-local `AGENTS.md`")); + assert!( + prompt.contains("product, architecture, and vision documents as design constraints") + ); + assert!(prompt.contains("CI and live workflow evidence answer different questions")); + assert!(prompt.contains("record the invariant in the same session")); + assert!(prompt.contains("update the team's shared guidance")); + } + #[test] fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("use the person's **exact display name as shown in Buzz**")); + assert!(prompt.contains("Do not expand a short display name, infer a surname")); + assert!(prompt.contains("Preserve it exactly; do not infer, expand, or look up a surname")); assert!(prompt.contains("--mention ")); assert!(prompt.contains("every presentation-only name that should notify")); assert!( @@ -4574,17 +5064,23 @@ mod heartbeat_base_prompt_tests { // heartbeat user message, composed as `[Base]\n{bp}\n\n{prompt}`. This is // the second half of the round-2 regression (the first being initial_message). + fn heartbeat_standing() -> queue::StandingContext<'static> { + queue::StandingContext { + base_prompt: Some("you are a helpful agent"), + ..Default::default() + } + } + #[test] fn test_heartbeat_legacy_agent_gets_base_prepended() { // protocol_version 1 + Some(base_prompt): heartbeat prompt is prefixed // with the [Base] section exactly as the legacy session/new path would. let prompt = "[System: Heartbeat]\nrun feed get"; - let composed = pool::prepend_base_for_legacy(1, Some("you are a helpful agent"), prompt); + let composed = pool::prepend_standing_for_legacy(1, &heartbeat_standing(), prompt); assert_eq!( composed, "[Base]\nyou are a helpful agent\n\n[System: Heartbeat]\nrun feed get" ); - assert!(composed.starts_with("[Base]\nyou are a helpful agent\n\n")); } #[test] @@ -4592,7 +5088,7 @@ mod heartbeat_base_prompt_tests { // protocol_version 2 gets base_prompt via session/new; the heartbeat // prompt is sent verbatim. let prompt = "[System: Heartbeat]\nrun feed get"; - let composed = pool::prepend_base_for_legacy(2, Some("you are a helpful agent"), prompt); + let composed = pool::prepend_standing_for_legacy(2, &heartbeat_standing(), prompt); assert_eq!(composed, prompt); } } @@ -4703,6 +5199,7 @@ mod owner_control_command_tests { recoverable_batch: None, control_tx: Some(control_tx), steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); @@ -4723,6 +5220,59 @@ mod owner_control_command_tests { ControlSignal::Rotate )); } + + #[test] + fn project_owner_control_signs_only_addressable_project_events() { + let keys = Keys::generate(); + let events = build_project_owner_announcement_events( + vec![ + ProjectOwnerAnnouncementTemplate { + kind: 30_621, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }, + ProjectOwnerAnnouncementTemplate { + kind: 30_617, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "repository".to_string()]], + }, + ], + &keys, + ) + .expect("valid project events"); + + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| event.pubkey == keys.public_key())); + assert!(events.iter().all(|event| event.verify().is_ok())); + } + + #[test] + fn project_owner_control_rejects_arbitrary_or_unaddressed_events() { + let keys = Keys::generate(); + let arbitrary = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 1, + content: String::new(), + created_at: None, + tags: vec![vec!["d".to_string(), "project".to_string()]], + }], + &keys, + ); + assert!(arbitrary.is_err()); + + let unaddressed = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 30_621, + content: String::new(), + created_at: None, + tags: vec![], + }], + &keys, + ); + assert!(unaddressed.is_err()); + } } #[cfg(test)] @@ -4990,6 +5540,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "dm".into(), channel_type: "dm".into(), + description: None, }, ), ( @@ -4997,6 +5548,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "stream".into(), channel_type: "stream".into(), + description: None, }, ), ]); @@ -5013,6 +5565,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "unknown".into(), channel_type: "unknown".into(), + description: None, }, )]); assert!( @@ -6208,6 +6761,7 @@ mod build_mcp_servers_tests { relay_observer: false, exit_after_inactivity_secs: 0, lazy_pool: false, + idle_pool_sleep_secs: 0, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -6430,6 +6984,7 @@ mod error_outcome_emission_tests { relay_observer: false, exit_after_inactivity_secs: 0, lazy_pool: false, + idle_pool_sleep_secs: 0, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -6473,6 +7028,263 @@ mod error_outcome_emission_tests { } } + #[tokio::test] + async fn successful_native_steer_is_transferred_to_live_session_delivery_state() { + let channel_id = Uuid::new_v4(); + let steer_event_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let mut agent = dummy_agent(0).await; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, Default::default()); + + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "test-turn-id".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::from([ + crate::pool::SuccessfulSteerDelivery { + event_id: steer_event_id.into(), + session_id: "live-session".into(), + }, + ]), + }, + ); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + turn_id: "test-turn-id".into(), + outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), + batch: None, + }; + + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); + assert!(returned.state.deliveries[&channel_id] + .delivered_event_ids + .contains(steer_event_id)); + } + + #[tokio::test] + async fn in_flight_stale_native_steer_ack_cannot_update_replacement_session() { + let channel_id = Uuid::new_v4(); + let mut agent = dummy_agent(0).await; + agent + .state + .sessions + .insert(channel_id, "replacement-session".into()); + agent + .state + .deliveries + .insert(channel_id, Default::default()); + + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "test-turn-id".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::from([ + crate::pool::SuccessfulSteerDelivery { + event_id: "stale-event".into(), + session_id: "old-session".into(), + }, + ]), + }, + ); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + turn_id: "test-turn-id".into(), + outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), + batch: None, + }; + + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); + assert!(returned.state.deliveries[&channel_id] + .delivered_event_ids + .is_empty()); + } + + #[tokio::test] + async fn successful_native_steer_ack_after_task_return_updates_matching_live_session() { + let channel_id = Uuid::new_v4(); + let steer_event_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let mut agent = dummy_agent(0).await; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, Default::default()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + assert!(pool.record_successful_steer( + channel_id, + steer_event_id.into(), + "live-session".into(), + )); + let returned = pool.agents_mut()[0].as_ref().expect("idle returned agent"); + assert!(returned.state.deliveries[&channel_id] + .delivered_event_ids + .contains(steer_event_id)); + } + + #[tokio::test] + async fn late_native_steer_ack_cannot_update_replacement_session() { + let channel_id = Uuid::new_v4(); + let mut agent = dummy_agent(0).await; + agent + .state + .sessions + .insert(channel_id, "replacement-session".into()); + agent + .state + .deliveries + .insert(channel_id, Default::default()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + assert!(!pool.record_successful_steer( + channel_id, + "stale-event".into(), + "old-session".into(), + )); + let returned = pool.agents_mut()[0].as_ref().expect("replacement agent"); + assert!(returned.state.deliveries[&channel_id] + .delivered_event_ids + .is_empty()); + } + + #[tokio::test] + async fn invalidated_session_does_not_resurrect_successful_steer_delivery_state() { + let channel_id = Uuid::new_v4(); + let agent = dummy_agent(0).await; + // No live session: simulates the prompt task invalidating before return. + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "test-turn-id".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::from([ + crate::pool::SuccessfulSteerDelivery { + event_id: "stale-event".into(), + session_id: "invalidated-session".into(), + }, + ]), + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + turn_id: "test-turn-id".into(), + outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), + batch: None, + }; + + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); + assert!(!returned.state.deliveries.contains_key(&channel_id)); + } + /// Drive one error outcome through `handle_prompt_result` and return how /// many `turn_error` events it emitted to the observer feed. async fn turn_errors_emitted_for(outcome: PromptOutcome) -> usize { @@ -6493,6 +7305,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); @@ -6569,6 +7382,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); started_rx.await.unwrap(); @@ -6661,6 +7475,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -6752,6 +7567,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -6857,6 +7673,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -6933,6 +7750,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7027,6 +7845,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let config = test_config(); @@ -7143,6 +7962,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7282,6 +8102,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7470,6 +8291,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7555,6 +8377,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index ddc0330d9f2..2efacce2b19 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -50,6 +50,12 @@ const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60); // a recoverable copy in TaskMeta for panic recovery in Queue mode. /// Metadata stored per in-flight task for panic recovery. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct SuccessfulSteerDelivery { + pub event_id: String, + pub session_id: String, +} + pub struct TaskMeta { pub agent_index: usize, pub channel_id: Option, @@ -67,6 +73,10 @@ pub struct TaskMeta { /// tasks only — all prompt tasks install a steer channel regardless /// of the agent's name. pub steer_tx: Option>, + /// Successful non-cancelling steers acknowledged while this task owned the + /// live session. The session ID prevents a late ack from contaminating a + /// replacement session after task return. + pub successful_steer_deliveries: HashSet, } /// Agent-level model capabilities. Populated on first session creation. @@ -80,7 +90,17 @@ pub struct AgentModelCapabilities { pub available_models_raw: Option, } -/// Per-channel session IDs and turn counters. +/// Successful deliveries associated with one live channel session. +#[derive(Default)] +pub struct ChannelDeliveryState { + /// Whether a legacy user message has successfully carried standing context. + pub standing_context_sent: bool, + /// Buzz event IDs already delivered to this ACP session, either as trigger + /// events or conversation context. + pub delivered_event_ids: HashSet, +} + +/// Per-channel session IDs, turn counters, and delivery state. /// /// Separated from `OwnedAgent` so the state machine is testable without /// spawning a real agent subprocess. @@ -94,6 +114,8 @@ pub struct SessionState { pub turn_counts: HashMap, /// Turn counter for the heartbeat session. pub heartbeat_turn_count: u32, + /// Whether the live heartbeat session has successfully received `[Base]`. + pub heartbeat_standing_context_sent: bool, /// channel_id → rendered NIP-AE core prompt section, populated once at /// session creation per Tyler's spec (no mid-session refresh). pub core_sections: HashMap, @@ -104,6 +126,9 @@ pub struct SessionState { /// fetch fails — all fail open. Cleared on session invalidation alongside /// `core_sections` so the next session picks up any canvas change. pub canvas_sections: HashMap, + /// Per-channel successful-delivery state. Created with the ACP session and + /// cleared atomically with every invalidation path. + pub deliveries: HashMap, } impl SessionState { @@ -116,6 +141,7 @@ impl SessionState { PromptSource::Heartbeat => { self.heartbeat_session = None; self.heartbeat_turn_count = 0; + self.heartbeat_standing_context_sent = false; } } } @@ -126,6 +152,7 @@ impl SessionState { self.turn_counts.remove(channel_id); self.core_sections.remove(channel_id); self.canvas_sections.remove(channel_id); + self.deliveries.remove(channel_id); self.sessions.remove(channel_id).is_some() } @@ -135,8 +162,21 @@ impl SessionState { self.turn_counts.clear(); self.heartbeat_session = None; self.heartbeat_turn_count = 0; + self.heartbeat_standing_context_sent = false; self.core_sections.clear(); self.canvas_sections.clear(); + self.deliveries.clear(); + } + + pub(crate) fn mark_channel_delivery_success( + &mut self, + channel_id: Uuid, + standing_context_sent: bool, + event_ids: impl IntoIterator, + ) { + let delivery = self.deliveries.entry(channel_id).or_default(); + delivery.standing_context_sent |= standing_context_sent; + delivery.delivered_event_ids.extend(event_ids); } #[cfg(test)] @@ -145,6 +185,7 @@ impl SessionState { || self.turn_counts.contains_key(channel_id) || self.core_sections.contains_key(channel_id) || self.canvas_sections.contains_key(channel_id) + || self.deliveries.contains_key(channel_id) } } @@ -408,7 +449,7 @@ pub enum SteerAck { /// The agent returned a successful response to the steer request. /// The main loop must drop the withheld event (`remove_event`) — it /// has been delivered via the non-cancelling path. - Success, + Success { session_id: String }, /// The steer was attempted but failed. Delivery state for the /// underlying message is unknown after prompt completion; the main /// loop must release the withheld event and fall back to the @@ -483,6 +524,7 @@ impl ChannelInfoResolver { PromptChannelInfo { name: info.name, channel_type: info.channel_type, + description: info.description, }, )) }) @@ -696,6 +738,40 @@ impl AgentPool { .map_err(|e| SteerError::Transport(e.to_string())) } + /// Durably associate a successful steer with the exact ACP session that + /// accepted it. Acks may arrive before or after the prompt result: while + /// the task is in flight we stage the delivery in `TaskMeta`; after return + /// we write directly to the idle agent's matching live-session ledger. + pub fn record_successful_steer( + &mut self, + channel_id: Uuid, + event_id: String, + session_id: String, + ) -> bool { + if let Some(meta) = self + .task_map + .values_mut() + .find(|meta| meta.channel_id == Some(channel_id)) + { + meta.successful_steer_deliveries + .insert(SuccessfulSteerDelivery { + event_id, + session_id, + }); + return true; + } + + let Some(agent) = self.agents.iter_mut().flatten().find(|agent| { + agent.state.sessions.get(&channel_id).map(String::as_str) == Some(session_id.as_str()) + }) else { + return false; + }; + agent + .state + .mark_channel_delivery_success(channel_id, false, [event_id]); + true + } + pub fn result_tx(&self) -> mpsc::UnboundedSender { self.result_tx.clone() } @@ -882,14 +958,19 @@ async fn resolve_new_session_channel_context( /// On error from `session_new_full()`, returns the `AcpError` — caller handles /// error reporting. Model-switch failures are logged and gracefully ignored /// (the agent proceeds with its default model). +struct NewSessionChannelContext<'a> { + huddle_instructions: Option<&'a str>, + canvas: Option<&'a str>, + name: Option<&'a str>, + id: Option, + channel_type: Option<&'a str>, +} + async fn create_session_and_apply_model( agent: &mut OwnedAgent, ctx: &PromptContext, agent_core: Option<&str>, - agent_canvas: Option<&str>, - channel_name: Option<&str>, - channel_id: Option, - channel_type: Option<&str>, + channel: NewSessionChannelContext<'_>, ) -> Result { // Build base_prompt + system_prompt + agent core + canvas metadata into a // single prompt. Standard protocol-v2 agents receive it in `session/new`; @@ -899,24 +980,27 @@ async fn create_session_and_apply_model( // `[Channel Canvas]` header; both are appended with a blank-line separator. let is_goose = agent.agent_name == "goose"; let combined_system_prompt = with_canvas( - with_core( - with_team( - framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), - ctx.team_instructions.as_deref(), + with_huddle_instructions( + with_core( + with_team( + framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), + ctx.team_instructions.as_deref(), + ), + agent_core, ), - agent_core, + channel.huddle_instructions, ), - agent_canvas, + channel.canvas, ); let session_title = ctx .session_title .as_deref() - .map(|agent_name| compose_session_title(agent_name, channel_name)); + .map(|agent_name| compose_session_title(agent_name, channel.name)); let mcp_servers = mcp_servers_with_git_origin( &ctx.mcp_servers, - channel_id, - channel_type, + channel.id, + channel.channel_type, ctx.session_title.as_deref(), ); @@ -1205,42 +1289,30 @@ async fn apply_permission_mode( Ok(()) } -/// Prepend the `[Base]` section to a user-message body for legacy agents. +/// Prepend a legacy agent's standing context to a user-message body. /// -/// Legacy agents (`protocol_version < 2`) don't receive `base_prompt` via the -/// system role in `session/new`, so it must ride along in the user message. -/// Agents with `protocol_version >= 2`, or any agent without a `base_prompt`, -/// get `body` unchanged. The gate lives here so the heartbeat and -/// initial-message dispatch paths can't drift apart again. -pub(crate) fn prepend_base_for_legacy( - protocol_version: u32, - base_prompt: Option<&str>, - body: &str, -) -> String { - match base_prompt { - Some(bp) if protocol_version < 2 => { - format!("{}\n\n{body}", crate::queue::base_section(bp)) - } - _ => body.to_string(), - } -} - -/// Prepend the `[Channel Canvas]` section to the legacy initial-message body. +/// Legacy agents (`protocol_version < 2`) don't receive standing context via +/// the system role in `session/new`, so it must ride along in the user message +/// — in the session's *first* one, and never again. Agents with +/// `protocol_version >= 2`, or an empty [`StandingContext`], get `body` +/// unchanged. Both legacy dispatch paths (initial message, heartbeat) go +/// through this one gate so they can't drift apart again. /// -/// Protocol-v2 agents already receive the canvas in `systemPrompt`; only -/// legacy (protocol_version < 2) agents need it injected here so it arrives -/// before the first prompt — the same "every turn" semantics as per-turn core. -/// Heartbeats never have an initial_message, so the caller is responsible for -/// not passing a canvas when `source` is `Heartbeat`. -pub(crate) fn prepend_canvas_for_legacy( +/// A heartbeat passes base only: it has no channel, so there is no core or +/// canvas to carry, and it has never been given the persona. +pub(crate) fn prepend_standing_for_legacy( protocol_version: u32, - agent_canvas: Option<&str>, + standing: &crate::queue::StandingContext<'_>, body: &str, ) -> String { - match agent_canvas { - Some(canvas) if protocol_version < 2 => format!("{canvas}\n\n{body}"), - _ => body.to_string(), + if protocol_version >= 2 { + return body.to_string(); + } + let sections = standing.sections(); + if sections.is_empty() { + return body.to_string(); } + format!("{}\n\n{body}", sections.join("\n\n")) } /// Frame the `session/new` `systemPrompt` so each present prompt carries its own @@ -1330,6 +1402,21 @@ fn with_core(framed: Option, core: Option<&str>) -> Option { } } +/// Append owner-signed huddle instructions to this channel session's system prompt. +fn with_huddle_instructions(prompt: Option, instructions: Option<&str>) -> Option { + let instructions = instructions + .map(str::trim) + .filter(|value| !value.is_empty()); + match (prompt, instructions) { + (Some(prompt), Some(instructions)) => { + Some(format!("{prompt}\n\n[Huddle Instructions]\n{instructions}")) + } + (None, Some(instructions)) => Some(format!("[Huddle Instructions]\n{instructions}")), + (Some(prompt), None) => Some(prompt), + (None, None) => None, + } +} + /// Append the `[Channel Canvas]` metadata section onto the accumulated system prompt. /// /// The canvas section already carries its `[Channel Canvas]` header (from @@ -1552,6 +1639,7 @@ pub async fn run_prompt_task( // prevents a stale revision A surviving a failed create and being re-used by // the next attempt after the canvas was cleared. let mut pending_canvas: Option<(Uuid, String)> = None; + let mut huddle_instructions: Option = None; // Channel name for the session title, from the same single resolve the // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; @@ -1564,6 +1652,10 @@ pub async fn run_prompt_task( resolve_new_session_channel_context(&ctx.channel_info, *cid).await; title_channel = resolved_channel; origin_channel_type = resolved_channel_type; + if let Some(owner) = ctx.agent_owner_pubkey.as_ref() { + huddle_instructions = + fetch_huddle_instructions(*cid, owner, &ctx.rest_client).await; + } // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. if needs_canvas && !is_dm { @@ -1606,10 +1698,13 @@ pub async fn run_prompt_task( &mut agent, &ctx, agent_core.as_deref(), - agent_canvas.as_deref(), - title_channel.as_deref(), - Some(*cid), - origin_channel_type.as_deref(), + NewSessionChannelContext { + huddle_instructions: huddle_instructions.as_deref(), + canvas: agent_canvas.as_deref(), + name: title_channel.as_deref(), + id: Some(*cid), + channel_type: origin_channel_type.as_deref(), + }, ) .await { @@ -1619,6 +1714,13 @@ pub async fn run_prompt_task( "created session {sid} for channel {cid}" ); agent.state.sessions.insert(*cid, sid.clone()); + agent + .state + .deliveries + .insert(*cid, ChannelDeliveryState::default()); + // Seed a zero usage baseline: buzz-acp spawned this session + // so prior usage is zero by definition — first turn is reliable. + agent.acp.notify_session_spawned(&sid); // Commit canvas only after session creation succeeds (I3). if let Some((pending_cid, section)) = pending_canvas.take() { agent.state.canvas_sections.insert(pending_cid, section); @@ -1657,8 +1759,19 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.state.heartbeat_session { (sid.clone(), false) } else { - match create_session_and_apply_model(&mut agent, &ctx, None, None, None, None, None) - .await + match create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await { Ok(sid) => { tracing::info!( @@ -1667,6 +1780,8 @@ pub async fn run_prompt_task( agent.index ); agent.state.heartbeat_session = Some(sid.clone()); + // Seed a zero usage baseline: buzz-acp spawned this session. + agent.acp.notify_session_spawned(&sid); (sid, true) } Err(AcpError::AgentExited) => { @@ -1713,6 +1828,33 @@ pub async fn run_prompt_task( }), ); + // Standing context is fixed for the life of a session. Agents with + // systemPrompt support already hold it from session/new; legacy agents + // receive it in the session's first user message and never again. + // + // `is_new_session` comes from the session registry, which is cleared + // whenever a session is invalidated — so the replacement session re-delivers + // rather than leaving the agent unbriefed. + let standing = crate::queue::StandingContext { + base_prompt: ctx.base_prompt, + system_prompt: ctx.system_prompt.as_deref(), + team_instructions: ctx.team_instructions.as_deref(), + agent_core: agent_core.as_deref(), + huddle_instructions: huddle_instructions.as_deref(), + agent_canvas: agent_canvas.as_deref(), + }; + // Delivery state is committed only after ACP confirms success. Existing + // sessions created before this field existed fail safe by behaving as + // undelivered once, rather than silently omitting standing context. + let mut standing_context_sent = match &source { + PromptSource::Channel(cid) => agent + .state + .deliveries + .get(cid) + .is_some_and(|delivery| delivery.standing_context_sent), + PromptSource::Heartbeat => agent.state.heartbeat_standing_context_sent, + }; + if is_new_session { if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message) { @@ -1720,30 +1862,15 @@ pub async fn run_prompt_task( target: "pool::session", "sending initial_message to session {session_id} for channel {cid}" ); - // For agents with systemPrompt support (protocol_version >= 2), - // base_prompt is delivered via the system role in session/new. - // Legacy agents receive it via [Base] in the user message instead. - // Canvas is also injected here for legacy agents: protocol-v2 agents - // already have it in systemPrompt; legacy agents need it before the - // first prompt, matching the "every turn" per-turn delivery semantics. - let init_msg = prepend_base_for_legacy( + let init_msg = prepend_standing_for_legacy( if agent.has_system_prompt_support() { 2 } else { 1 }, - ctx.base_prompt, + &standing, initial_msg, ); - let init_msg = prepend_canvas_for_legacy( - if agent.has_system_prompt_support() { - 2 - } else { - 1 - }, - agent_canvas.as_deref(), - &init_msg, - ); let init_result = agent .acp .session_prompt_with_idle_timeout( @@ -1760,6 +1887,22 @@ pub async fn run_prompt_task( target: "pool::session", "initial_message complete for channel {cid}: {stop_reason:?}" ); + // The legacy agent has its standing context now; the turn + // prompt below must not repeat it. Every other arm returns. + standing_context_sent = true; + if !agent.has_system_prompt_support() { + agent.state.mark_channel_delivery_success(*cid, true, []); + } + let usage = agent.acp.take_turn_usage(); + publish_agent_turn_metric( + &ctx, + usage, + Some(*cid), + &session_id, + &format!("{turn_id}:initial"), + Some(acp_stop_to_core(&stop_reason)), + ) + .await; } Err(AcpError::AgentExited) => { agent.state.invalidate_all(); @@ -1784,7 +1927,17 @@ pub async fn run_prompt_task( .cancel_with_cleanup(&session_id, ctx.idle_timeout) .await { - Ok(_) => { + Ok(stop_reason) => { + let usage = agent.acp.take_turn_usage(); + publish_agent_turn_metric( + &ctx, + usage, + Some(*cid), + &session_id, + &format!("{turn_id}:initial"), + Some(acp_stop_to_core(&stop_reason)), + ) + .await; agent.state.invalidate(&source); } Err(AcpError::AgentExited) => { @@ -1861,18 +2014,31 @@ pub async fn run_prompt_task( // (`prompt[0].text.startsWith("/")`) fires; the wrapped Buzz context // follows as a second block. let mut slash_command: Option = None; + // Event IDs represented by this prompt. Commit only after ACP reports a + // successful turn; failed/cancelled prompts must be retryable without loss. + let mut pending_delivered_event_ids = HashSet::new(); let prompt_sections: Vec = if let Some(text) = prompt_text { // Heartbeats create their session before this point, so a Goose method-not-found // probe has already selected the correct framing for this process. - let text = prepend_base_for_legacy( - if agent.has_system_prompt_support() { - 2 - } else { - 1 - }, - ctx.base_prompt, - &text, - ); + // + // Only the first heartbeat of a session carries `[Base]`; later ticks + // reuse the same session, so the agent already has it. + let text = if standing_context_sent { + text + } else { + prepend_standing_for_legacy( + if agent.has_system_prompt_support() { + 2 + } else { + 1 + }, + &crate::queue::StandingContext { + base_prompt: ctx.base_prompt, + ..Default::default() + }, + &text, + ) + }; vec![text] } else if let Some(ref b) = batch { // Build prompt from batch with context enrichment. @@ -1884,6 +2050,31 @@ pub async fn run_prompt_task( } else { None }; + let rendered_batch_ids: HashSet = b + .events + .iter() + .chain(b.cancelled_events.iter()) + .map(|event| event.event.id.to_hex()) + .collect(); + let delivered_ids = agent + .state + .deliveries + .get(&b.channel_id) + .map(|delivery| &delivery.delivered_event_ids) + .cloned() + .unwrap_or_default(); + let conversation_context_had_delivered_events = + conversation_context.as_ref().is_some_and(|context| { + conversation_context_event_ids(Some(context)) + .iter() + .any(|event_id| delivered_ids.contains(event_id)) + }); + let conversation_context = + conversation_context_delta(conversation_context, &delivered_ids, &rendered_batch_ids); + pending_delivered_event_ids.extend(rendered_batch_ids); + pending_delivered_event_ids.extend(conversation_context_event_ids( + conversation_context.as_ref(), + )); let profile_lookup = fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await; @@ -1907,15 +2098,18 @@ pub async fn run_prompt_task( crate::queue::format_prompt( b, &crate::queue::FormatPromptArgs { - agent_core: agent_core.as_deref(), + agent_core: standing.agent_core, + huddle_instructions: standing.huddle_instructions, channel_info: channel_info.as_ref(), conversation_context: conversation_context.as_ref(), + conversation_context_had_delivered_events, profile_lookup: profile_lookup.as_ref(), has_system_prompt_support: agent.has_system_prompt_support(), - base_prompt: ctx.base_prompt, - system_prompt: ctx.system_prompt.as_deref(), - team_instructions: ctx.team_instructions.as_deref(), - agent_canvas: agent_canvas.as_deref(), + base_prompt: standing.base_prompt, + system_prompt: standing.system_prompt, + team_instructions: standing.team_instructions, + agent_canvas: standing.agent_canvas, + standing_context_sent, }, ) } else { @@ -1955,6 +2149,28 @@ pub async fn run_prompt_task( .collect(), None => prompt_sections.iter().map(String::as_str).collect(), }; + let prompt_bytes: usize = prompt_blocks.iter().map(|block| block.len()).sum(); + let has_standing_context = match &source { + PromptSource::Channel(_) => !standing.sections().is_empty(), + PromptSource::Heartbeat => ctx.base_prompt.is_some(), + }; + let standing_context_included = + !agent.has_system_prompt_support() && !standing_context_sent && has_standing_context; + tracing::info!( + target: "pool::prompt", + prompt_bytes, + standing_context_included, + delivered_event_delta = pending_delivered_event_ids.len(), + "prompt context delivery" + ); + agent.acp.observe( + "prompt_context_delivery", + serde_json::json!({ + "promptBytes": prompt_bytes, + "standingContextIncluded": standing_context_included, + "eventDeltaCount": pending_delivered_event_ids.len(), + }), + ); // Turn start, labelled exactly as `log_stop_reason` labels the end, so a // log reads as start/stop pairs. Purely observational: an unpaired start is @@ -2105,6 +2321,14 @@ pub async fn run_prompt_task( "control signal arrived but turn already completed — treating as success" ); } + if let PromptSource::Channel(cid) = &source { + let standing_sent = !agent.has_system_prompt_support(); + agent.state.mark_channel_delivery_success( + *cid, + standing_sent, + pending_delivered_event_ids.iter().cloned(), + ); + } apply_completed_before_control_signal( &mut agent.state, &source, @@ -2139,6 +2363,17 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); + if let PromptSource::Channel(cid) = &source { + let standing_sent = !agent.has_system_prompt_support(); + agent.state.mark_channel_delivery_success( + *cid, + standing_sent, + pending_delivered_event_ids.iter().cloned(), + ); + } else if !agent.has_system_prompt_support() { + agent.state.heartbeat_standing_context_sent = true; + } + let should_rotate = matches!( stop_reason, StopReason::MaxTokens | StopReason::MaxTurnRequests @@ -2407,17 +2642,25 @@ pub(crate) async fn fetch_channel_info( let ev = events.first()?; let tags = ev.get("tags")?.as_array()?; let mut name = None; + let mut description = None; for tag in tags { if let Some(arr) = tag.as_array() { - if arr.first().and_then(|v| v.as_str()) == Some("name") { - name = arr.get(1).and_then(|v| v.as_str()); + match arr.first().and_then(|v| v.as_str()) { + Some("name") => name = arr.get(1).and_then(|v| v.as_str()), + Some("about") => description = arr.get(1).and_then(|v| v.as_str()), + _ => {} } } } let channel_type = crate::relay::channel_type_from_tags(tags); + let description = description + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string); Some(PromptChannelInfo { name: name.unwrap_or(UNKNOWN_CHANNEL_NAME).to_string(), channel_type, + description, }) } Ok(Err(e)) => { @@ -2439,6 +2682,67 @@ pub(crate) async fn fetch_channel_info( .await } +/// Fetch owner-signed huddle instructions for a new channel session. +/// +/// The event is promoted into the system role, so accepting any channel member's +/// event would be a privilege escalation. Only the configured agent owner's +/// valid signature is accepted; absence or failure simply yields no section. +async fn fetch_huddle_instructions( + channel_id: Uuid, + owner: &nostr::PublicKey, + rest: &RestClient, +) -> Option { + use nostr::{Alphabet, SingleLetterTag}; + + let h_tag = SingleLetterTag::lowercase(Alphabet::H); + let filter = nostr::Filter::new() + .kind(nostr::Kind::Custom( + buzz_core::kind::KIND_HUDDLE_GUIDELINES as u16, + )) + .author(*owner) + .custom_tags(h_tag, [channel_id.to_string()]) + .limit(1); + let json = match timeout( + CONTEXT_FETCH_TIMEOUT, + rest.query(std::slice::from_ref(&filter)), + ) + .await + { + Ok(Ok(json)) => json, + Ok(Err(error)) => { + tracing::warn!(channel = %channel_id, "huddle instructions query failed: {error}"); + return None; + } + Err(_) => { + tracing::warn!(channel = %channel_id, "huddle instructions query timed out"); + return None; + } + }; + huddle_instructions_from_query_response(json.as_array()?, channel_id, owner) +} + +fn huddle_instructions_from_query_response( + events: &[serde_json::Value], + channel_id: Uuid, + owner: &nostr::PublicKey, +) -> Option { + let raw = events.first()?; + let event = serde_json::from_value::(raw.clone()).ok()?; + event.verify().ok()?; + let channel_id = channel_id.to_string(); + if event.pubkey != *owner + || event.kind.as_u16() as u32 != buzz_core::kind::KIND_HUDDLE_GUIDELINES + || !event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "h" && tag.content() == Some(channel_id.as_str())) + { + return None; + } + let content = event.content.trim(); + (!content.is_empty()).then(|| content.to_owned()) +} + /// Fetch the latest canvas event for `channel_id` and return a rendered /// `[Channel Canvas]` metadata section, or `None` if absent/blank/error. /// @@ -2633,6 +2937,67 @@ pub(crate) fn render_canvas_section(event_id: &str, timestamp: &str, channel_uui ) } +fn conversation_context_event_ids(context: Option<&ConversationContext>) -> HashSet { + match context { + Some(ConversationContext::Thread { messages, .. }) + | Some(ConversationContext::Dm { messages, .. }) => messages + .iter() + .filter(|message| !message.event_id.is_empty()) + .map(|message| message.event_id.clone()) + .collect(), + None => HashSet::new(), + } +} + +/// Remove events already delivered to this live ACP session. Triggering events +/// are also excluded because they are rendered separately in `[Event]`. +/// IDs are compared in Buzz's canonical 64-character lowercase hex form: relay +/// context JSON supplies the same form emitted by `EventId::to_hex()`. A +/// non-canonical or missing ID deliberately fails open and may be re-sent. +fn conversation_context_delta( + context: Option, + delivered: &HashSet, + triggering: &HashSet, +) -> Option { + let filter = |messages: Vec| { + messages + .into_iter() + .filter(|message| { + message.event_id.is_empty() + || (!delivered.contains(&message.event_id) + && !triggering.contains(&message.event_id)) + }) + .collect::>() + }; + + match context? { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + let messages = filter(messages); + (!messages.is_empty()).then_some(ConversationContext::Thread { + messages, + total, + truncated, + }) + } + ConversationContext::Dm { + messages, + total, + truncated, + } => { + let messages = filter(messages); + (!messages.is_empty()).then_some(ConversationContext::Dm { + messages, + total, + truncated, + }) + } + } +} + /// Fetch conversation context (thread or DM) for a batch before prompting. /// /// Returns `None` if: @@ -3145,7 +3510,14 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option { }) .unwrap_or_else(|| "unknown".to_string()); + let event_id = obj + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + Some(ContextMessage { + event_id, pubkey: pubkey.to_string(), timestamp, content: content.to_string(), @@ -3682,9 +4054,8 @@ pub(crate) fn build_turn_metric_counts( // Field-local: present when the cumulative counter was monotonic // across this turn. Zero means no cache hits this turn (not absent). cache_read_tokens: usage.turn_cache_read_tokens, - // buzz-agent does not emit a cache-write count on the wire today; - // leave None rather than deriving it from other fields. - cache_write_tokens: None, + // Field-local: same contract as cache_read_tokens. + cache_write_tokens: usage.turn_cache_write_tokens, }) } else { // Defense-in-depth: UsageTracker already sets all turn_* fields to None @@ -3694,8 +4065,8 @@ pub(crate) fn build_turn_metric_counts( None }; let cumulative_counts = Some(TokenCounts { - input_tokens: Some(usage.cumulative_input_tokens), - output_tokens: Some(usage.cumulative_output_tokens), + input_tokens: usage.cumulative_input_tokens, + output_tokens: usage.cumulative_output_tokens, // Present when every turn in the session reported a genuine provider // total. None when the session has never emitted one or any turn lacked // one. Never derived from input+output (NIP-AM MUST NOT). @@ -3706,9 +4077,9 @@ pub(crate) fn build_turn_metric_counts( // Passes through directly — do not wrap in Some() as the field already // carries provenance (None vs Some(0) are distinct meanings). cache_read_tokens: usage.cumulative_cache_read_tokens, - // buzz-agent does not emit a cache-write count on the wire today; - // leave None rather than deriving it from other fields. - cache_write_tokens: None, + // Session-cumulative cache-write tokens; same provenance contract as + // cache_read_tokens. + cache_write_tokens: usage.cumulative_cache_write_tokens, }); (turn_counts, cumulative_counts) } @@ -3749,6 +4120,7 @@ async fn publish_agent_turn_metric( cumulative: cumulative_counts, delta_reliable: usage.delta_reliable, stop_reason, + pricing_identity: usage.pricing_identity.clone(), }; let ciphertext = match buzz_core::agent_turn_metric::encrypt_agent_turn_metric( &ctx.agent_keys, @@ -4078,23 +4450,46 @@ mod tests { // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. + fn base_only(base_prompt: Option<&str>) -> crate::queue::StandingContext<'_> { + crate::queue::StandingContext { + base_prompt, + ..Default::default() + } + } + #[test] fn test_initial_message_legacy_agent_gets_base_prepended() { // protocol_version 1 + Some(base_prompt): [Base] rides along in the // user message, composed as `[Base]\n{bp}\n\n{initial_msg}`. - let composed = prepend_base_for_legacy(1, Some("you are a helpful agent"), "hello channel"); + let composed = prepend_standing_for_legacy( + 1, + &base_only(Some("you are a helpful agent")), + "hello channel", + ); assert_eq!(composed, "[Base]\nyou are a helpful agent\n\nhello channel"); - assert!(composed.starts_with("[Base]\nyou are a helpful agent\n\n")); } #[test] fn test_initial_message_modern_agent_omits_base() { // protocol_version 2 receives base_prompt via session/new, so the user // message is left untouched even when a base_prompt is present. - let composed = prepend_base_for_legacy(2, Some("you are a helpful agent"), "hello channel"); + let composed = prepend_standing_for_legacy( + 2, + &base_only(Some("you are a helpful agent")), + "hello channel", + ); assert_eq!(composed, "hello channel"); } + #[test] + fn test_heartbeat_standing_block_is_base_only() { + // A heartbeat has no channel, so core and canvas are absent by + // construction — and it has never carried the persona. Pin that the + // shared helper does not start handing heartbeats [System]. + let composed = prepend_standing_for_legacy(1, &base_only(Some("be helpful")), "tick"); + assert_eq!(composed, "[Base]\nbe helpful\n\ntick"); + } + #[test] fn goose_uses_system_prompt_only_after_custom_method_succeeds() { assert!(!has_system_prompt_support(2, "goose", None)); @@ -4149,82 +4544,77 @@ mod tests { #[test] fn test_initial_message_legacy_agent_without_base_is_unchanged() { // No base_prompt configured: nothing to prepend regardless of version. - let composed = prepend_base_for_legacy(1, None, "hello channel"); + let composed = prepend_standing_for_legacy(1, &base_only(None), "hello channel"); assert_eq!(composed, "hello channel"); } - // ── prepend_canvas_for_legacy ───────────────────────────────────────────── + // ── prepend_standing_for_legacy ─────────────────────────────────────────── + + fn full_standing() -> crate::queue::StandingContext<'static> { + crate::queue::StandingContext { + base_prompt: Some("be helpful"), + system_prompt: Some("you are Eva"), + team_instructions: Some("ship small"), + agent_core: Some("[Agent Memory — core]\nremember this"), + huddle_instructions: Some("reply immediately"), + agent_canvas: Some("[Channel Canvas]\ncanvas content"), + } + } #[test] - fn test_initial_message_legacy_agent_gets_canvas_prepended() { - // Legacy agents (protocol_version < 2) receive the canvas section before - // the initial-message body so it arrives before the first prompt. - let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00Z\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; - let composed = prepend_canvas_for_legacy(1, Some(canvas), "do the thing"); - assert!( - composed.starts_with("[Channel Canvas]"), - "canvas must precede the body" - ); - assert!( - composed.ends_with("do the thing"), - "body must follow the canvas" - ); + fn test_initial_message_legacy_agent_gets_whole_standing_block() { + // The initial message is the legacy agent's first contact, so it must + // carry every standing section — not just [Base] and the canvas, which + // left the agent acting on its first turn with no persona and no memory. + let composed = prepend_standing_for_legacy(1, &full_standing(), "do the thing"); + let positions: Vec = [ + "[Base]", + "[System]", + "[Team Instructions]", + "[Agent Memory — core]", + "[Huddle Instructions]", + "[Channel Canvas]", + "do the thing", + ] + .iter() + .map(|needle| { + composed + .find(needle) + .unwrap_or_else(|| panic!("missing {needle} in: {composed}")) + }) + .collect(); assert!( - composed.contains("\n\ndo the thing"), - "canvas and body separated by blank line" + positions.windows(2).all(|w| w[0] < w[1]), + "sections must match the per-turn order, body last; got: {composed}" ); } #[test] - fn test_initial_message_modern_agent_omits_canvas_from_body() { - // Protocol-v2 agents receive canvas in systemPrompt; it must NOT be - // duplicated in the initial-message user turn. - let canvas = "[Channel Canvas]\nsome section"; - let composed = prepend_canvas_for_legacy(2, Some(canvas), "do the thing"); + fn test_initial_message_standing_order_matches_per_turn_order() { + // Both legacy paths render through StandingContext, so the initial + // message and a first-turn prompt agree section-for-section. + let standing = full_standing(); + let composed = prepend_standing_for_legacy(1, &standing, "do the thing"); assert_eq!( - composed, "do the thing", - "modern agent initial message must not contain canvas" - ); - assert!( - !composed.contains("[Channel Canvas]"), - "canvas must be absent from modern agent initial message" + composed, + format!("{}\n\ndo the thing", standing.sections().join("\n\n")) ); } #[test] - fn test_initial_message_legacy_agent_no_canvas_is_unchanged() { - // No canvas present: body passes through unmodified. - let composed = prepend_canvas_for_legacy(1, None, "do the thing"); + fn test_initial_message_modern_agent_omits_standing_block() { + // Protocol-v2 agents hold all of this from session/new; repeating it in + // the initial-message user turn would double-render every section. + let composed = prepend_standing_for_legacy(2, &full_standing(), "do the thing"); assert_eq!(composed, "do the thing"); } #[test] - fn test_initial_message_legacy_canvas_and_base_compose_correctly() { - // Verify the full composition order when both base and canvas are present: - // [Base] → canvas section → initial-message body. - let canvas = "[Channel Canvas]\ncanvas content"; - let base_composed = prepend_base_for_legacy(1, Some("be helpful"), "do the thing"); - let full = prepend_canvas_for_legacy(1, Some(canvas), &base_composed); - assert!( - full.starts_with("[Channel Canvas]"), - "canvas must be first in composed message" - ); - assert!( - full.contains("[Base]"), - "base must be present in composed message" - ); - assert!( - full.ends_with("do the thing"), - "body must be last in composed message" - ); - // Order: canvas → base → body - let canvas_pos = full.find("[Channel Canvas]").unwrap(); - let base_pos = full.find("[Base]").unwrap(); - let body_pos = full.find("do the thing").unwrap(); - assert!( - canvas_pos < base_pos && base_pos < body_pos, - "order must be: canvas → base → body" - ); + fn test_initial_message_legacy_agent_without_standing_is_unchanged() { + // Nothing configured: body passes through with no stray blank lines. + let composed = + prepend_standing_for_legacy(1, &crate::queue::StandingContext::default(), "do it"); + assert_eq!(composed, "do it"); } // Pin the session/new systemPrompt framing: each present prompt carries its @@ -5149,6 +5539,7 @@ mod tests { }; let context = ConversationContext::Thread { messages: vec![ContextMessage { + event_id: String::new(), pubkey: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), timestamp: "2026-03-25T05:51:25Z".into(), content: "follow up".into(), @@ -5222,6 +5613,637 @@ mod tests { assert!(parse_kind0_profile_lookup(json!({})).is_none()); } + fn context_message(event_id: &str, content: &str) -> ContextMessage { + ContextMessage { + event_id: event_id.to_string(), + pubkey: "author".into(), + timestamp: "2026-08-09T00:00:00Z".into(), + content: content.into(), + } + } + + #[tokio::test] + async fn run_prompt_task_commits_standing_context_only_after_acp_success() { + let capture = std::env::temp_dir().join(format!( + "buzz-acp-standing-lifecycle-{}.ndjson", + Uuid::new_v4() + )); + let quoted_capture = capture.to_string_lossy().replace('\'', "'\\''"); + let script = format!( + r#"count=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> '{quoted_capture}' + count=$((count + 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"error":{{"code":-32000,"message":"retry me"}}}}' + else + printf '%s\n' "{{\"jsonrpc\":\"2.0\",\"id\":$((count - 1)),\"result\":{{\"stopReason\":\"end_turn\"}}}}" + fi +done"# + ); + let acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn lifecycle ACP script"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "legacy-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + agent.state.heartbeat_session = Some("live-session".into()); + + let mut ctx = make_prompt_context_no_owner(); + ctx.base_prompt = Some("standing-once"); + let ctx = Arc::new(ctx); + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + + for turn in 1..=3 { + run_prompt_task( + agent, + None, + Some(format!("heartbeat-{turn}")), + Arc::clone(&ctx), + result_tx.clone(), + None, + format!("turn-{turn}"), + ) + .await; + let result = result_rx.recv().await.expect("prompt result"); + match turn { + 1 => assert!(matches!(result.outcome, PromptOutcome::Error(_))), + _ => assert!(matches!( + result.outcome, + PromptOutcome::Ok(StopReason::EndTurn) + )), + } + assert_eq!( + result.agent.state.heartbeat_standing_context_sent, + turn >= 2, + "failed first delivery must not commit; first success must commit" + ); + agent = result.agent; + } + agent.acp.shutdown().await; + + let requests: Vec = std::fs::read_to_string(&capture) + .expect("read captured ACP requests") + .lines() + .map(|line| serde_json::from_str(line).expect("captured request is JSON")) + .collect(); + std::fs::remove_file(&capture).expect("remove ACP capture"); + assert_eq!(requests.len(), 3); + let prompt_text = |index: usize| { + requests[index]["params"]["prompt"][0]["text"] + .as_str() + .expect("text prompt") + }; + assert_eq!(prompt_text(0), "[Base]\nstanding-once\n\nheartbeat-1"); + assert_eq!( + prompt_text(1), + "[Base]\nstanding-once\n\nheartbeat-2", + "retry after ACP failure must resend standing context" + ); + assert_eq!( + prompt_text(2), + "heartbeat-3", + "turn after ACP success must omit standing context" + ); + } + + #[tokio::test] + async fn channel_prompt_commits_delivery_state_only_after_acp_success() { + let capture = std::env::temp_dir().join(format!( + "buzz-acp-channel-delivery-lifecycle-{}.ndjson", + Uuid::new_v4() + )); + let quoted_capture = capture.to_string_lossy().replace('\'', "'\\''"); + let script = format!( + r#"count=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> '{quoted_capture}' + count=$((count + 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"error":{{"code":-32000,"message":"retry me"}}}}' + else + printf '%s\n' "{{\"jsonrpc\":\"2.0\",\"id\":$((count - 1)),\"result\":{{\"stopReason\":\"end_turn\"}}}}" + fi +done"# + ); + let acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn channel lifecycle ACP script"); + let channel_id = Uuid::new_v4(); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "legacy-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, ChannelDeliveryState::default()); + + let mut ctx = make_prompt_context_no_owner(); + ctx.base_prompt = Some("standing-once"); + let ctx = Arc::new(ctx); + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + + for turn in 1..=3 { + let event = EventBuilder::new(Kind::Custom(9), format!("channel-{turn}")) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let event_id = event.id.to_hex(); + let batch = FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + run_prompt_task( + agent, + Some(batch), + None, + Arc::clone(&ctx), + result_tx.clone(), + None, + format!("turn-{turn}"), + ) + .await; + let result = result_rx.recv().await.expect("prompt result"); + match turn { + 1 => assert!(matches!(result.outcome, PromptOutcome::Error(_))), + _ => assert!(matches!( + result.outcome, + PromptOutcome::Ok(StopReason::EndTurn) + )), + } + let delivery = &result.agent.state.deliveries[&channel_id]; + assert_eq!( + delivery.standing_context_sent, + turn >= 2, + "failed channel delivery must not commit; first success must commit" + ); + assert_eq!( + delivery.delivered_event_ids.contains(&event_id), + turn >= 2, + "channel event IDs must commit only after ACP success" + ); + agent = result.agent; + } + agent.acp.shutdown().await; + + let requests: Vec = std::fs::read_to_string(&capture) + .expect("read captured ACP requests") + .lines() + .map(|line| serde_json::from_str(line).expect("captured request is JSON")) + .collect(); + std::fs::remove_file(&capture).expect("remove ACP capture"); + let prompt_text = |index: usize| { + requests[index]["params"]["prompt"][0]["text"] + .as_str() + .expect("text prompt") + }; + assert!(prompt_text(0).contains("[Base]\nstanding-once")); + assert!( + prompt_text(1).contains("[Base]\nstanding-once"), + "retry after channel ACP failure must resend standing context" + ); + assert!( + !prompt_text(2).contains("[Base]\nstanding-once"), + "turn after channel ACP success must omit standing context" + ); + } + + #[tokio::test] + async fn merged_cancel_prompt_commits_and_deduplicates_all_rendered_event_ids() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let channel_id = Uuid::new_v4(); + let keys = Keys::generate(); + let carry_over = EventBuilder::new(Kind::Custom(9), "merged carry-over sentinel") + .sign_with_keys(&keys) + .unwrap(); + let carry_over_id = carry_over.id.to_hex(); + let new_event = EventBuilder::new(Kind::Custom(9), "merged new-event sentinel") + .sign_with_keys(&keys) + .unwrap(); + let new_event_id = new_event.id.to_hex(); + let next_event = EventBuilder::new(Kind::Custom(9), "ordinary next-turn sentinel") + .sign_with_keys(&keys) + .unwrap(); + let merged_batch = FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event: new_event.clone(), + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![crate::queue::BatchEvent { + event: carry_over.clone(), + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancel_reason: Some(crate::queue::CancelReason::Steer), + }; + let next_batch = FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event: next_event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + // Return both merged events as DM history. They must be excluded from + // the merged prompt's context and, after success, from the next turn. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind context server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let response_body = serde_json::to_string(&vec![carry_over, new_event]).unwrap(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 16 * 1024]; + let _ = socket.read(&mut request).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), response_body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + + let capture = std::env::temp_dir().join(format!( + "buzz-acp-merged-delivery-wire-{}.ndjson", + Uuid::new_v4() + )); + let quoted_capture = capture.to_string_lossy().replace('\'', "'\\''"); + let script = format!( + r#"count=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> '{quoted_capture}' + printf '%s\n' "{{\"jsonrpc\":\"2.0\",\"id\":$count,\"result\":{{\"stopReason\":\"end_turn\"}}}}" + count=$((count + 1)) +done"# + ); + let acp = AcpClient::spawn("bash", &["-c".into(), script], &[], false) + .await + .expect("spawn wire-capture ACP"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "legacy-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, ChannelDeliveryState::default()); + + let mut ctx = make_prompt_context_no_owner(); + ctx.context_message_limit = 10; + ctx.rest_client.base_url = base_url.clone(); + ctx.channel_info = ChannelInfoResolver::new( + HashMap::from([( + channel_id, + crate::relay::ChannelInfo { + name: "test-dm".into(), + channel_type: "dm".into(), + description: None, + }, + )]), + RestClient { + http: reqwest::Client::new(), + base_url, + keys: ctx.agent_keys.clone(), + auth_tag_json: None, + }, + ); + let ctx = Arc::new(ctx); + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + + for (turn_id, batch) in [("merged-turn", merged_batch), ("next-turn", next_batch)] { + run_prompt_task( + agent, + Some(batch), + None, + Arc::clone(&ctx), + result_tx.clone(), + None, + turn_id.into(), + ) + .await; + let result = result_rx.recv().await.expect("prompt result"); + assert!(matches!( + result.outcome, + PromptOutcome::Ok(StopReason::EndTurn) + )); + agent = result.agent; + } + let delivery = &agent.state.deliveries[&channel_id]; + assert!(delivery.delivered_event_ids.contains(&carry_over_id)); + assert!(delivery.delivered_event_ids.contains(&new_event_id)); + agent.acp.shutdown().await; + server.abort(); + + let requests: Vec = std::fs::read_to_string(&capture) + .expect("read captured prompts") + .lines() + .map(|line| serde_json::from_str(line).expect("captured prompt JSON")) + .collect(); + std::fs::remove_file(&capture).expect("remove prompt capture"); + assert_eq!(requests.len(), 2); + let wire = |index: usize| { + requests[index]["params"]["prompt"] + .as_array() + .expect("prompt blocks") + .iter() + .filter_map(|block| block["text"].as_str()) + .collect::>() + .join("\n") + }; + let merged_wire = wire(0); + assert_eq!(merged_wire.matches("merged carry-over sentinel").count(), 1); + assert_eq!(merged_wire.matches("merged new-event sentinel").count(), 1); + let next_wire = wire(1); + assert!(next_wire.contains("ordinary next-turn sentinel")); + assert!(!next_wire.contains("merged carry-over sentinel")); + assert!(!next_wire.contains("merged new-event sentinel")); + assert!(!next_wire.contains(&carry_over_id)); + assert!(!next_wire.contains(&new_event_id)); + } + + #[tokio::test] + async fn late_successful_steer_ack_excludes_event_from_next_channel_wire_prompt() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let channel_id = Uuid::new_v4(); + let keys = Keys::generate(); + let steered_event = EventBuilder::new(Kind::Custom(9), "steered context must not replay") + .sign_with_keys(&keys) + .unwrap(); + let steered_event_id = steered_event.id.to_hex(); + let trigger = EventBuilder::new(Kind::Custom(9), "ordinary next turn") + .sign_with_keys(&keys) + .unwrap(); + let batch = FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event: trigger, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + // The local REST bridge returns the already-delivered steer as DM + // history. Profile/reaction requests may also arrive; the same valid + // event array is harmless for those best-effort paths. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind context server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let response_body = serde_json::to_string(&vec![steered_event]).unwrap(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 16 * 1024]; + let _ = socket.read(&mut request).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), response_body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + + let capture = std::env::temp_dir().join(format!( + "buzz-acp-late-steer-wire-{}.ndjson", + Uuid::new_v4() + )); + let quoted_capture = capture.to_string_lossy().replace('\'', "'\\''"); + let script = format!( + r#"IFS= read -r line +printf '%s\n' "$line" > '{quoted_capture}' +printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"# + ); + let acp = AcpClient::spawn("bash", &["-c".into(), script], &[], false) + .await + .expect("spawn wire-capture ACP"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "legacy-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, ChannelDeliveryState::default()); + + // Model the adversarial ordering: the task result has already retired + // its TaskMeta and returned the agent before the successful ack arrives. + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + assert!(pool.record_successful_steer( + channel_id, + steered_event_id.clone(), + "live-session".into(), + )); + let agent = pool + .try_claim(Some(channel_id)) + .expect("claim returned agent"); + + let mut ctx = make_prompt_context_no_owner(); + ctx.context_message_limit = 10; + ctx.rest_client.base_url = base_url.clone(); + ctx.channel_info = ChannelInfoResolver::new( + HashMap::from([( + channel_id, + crate::relay::ChannelInfo { + name: "test-dm".into(), + channel_type: "dm".into(), + description: None, + }, + )]), + RestClient { + http: reqwest::Client::new(), + base_url, + keys: ctx.agent_keys.clone(), + auth_tag_json: None, + }, + ); + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + run_prompt_task( + agent, + Some(batch), + None, + Arc::new(ctx), + result_tx, + None, + "next-turn".into(), + ) + .await; + let mut result = result_rx.recv().await.expect("next prompt result"); + assert!(matches!( + result.outcome, + PromptOutcome::Ok(StopReason::EndTurn) + )); + result.agent.acp.shutdown().await; + server.abort(); + + let request: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&capture).expect("read captured prompt")) + .expect("captured prompt JSON"); + std::fs::remove_file(&capture).expect("remove prompt capture"); + let wire = request["params"]["prompt"] + .as_array() + .expect("prompt blocks") + .iter() + .filter_map(|block| block["text"].as_str()) + .collect::>() + .join("\n"); + assert!(wire.contains("ordinary next turn")); + assert!(!wire.contains("steered context must not replay")); + assert!(!wire.contains(&steered_event_id)); + } + + #[test] + fn delivery_state_commits_only_when_explicitly_marked_successful() { + let channel = Uuid::new_v4(); + let mut state = SessionState::default(); + state + .deliveries + .insert(channel, ChannelDeliveryState::default()); + + // Building or attempting a prompt does not mutate delivery state. + let delivery = state.deliveries.get(&channel).unwrap(); + assert!(!delivery.standing_context_sent); + assert!(delivery.delivered_event_ids.is_empty()); + + state.mark_channel_delivery_success( + channel, + true, + ["trigger".to_string(), "context".to_string()], + ); + let delivery = state.deliveries.get(&channel).unwrap(); + assert!(delivery.standing_context_sent); + assert_eq!(delivery.delivered_event_ids.len(), 2); + } + + #[test] + fn delivery_state_is_cleared_on_rotation_and_restarts_empty() { + let channel = Uuid::new_v4(); + let mut state = SessionState::default(); + state.sessions.insert(channel, "old-session".into()); + state.mark_channel_delivery_success(channel, true, ["old-event".to_string()]); + + assert!(state.invalidate_channel(&channel)); + assert!(!state.deliveries.contains_key(&channel)); + + state.sessions.insert(channel, "new-session".into()); + state + .deliveries + .insert(channel, ChannelDeliveryState::default()); + let delivery = state.deliveries.get(&channel).unwrap(); + assert!(!delivery.standing_context_sent); + assert!(delivery.delivered_event_ids.is_empty()); + } + + #[test] + fn conversation_context_delta_omits_delivered_and_triggering_events() { + let delivered = HashSet::from(["old".to_string()]); + let triggering = HashSet::from(["trigger".to_string()]); + let context = ConversationContext::Thread { + messages: vec![ + context_message("old", "already sent"), + context_message("trigger", "rendered as trigger"), + context_message("new", "new context"), + ], + total: 3, + truncated: false, + }; + + let delta = conversation_context_delta(Some(context), &delivered, &triggering) + .expect("new context remains"); + match delta { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].event_id, "new"); + assert_eq!(total, 3); + assert!(!truncated); + } + _ => panic!("expected thread context"), + } + } + + #[test] + fn conversation_context_delta_returns_none_when_no_new_events_remain() { + let delivered = HashSet::from(["old".to_string()]); + let context = ConversationContext::Dm { + messages: vec![context_message("old", "already sent")], + total: 1, + truncated: false, + }; + + assert!(conversation_context_delta(Some(context), &delivered, &HashSet::new()).is_none()); + } + + #[test] + fn conversation_context_delta_preserves_unidentified_legacy_messages() { + let context = ConversationContext::Dm { + messages: vec![context_message("", "cannot safely deduplicate")], + total: 1, + truncated: false, + }; + + assert!( + conversation_context_delta(Some(context), &HashSet::new(), &HashSet::new()).is_some() + ); + } + #[test] fn test_json_to_context_message_missing_pubkey_uses_default() { let obj = json!({ "content": "hello" }); @@ -5274,8 +6296,23 @@ mod tests { s.turn_counts.insert(ch_b, 3); s.core_sections.insert(ch_a, "core-a".into()); s.core_sections.insert(ch_b, "core-b".into()); + s.deliveries.insert( + ch_a, + ChannelDeliveryState { + standing_context_sent: true, + delivered_event_ids: HashSet::from(["event-a".into()]), + }, + ); + s.deliveries.insert( + ch_b, + ChannelDeliveryState { + standing_context_sent: true, + delivered_event_ids: HashSet::from(["event-b".into()]), + }, + ); s.heartbeat_session = Some("sess-hb".into()); s.heartbeat_turn_count = 7; + s.heartbeat_standing_context_sent = true; (s, ch_a, ch_b) } @@ -5341,6 +6378,7 @@ mod tests { assert!(s.heartbeat_session.is_none()); assert_eq!(s.heartbeat_turn_count, 0); + assert!(!s.heartbeat_standing_context_sent); // channels untouched assert_eq!(s.sessions.len(), 2); assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); @@ -5359,6 +6397,7 @@ mod tests { assert!(s.core_sections.is_empty()); assert!(s.heartbeat_session.is_none()); assert_eq!(s.heartbeat_turn_count, 0); + assert!(!s.heartbeat_standing_context_sent); } #[test] @@ -6162,12 +7201,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 100, - cumulative_output_tokens: 50, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(100), + cumulative_output_tokens: Some(50), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; // owner_pubkey = None → early return, no panic. publish_agent_turn_metric( @@ -6198,12 +7240,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: Some(0.001), turn_cache_read_tokens: None, - cumulative_input_tokens: 200, - cumulative_output_tokens: 80, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(200), + cumulative_output_tokens: Some(80), cumulative_total_tokens: None, cumulative_cost_usd: Some(0.001), cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; // Will try to publish and fail (no real relay) but must not panic. publish_agent_turn_metric( @@ -6235,12 +7280,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 150, - cumulative_output_tokens: 70, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(150), + cumulative_output_tokens: Some(70), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; // Must not panic; HTTP submit will fail (no real relay) — that's fine. publish_agent_turn_metric( @@ -6272,12 +7320,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 400, - cumulative_output_tokens: 100, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(400), + cumulative_output_tokens: Some(100), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; // Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic. publish_agent_turn_metric( @@ -6306,12 +7357,15 @@ mod tests { turn_total_tokens: Some(130), // genuine per-turn total turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 500, - cumulative_output_tokens: 120, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(500), + cumulative_output_tokens: Some(120), cumulative_total_tokens: Some(620), // genuine cumulative total cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); @@ -6355,12 +7409,15 @@ mod tests { turn_total_tokens: None, // provider did not supply a total turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 200, - cumulative_output_tokens: 60, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(200), + cumulative_output_tokens: Some(60), cumulative_total_tokens: None, // session has no total cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); @@ -6471,10 +7528,11 @@ mod tests { Some(5_967), "turn.cacheReadTokens must be the per-turn delta" ); - // cache_write_tokens is always None — buzz-agent doesn't emit it. + // cache_write_tokens: None in this test because the payloads don't + // include accumulatedCacheWriteTokens (Anthropic cache-read only test). assert!( turn2.cache_write_tokens.is_none(), - "cache_write_tokens must be None — not emitted by buzz-agent" + "cache_write_tokens must be None when harness omits the field" ); let cum2 = cum2.expect("cumulative always present"); @@ -6545,6 +7603,59 @@ mod tests { } } + // ── huddle instructions ───────────────────────────────────────────────── + + #[test] + fn huddle_instructions_append_as_system_section() { + assert_eq!( + with_huddle_instructions(Some("base".into()), Some(" reply now ")).as_deref(), + Some("base\n\n[Huddle Instructions]\nreply now") + ); + } + + #[test] + fn huddle_instructions_require_owner_signature_and_channel() { + let owner = Keys::generate(); + let stranger = Keys::generate(); + let channel = Uuid::parse_str("00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae").unwrap(); + let event = |keys: &Keys, channel_id: Uuid| { + let channel_id = channel_id.to_string(); + let h_tag = Tag::parse(["h", channel_id.as_str()]).unwrap(); + serde_json::to_value( + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_HUDDLE_GUIDELINES as u16), + "reply immediately", + ) + .tags([h_tag]) + .sign_with_keys(keys) + .unwrap(), + ) + .unwrap() + }; + + assert_eq!( + huddle_instructions_from_query_response( + &[event(&owner, channel)], + channel, + &owner.public_key(), + ) + .as_deref(), + Some("reply immediately") + ); + assert!(huddle_instructions_from_query_response( + &[event(&stranger, channel)], + channel, + &owner.public_key(), + ) + .is_none()); + assert!(huddle_instructions_from_query_response( + &[event(&owner, Uuid::new_v4())], + channel, + &owner.public_key(), + ) + .is_none()); + } + // ── render_canvas_section ──────────────────────────────────────────────── #[test] @@ -6935,6 +8046,38 @@ mod tests { server.abort(); } + /// A channel's `about` tag is parsed through the lazy-fetch path and + /// delivered as the resolved description. + #[tokio::test] + async fn test_channel_resolver_delivers_description() { + let id = Uuid::new_v4(); + let response = channel_metadata_response( + id, + &[ + ["name", "team-chat"], + ["t", "stream"], + ["about", "Engineering discussions"], + ], + ); + let (resolver, _requests, server) = counting_resolver(response).await; + + let info = resolver.resolve(id).await.expect("should resolve"); + assert_eq!(info.description.as_deref(), Some("Engineering discussions")); + server.abort(); + } + + /// A metadata event with no `about` tag yields no description. + #[tokio::test] + async fn test_channel_resolver_absent_description_when_no_about_tag() { + let id = Uuid::new_v4(); + let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]); + let (resolver, _requests, server) = counting_resolver(response).await; + + let info = resolver.resolve(id).await.expect("should resolve"); + assert_eq!(info.description, None); + server.abort(); + } + /// A DM carries no useful name, so it gets the bare agent title (and no /// canvas section). #[tokio::test] diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 5c960de2024..b0f0fa248e3 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -590,6 +590,39 @@ impl EventQueue { .any(|id| !self.in_flight_channels.contains(id)) } + /// Returns `true` if any undispatched work remains for a channel that is + /// NOT currently in-flight — *including* work held back only by a + /// `retry_after` backoff throttle. + /// + /// This is deliberately broader than [`has_flushable_work`](Self::has_flushable_work): + /// that method excludes `retry_after`-throttled channels because they are + /// not flushable *right now*, but the events are still queued and MUST be + /// delivered once the backoff deadline passes. Idle-pool-sleep teardown + /// must gate on this, not on flushability — a failed turn requeued with a + /// future backoff deadline is real queued work, and sleeping on it (while + /// the maintenance timer is disabled and lazy re-wake is itself gated by + /// flushability) would strand the batch until unrelated traffic arrives. + /// + /// Covers the three tables where undispatched, non-in-flight work can + /// live: non-empty `queues` (throttled or not), pending `cancelled_batches`, + /// and `withheld_native_steer` events. Read-only (no in-flight expiry) — + /// in-flight liveness is gated separately by [`has_in_flight`](Self::has_in_flight). + pub fn has_undispatched_work(&self) -> bool { + let has_queued = self + .queues + .iter() + .any(|(id, q)| !q.is_empty() && !self.in_flight_channels.contains(id)); + let has_cancelled = self + .cancelled_batches + .keys() + .any(|id| !self.in_flight_channels.contains(id)); + let has_withheld = self + .withheld_native_steer + .iter() + .any(|(id, v)| !v.is_empty() && !self.in_flight_channels.contains(id)); + has_queued || has_cancelled || has_withheld + } + /// Number of channels with pending events. pub fn pending_channels(&self) -> usize { self.queues.len() @@ -990,6 +1023,9 @@ pub enum ConversationContext { /// A single message in a conversation context section. #[derive(Debug, Clone)] pub struct ContextMessage { + /// Nostr event ID. Legacy REST fixtures may omit it, in which case it is + /// empty and cannot participate in delivery deduplication. + pub event_id: String, pub pubkey: String, pub timestamp: String, pub content: String, @@ -1000,6 +1036,8 @@ pub struct ContextMessage { pub struct PromptChannelInfo { pub name: String, pub channel_type: String, + /// Channel description from the kind-39000 `about` tag, if present. + pub description: Option, } /// Minimal profile fields needed to label users in ACP prompts. @@ -1228,6 +1266,48 @@ fn resolve_reply_anchor( ) } +/// Maximum length (in characters) of a channel description rendered into `[Context]`. +/// +/// Limits prompt bloat from unusually long descriptions; a raw embedded newline +/// in a description must not be able to spoof another `[Context]` field, so +/// multiline text is collapsed to single-space-joined lines before truncation. +const MAX_DESCRIPTION_LEN: usize = 500; + +/// Append a `Description: …` line to a `[Context]` block when non-empty. +/// +/// Collapses internal newlines (any `\r\n`, `\r`, or `\n`) to a single space +/// so a multi-line description cannot inject a fake `[Context]` field line. +/// Truncates at [`MAX_DESCRIPTION_LEN`] characters with a `…` marker. +fn append_channel_description(s: &mut String, channel_info: Option<&PromptChannelInfo>) { + let desc = match channel_info.and_then(|ci| ci.description.as_deref()) { + Some(d) if !d.is_empty() => d, + _ => return, + }; + // Collapse newlines to spaces so the description can never spoof another field. + let collapsed: String = desc + .split(['\n', '\r']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + if collapsed.is_empty() { + return; + } + // Truncate at a character boundary (not byte boundary) to avoid splitting + // multi-byte sequences. + let truncated = if collapsed.chars().count() > MAX_DESCRIPTION_LEN { + let end = collapsed + .char_indices() + .nth(MAX_DESCRIPTION_LEN) + .map(|(i, _)| i) + .unwrap_or(collapsed.len()); + format!("{}…", &collapsed[..end]) + } else { + collapsed + }; + s.push_str(&format!("\nDescription: {truncated}")); +} + /// Format a `[Context]` hints section based on event scope. /// /// `reply_anchor` is the pre-resolved `--reply-to` target for this turn (see @@ -1241,6 +1321,7 @@ fn format_context_hints( thread_tags: &ThreadTags, is_dm: bool, has_conversation_context: bool, + conversation_context_had_delivered_events: bool, reply_anchor: Option<&str>, ) -> String { let channel_display = match channel_info { @@ -1258,6 +1339,10 @@ fn format_context_hints( "Thread context included below. Use `buzz messages thread --channel --event ` for full history if truncated." } else if has_conversation_context { "Conversation context included below. Use `buzz messages get --channel ` for full history if truncated." + } else if conversation_context_had_delivered_events && is_reply { + "Earlier thread context was already delivered in this session. Use `buzz messages thread --channel --event ` to re-read the reply chain." + } else if conversation_context_had_delivered_events { + "Earlier conversation context was already delivered in this session. Use `buzz messages get --channel ` to re-read it." } else if is_reply { "Use `buzz messages thread --channel --event ` to fetch the reply chain." } else { @@ -1285,15 +1370,18 @@ fn format_context_hints( } else if let Some(ref root) = thread_tags.root_event_id { let ctx_hint = if has_conversation_context { "Thread context included below. Use `buzz messages thread --channel --event ` for full history if truncated." + } else if conversation_context_had_delivered_events { + "Earlier thread context was already delivered in this session. Use `buzz messages thread --channel --event ` to re-read it." } else { "Use `buzz messages thread --channel --event ` to fetch thread context." }; let mut s = format!( "[Context]\n\ Scope: thread\n\ - Channel: {channel_display}\n\ - Thread root: {root}" + Channel: {channel_display}" ); + append_channel_description(&mut s, channel_info); + s.push_str(&format!("\nThread root: {root}")); if let Some(ref parent) = thread_tags.parent_event_id { if parent != root { s.push_str(&format!("\nParent: {parent}")); @@ -1308,8 +1396,11 @@ fn format_context_hints( let mut s = format!( "[Context]\n\ Scope: channel\n\ - Channel: {channel_display}\n\ - Hint: Use `buzz messages get --channel ` for recent messages if needed." + Channel: {channel_display}" + ); + append_channel_description(&mut s, channel_info); + s.push_str( + "\nHint: Use `buzz messages get --channel ` for recent messages if needed.", ); if let Some(event_id) = reply_anchor { append_new_thread_reply_instruction(&mut s, event_id); @@ -1357,8 +1448,13 @@ fn format_conversation_context( #[derive(Default)] pub struct FormatPromptArgs<'a> { pub agent_core: Option<&'a str>, + /// Owner-signed instructions for an active huddle channel. + pub huddle_instructions: Option<&'a str>, pub channel_info: Option<&'a PromptChannelInfo>, pub conversation_context: Option<&'a ConversationContext>, + /// True when delivery-delta filtering removed at least one event that this + /// live session had already received. Trigger-only context does not set it. + pub conversation_context_had_delivered_events: bool, pub profile_lookup: Option<&'a PromptProfileLookup>, /// When true, base_prompt and system_prompt are delivered via the system /// role (session/new) and omitted from the user message. When false @@ -1374,9 +1470,70 @@ pub struct FormatPromptArgs<'a> { /// /// For modern agents (protocol_version >= 2) the section is delivered via /// the system role in session/new; omit here to avoid duplication. - /// For legacy agents it rides in the user message on every turn of the - /// session, alongside `[Base]`/`[System]`/`[Agent Memory — core]`. pub agent_canvas: Option<&'a str>, + /// Set once this session's standing context has already been delivered — + /// see [`StandingContext`]. Only meaningful for legacy agents; modern + /// agents are gated by `has_system_prompt_support` regardless. + /// + /// Defaults to `false` so a caller that never sets it behaves as if this + /// were the session's first message. + pub standing_context_sent: bool, +} + +/// The prompt sections that do not change for the life of a session: base +/// prompt, persona, team instructions, core memory, and channel canvas. +/// +/// Protocol-v2 agents receive all of this through the system role at +/// `session/new`, once. Legacy agents (`protocol_version < 2`) have no system +/// role, so it has to ride in a user message — but only in the session's +/// *first* one. Re-sending it every turn makes the standing framing the newest +/// and most-repeated text in the window, outweighing the conversation it exists +/// to frame, and evicting real channel history that much sooner. +/// +/// Both legacy dispatch paths (initial message, batch flush) render through +/// this one type so their section set and ordering cannot drift apart. +#[derive(Default)] +pub(crate) struct StandingContext<'a> { + pub base_prompt: Option<&'a str>, + pub system_prompt: Option<&'a str>, + pub team_instructions: Option<&'a str>, + pub agent_core: Option<&'a str>, + pub huddle_instructions: Option<&'a str>, + pub agent_canvas: Option<&'a str>, +} + +impl StandingContext<'_> { + /// Render the sections in the order legacy agents have always seen them. + pub(crate) fn sections(&self) -> Vec { + let mut sections = Vec::with_capacity(6); + if let Some(bp) = self.base_prompt { + sections.push(base_section(bp)); + } + if let Some(sp) = self.system_prompt { + sections.push(format!("[System]\n{sp}")); + } + if let Some(team) = self + .team_instructions + .map(str::trim) + .filter(|value| !value.is_empty()) + { + sections.push(format!("[Team Instructions]\n{team}")); + } + if let Some(core) = self.agent_core { + sections.push(core.to_string()); + } + if let Some(instructions) = self + .huddle_instructions + .map(str::trim) + .filter(|value| !value.is_empty()) + { + sections.push(format!("[Huddle Instructions]\n{instructions}")); + } + if let Some(canvas) = self.agent_canvas { + sections.push(canvas.to_string()); + } + sections + } } /// Format the `[Base]` section for the base prompt. @@ -1391,12 +1548,12 @@ pub(crate) fn base_section(base_prompt: &str) -> String { /// Format a [`FlushBatch`] into the per-section prompt blocks for the agent. /// /// Produces a stable prompt with these sections (in order): -/// 0. `[Base]` — base prompt (only for legacy agents without systemPrompt support) -/// 1. `[System]` — system prompt (only for legacy agents without systemPrompt support) -/// 2. `[Agent Memory — core]` — if agent core memory is set -/// 3. `[Context]` — scope, channel name, and contextual hints for the agent -/// 4. `[Thread Context]` or `[Conversation Context]` — if fetched -/// 5. `[Event]` / `[Buzz events]` — the triggering event(s) +/// 0. [`StandingContext`] — `[Base]`, `[System]`, `[Team Instructions]`, +/// `[Agent Memory — core]`, `[Channel Canvas]`. Legacy agents only, and only +/// on the session's first message (see `standing_context_sent`) +/// 1. `[Context]` — scope, channel name, and contextual hints for the agent +/// 2. `[Thread Context]` or `[Conversation Context]` — if fetched +/// 3. `[Event]` / `[Buzz events]` — the triggering event(s) /// /// Each section is returned as its own block rather than one joined string so /// the observer frame's size trimmer (`fit_observer_event_to_budget`) elides @@ -1428,38 +1585,23 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec = Vec::with_capacity(7); - // For legacy agents (protocol_version < 2), inject base_prompt and - // system_prompt as user-message sections. Modern agents receive these - // via the system role in session/new. - if !args.has_system_prompt_support { - if let Some(bp) = args.base_prompt { - sections.push(base_section(bp)); - } - if let Some(sp) = args.system_prompt { - sections.push(format!("[System]\n{sp}")); - } - if let Some(team) = args - .team_instructions - .map(str::trim) - .filter(|value| !value.is_empty()) - { - sections.push(format!("[Team Instructions]\n{team}")); - } - } - - // NIP-AE agent core memory (rendered by `engram_fetch::build_core_section`). - // For modern agents (protocol_version >= 2), core is delivered via the - // system role in session/new, so it is omitted here to avoid duplication. - // Legacy agents have no system role, so core rides in the user message - // alongside `[Base]`/`[System]`. - if !args.has_system_prompt_support { - if let Some(core) = args.agent_core { - sections.push(core.to_string()); - } - // Channel canvas metadata — same delivery semantics as core for legacy agents. - if let Some(canvas) = args.agent_canvas { - sections.push(canvas.to_string()); - } + // Standing context — base prompt, persona, team instructions, core memory + // and canvas. Modern agents received all of it via the system role in + // session/new. Legacy agents get it here, in the session's first message + // only; `standing_context_sent` means an earlier message in this session + // already carried it. + if !args.has_system_prompt_support && !args.standing_context_sent { + sections.extend( + StandingContext { + base_prompt: args.base_prompt, + system_prompt: args.system_prompt, + team_instructions: args.team_instructions, + agent_core: args.agent_core, + huddle_instructions: args.huddle_instructions, + agent_canvas: args.agent_canvas, + } + .sections(), + ); } // 2. Context hints (with a human-aware reply anchor). @@ -1489,6 +1631,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec Instant::now()), + "requeue must have set a future backoff deadline" + ); + assert!(!queue.has_in_flight(), "turn completed, nothing in-flight"); + + // The bug: throttled work is invisible to flushability... + assert!( + !queue.has_flushable_work(), + "throttled batch must NOT be flushable yet" + ); + // ...but it IS undispatched work the sleep gate must protect. + assert!( + queue.has_undispatched_work(), + "retry-throttled batch MUST count as undispatched work" + ); + } + + #[test] + fn test_has_undispatched_work_false_when_truly_empty_or_in_flight() { + let mut queue = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + + // Empty queue: no undispatched work. + assert!(!queue.has_undispatched_work()); + + // Dispatched batch (in-flight): the events left the queue, and an + // in-flight turn is gated separately (has_in_flight), so this must be + // false — otherwise the pool could never sleep after any turn. + queue.push(make_queued(ch, "msg1")); + assert!( + queue.has_undispatched_work(), + "queued-but-not-flushed is undispatched" + ); + let batch = queue.flush_next().unwrap(); + assert!(queue.has_in_flight()); + assert!( + !queue.has_undispatched_work(), + "in-flight work is not undispatched — it is gated by has_in_flight" + ); + + // Completed cleanly (no requeue): fully drained, nothing left. + queue.mark_complete(batch.channel_id); + assert!(!queue.has_undispatched_work()); + assert!(!queue.has_in_flight()); + } + #[test] fn test_requeue_interleaves_with_other_channels() { let mut queue = EventQueue::new(DedupMode::Queue); @@ -2408,6 +2630,61 @@ mod tests { ); } + #[test] + fn test_format_prompt_legacy_agent_omits_standing_after_first_message() { + // The defect this pins: standing context was re-sent on every turn of a + // legacy session, so the largest and least informative part of the + // prompt was also the most recent — crowding out the conversation and + // evicting real channel history sooner. + let ch = Uuid::new_v4(); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event: make_event("hello"), + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let canvas = "[Channel Canvas]\ncanvas content"; + let core = "[Agent Memory — core]\nremember this"; + let args = |sent| FormatPromptArgs { + has_system_prompt_support: false, + base_prompt: Some("test base prompt"), + system_prompt: Some("test system prompt"), + team_instructions: Some("ship small"), + agent_core: Some(core), + huddle_instructions: None, + agent_canvas: Some(canvas), + standing_context_sent: sent, + ..Default::default() + }; + + let first = format_prompt(&batch, &args(false)).join("\n\n"); + let later = format_prompt(&batch, &args(true)).join("\n\n"); + + for section in [ + "[Base]", + "[System]", + "[Team Instructions]", + "[Agent Memory — core]", + "[Channel Canvas]", + ] { + assert!(first.contains(section), "first message missing {section}"); + assert!(!later.contains(section), "turn 2 repeated {section}"); + } + // What the turn is actually about survives, and now leads. + assert!(later.starts_with("[Context]"), "got: {later}"); + assert!(later.contains("hello")); + assert!( + later.len() < first.len(), + "later turns must be smaller: {} vs {}", + later.len(), + first.len() + ); + } + #[test] fn test_format_prompt_modern_agent_suppresses_base_and_system() { let ch = Uuid::new_v4(); @@ -2464,6 +2741,7 @@ mod tests { let ctx = ConversationContext::Thread { messages: vec![ContextMessage { + event_id: String::new(), pubkey: "npub1test".into(), content: "prior message".into(), timestamp: "2024-01-01T00:00:00Z".into(), @@ -2981,6 +3259,7 @@ mod tests { let ci = PromptChannelInfo { name: "engineering".into(), channel_type: "stream".into(), + description: None, }; let prompt = format_prompt( @@ -3012,6 +3291,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let prompt = format_prompt( @@ -3078,11 +3358,13 @@ mod tests { let ctx = ConversationContext::Thread { messages: vec![ ContextMessage { + event_id: String::new(), pubkey: "npub1xyz".into(), timestamp: "2026-03-15T16:30:00Z".into(), content: "Let's refactor auth".into(), }, ContextMessage { + event_id: String::new(), pubkey: "npub1def".into(), timestamp: "2026-03-15T16:35:00Z".into(), content: "yes go ahead".into(), @@ -3122,9 +3404,11 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let ctx = ConversationContext::Dm { messages: vec![ContextMessage { + event_id: String::new(), pubkey: "npub1abc".into(), timestamp: "2026-03-15T16:00:00Z".into(), content: "Can you deploy?".into(), @@ -3170,6 +3454,7 @@ mod tests { }; let ctx = ConversationContext::Thread { messages: vec![ContextMessage { + event_id: String::new(), pubkey: author_hex.clone(), timestamp: "2026-03-25T05:51:25Z".into(), content: "follow up".into(), @@ -3378,10 +3663,12 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; // Thread context fetched (as the fetch path does for DM replies). let ctx = ConversationContext::Thread { messages: vec![ContextMessage { + event_id: String::new(), pubkey: "npub1xyz".into(), timestamp: "2026-03-15T16:30:00Z".into(), content: "Should I deploy?".into(), @@ -3418,6 +3705,96 @@ mod tests { assert!(prompt.contains("Should I deploy?")); } + #[test] + fn test_format_prompt_empty_thread_delta_distinguishes_trigger_only_from_delivered() { + let ch = Uuid::new_v4(); + let event = make_event_with_tags( + "follow up", + vec![vec![ + "e".into(), + "root123".into(), + "".into(), + "reply".into(), + ]], + ); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let trigger_only_prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); + assert!(trigger_only_prompt.contains("fetch thread context")); + assert!(!trigger_only_prompt.contains("already delivered in this session")); + + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + conversation_context_had_delivered_events: true, + ..Default::default() + }, + ) + .join("\n\n"); + + assert!(prompt.contains("Earlier thread context was already delivered in this session")); + assert!(prompt.contains("buzz messages thread")); + assert!(!prompt.contains("Thread context included below")); + assert!(!prompt.contains("[Thread Context")); + } + + #[test] + fn test_format_prompt_empty_dm_delta_distinguishes_trigger_only_from_delivered() { + let ch = Uuid::new_v4(); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event: make_event("follow up"), + prompt_tag: "dm".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let ci = PromptChannelInfo { + name: "DM".into(), + channel_type: "dm".into(), + description: None, + }; + + let trigger_only_prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + ..Default::default() + }, + ) + .join("\n\n"); + assert!(trigger_only_prompt.contains("for conversation context")); + assert!(!trigger_only_prompt.contains("already delivered in this session")); + + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + conversation_context_had_delivered_events: true, + ..Default::default() + }, + ) + .join("\n\n"); + + assert!( + prompt.contains("Earlier conversation context was already delivered in this session") + ); + assert!(prompt.contains("buzz messages get")); + assert!(!prompt.contains("Conversation context included below")); + assert!(!prompt.contains("[Conversation Context")); + } + #[test] fn test_format_prompt_dm_non_reply_hints_get_messages() { let ch = Uuid::new_v4(); @@ -3435,6 +3812,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; // No context fetched — hints only. @@ -3930,6 +4308,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let prompt = format_prompt( @@ -3993,6 +4372,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + description: None, }; let prompt = format_prompt( @@ -4761,4 +5141,254 @@ mod tests { "second extend must not move deadline backward (monotonic)" ); } + + // ── channel description delivery ───────────────────────────────────────── + + #[test] + fn test_append_channel_description_adds_description_line() { + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some("Engineering discussions".into()), + }; + let mut s = "[Context]\nScope: channel\nChannel: team (#abc)".to_string(); + append_channel_description(&mut s, Some(&ci)); + assert!( + s.contains("\nDescription: Engineering discussions"), + "description must be appended; got: {s}" + ); + } + + #[test] + fn test_append_channel_description_absent_when_none() { + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: None, + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + assert!( + !s.contains("Description:"), + "no description must be appended when None; got: {s}" + ); + } + + #[test] + fn test_append_channel_description_absent_when_channel_info_none() { + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, None); + assert!( + !s.contains("Description:"), + "no description must be appended when channel_info is None; got: {s}" + ); + } + + #[test] + fn test_append_channel_description_collapses_newlines_spoof_prevention() { + // A multiline description must not be able to inject a fake [Context] field. + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some("Line one\nScope: injected\nLine two".into()), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + // The whole description is on a single Description line — no injected field. + let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); + assert_eq!( + desc_line, "Description: Line one Scope: injected Line two", + "multiline description must collapse to one line, never a fake field" + ); + assert_eq!( + s.lines().filter(|l| l.starts_with("Description:")).count(), + 1, + "exactly one Description line is rendered" + ); + } + + #[test] + fn test_append_channel_description_truncates_at_cap() { + let long_desc = "x".repeat(600); + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some(long_desc), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); + assert!( + desc_line.ends_with('…'), + "truncated description must end with '…'; got: {desc_line}" + ); + // Value = first MAX_DESCRIPTION_LEN chars + the "…" marker. + let value = desc_line.strip_prefix("Description: ").unwrap(); + assert_eq!( + value.chars().count(), + MAX_DESCRIPTION_LEN + 1, + "truncated value is exactly the cap plus the ellipsis marker" + ); + } + + #[test] + fn test_append_channel_description_multibyte_truncation_is_char_safe() { + // Truncation must land on a char boundary, never split a multi-byte code point. + let long_desc = "é".repeat(600); + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some(long_desc), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); + let value = desc_line.strip_prefix("Description: ").unwrap(); + assert_eq!(value.chars().count(), MAX_DESCRIPTION_LEN + 1); + } + + #[test] + fn test_append_channel_description_whitespace_only_is_absent() { + let ci = PromptChannelInfo { + name: "team".into(), + channel_type: "stream".into(), + description: Some("\n \r\n \n".into()), + }; + let mut s = "[Context]\nScope: channel".to_string(); + append_channel_description(&mut s, Some(&ci)); + assert!( + !s.contains("Description:"), + "a whitespace-only description collapses to empty and is not rendered; got: {s}" + ); + } + + fn description_batch(ch: Uuid, event: Event) -> FlushBatch { + FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + #[test] + fn test_format_prompt_includes_description_in_context_for_channel_turn() { + let ch = Uuid::new_v4(); + let batch = description_batch(ch, make_event("what should we build?")); + let ci = PromptChannelInfo { + name: "engineering".into(), + channel_type: "stream".into(), + description: Some("Engineering discussions and planning.".into()), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: channel"), + "channel-scope turn expected; got: {prompt}" + ); + assert!( + prompt.contains("Description: Engineering discussions and planning."), + "description must appear in [Context] for channel turns; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_includes_description_in_context_for_thread_turn() { + let ch = Uuid::new_v4(); + let event = make_event_with_tags( + "reply in thread", + vec![vec![ + "e".into(), + "root123".into(), + "".into(), + "reply".into(), + ]], + ); + let batch = description_batch(ch, event); + let ci = PromptChannelInfo { + name: "engineering".into(), + channel_type: "stream".into(), + description: Some("Engineering discussions and planning.".into()), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: thread"), + "thread-scope turn expected; got: {prompt}" + ); + assert!( + prompt.contains("Description: Engineering discussions and planning."), + "description must appear in [Context] for thread turns; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_excludes_description_for_dm_turn() { + let ch = Uuid::new_v4(); + let batch = description_batch(ch, make_event("hey")); + let ci = PromptChannelInfo { + name: "DM".into(), + channel_type: "dm".into(), + description: Some("This should not appear.".into()), + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: dm"), + "dm-scope turn expected; got: {prompt}" + ); + assert!( + !prompt.contains("Description:"), + "DM turn must not include a Description field; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_no_description_when_channel_metadata_unresolved() { + let ch = Uuid::new_v4(); + let batch = description_batch(ch, make_event("what should we build?")); + // channel_info None models unresolved metadata: no name, no description. + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: None, + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("Scope: channel"), + "channel-scope turn expected; got: {prompt}" + ); + assert!( + !prompt.contains("Description:"), + "unresolved metadata must not render a Description field; got: {prompt}" + ); + } } diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 2cbb82411fd..17a818867dd 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -136,6 +136,8 @@ use crate::config::ChannelFilter; pub struct ChannelInfo { pub name: String, pub channel_type: String, + /// Channel description from the kind-39000 `about` tag, if present. + pub description: Option, } pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String { @@ -175,7 +177,7 @@ pub(crate) fn merge_discovered_channels( channel_uuids: Vec, meta_events: &serde_json::Value, ) -> HashMap { - let mut meta_map: HashMap = HashMap::new(); + let mut meta_map: HashMap)> = HashMap::new(); let mut archived: std::collections::HashSet = std::collections::HashSet::new(); if let Some(arr) = meta_events.as_array() { for ev in arr { @@ -186,11 +188,13 @@ pub(crate) fn merge_discovered_channels( let mut d_val = None; let mut name = None; let mut is_archived = false; + let mut description = None; for tag in tags { if let Some(arr) = tag.as_array() { match arr.first().and_then(|v| v.as_str()) { Some("d") => d_val = arr.get(1).and_then(|v| v.as_str()), Some("name") => name = arr.get(1).and_then(|v| v.as_str()), + Some("about") => description = arr.get(1).and_then(|v| v.as_str()), Some("archived") => { is_archived = arr.get(1).and_then(|v| v.as_str()) == Some("true") } @@ -206,7 +210,11 @@ pub(crate) fn merge_discovered_channels( } let ch_name = name.unwrap_or("unknown").to_string(); let ch_type = channel_type_from_tags(tags); - meta_map.insert(uuid, (ch_name, ch_type)); + let ch_desc = description + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + meta_map.insert(uuid, (ch_name, ch_type, ch_desc)); } } } @@ -217,10 +225,17 @@ pub(crate) fn merge_discovered_channels( if archived.contains(&uuid) { continue; } - let (name, channel_type) = meta_map + let (name, channel_type, description) = meta_map .remove(&uuid) - .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string())); - map.insert(uuid, ChannelInfo { name, channel_type }); + .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string(), None)); + map.insert( + uuid, + ChannelInfo { + name, + channel_type, + description, + }, + ); } map } @@ -4163,6 +4178,46 @@ mod tests { assert!(map.contains_key(&ch), "archived=false is treated as live"); } + #[test] + fn merge_discovered_channels_parses_about_as_description() { + let ch = Uuid::new_v4(); + let meta = serde_json::json!([meta_event( + ch, + "team", + &["t", "stream", "about", "Engineering discussions"] + )]); + + let map = merge_discovered_channels(vec![ch], &meta); + + assert_eq!( + map[&ch].description.as_deref(), + Some("Engineering discussions") + ); + } + + #[test] + fn merge_discovered_channels_blank_about_is_none() { + let ch = Uuid::new_v4(); + let meta = serde_json::json!([meta_event(ch, "team", &["about", " "])]); + + let map = merge_discovered_channels(vec![ch], &meta); + + assert_eq!( + map[&ch].description, None, + "a whitespace-only about tag is trimmed away to None" + ); + } + + #[test] + fn merge_discovered_channels_missing_about_is_none() { + let ch = Uuid::new_v4(); + let meta = serde_json::json!([meta_event(ch, "team", &["t", "stream"])]); + + let map = merge_discovered_channels(vec![ch], &meta); + + assert_eq!(map[&ch].description, None); + } + #[test] fn parse_ok_accepted() { let text = r#"["OK","abc123",true,""]"#; diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index 56b772d12c4..2197b99ef5f 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -65,7 +65,7 @@ pub(crate) struct GooseSessionUpdateNotification { #[derive(Debug, Clone, serde::Deserialize)] #[serde(tag = "sessionUpdate", rename_all = "snake_case")] pub(crate) enum GooseSessionUpdateVariant { - UsageUpdate(UsageUpdatePayload), + UsageUpdate(Box), #[serde(other)] Other, } @@ -83,8 +83,19 @@ pub(crate) struct UsageUpdatePayload { #[serde(default)] #[allow(dead_code)] pub context_limit: u64, - pub accumulated_input_tokens: u64, - pub accumulated_output_tokens: u64, + /// Session-cumulative inclusive input tokens. + /// + /// `None` when buzz-agent omitted the field — this happens when the + /// session-cumulative sum overflowed `u64::MAX`. Goose always emits this + /// field, so `None` from goose is not expected; `#[serde(default)]` keeps + /// backward compatibility with any producer that omits it. + #[serde(default)] + pub accumulated_input_tokens: Option, + /// Session-cumulative output tokens. + /// + /// Same overflow-omit contract as `accumulated_input_tokens`. + #[serde(default)] + pub accumulated_output_tokens: Option, /// The cache-served subset of `accumulated_input_tokens`. /// /// `None` when the harness did not include the field (e.g. goose, which @@ -95,6 +106,15 @@ pub(crate) struct UsageUpdatePayload { /// Do NOT use `#[serde(default)]` here — that would collapse the absent /// case into `Some(0)` and destroy provenance in the append-only archive. pub accumulated_cached_input_tokens: Option, + /// The cache-written subset of `accumulated_input_tokens`. + /// + /// `None` when the harness did not include the field (e.g. goose or any + /// provider that does not report cache-write tokens). `Some(0)` when the + /// harness explicitly reported zero cache writes. Same absence-vs-zero + /// semantics as `accumulated_cached_input_tokens` above. + /// + /// Do NOT use `#[serde(default)]` here for the same reason. + pub accumulated_cache_write_tokens: Option, pub accumulated_cost: Option, /// Session-cumulative genuine provider total tokens. Optional — only /// emitted by buzz-agent when every turn in the session so far supplied a @@ -108,6 +128,15 @@ pub(crate) struct UsageUpdatePayload { /// predate this field deserialize cleanly as `None`. #[serde(default)] pub model: Option, + /// Billing identity as stamped by the publisher. Optional — absent when + /// the publisher could not prove applicability (unrecognised endpoint, + /// mixed identities within the turn, etc.). Old harnesses that do not emit + /// this field deserialise to `None` cleanly via `#[serde(default)]`. + /// + /// Do NOT use this value directly to advance the session-cumulative + /// baseline: it is per-turn only and must not persist to `SessionState`. + #[serde(default)] + pub pricing_identity: Option, } /// Per-session normalization state: the last cumulative snapshot we saw. @@ -120,9 +149,11 @@ struct SessionState { published_seq: u64, /// Cumulative input tokens at the end of the LAST PUBLISHED turn. /// Advanced only on publish (i.e. in `take()`), not on every notification. - last_input: u64, + /// `None` when the publisher omitted the field in a prior turn. + last_input: Option, /// Cumulative output tokens at the end of the LAST PUBLISHED turn. - last_output: u64, + /// `None` when the publisher omitted the field in a prior turn. + last_output: Option, /// Cumulative cost at the end of the LAST PUBLISHED turn. last_cost: Option, /// Cumulative total tokens at the end of the LAST PUBLISHED turn. @@ -135,6 +166,19 @@ struct SessionState { /// a decrease in this counter taints only the cache-read delta, not /// `delta_reliable` or the input/output deltas. last_cached_input: Option, + /// Cumulative cache-write tokens at the end of the LAST PUBLISHED turn. + /// `None` when the harness has never reported this field. Field-local: + /// a decrease taints only the cache-write delta, not `delta_reliable`. + last_cache_write: Option, + /// Sticky poison flag for the input field: set the first time ACP observes + /// an absent `accumulated_input_tokens` snapshot for this session and never + /// cleared. Once true, `delta_reliable` stays false for every subsequent + /// turn regardless of whether the publisher later resumes emitting the + /// field. ACP cannot trust the producer's permanence guarantee. + input_ever_poisoned: bool, + /// Sticky poison flag for the output field: same contract as + /// `input_ever_poisoned` but for `accumulated_output_tokens`. + output_ever_poisoned: bool, } /// Per-turn usage record exposed to `TurnCompletionGuard` for NIP-AM publishing. @@ -167,10 +211,16 @@ pub struct TurnUsage { /// a decrease here never flips `delta_reliable` or invalidates the /// input/output deltas. pub turn_cache_read_tokens: Option, + /// Per-turn cache-write token delta (`current − previous`); `None` when no + /// baseline exists, either snapshot is `None`, or the counter decreased. + /// Field-local — same contract as `turn_cache_read_tokens`. + pub turn_cache_write_tokens: Option, /// Session-cumulative input tokens as reported by goose at end of turn. - pub cumulative_input_tokens: u64, + /// `None` when the publisher omitted the field (overflow-poisoned session). + pub cumulative_input_tokens: Option, /// Session-cumulative output tokens as reported by goose at end of turn. - pub cumulative_output_tokens: u64, + /// `None` when the publisher omitted the field (overflow-poisoned session). + pub cumulative_output_tokens: Option, /// Session-cumulative genuine provider total tokens as reported by buzz-agent; /// `None` when the session has never emitted one or any turn lacked one. pub cumulative_total_tokens: Option, @@ -181,9 +231,169 @@ pub struct TurnUsage { /// any harness that omits `accumulatedCachedInputTokens`). /// `Some(0)` when the harness reported zero cache hits. pub cumulative_cache_read_tokens: Option, + /// Session-cumulative cache-write tokens as reported by buzz-agent. + /// `None` when the harness has never reported this field. + /// `Some(0)` when the harness reported zero cache writes. + pub cumulative_cache_write_tokens: Option, /// Effective model id for this turn (maps to NIP-AM `model`). `None` if the /// harness did not include the model in its usage notification. pub model: Option, + /// Billing identity for this turn, as received from the publisher. + /// `None` when the publisher omitted it (unrecognised endpoint, mixed + /// identities, old harness). Per-turn only — not session-cumulative. + pub pricing_identity: Option, +} + +/// Per-turn usage carried by a standard ACP `session/prompt` response. +/// Adapter input excludes cache reads and writes, so NIP-AM input must add +/// those subsets with checked arithmetic. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PromptResponseUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub cached_read_tokens: Option, + pub cached_write_tokens: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StandardAdapterKind { + Claude, + Codex, +} + +#[derive(Debug, Default)] +struct StandardSessionState { + published_seq: u64, + last_cost: Option, + cost_poisoned: bool, +} + +#[derive(Debug, Default)] +pub(crate) struct StandardUsageTracker { + sessions: HashMap, + in_flight_session: Option, + pending_cost: Option<(String, f64)>, + pending_prompt: Option<(String, PromptResponseUsage, StandardAdapterKind)>, +} + +impl StandardUsageTracker { + pub(crate) fn seed_zero_baseline(&mut self, session_id: &str) { + self.sessions + .entry(session_id.to_string()) + .or_insert_with(|| StandardSessionState { + published_seq: 0, + last_cost: Some(0.0), + cost_poisoned: false, + }); + } + + pub(crate) fn begin_turn(&mut self, session_id: &str) { + self.in_flight_session = Some(session_id.to_string()); + self.pending_cost = None; + self.pending_prompt = None; + } + + /// Claude's `usage_update.cost.amount` is a raw session-cumulative total. + pub(crate) fn record_cost(&mut self, session_id: &str, cost: f64) { + if cost.is_finite() && cost >= 0.0 && self.in_flight_session.as_deref() == Some(session_id) + { + self.pending_cost = Some((session_id.to_string(), cost)); + } + } + + pub(crate) fn record_prompt_usage( + &mut self, + session_id: &str, + usage: PromptResponseUsage, + adapter: StandardAdapterKind, + ) { + if self.in_flight_session.as_deref() == Some(session_id) { + self.pending_prompt = Some((session_id.to_string(), usage, adapter)); + } + } + + pub(crate) fn take(&mut self) -> Option { + self.in_flight_session = None; + let prompt = self.pending_prompt.take(); + let cost = self.pending_cost.take(); + let session_id = prompt + .as_ref() + .map(|(session_id, _, _)| session_id.clone()) + .or_else(|| cost.as_ref().map(|(session_id, _)| session_id.clone()))?; + + let (inclusive_input, output_tokens, total_tokens, cache_read, cache_write) = match prompt { + Some((_, usage, adapter)) => { + let inclusive_input = usage + .input_tokens + .checked_add(usage.cached_read_tokens.unwrap_or(0)) + .and_then(|input| input.checked_add(usage.cached_write_tokens.unwrap_or(0))); + let total_tokens = + (adapter == StandardAdapterKind::Codex).then_some(usage.total_tokens); + ( + inclusive_input, + inclusive_input.map(|_| usage.output_tokens), + inclusive_input.and(total_tokens), + inclusive_input.and(usage.cached_read_tokens), + inclusive_input.and(usage.cached_write_tokens), + ) + } + None => (None, None, None, None, None), + }; + + let state = self.sessions.entry(session_id.clone()).or_default(); + let cumulative_cost = cost.map(|(_, cost)| cost); + let turn_cost = match (state.cost_poisoned, state.last_cost, cumulative_cost) { + (false, Some(previous), Some(current)) if current >= previous => { + let delta = current - previous; + delta.is_finite().then_some(delta) + } + _ => None, + }; + if let Some(current) = cumulative_cost { + // A decrease means the cumulative series restarted or is corrupt. + // Poison the baseline rather than deriving a later delta across the + // discontinuity. The raw cumulative value still remains observable. + if state.last_cost.is_some_and(|previous| current < previous) { + state.cost_poisoned = true; + state.last_cost = None; + } else if !state.cost_poisoned { + state.last_cost = Some(current); + } + } + + // Input overflow invalidates the standard prompt counters. Emit only if + // another valid signal (normally Claude cost) remains; NIP-AM forbids an + // otherwise all-null usage record. + if inclusive_input.is_none() && cumulative_cost.is_none() { + return None; + } + + state.published_seq += 1; + Some(TurnUsage { + session_id, + turn_seq: state.published_seq, + // Standard prompt counters are per-turn already. A cost-only record + // is reliable only when a seeded/previous cumulative baseline made + // the cost delta provable. + delta_reliable: inclusive_input.is_some() || turn_cost.is_some(), + turn_input_tokens: inclusive_input, + turn_output_tokens: output_tokens, + turn_total_tokens: total_tokens, + turn_cost_usd: turn_cost, + turn_cache_read_tokens: cache_read, + turn_cache_write_tokens: cache_write, + cumulative_input_tokens: None, + cumulative_output_tokens: None, + cumulative_total_tokens: None, + cumulative_cost_usd: cumulative_cost, + cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, + model: None, + pricing_identity: None, + }) + } } /// Tracks per-session cumulative usage state across turns. @@ -217,6 +427,30 @@ pub(crate) struct UsageTracker { in_flight_session: Option, /// The most recently computed turn usage, ready for `take()`. pending: Option, + /// Per-in-flight-turn identity accumulator — three-state: + /// `None` = no usage notification yet (initial / after begin_turn) + /// `Some(Some(pi))` = all notifications so far carry the same proven identity + /// `Some(None)` = poisoned (mismatch, absent on a token-advancing + /// notification, or first notification had no identity) + /// + /// Folded on every in-flight `record()` call (last-update-wins is the + /// wrong contract for cumulative-snapshot notifications — a later + /// notification that carries A after an unproven/absent one must NOT + /// resurrect the identity). Reset to `None` in `begin_turn()` and `take()`. + pending_identity: Option>, + /// Per-in-flight-turn fold accumulator for input-field absence. + /// + /// Set to `true` the first time any in-flight `record()` call for the + /// current turn observes `accumulated_input_tokens: None`. Monotonically + /// grows (never cleared mid-turn); reset to `false` by `begin_turn()`. + /// At `take()` this value is OR-ed into the session's `input_ever_poisoned` + /// flag, creating or updating the session entry as needed. If `take()` is + /// never called before the next `begin_turn()`, the flush is performed at + /// `begin_turn()` time instead, so no observed absence is ever discarded. + input_absence_observed: bool, + /// Per-in-flight-turn fold accumulator for output-field absence. + /// Symmetric contract to `input_absence_observed`. + output_absence_observed: bool, } impl UsageTracker { @@ -226,9 +460,71 @@ impl UsageTracker { /// in-flight. Must be called before the corresponding `session/prompt` /// request is sent so that setup notifications received before this call /// do not become publishable for this turn. + /// + /// If the previous turn's fold accumulators hold observed absences and + /// `take()` was never called (e.g. the initial-message path calls + /// `begin_turn` twice without a `take()` between them), those absences are + /// committed here into the previous in-flight session's sticky + /// `*_ever_poisoned` state before the accumulators are reset. This + /// prevents the "take-skipped turn" escape: observed absences are + /// impossible to discard regardless of whether `take()` was called. pub(crate) fn begin_turn(&mut self, session_id: &str) { + // Flush any outstanding fold state from the previous in-flight turn + // into the previous session's entry BEFORE resetting the accumulators. + // + // This closes two discard points: + // 1. **Take-skipped same-session** — `begin_turn("s")` called twice + // without a `take()` in between (the initial-message path in + // pool.rs does exactly this). + // 2. **Cross-session** — session A's turn observed absences, then + // `begin_turn("B")` runs next. A's poison must survive. + // + // The fold accumulators can only be non-false when `in_flight_session` + // is Some, because only in-flight `record()` calls set them. The outer + // guard is a performance short-circuit (skip the map lookup on the + // common no-absence path); correctness does not depend on it. + if self.input_absence_observed || self.output_absence_observed { + if let Some(ref prev_session) = self.in_flight_session { + let key = prev_session.clone(); + let existing = self.sessions.get(key.as_str()); + let input_ever_poisoned = + existing.is_some_and(|s| s.input_ever_poisoned) || self.input_absence_observed; + let output_ever_poisoned = existing.is_some_and(|s| s.output_ever_poisoned) + || self.output_absence_observed; + let (published_seq, last_input, last_output, last_cost, last_total, lci, lcw) = + match existing { + Some(s) => ( + s.published_seq, + s.last_input, + s.last_output, + s.last_cost, + s.last_total, + s.last_cached_input, + s.last_cache_write, + ), + None => (0, None, None, None, None, None, None), + }; + self.sessions.insert( + key, + SessionState { + published_seq, + last_input, + last_output, + last_cost, + last_total, + last_cached_input: lci, + last_cache_write: lcw, + input_ever_poisoned, + output_ever_poisoned, + }, + ); + } + } self.in_flight_session = Some(session_id.to_string()); self.pending = None; + self.pending_identity = None; + self.input_absence_observed = false; + self.output_absence_observed = false; } /// Process a `usage_update` notification payload. @@ -238,7 +534,11 @@ impl UsageTracker { /// session produces a publishable `pending` record. A notification that /// arrives outside any turn (e.g. during `session/new` setup) advances the /// committed baseline so the next in-flight turn computes a correct delta. - /// A notification for a *different* in-flight session is ignored entirely. + /// A notification for a *different* in-flight session drops its counters + /// (advancing the baseline would undercount that session's next turn) but + /// latches any observed input/output absence into that session's committed + /// `*_ever_poisoned` state — the sticky-absence contract has no cross-session + /// exemption. /// /// When multiple notifications arrive during the same turn, the **last one /// wins** on the cumulative totals, and the delta is always measured from @@ -253,57 +553,115 @@ impl UsageTracker { /// 2. **Not in-flight at all** (`in_flight_session == None`): advances the /// committed baseline (setup notification path). /// 3. **In-flight for another session** (`in_flight_session == Some(other)`): - /// ignored entirely — touching this session's baseline while another is - /// in-flight would undercount this session's next published delta. + /// counters are dropped (advancing this session's baseline would undercount + /// its next published delta), but any observed input/output absence is + /// latched into this session's `*_ever_poisoned` state — the sticky-absence + /// contract has no cross-session exemption. pub(crate) fn record(&mut self, session_id: &str, payload: &UsageUpdatePayload) { let current_input = payload.accumulated_input_tokens; let current_output = payload.accumulated_output_tokens; let current_cost = payload.accumulated_cost; let current_total = payload.accumulated_total_tokens; let current_cached_input = payload.accumulated_cached_input_tokens; + let current_cache_write = payload.accumulated_cache_write_tokens; // Determine whether this session is currently in-flight so we know // whether to set `pending`. We compute the delta regardless so that // setup notifications (no in-flight turn) still advance the baseline. let is_in_flight = self.in_flight_session.as_deref() == Some(session_id); - let (delta_reliable, turn_input, turn_output, turn_cost, turn_seq) = - match self.sessions.get(session_id) { - None => { - // First notification for this session — no baseline yet. - (false, None, None, None, 1u64) - } - Some(prev) => { - // turn_seq for this pending record is one above the last - // *published* seq — constant for all notifications in this - // turn, advanced only on publish. - let seq = prev.published_seq + 1; - // Token counter decrease → unreliable delta. - if current_input < prev.last_input || current_output < prev.last_output { - (false, None, None, None, seq) - } else { - let di = current_input - prev.last_input; - let dout = current_output - prev.last_output; - // Cost delta: only when both snapshots have cost. - // A cost *decrease* is also unreliable (NIP-AM: negative - // delta ⇒ delta_reliable false, null all turn fields). - let (dc, cost_reliable) = match (current_cost, prev.last_cost) { - (Some(c), Some(p)) if c >= p => (Some(c - p), true), - (Some(_), Some(_)) => { - // Both present but current < prev — counter decreased. - (None, false) + // For in-flight notifications, fold the absence of each field into the + // per-turn accumulators BEFORE computing the delta. This ensures the + // second `record()` call in a turn sees the absence observed by the first, + // even when no session entry exists yet (un-baselined path) and even when + // the second notification reintroduces the field. The fold is monotonic + // (OR — never cleared mid-turn); it is reset by `begin_turn()` and + // committed to the session's sticky flags in `take()`. + // + // Case 3 (in-flight for another session) is handled separately in the + // `else` branch below: counters are dropped, but any observed absence is + // latched directly into that session's committed `*_ever_poisoned` state. + if is_in_flight { + if current_input.is_none() { + self.input_absence_observed = true; + } + if current_output.is_none() { + self.output_absence_observed = true; + } + } + + let (delta_reliable, turn_input, turn_output, turn_cost, turn_seq) = match self + .sessions + .get(session_id) + { + None => { + // First notification for this session — no baseline yet. + (false, None, None, None, 1u64) + } + Some(prev) => { + // turn_seq for this pending record is one above the last + // *published* seq — constant for all notifications in this + // turn, advanced only on publish. + let seq = prev.published_seq + 1; + // Sticky-poison check: if ACP ever observed an absent input + // or output snapshot for this session, delta_reliable is + // permanently false. A later reintroduced value must NOT + // heal the reliability — ACP cannot trust the producer's + // permanence guarantee; the prefix delta is irrecoverably + // unknown. + // + // Three sources of poison — all monotonic (OR): + // 1. The session's committed flag from prior turns. + // 2. The per-turn fold accumulator (captures absences seen + // earlier in THIS turn before take() commits them). + // 3. Whether THIS notification is itself absent. + let this_input_absent = current_input.is_none(); + let this_output_absent = current_output.is_none(); + let input_poisoned = + prev.input_ever_poisoned || self.input_absence_observed || this_input_absent; + let output_poisoned = + prev.output_ever_poisoned || self.output_absence_observed || this_output_absent; + if input_poisoned || output_poisoned { + (false, None, None, None, seq) + } else { + match ( + current_input, + current_output, + prev.last_input, + prev.last_output, + ) { + (Some(ci), Some(co), Some(pi), Some(po)) => { + // Token counter decrease → unreliable delta. + if ci < pi || co < po { + (false, None, None, None, seq) + } else { + let di = ci - pi; + let dout = co - po; + // Cost delta: only when both snapshots have cost. + // A cost *decrease* is also unreliable (NIP-AM: negative + // delta ⇒ delta_reliable false, null all turn fields). + let (dc, cost_reliable) = match (current_cost, prev.last_cost) { + (Some(c), Some(p)) if c >= p => (Some(c - p), true), + (Some(_), Some(_)) => { + // Both present but current < prev — counter decreased. + (None, false) + } + _ => (None, true), // absent on either side: null cost, reliable tokens + }; + if cost_reliable { + (true, Some(di), Some(dout), dc, seq) + } else { + // Cost decrease overrides the whole record to unreliable. + (false, None, None, None, seq) + } } - _ => (None, true), // absent on either side: null cost, reliable tokens - }; - if cost_reliable { - (true, Some(di), Some(dout), dc, seq) - } else { - // Cost decrease overrides the whole record to unreliable. - (false, None, None, None, seq) } + // One or both sides absent (no prior baseline) → unreliable. + _ => (false, None, None, None, seq), } } - }; + } + }; // Total-token delta: field-local — never affects `delta_reliable` or // the input/output deltas. Null when: no baseline exists, either @@ -331,9 +689,46 @@ impl UsageTracker { None => None, // no baseline yet }; + // Cache-write token delta: same field-local contract as cache-read. + let turn_cache_write = match self.sessions.get(session_id) { + Some(prev) => match (current_cache_write, prev.last_cache_write) { + (Some(cur), Some(p)) if cur >= p => Some(cur - p), + (Some(_), Some(_)) => None, // decrease → field-local taint + _ => None, // either snapshot absent → no delta + }, + None => None, // no baseline yet + }; + if is_in_flight { // In-flight-match: update pending with the latest cumulative values. // Baseline is NOT advanced here — it advances only on take(). + // + // Fold the per-notification identity into the per-turn accumulator. + // Last-update-wins is wrong for cumulative-snapshot notifications: a + // later notification that carries a proven identity A after an + // absent/unproven one must NOT resurrect the identity. + // + // Fold contract (mirrors the publisher-side `fold_pricing_identity`): + // - `None` acc (first notification): adopt whatever the payload carries. + // - `Some(Some(pi))` acc: if this notification matches exactly, keep; + // otherwise poison to `Some(None)`. + // - `Some(None)` acc (poisoned): stays poisoned, no healing. + let incoming = payload.pricing_identity.clone(); + self.pending_identity = match self.pending_identity.take() { + // First in-flight notification: adopt the payload identity. + None => Some(incoming), + // Already consistent: keep only if this notification matches exactly. + Some(Some(ref existing)) => { + if Some(existing) == incoming.as_ref() { + Some(incoming) + } else { + // Mismatch (different identity, absent, or unproven) → poison. + Some(None) + } + } + // Already poisoned: stays poisoned regardless of this notification. + poisoned @ Some(None) => poisoned, + }; self.pending = Some(TurnUsage { session_id: session_id.to_string(), turn_seq, @@ -343,18 +738,30 @@ impl UsageTracker { turn_total_tokens: turn_total, turn_cost_usd: turn_cost, turn_cache_read_tokens: turn_cache_read, + turn_cache_write_tokens: turn_cache_write, cumulative_input_tokens: current_input, cumulative_output_tokens: current_output, cumulative_total_tokens: current_total, cumulative_cost_usd: current_cost, cumulative_cache_read_tokens: current_cached_input, + cumulative_cache_write_tokens: current_cache_write, model: payload.model.clone(), + // The folded identity is written in take() — use a placeholder + // here and replace it before returning the record. + pricing_identity: None, }); } else if self.in_flight_session.is_none() { // Not in-flight at all: advance the committed baseline so the next // in-flight turn computes its delta from this notification. // This handles setup notifications that fire during `session/new` // before the first `begin_turn`. + // + // Carry forward any existing sticky-poison flags (they only grow). + let existing = self.sessions.get(session_id); + let input_ever_poisoned = + existing.is_some_and(|s| s.input_ever_poisoned) || current_input.is_none(); + let output_ever_poisoned = + existing.is_some_and(|s| s.output_ever_poisoned) || current_output.is_none(); self.sessions.insert( session_id.to_string(), SessionState { @@ -367,12 +774,94 @@ impl UsageTracker { last_cost: current_cost, last_total: current_total, last_cached_input: current_cached_input, + last_cache_write: current_cache_write, + input_ever_poisoned, + output_ever_poisoned, }, ); + } else { + // In-flight-for-another-session — counters are dropped; absence is + // latched. Advancing X's baseline while Y is in-flight would + // undercount X's next published delta, so counters stay unchanged. + // But the sticky-absence contract has no cross-session exemption: if + // this notification is absent, that observation must survive into X's + // next in-flight turn even though the record is otherwise discarded. + let input_absent = current_input.is_none(); + let output_absent = current_output.is_none(); + if input_absent || output_absent { + let existing = self.sessions.get(session_id); + let input_ever_poisoned = + existing.is_some_and(|s| s.input_ever_poisoned) || input_absent; + let output_ever_poisoned = + existing.is_some_and(|s| s.output_ever_poisoned) || output_absent; + let (published_seq, last_input, last_output, last_cost, last_total, lci, lcw) = + match existing { + Some(s) => ( + s.published_seq, + s.last_input, + s.last_output, + s.last_cost, + s.last_total, + s.last_cached_input, + s.last_cache_write, + ), + None => (0, None, None, None, None, None, None), + }; + self.sessions.insert( + session_id.to_string(), + SessionState { + published_seq, + last_input, + last_output, + last_cost, + last_total, + last_cached_input: lci, + last_cache_write: lcw, + input_ever_poisoned, + output_ever_poisoned, + }, + ); + } } - // else: in-flight-for-another-session — ignore. A late notification - // for session X while session Y is in-flight must NOT advance X's - // committed baseline; doing so would undercount X's next published delta. + } + + /// Seed a zero baseline for a session that buzz-acp just spawned. + /// + /// When buzz-acp creates a session itself via `session/new`, the session's + /// prior token usage is zero by definition — no provider calls have been + /// made yet. Seeding a zero baseline here means the first usage + /// notification for this session will see `current − 0 == cumulative` and + /// can emit `delta_reliable: true` with `turn.* == cumulative.*`. + /// + /// This must be called **only** from the code path that issues `session/new` + /// (i.e. `create_session_and_apply_model` in `pool.rs`). It must **not** be + /// called when attaching to a pre-existing session whose prior usage is + /// genuinely unknown — that case correctly stays fail-closed with the + /// existing no-baseline behavior. + /// + /// No-op if a baseline for this session already exists (guards against + /// accidental double-seeding across session rotation). + pub(crate) fn seed_zero_baseline(&mut self, session_id: &str) { + self.sessions + .entry(session_id.to_string()) + .or_insert(SessionState { + published_seq: 0, + last_input: Some(0), + last_output: Some(0), + last_cost: Some(0.0), + // At spawn all counters are zero — seed known-zero baselines so + // the first real turn delta is computed exactly, not discarded as + // "no prior baseline". Cache values use the same argument as + // input/output: a freshly-spawned session has accumulated nothing, + // so the provider-reported cumulative IS the turn delta. + last_total: Some(0), + last_cached_input: Some(0), + last_cache_write: Some(0), + // A freshly-spawned session has no prior absence — poison flags + // start clear and are set only if a subsequent snapshot is absent. + input_ever_poisoned: false, + output_ever_poisoned: false, + }); } /// Consume and return the most recently computed turn usage record, then @@ -384,9 +873,37 @@ impl UsageTracker { #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn take(&mut self) -> Option { self.in_flight_session = None; - let record = self.pending.take()?; + // Consume the folded identity accumulator: emit the proven identity when + // every in-flight notification carried the same one; emit `None` when + // any notification was absent/unproven or they disagreed. + let folded_identity = self.pending_identity.take().and_then(|inner| inner); + // Consume and reset the per-turn fold accumulators before returning. + // These must be reset even on the None path (no pending record) so a + // subsequent begin_turn/take cycle starts clean. + let input_absence_this_turn = std::mem::replace(&mut self.input_absence_observed, false); + let output_absence_this_turn = std::mem::replace(&mut self.output_absence_observed, false); + let mut record = self.pending.take()?; + record.pricing_identity = folded_identity; // Advance the committed baseline to this published record so the // *next* turn measures its delta from here. + // + // Compute sticky-poison flags by combining three sources — all monotonic: + // 1. Any prior session-level flag (from a previous turn). + // 2. `input_absence_this_turn` / `output_absence_this_turn` — whether any + // in-flight notification this turn observed an absent field. This is + // the fold accumulator that closes the un-baselined escape: for sessions + // on the attach-to-existing path (no `seed_zero_baseline`), no session + // entry exists yet, so a `get_mut`-based latch would be a no-op — the + // fold captures the absence regardless and commits it here. + // 3. Whether the final published record's cumulative field is None (the + // last-notification check that was already present). + let existing = self.sessions.get(&record.session_id); + let input_ever_poisoned = existing.is_some_and(|s| s.input_ever_poisoned) + || input_absence_this_turn + || record.cumulative_input_tokens.is_none(); + let output_ever_poisoned = existing.is_some_and(|s| s.output_ever_poisoned) + || output_absence_this_turn + || record.cumulative_output_tokens.is_none(); self.sessions.insert( record.session_id.clone(), SessionState { @@ -396,6 +913,9 @@ impl UsageTracker { last_cost: record.cumulative_cost_usd, last_total: record.cumulative_total_tokens, last_cached_input: record.cumulative_cache_read_tokens, + last_cache_write: record.cumulative_cache_write_tokens, + input_ever_poisoned, + output_ever_poisoned, }, ); Some(record) @@ -421,7 +941,7 @@ mod tests { })) .expect("payload must deserialize"); assert_eq!(p.accumulated_cached_input_tokens, Some(5_033)); - assert!(p.accumulated_cached_input_tokens.unwrap() <= p.accumulated_input_tokens); + assert!(p.accumulated_cached_input_tokens.unwrap() <= p.accumulated_input_tokens.unwrap()); } /// goose does not send the field; its payloads must deserialize with None — @@ -463,12 +983,14 @@ mod tests { UsageUpdatePayload { used: input + output, context_limit: 200_000, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, + pricing_identity: None, } } @@ -476,12 +998,14 @@ mod tests { UsageUpdatePayload { used: 0, context_limit: 0, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, + pricing_identity: None, } } @@ -548,8 +1072,9 @@ mod tests { // publishing a metric, so A's next turn (2000/250) would see a delta of // only 500/100 instead of the correct 1000/150. // - // With the fixed three-way branch, the cross-session notification is - // ignored entirely and A's baseline stays at its last published state. + // With the fixed three-way branch, the cross-session notification drops + // its counters (advancing A's baseline would undercount A's next turn) + // and latches any observed absence into A's committed poison flags. let mut tracker = UsageTracker::default(); // ── Turn A1 — establish A's committed baseline at 1000/100, seq=1 ── @@ -558,7 +1083,7 @@ mod tests { let a1 = tracker.take().expect("A turn 1"); assert_eq!(a1.turn_seq, 1); assert!(!a1.delta_reliable, "first turn is unreliable"); - assert_eq!(a1.cumulative_input_tokens, 1000); + assert_eq!(a1.cumulative_input_tokens, Some(1000)); // ── B is now in-flight; A late notification arrives ── tracker.begin_turn("sess-b"); @@ -591,8 +1116,8 @@ mod tests { late cross-session advance (500)" ); assert_eq!(a2.turn_output_tokens, Some(150)); - assert_eq!(a2.cumulative_input_tokens, 2000); - assert_eq!(a2.cumulative_output_tokens, 250); + assert_eq!(a2.cumulative_input_tokens, Some(2000)); + assert_eq!(a2.cumulative_output_tokens, Some(250)); } // ── Delta computation: non-happy paths ───────────────────────────────── @@ -614,8 +1139,8 @@ mod tests { assert!(usage.turn_output_tokens.is_none()); assert!(usage.turn_cost_usd.is_none()); // Cumulative is still populated. - assert_eq!(usage.cumulative_input_tokens, 1000); - assert_eq!(usage.cumulative_output_tokens, 200); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert_eq!(usage.cumulative_output_tokens, Some(200)); assert_eq!(usage.cumulative_cost_usd, Some(0.01)); } @@ -671,8 +1196,8 @@ mod tests { assert!(usage.turn_output_tokens.is_none()); assert!(usage.turn_cost_usd.is_none()); // Cumulative values are unaffected. - assert_eq!(usage.cumulative_input_tokens, 1500); - assert_eq!(usage.cumulative_output_tokens, 350); + assert_eq!(usage.cumulative_input_tokens, Some(1500)); + assert_eq!(usage.cumulative_output_tokens, Some(350)); assert_eq!(usage.cumulative_cost_usd, Some(0.05)); } @@ -743,8 +1268,8 @@ mod tests { // cost delta: 0.018 - 0.01 = 0.008 (floating-point; use approx check) let dc = usage.turn_cost_usd.expect("cost delta present"); assert!((dc - 0.008).abs() < 1e-9, "cost delta: {dc}"); - assert_eq!(usage.cumulative_input_tokens, 1800); - assert_eq!(usage.cumulative_output_tokens, 450); + assert_eq!(usage.cumulative_input_tokens, Some(1800)); + assert_eq!(usage.cumulative_output_tokens, Some(450)); } #[test] @@ -781,8 +1306,8 @@ mod tests { let usage = tracker.take().expect("turn 2"); // Cumulative from the last notification. - assert_eq!(usage.cumulative_input_tokens, 2000); - assert_eq!(usage.cumulative_output_tokens, 250); + assert_eq!(usage.cumulative_input_tokens, Some(2000)); + assert_eq!(usage.cumulative_output_tokens, Some(250)); // Delta is from committed baseline (1000, 100) → (2000, 250) = 1000/150. assert_eq!(usage.turn_input_tokens, Some(1000)); assert_eq!(usage.turn_output_tokens, Some(150)); @@ -819,8 +1344,8 @@ mod tests { assert_eq!(notif.session_id, "abc-123"); match notif.update { GooseSessionUpdateVariant::UsageUpdate(p) => { - assert_eq!(p.accumulated_input_tokens, 40000); - assert_eq!(p.accumulated_output_tokens, 10000); + assert_eq!(p.accumulated_input_tokens, Some(40000)); + assert_eq!(p.accumulated_output_tokens, Some(10000)); assert_eq!(p.accumulated_cost, Some(0.42)); } GooseSessionUpdateVariant::Other => panic!("expected UsageUpdate"), @@ -842,8 +1367,8 @@ mod tests { serde_json::from_value(raw).expect("deserialization"); match notif.update { GooseSessionUpdateVariant::UsageUpdate(p) => { - assert_eq!(p.accumulated_input_tokens, 500); - assert_eq!(p.accumulated_output_tokens, 100); + assert_eq!(p.accumulated_input_tokens, Some(500)); + assert_eq!(p.accumulated_output_tokens, Some(100)); assert_eq!(p.used, 0); assert_eq!(p.context_limit, 0); assert!(p.accumulated_cost.is_none()); @@ -919,7 +1444,7 @@ mod tests { } let t1 = tracker.take().expect("turn 1"); assert!(!t1.delta_reliable, "first turn: unreliable"); - assert_eq!(t1.cumulative_input_tokens, 300); + assert_eq!(t1.cumulative_input_tokens, Some(300)); // Turn 2 — delta reliable. tracker.begin_turn("buzz-s1"); @@ -974,12 +1499,14 @@ mod tests { UsageUpdatePayload { used: input + output, context_limit: 200_000, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: model.map(str::to_string), + pricing_identity: None, } } @@ -1038,12 +1565,14 @@ mod tests { UsageUpdatePayload { used: input + output, context_limit: 200_000, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, accumulated_cost: None, accumulated_total_tokens: total, model: None, + pricing_identity: None, } } @@ -1206,12 +1735,14 @@ mod tests { UsageUpdatePayload { used: input + output, context_limit: 200_000, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: cached_input, + accumulated_cache_write_tokens: None, accumulated_cost: None, accumulated_total_tokens: None, model: None, + pricing_identity: None, } } @@ -1449,12 +1980,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 700, - cumulative_output_tokens: 200, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(700), + cumulative_output_tokens: Some(200), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, // harness did not report the field + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); @@ -1487,12 +2021,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: Some(300), - cumulative_input_tokens: 700, - cumulative_output_tokens: 200, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(700), + cumulative_output_tokens: Some(200), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: Some(600), + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); @@ -1511,4 +2048,1442 @@ mod tests { "nonzero cumulative cache: must appear in kind:44200 cumulative counts" ); } + + // ── seed_zero_baseline / first-turn fix ───────────────────────────────── + + /// (a) Self-spawned session: first notification must be delta_reliable=true, + /// turn deltas equal to the cumulative values (baseline was zero). + #[test] + fn spawned_session_first_turn_is_reliable_with_zero_baseline() { + let mut tracker = UsageTracker::default(); + // Simulate what pool.rs does immediately after create_session_and_apply_model. + tracker.seed_zero_baseline("sess-spawned"); + + tracker.begin_turn("sess-spawned"); + tracker.record("sess-spawned", &payload(1000, 200, Some(0.01))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.delta_reliable, + "spawned session first turn must be reliable" + ); + assert_eq!(usage.turn_seq, 1); + // Turn deltas == cumulative (baseline was zero). + assert_eq!(usage.turn_input_tokens, Some(1000)); + assert_eq!(usage.turn_output_tokens, Some(200)); + let dc = usage.turn_cost_usd.expect("cost delta present"); + assert!((dc - 0.01).abs() < 1e-9, "cost delta: {dc}"); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert_eq!(usage.cumulative_output_tokens, Some(200)); + } + + /// (b) Re-attach session (no seed): first notification must remain + /// fail-closed (delta_reliable=false, turn.*=None). + #[test] + fn reattach_session_first_turn_stays_fail_closed() { + let mut tracker = UsageTracker::default(); + // No seed_zero_baseline call — simulates re-attach to pre-existing session. + + tracker.begin_turn("sess-reattach"); + tracker.record("sess-reattach", &payload(5000, 1000, Some(0.05))); + let usage = tracker.take().expect("pending"); + + assert!( + !usage.delta_reliable, + "re-attach first turn must remain fail-closed (delta_reliable=false)" + ); + assert_eq!(usage.turn_seq, 1); + assert!( + usage.turn_input_tokens.is_none(), + "no turn delta on re-attach" + ); + assert!(usage.turn_output_tokens.is_none()); + assert!(usage.turn_cost_usd.is_none()); + // Cumulative still passes through. + assert_eq!(usage.cumulative_input_tokens, Some(5000)); + assert_eq!(usage.cumulative_output_tokens, Some(1000)); + } + + /// (c) Second turn and beyond are unaffected in both modes. + #[test] + fn second_turn_reliable_in_both_spawned_and_reattach_paths() { + // Spawned path: turn 2 must be reliable (baseline from turn 1's take()). + let mut spawned = UsageTracker::default(); + spawned.seed_zero_baseline("sess-s"); + spawned.begin_turn("sess-s"); + spawned.record("sess-s", &payload(1000, 100, None)); + let _ = spawned.take(); + + spawned.begin_turn("sess-s"); + spawned.record("sess-s", &payload(1800, 250, None)); + let t2_s = spawned.take().expect("spawned turn 2"); + assert!(t2_s.delta_reliable, "spawned path: turn 2 reliable"); + assert_eq!(t2_s.turn_seq, 2); + assert_eq!(t2_s.turn_input_tokens, Some(800)); + assert_eq!(t2_s.turn_output_tokens, Some(150)); + + // Re-attach path: turn 2 must also be reliable. + let mut reattach = UsageTracker::default(); + reattach.begin_turn("sess-r"); + reattach.record("sess-r", &payload(5000, 1000, None)); + let _ = reattach.take(); // turn 1: unreliable (no baseline), but take() seeds it + + reattach.begin_turn("sess-r"); + reattach.record("sess-r", &payload(6000, 1200, None)); + let t2_r = reattach.take().expect("reattach turn 2"); + assert!(t2_r.delta_reliable, "re-attach path: turn 2 reliable"); + assert_eq!(t2_r.turn_seq, 2); + assert_eq!(t2_r.turn_input_tokens, Some(1000)); + assert_eq!(t2_r.turn_output_tokens, Some(200)); + } + + /// (d) Wire-frame assertions on the emitted TurnUsage payload fields for + /// the spawned-session first turn (not just internal delta_reliable). + #[test] + fn spawned_session_first_turn_payload_fields_are_correct() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-wire"); + + tracker.begin_turn("sess-wire"); + tracker.record( + "sess-wire", + &UsageUpdatePayload { + used: 12345, + context_limit: 200_000, + accumulated_input_tokens: Some(10000), + accumulated_output_tokens: Some(2345), + accumulated_cached_input_tokens: Some(500), + accumulated_cache_write_tokens: None, + accumulated_cost: Some(0.042), + accumulated_total_tokens: Some(12345), + model: Some("claude-opus-4-5".to_string()), + pricing_identity: None, + }, + ); + let usage = tracker.take().expect("pending"); + + // Wire payload fields — every field checked. + assert_eq!(usage.session_id, "sess-wire"); + assert_eq!(usage.turn_seq, 1); + assert!(usage.delta_reliable); + assert_eq!(usage.turn_input_tokens, Some(10000)); + assert_eq!(usage.turn_output_tokens, Some(2345)); + // turn_total: cumulative_total(12345) - baseline_total(Some(0)) = 12345. + // seed_zero_baseline now seeds last_total = Some(0) — same known-zero + // argument as input/output: a freshly-spawned session has accumulated nothing. + assert_eq!( + usage.turn_total_tokens, + Some(12345), + "turn_total_tokens must be Some(12345) on seeded first turn (baseline = Some(0))" + ); + let dc = usage.turn_cost_usd.expect("cost delta present"); + assert!((dc - 0.042).abs() < 1e-9, "cost delta: {dc}"); + assert_eq!(usage.cumulative_input_tokens, Some(10000)); + assert_eq!(usage.cumulative_output_tokens, Some(2345)); + assert_eq!(usage.cumulative_total_tokens, Some(12345)); + assert_eq!(usage.cumulative_cost_usd, Some(0.042)); + assert_eq!(usage.model.as_deref(), Some("claude-opus-4-5")); + // Cache: baseline seeded with last_cached_input = Some(0), so first turn + // delta = snapshot(500) - baseline(0) = Some(500). + assert_eq!( + usage.turn_cache_read_tokens, + Some(500), + "turn_cache_read_tokens: seeded baseline = Some(0) → delta = Some(500)" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(500), + "cumulative_cache_read_tokens passes through from payload" + ); + } + + /// seed_zero_baseline is a no-op when a baseline already exists — guards + /// against accidental double-seeding across session rotation. + #[test] + fn seed_zero_baseline_is_noop_when_baseline_already_exists() { + let mut tracker = UsageTracker::default(); + // Establish a real baseline via turn 1. + tracker.seed_zero_baseline("sess-noop"); + tracker.begin_turn("sess-noop"); + tracker.record("sess-noop", &payload(1000, 200, None)); + let _ = tracker.take(); + + // A second seed call (e.g. a bug in pool.rs) must not reset the baseline. + tracker.seed_zero_baseline("sess-noop"); + + // Turn 2 delta must still measure from the real baseline (1000/200), not zero. + tracker.begin_turn("sess-noop"); + tracker.record("sess-noop", &payload(1500, 300, None)); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_input_tokens, + Some(500), + "baseline must not have been reset to zero by the second seed call" + ); + assert_eq!(usage.turn_output_tokens, Some(100)); + } + + // ── PricingIdentity wire threading ────────────────────────────────────── + + fn make_pricing_identity_payload( + input: u64, + output: u64, + authority: &str, + model: &str, + ) -> UsageUpdatePayload { + UsageUpdatePayload { + used: input + output, + context_limit: 200_000, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: Some(model.to_string()), + pricing_identity: Some(buzz_core::agent_turn_metric::PricingIdentity { + authority: authority.to_string(), + model: model.to_string(), + cache_class: None, + }), + } + } + + /// A payload with a well-formed `pricingIdentity` field must thread + /// it through to `TurnUsage.pricing_identity`. The field is per-turn + /// only (not session-cumulative) and must not affect other deltas. + #[test] + fn pricing_identity_threads_from_payload_to_turn_usage() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-pi"); + + tracker.begin_turn("sess-pi"); + tracker.record( + "sess-pi", + &make_pricing_identity_payload(1000, 200, "api.anthropic.com", "claude-opus-4-5"), + ); + let usage = tracker.take().expect("pending"); + + let pi = usage + .pricing_identity + .expect("pricing_identity must be Some"); + assert_eq!(pi.authority, "api.anthropic.com"); + assert_eq!(pi.model, "claude-opus-4-5"); + assert!(pi.cache_class.is_none()); + // Other fields must be unaffected. + assert!(usage.delta_reliable); + assert_eq!(usage.turn_input_tokens, Some(1000)); + } + + /// Old harnesses (goose, older buzz-agent) that do not emit `pricingIdentity` + /// must produce `TurnUsage.pricing_identity = None` — no default injection. + #[test] + fn old_harness_no_pricing_identity_field_yields_none() { + // Deserialize a payload with no pricingIdentity field. + let raw = serde_json::json!({ + "used": 1200, + "contextLimit": 200_000, + "accumulatedInputTokens": 1000, + "accumulatedOutputTokens": 200, + "model": "claude-opus-4", + }); + let p: UsageUpdatePayload = + serde_json::from_value(raw).expect("must deserialize without pricingIdentity field"); + assert!( + p.pricing_identity.is_none(), + "old harness compat: absent field must deserialize to None, not inject a default" + ); + + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-old"); + tracker.begin_turn("sess-old"); + tracker.record("sess-old", &p); + let usage = tracker.take().expect("pending"); + + assert!( + usage.pricing_identity.is_none(), + "old harness: pricing_identity must be None in TurnUsage" + ); + } + + /// `pricingIdentity` in the JSON wire format uses camelCase keys as + /// required by the NIP-AM wire contract (`#[serde(rename_all = "camelCase")]`). + #[test] + fn pricing_identity_deserializes_from_camel_case_wire_key() { + let raw = serde_json::json!({ + "used": 1500, + "contextLimit": 0, + "accumulatedInputTokens": 1200, + "accumulatedOutputTokens": 300, + "pricingIdentity": { + "authority": "api.openai.com", + "model": "gpt-4o", + } + }); + let p: UsageUpdatePayload = serde_json::from_value(raw).expect("payload must deserialize"); + let pi = p.pricing_identity.expect("pricingIdentity must parse"); + assert_eq!(pi.authority, "api.openai.com"); + assert_eq!(pi.model, "gpt-4o"); + assert!(pi.cache_class.is_none()); + } + + /// `pricingIdentity` with a `cacheClass` field threads through correctly. + #[test] + fn pricing_identity_cache_class_threads_through() { + let raw = serde_json::json!({ + "used": 800, + "contextLimit": 0, + "accumulatedInputTokens": 600, + "accumulatedOutputTokens": 200, + "pricingIdentity": { + "authority": "api.anthropic.com", + "model": "claude-3-5-haiku", + "cacheClass": "ephemeral", + } + }); + let p: UsageUpdatePayload = serde_json::from_value(raw).expect("payload must deserialize"); + let pi = p.pricing_identity.expect("pricingIdentity must parse"); + assert_eq!(pi.cache_class.as_deref(), Some("ephemeral")); + } + + /// `pricing_identity` is per-turn only — it must NOT be stored in or + /// influence the session-cumulative baseline (`SessionState`). A second + /// turn must still carry its own `pricing_identity` from the latest payload. + #[test] + fn pricing_identity_is_not_session_cumulative() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-perturn"); + + // Turn 1: identity present. + tracker.begin_turn("sess-perturn"); + tracker.record( + "sess-perturn", + &make_pricing_identity_payload(1000, 200, "api.anthropic.com", "claude-opus-4-5"), + ); + let t1 = tracker.take().expect("turn 1"); + assert!( + t1.pricing_identity.is_some(), + "turn 1 must carry pricing_identity" + ); + + // Turn 2: no identity in payload. + tracker.begin_turn("sess-perturn"); + tracker.record("sess-perturn", &payload(1500, 300, None)); + let t2 = tracker.take().expect("turn 2"); + assert!( + t2.pricing_identity.is_none(), + "turn 2 must NOT inherit turn 1's pricing_identity" + ); + } + + // ── First-turn cache deltas via seed_zero_baseline ─────────────────────── + // + // When `seed_zero_baseline` is called before the first turn (the normal path + // for freshly-spawned sessions), the zero-seeded baselines mean the first + // snapshot's cumulative values equal the per-turn deltas — no data is lost. + + #[test] + fn seed_zero_baseline_first_turn_cache_read_and_write_produce_exact_deltas() { + // A freshly-spawned session seeds last_cached_input = Some(0) and + // last_cache_write = Some(0). The first turn's snapshot values ARE the + // deltas; both categories must surface as exact non-None values. + let mut tracker = UsageTracker::default(); + + // Simulate session spawn: seed before the first turn. + tracker.seed_zero_baseline("sess-seed1"); + tracker.begin_turn("sess-seed1"); + + // First snapshot: cache-read = 500, cache-write = 120. + let payload = UsageUpdatePayload { + used: 1200, + context_limit: 200_000, + accumulated_input_tokens: Some(1000), + accumulated_output_tokens: Some(200), + accumulated_cached_input_tokens: Some(500), + accumulated_cache_write_tokens: Some(120), + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-seed1", &payload); + let usage = tracker + .take() + .expect("first seeded turn must produce a record"); + + // Input/output deltas are always reliable on the seeded path. + assert!( + usage.delta_reliable, + "seeded first turn must be delta_reliable" + ); + assert_eq!( + usage.turn_cache_read_tokens, + Some(500), + "seeded first turn: cache-read delta must equal the snapshot value" + ); + assert_eq!( + usage.turn_cache_write_tokens, + Some(120), + "seeded first turn: cache-write delta must equal the snapshot value" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(500), + "seeded first turn: cumulative cache-read must pass through" + ); + assert_eq!( + usage.cumulative_cache_write_tokens, + Some(120), + "seeded first turn: cumulative cache-write must pass through" + ); + } + + // ── ACP identity fold across multiple notifications ────────────────────── + // + // The publisher fold (agent.rs `fold_pricing_identity`) covers the case where + // a single cumulative snapshot has no provable identity within the agent loop. + // The ACP tracker has a DISTINCT multi-notification path: buzz-agent sends + // multiple `usage_update` notifications per turn (one per round), and the ACP + // tracker must fold identity across those notifications — not last-update-wins. + // + // Three acceptance tests per the dispatch contract (Paul event 4ad5390e): + + fn pi_payload(input: u64, output: u64, authority: &str, model: &str) -> UsageUpdatePayload { + make_pricing_identity_payload(input, output, authority, model) + } + + fn no_identity_payload(input: u64, output: u64) -> UsageUpdatePayload { + payload(input, output, None) + } + + /// ACP identity fold — case A→B: two notifications with different proven + /// identities in one turn must poison; published `AgentTurnMetricPayload` + /// (i.e. `TurnUsage.pricing_identity`) must be absent. + #[test] + fn acp_identity_fold_different_identities_poisons() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-acp-ab"); + tracker.begin_turn("sess-acp-ab"); + + // Notification 1: identity A (anthropic / claude-opus). + tracker.record( + "sess-acp-ab", + &pi_payload(1000, 200, "api.anthropic.com", "claude-opus-4-5"), + ); + // Notification 2: identity B (openai / gpt-4o). + tracker.record( + "sess-acp-ab", + &pi_payload(2000, 400, "api.openai.com", "gpt-4o"), + ); + let usage = tracker.take().expect("pending"); + + assert!( + usage.pricing_identity.is_none(), + "A→B in one turn: pricing_identity must be absent (poisoned by mismatch)" + ); + } + + /// ACP identity fold — case A→absent: proven identity followed by a + /// notification with no identity must poison; published identity must be absent. + #[test] + fn acp_identity_fold_absent_notification_poisons() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-acp-aabs"); + tracker.begin_turn("sess-acp-aabs"); + + // Notification 1: identity A. + tracker.record( + "sess-acp-aabs", + &pi_payload(1000, 200, "api.anthropic.com", "claude-3-5-haiku"), + ); + // Notification 2: no identity (e.g. unpairable cumulative snapshot). + tracker.record("sess-acp-aabs", &no_identity_payload(2000, 400)); + + let usage = tracker.take().expect("pending"); + + assert!( + usage.pricing_identity.is_none(), + "A→absent in one turn: pricing_identity must be absent (poisoned by missing identity)" + ); + } + + /// ACP identity fold — case A→absent→A: proven identity, then an absent + /// notification, then the original identity again — must NOT heal; + /// published identity must still be absent. + #[test] + fn acp_identity_fold_never_heals_after_poison() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-acp-heal"); + tracker.begin_turn("sess-acp-heal"); + + // Notification 1: identity A. + tracker.record( + "sess-acp-heal", + &pi_payload(1000, 200, "api.openai.com", "gpt-4o"), + ); + // Notification 2: absent identity — poisons. + tracker.record("sess-acp-heal", &no_identity_payload(2000, 400)); + // Notification 3: identity A again — must NOT resurrect it. + tracker.record( + "sess-acp-heal", + &pi_payload(3000, 600, "api.openai.com", "gpt-4o"), + ); + + let usage = tracker.take().expect("pending"); + + assert!( + usage.pricing_identity.is_none(), + "A→absent→A in one turn: pricing_identity must remain absent (no healing after poison)" + ); + } + + // ── Overflow-poison ACP consumer tests ─────────────────────────────────── + + /// A payload with absent `accumulatedInputTokens` (publisher overflow-poisoned) + /// must produce `delta_reliable: false`, null turn fields, and null cumulative + /// input/output in `TurnUsage`. + #[test] + fn absent_input_tokens_produces_unreliable_delta_and_null_cumulative() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-poison-in"); + tracker.begin_turn("sess-poison-in"); + + // Publisher omitted accumulatedInputTokens (overflow-poisoned). + let p = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: None, // overflow-poisoned + accumulated_output_tokens: Some(200), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-poison-in", &p); + let usage = tracker.take().expect("pending"); + + assert!( + !usage.delta_reliable, + "absent input: delta_reliable must be false" + ); + assert!( + usage.turn_input_tokens.is_none(), + "absent input: turn_input_tokens must be None" + ); + assert!( + usage.turn_output_tokens.is_none(), + "absent input: turn_output_tokens must be None" + ); + assert!( + usage.cumulative_input_tokens.is_none(), + "absent input: cumulative_input_tokens must be None" + ); + // cumulative_output_tokens passes through as-is (it's separate). + assert_eq!(usage.cumulative_output_tokens, Some(200)); + } + + /// A payload with absent `accumulatedOutputTokens` (publisher overflow-poisoned) + /// must produce `delta_reliable: false`, null turn fields, and null cumulative output. + #[test] + fn absent_output_tokens_produces_unreliable_delta_and_null_cumulative() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-poison-out"); + tracker.begin_turn("sess-poison-out"); + + let p = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: Some(1000), + accumulated_output_tokens: None, // overflow-poisoned + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-poison-out", &p); + let usage = tracker.take().expect("pending"); + + assert!( + !usage.delta_reliable, + "absent output: delta_reliable must be false" + ); + assert!(usage.turn_input_tokens.is_none()); + assert!(usage.turn_output_tokens.is_none()); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert!( + usage.cumulative_output_tokens.is_none(), + "absent output: cumulative_output_tokens must be None" + ); + } + + /// A goose-shaped payload with both input and output present must produce + /// the same behavior as before — delta_reliable true on seeded sessions, + /// cumulative values passed through exactly. + #[test] + fn goose_shaped_payload_both_present_unchanged_behavior() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-goose"); + tracker.begin_turn("sess-goose"); + + tracker.record("sess-goose", &payload(1500, 300, None)); + let usage = tracker.take().expect("pending"); + + assert!( + usage.delta_reliable, + "goose payload: delta_reliable must be true" + ); + assert_eq!(usage.turn_input_tokens, Some(1500)); + assert_eq!(usage.turn_output_tokens, Some(300)); + assert_eq!(usage.cumulative_input_tokens, Some(1500)); + assert_eq!(usage.cumulative_output_tokens, Some(300)); + } + + /// Once a session emits a poisoned snapshot (absent fields), subsequent turns + /// stay unknown — not advancing is correct since publisher poison is permanent. + #[test] + fn poison_mid_session_subsequent_turns_stay_unknown() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-poison-mid"); + + // Turn 1: normal. + tracker.begin_turn("sess-poison-mid"); + tracker.record("sess-poison-mid", &payload(1000, 200, None)); + let t1 = tracker.take().expect("t1"); + assert!(t1.delta_reliable); + assert_eq!(t1.cumulative_input_tokens, Some(1000)); + + // Turn 2: overflow-poisoned (publisher omits input). + tracker.begin_turn("sess-poison-mid"); + let poisoned = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: None, + accumulated_output_tokens: Some(500), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-poison-mid", &poisoned); + let t2 = tracker.take().expect("t2"); + assert!(!t2.delta_reliable, "poisoned turn: delta_reliable false"); + assert!(t2.cumulative_input_tokens.is_none()); + + // Turn 3: subsequent snapshot also absent → still unreliable. + tracker.begin_turn("sess-poison-mid"); + let also_poisoned = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: None, + accumulated_output_tokens: Some(700), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-poison-mid", &also_poisoned); + let t3 = tracker.take().expect("t3"); + assert!( + !t3.delta_reliable, + "turn after poison: delta_reliable still false" + ); + assert!( + t3.cumulative_input_tokens.is_none(), + "turn after poison: cumulative_input_tokens stays None" + ); + } + + /// Wes's P1 reproducer: once ACP has observed an absent input cumulative, + /// a later turn that resumes emitting the field must NOT heal + /// `delta_reliable`. The prefix delta is irrecoverably unknown; sticky + /// poison persists for the rest of the session. + #[test] + fn sticky_poison_input_absent_then_present_stays_unreliable() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-sticky-input"); + + // Turn 1: normal — establishes a baseline. + tracker.begin_turn("sess-sticky-input"); + tracker.record("sess-sticky-input", &payload(500, 100, None)); + let t1 = tracker.take().expect("t1"); + assert!(t1.delta_reliable, "pre-poison turn must be reliable"); + + // Turn 2: publisher poisons (absent input). + tracker.begin_turn("sess-sticky-input"); + let poisoned = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: None, + accumulated_output_tokens: Some(300), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-sticky-input", &poisoned); + let t2 = tracker.take().expect("t2"); + assert!(!t2.delta_reliable, "poisoned turn must be unreliable"); + + // Turn 3: publisher resumes emitting input — but poison must be sticky. + tracker.begin_turn("sess-sticky-input"); + tracker.record("sess-sticky-input", &payload(100, 400, None)); + let t3 = tracker.take().expect("t3"); + assert!( + !t3.delta_reliable, + "turn after absent→present must stay unreliable (sticky poison)" + ); + assert!( + t3.turn_input_tokens.is_none(), + "turn_input_tokens must be None after sticky poison" + ); + assert!( + t3.turn_output_tokens.is_none(), + "turn_output_tokens must be None after sticky poison" + ); + + // Turn 4: publisher continues emitting — poison persists. + tracker.begin_turn("sess-sticky-input"); + tracker.record("sess-sticky-input", &payload(150, 500, None)); + let t4 = tracker.take().expect("t4"); + assert!( + !t4.delta_reliable, + "delta_reliable stays false for the remainder of the session" + ); + } + + /// Symmetric to the input test: once ACP has observed an absent *output* + /// cumulative, subsequent turns that resume emitting output must NOT heal + /// `delta_reliable`. + #[test] + fn sticky_poison_output_absent_then_present_stays_unreliable() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-sticky-output"); + + // Turn 1: normal. + tracker.begin_turn("sess-sticky-output"); + tracker.record("sess-sticky-output", &payload(500, 100, None)); + let t1 = tracker.take().expect("t1"); + assert!(t1.delta_reliable); + + // Turn 2: absent output poisons the session. + tracker.begin_turn("sess-sticky-output"); + let poisoned = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: Some(600), + accumulated_output_tokens: None, // <-- absent output + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-sticky-output", &poisoned); + let t2 = tracker.take().expect("t2"); + assert!( + !t2.delta_reliable, + "absent output must make delta unreliable" + ); + + // Turn 3: output resumes — sticky poison holds. + tracker.begin_turn("sess-sticky-output"); + tracker.record("sess-sticky-output", &payload(700, 200, None)); + let t3 = tracker.take().expect("t3"); + assert!( + !t3.delta_reliable, + "output absent→present must stay unreliable (sticky poison)" + ); + assert!(t3.turn_input_tokens.is_none()); + assert!(t3.turn_output_tokens.is_none()); + + // Turn 4: persists. + tracker.begin_turn("sess-sticky-output"); + tracker.record("sess-sticky-output", &payload(800, 250, None)); + let t4 = tracker.take().expect("t4"); + assert!( + !t4.delta_reliable, + "delta_reliable stays false for the remainder of the session" + ); + } + + /// Convenience helper: build a payload with optional input and output. + /// Used by within-turn sticky-poison tests that need to inject absence + /// mid-turn without building the full struct every time. + fn payload_opt(input: Option, output: Option) -> UsageUpdatePayload { + UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + } + } + + /// Rich payload for cross-session baseline-preservation tests. + /// + /// Carries all six counter fields so that `take()` commits a fully-populated + /// `SessionState` with every baseline `Some(…)` and distinct. The + /// before/after comparisons then exercise every field in the preservation + /// assertion, not just the input/output pair. + fn rich_payload( + input: Option, + output: Option, + cost: Option, + total: Option, + cached_input: Option, + cache_write: Option, + ) -> UsageUpdatePayload { + UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cached_input_tokens: cached_input, + accumulated_cache_write_tokens: cache_write, + accumulated_cost: cost, + accumulated_total_tokens: total, + model: None, + pricing_identity: None, + } + } + + /// Once ACP observes an absent *input* snapshot mid-turn, a later + /// notification in the SAME turn that reintroduces the field must NOT + /// heal `delta_reliable`. The poison must also persist to subsequent turns. + /// + /// Wes's finding: his reproducer was stated at snapshot level ("a later + /// producer snapshot reintroduces the field"), not turn level. This test + /// pins the within-turn case that the turn-boundary latch missed. + #[test] + fn within_turn_input_absent_then_present_stays_unreliable() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("wt-input"); + t.begin_turn("wt-input"); + // First notification is normal — establishes a seeded baseline turn. + t.record("wt-input", &payload_opt(Some(50), Some(10))); + let t0 = t.take().expect("t0"); + assert!(t0.delta_reliable, "pre-poison turn must be reliable"); + + t.begin_turn("wt-input"); + t.record("wt-input", &payload_opt(None, Some(10))); // poison: input absent + t.record("wt-input", &payload_opt(Some(100), Some(20))); // reintroduced + let t1 = t.take().expect("t1"); + assert!( + !t1.delta_reliable, + "within-turn absent→present must stay unreliable (input)" + ); + + t.begin_turn("wt-input"); + t.record("wt-input", &payload_opt(Some(150), Some(30))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "poison must persist to next turn (input)" + ); + } + + /// Symmetric to the input case: once ACP observes an absent *output* + /// snapshot mid-turn, subsequent same-turn reintroductions and subsequent + /// turns must both stay unreliable. + #[test] + fn within_turn_output_absent_then_present_stays_unreliable() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("wt-output"); + t.begin_turn("wt-output"); + t.record("wt-output", &payload_opt(Some(50), Some(10))); + let t0 = t.take().expect("t0"); + assert!(t0.delta_reliable, "pre-poison turn must be reliable"); + + t.begin_turn("wt-output"); + t.record("wt-output", &payload_opt(Some(60), None)); // poison: output absent + t.record("wt-output", &payload_opt(Some(100), Some(20))); // reintroduced + let t1 = t.take().expect("t1"); + assert!( + !t1.delta_reliable, + "within-turn absent→present must stay unreliable (output)" + ); + + t.begin_turn("wt-output"); + t.record("wt-output", &payload_opt(Some(150), Some(30))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "poison must persist to next turn (output)" + ); + } + + /// Un-baselined session (attach-to-existing path, no seed_zero_baseline): + /// an absent input snapshot observed mid-turn must poison the session even + /// though no session entry exists yet — a later reintroduced value must not + /// heal delta_reliable in the next turn. + /// + /// This is Paul's probe that FAILED at eb24590e2e — the get_mut latch was a + /// no-op for un-baselined sessions. The fold accumulator on UsageTracker + /// captures the absence and commits it at take() regardless of whether a + /// session entry already exists. + #[test] + fn unbaselined_within_turn_input_absence_poisons_next_turn() { + let mut t = UsageTracker::default(); + // NO seed_zero_baseline — attach-to-existing path + t.begin_turn("s"); + t.record("s", &payload_opt(None, Some(10))); // poisoned snapshot + t.record("s", &payload_opt(Some(100), Some(20))); // reintroduced same turn + let t1 = t.take().expect("t1"); + assert!(!t1.delta_reliable, "t1: no baseline — must be unreliable"); + t.begin_turn("s"); + t.record("s", &payload_opt(Some(150), Some(30))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "t2: absence was observed in t1 — sticky poison must hold" + ); + } + + /// Symmetric output-field case for the un-baselined escape: + /// absent output snapshot mid-turn must poison the session and persist to + /// the next turn, even when no session entry existed at record() time. + #[test] + fn unbaselined_within_turn_output_absence_poisons_next_turn() { + let mut t = UsageTracker::default(); + // NO seed_zero_baseline — attach-to-existing path + t.begin_turn("s"); + t.record("s", &payload_opt(Some(10), None)); // poisoned snapshot: output absent + t.record("s", &payload_opt(Some(100), Some(20))); // reintroduced same turn + let t1 = t.take().expect("t1"); + assert!(!t1.delta_reliable, "t1: no baseline — must be unreliable"); + t.begin_turn("s"); + t.record("s", &payload_opt(Some(150), Some(30))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "t2: absence was observed in t1 — sticky poison must hold" + ); + } + + /// Take-skipped same-session: `begin_turn("s")` is called twice without + /// a `take()` in between (the initial-message path in pool.rs does this). + /// An absence observed in the skipped turn must NOT be discarded — the + /// next real turn must stay unreliable. + /// + /// This is Paul's probe that FAILED at 762e47bd31. The fold accumulators + /// were only committed in `take()`, so a skipped `take()` silently dropped + /// the observed absence. The fix flushes in `begin_turn()` instead. + #[test] + fn take_skipped_turn_input_absence_survives_to_next_turn() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("s"); + t.begin_turn("s"); + t.record("s", &payload_opt(None, Some(10))); // absence observed in init turn + // NO take() — init-message path goes straight to the next begin_turn + t.begin_turn("s"); + t.record("s", &payload_opt(Some(100), Some(20))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "absence must survive a skipped take() (input)" + ); + } + + /// Symmetric output-field case for the take-skipped escape. + #[test] + fn take_skipped_turn_output_absence_survives_to_next_turn() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("s"); + t.begin_turn("s"); + t.record("s", &payload_opt(Some(10), None)); // absence observed in init turn: output absent + // NO take() — init-message path goes straight to the next begin_turn + t.begin_turn("s"); + t.record("s", &payload_opt(Some(100), Some(20))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "absence must survive a skipped take() (output)" + ); + } + + /// Cross-session take-skipped: session A's turn observed an absence, then + /// `begin_turn("B")` runs next (no take() for A). A's poison must survive + /// — when A is next in-flight its delta must still be unreliable. + #[test] + fn cross_session_take_skipped_input_absence_survives() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("a"); + t.seed_zero_baseline("b"); + // Session A's turn: observe absence (no take) + t.begin_turn("a"); + t.record("a", &payload_opt(None, Some(10))); // input absence observed for A + // Session B starts — no take() for A + t.begin_turn("b"); + t.record("b", &payload_opt(Some(50), Some(5))); + let tb = t.take().expect("tb"); + assert!(tb.delta_reliable, "session B must still be reliable"); + // Session A resumes — poison must hold + t.begin_turn("a"); + t.record("a", &payload_opt(Some(100), Some(20))); + let ta = t.take().expect("ta"); + assert!( + !ta.delta_reliable, + "session A: absence observed before cross-session begin_turn must hold" + ); + } + + /// Symmetric output-field cross-session case. + #[test] + fn cross_session_take_skipped_output_absence_survives() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("a"); + t.seed_zero_baseline("b"); + // Session A's turn: observe output absence (no take) + t.begin_turn("a"); + t.record("a", &payload_opt(Some(10), None)); // output absence observed for A + // Session B starts — no take() for A + t.begin_turn("b"); + t.record("b", &payload_opt(Some(50), Some(5))); + let tb = t.take().expect("tb"); + assert!(tb.delta_reliable, "session B must still be reliable"); + // Session A resumes — poison must hold + t.begin_turn("a"); + t.record("a", &payload_opt(Some(100), Some(20))); + let ta = t.take().expect("ta"); + assert!( + !ta.delta_reliable, + "session A: output absence observed before cross-session begin_turn must hold" + ); + } + + /// Wes's reproducer (round-5 review): a cross-session absent notification + /// arrives while a different session is in-flight. The absence must latch + /// into the notified session's `*_ever_poisoned` state even though the + /// notification's counters are otherwise dropped. + /// + /// Round-7 upgrade: rich fixture with all six baselines populated and + /// distinct so every "must not change" assertion is discriminating. The + /// cross-session payload carries different values for every present counter + /// (including non-target output) so advance-to-current corruption is also + /// visible. Pre-flag false, post-flag true is explicitly asserted. + /// + /// Scenario: + /// - A publishes a reliable turn with full counters (turn 1). + /// - B becomes in-flight. + /// - A late A notification: input=None, all other counters present but + /// with different values from A's committed baseline. + /// - B's turn publishes normally (must be unaffected). + /// - A's turn 2 must be `!delta_reliable`. + #[test] + fn cross_session_absent_notification_latches_poison_input() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("a"); + t.seed_zero_baseline("b"); + + // ── A turn 1: commit a fully-populated baseline with all six fields ── + // + // Values chosen to be distinct so every baseline field is discriminating: + // last_input=50, last_output=10, last_cost=1.5, last_total=70, + // last_cached_input=5, last_cache_write=3. + t.begin_turn("a"); + t.record( + "a", + &rich_payload(Some(50), Some(10), Some(1.5), Some(70), Some(5), Some(3)), + ); + let a1 = t.take().expect("a1"); + assert!(a1.delta_reliable, "A turn 1 must be reliable"); + + // ── Snapshot A's SessionState BEFORE the cross-session record ── + let state_before = t + .sessions + .get("a") + .expect("A entry must exist after turn 1") + .clone(); + // Sanity: all six baselines are populated and have the expected values. + assert_eq!(state_before.last_input, Some(50)); + assert_eq!(state_before.last_output, Some(10)); + assert_eq!(state_before.last_cost, Some(1.5)); + assert_eq!(state_before.last_total, Some(70)); + assert_eq!(state_before.last_cached_input, Some(5)); + assert_eq!(state_before.last_cache_write, Some(3)); + // Pre-flag: input_ever_poisoned must be false before the latch. + assert!( + !state_before.input_ever_poisoned, + "input_ever_poisoned must be false before the cross-session record" + ); + + // ── B in-flight; late A notification: input absent, all other fields + // present with DIFFERENT values from A's committed baseline ── + // (output=20, cost=2.5, total=90, cached_input=8, cache_write=6) + t.begin_turn("b"); + t.record( + "a", + &rich_payload(None, Some(20), Some(2.5), Some(90), Some(8), Some(6)), + ); + + // ── Snapshot A's SessionState AFTER the cross-session record ── + let state_after = t + .sessions + .get("a") + .expect("A entry must still exist") + .clone(); + + // Every non-poison field must be byte-for-byte unchanged. + assert_eq!( + state_after.published_seq, state_before.published_seq, + "published_seq must not be advanced by a dropped cross-session notification" + ); + assert_eq!( + state_after.last_input, state_before.last_input, + "last_input baseline must not change" + ); + assert_eq!( + state_after.last_output, state_before.last_output, + "last_output baseline must not change" + ); + assert_eq!( + state_after.last_cost, state_before.last_cost, + "last_cost baseline must not change" + ); + assert_eq!( + state_after.last_total, state_before.last_total, + "last_total baseline must not change" + ); + assert_eq!( + state_after.last_cached_input, state_before.last_cached_input, + "last_cached_input baseline must not change" + ); + assert_eq!( + state_after.last_cache_write, state_before.last_cache_write, + "last_cache_write baseline must not change" + ); + // Poison: input flag grows from false (asserted above) to true. + assert!( + state_after.input_ever_poisoned, + "input_ever_poisoned must be latched by the cross-session absent notification" + ); + assert_eq!( + state_after.output_ever_poisoned, state_before.output_ever_poisoned, + "output_ever_poisoned must not change when only input is absent" + ); + + t.record( + "b", + &rich_payload( + Some(200), + Some(30), + Some(4.0), + Some(250), + Some(15), + Some(10), + ), + ); + let b1 = t.take().expect("b1"); + assert!( + b1.delta_reliable, + "B turn 1 must be unaffected by the late A notification" + ); + assert_eq!(b1.session_id, "b"); + + // ── A turn 2: record at 100/20 ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(100), Some(20))); + let a2 = t.take().expect("a2"); + assert!( + !a2.delta_reliable, + "A turn 2 must be poisoned: input absence observed in dropped cross-session notification" + ); + } + + /// Symmetric output-absent case for the cross-session absence latch. + /// + /// Round-7 upgrade: same rich-fixture approach as the input variant — all + /// six baselines populated with distinct values, cross-session payload + /// carries different present counters (including non-target input) while + /// output is absent, pre-flag false → post-flag true explicitly asserted. + #[test] + fn cross_session_absent_notification_latches_poison_output() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("a"); + t.seed_zero_baseline("b"); + + // ── A turn 1: commit a fully-populated baseline ── + // last_input=50, last_output=10, last_cost=1.5, last_total=70, + // last_cached_input=5, last_cache_write=3. + t.begin_turn("a"); + t.record( + "a", + &rich_payload(Some(50), Some(10), Some(1.5), Some(70), Some(5), Some(3)), + ); + let a1 = t.take().expect("a1"); + assert!(a1.delta_reliable, "A turn 1 must be reliable"); + + // ── Snapshot A's SessionState BEFORE the cross-session record ── + let state_before = t + .sessions + .get("a") + .expect("A entry must exist after turn 1") + .clone(); + // Sanity: all six baselines populated with expected values. + assert_eq!(state_before.last_input, Some(50)); + assert_eq!(state_before.last_output, Some(10)); + assert_eq!(state_before.last_cost, Some(1.5)); + assert_eq!(state_before.last_total, Some(70)); + assert_eq!(state_before.last_cached_input, Some(5)); + assert_eq!(state_before.last_cache_write, Some(3)); + // Pre-flag: output_ever_poisoned must be false before the latch. + assert!( + !state_before.output_ever_poisoned, + "output_ever_poisoned must be false before the cross-session record" + ); + + // ── B in-flight; late A notification: output absent, all other fields + // present with DIFFERENT values from A's committed baseline ── + // (input=80, cost=2.5, total=90, cached_input=8, cache_write=6) + t.begin_turn("b"); + t.record( + "a", + &rich_payload(Some(80), None, Some(2.5), Some(90), Some(8), Some(6)), + ); + + // ── Snapshot A's SessionState AFTER the cross-session record ── + let state_after = t + .sessions + .get("a") + .expect("A entry must still exist") + .clone(); + + // Every non-poison field must be byte-for-byte unchanged. + assert_eq!( + state_after.published_seq, state_before.published_seq, + "published_seq must not be advanced" + ); + assert_eq!( + state_after.last_input, state_before.last_input, + "last_input baseline must not change" + ); + assert_eq!( + state_after.last_output, state_before.last_output, + "last_output baseline must not change" + ); + assert_eq!( + state_after.last_cost, state_before.last_cost, + "last_cost baseline must not change" + ); + assert_eq!( + state_after.last_total, state_before.last_total, + "last_total baseline must not change" + ); + assert_eq!( + state_after.last_cached_input, state_before.last_cached_input, + "last_cached_input baseline must not change" + ); + assert_eq!( + state_after.last_cache_write, state_before.last_cache_write, + "last_cache_write baseline must not change" + ); + // Poison: output flag grows from false (asserted above) to true. + assert!( + state_after.output_ever_poisoned, + "output_ever_poisoned must be latched by the cross-session absent notification" + ); + assert_eq!( + state_after.input_ever_poisoned, state_before.input_ever_poisoned, + "input_ever_poisoned must not change when only output is absent" + ); + + t.record( + "b", + &rich_payload( + Some(200), + Some(30), + Some(4.0), + Some(250), + Some(15), + Some(10), + ), + ); + let b1 = t.take().expect("b1"); + assert!(b1.delta_reliable, "B turn 1 must be unaffected"); + assert_eq!(b1.session_id, "b"); + + // ── A turn 2 ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(100), Some(20))); + let a2 = t.take().expect("a2"); + assert!( + !a2.delta_reliable, + "A turn 2 must be poisoned: output absence observed in dropped cross-session notification" + ); + } + + /// Un-baselined variant (input-absent): A has NO session entry when the + /// cross-session absent notification arrives. The latch must CREATE an entry + /// with only `input_ever_poisoned = true` and zero-baseline fields (all six + /// `last_*` baselines remain `None`, `published_seq` = 0). The poison must + /// then survive into A's second real turn after A establishes its own baseline. + /// + /// Round-7 upgrade: the cross-session payload carries nonzero cost/total/ + /// cached-input/cache-write values (plus present non-target output) so the + /// created-entry shape assertions actually prove the latch did NOT initialize + /// baselines from the incoming payload. + /// + /// (A's first turn is unreliable regardless because it has no prior baseline; + /// the second turn is where the latch matters — without it, `take()` would + /// see `input_ever_poisoned: false` and flip `delta_reliable: true`.) + #[test] + fn cross_session_absent_notification_latches_poison_unbaselined() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("b"); + // A has NO entry at all. + assert!( + !t.sessions.contains_key("a"), + "A must have no entry before the cross-session record" + ); + + // ── B in-flight; A notification: input absent, but ALL other counter + // fields present with nonzero values ── + // output=15, cost=3.0, total=80, cached_input=7, cache_write=4. + // A has no prior entry, so the latch must CREATE one with all six + // baselines None — not initialized from these payload values. + t.begin_turn("b"); + t.record( + "a", + &rich_payload(None, Some(15), Some(3.0), Some(80), Some(7), Some(4)), + ); + + // Entry must now exist with exactly the right shape. + let created = t + .sessions + .get("a") + .expect("latch must create an entry for A"); + assert_eq!(created.published_seq, 0, "created entry has zero seq"); + assert!( + created.last_input.is_none(), + "created entry must have no input baseline" + ); + assert!( + created.last_output.is_none(), + "created entry must have no output baseline" + ); + assert!( + created.last_cost.is_none(), + "created entry must have no cost baseline" + ); + assert!( + created.last_total.is_none(), + "created entry must have no total baseline" + ); + assert!( + created.last_cached_input.is_none(), + "created entry must have no cache-read baseline" + ); + assert!( + created.last_cache_write.is_none(), + "created entry must have no cache-write baseline" + ); + assert!( + created.input_ever_poisoned, + "input_ever_poisoned must be set on the newly created entry" + ); + assert!( + !created.output_ever_poisoned, + "output_ever_poisoned must NOT be set (only input was absent)" + ); + + t.record("b", &payload_opt(Some(100), Some(20))); + let b1 = t.take().expect("b1"); + assert!(b1.delta_reliable, "B must be unaffected"); + + // ── A's first real turn (unreliable regardless — no prior baseline) ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(80), Some(15))); + let _a1 = t.take().expect("a1"); + + // ── A's second turn: with the fix, `input_ever_poisoned` was committed + // by take() above; without it, the flag would be false and delta heals. ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(150), Some(25))); + let a2 = t.take().expect("a2"); + assert!( + !a2.delta_reliable, + "A second turn must be poisoned: input absence from cross-session notification must hold even with no prior entry" + ); + } + + /// Un-baselined variant (output-absent): symmetric mirror of the input-absent + /// case above. A has no entry; a cross-session notification with output absent + /// (and all other counters present and nonzero) creates an entry with only + /// `output_ever_poisoned = true`; the poison holds through A's second real turn. + #[test] + fn cross_session_absent_notification_latches_poison_unbaselined_output() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("b"); + assert!( + !t.sessions.contains_key("a"), + "A must have no entry before the cross-session record" + ); + + // ── B in-flight; A notification: output absent, ALL other counter + // fields present with nonzero values ── + // input=15, cost=3.0, total=80, cached_input=7, cache_write=4. + // A has no prior entry, so the latch must CREATE one with all six + // baselines None — not initialized from these payload values. + t.begin_turn("b"); + t.record( + "a", + &rich_payload(Some(15), None, Some(3.0), Some(80), Some(7), Some(4)), + ); + + // Entry must now exist with exactly the right shape. + let created = t + .sessions + .get("a") + .expect("latch must create an entry for A"); + assert_eq!(created.published_seq, 0, "created entry has zero seq"); + assert!(created.last_input.is_none()); + assert!(created.last_output.is_none()); + assert!(created.last_cost.is_none()); + assert!(created.last_total.is_none()); + assert!(created.last_cached_input.is_none()); + assert!(created.last_cache_write.is_none()); + assert!( + !created.input_ever_poisoned, + "input_ever_poisoned must NOT be set (only output was absent)" + ); + assert!( + created.output_ever_poisoned, + "output_ever_poisoned must be set on the newly created entry" + ); + + t.record("b", &payload_opt(Some(100), Some(20))); + let b1 = t.take().expect("b1"); + assert!(b1.delta_reliable, "B must be unaffected"); + + // ── A first and second real turns ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(80), Some(15))); + let _a1 = t.take().expect("a1"); + + t.begin_turn("a"); + t.record("a", &payload_opt(Some(150), Some(25))); + let a2 = t.take().expect("a2"); + assert!( + !a2.delta_reliable, + "A second turn must be poisoned: output absence from cross-session notification must hold even with no prior entry" + ); + } } diff --git a/crates/buzz-admin/Cargo.toml b/crates/buzz-admin/Cargo.toml index 7a69e146bb9..263ba4eb319 100644 --- a/crates/buzz-admin/Cargo.toml +++ b/crates/buzz-admin/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] buzz-db = { workspace = true } +buzz-deletion = { workspace = true } buzz-core = { workspace = true } buzz-auth = { workspace = true } buzz-pubsub = { workspace = true } @@ -34,4 +35,5 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" tracing = { workspace = true } sqlx = { workspace = true } url = { workspace = true } +uuid = { workspace = true } clap = { version = "4", features = ["derive"] } diff --git a/crates/buzz-admin/src/deletions.rs b/crates/buzz-admin/src/deletions.rs new file mode 100644 index 00000000000..64cb8bd732a --- /dev/null +++ b/crates/buzz-admin/src/deletions.rs @@ -0,0 +1,19 @@ +//! Thin `buzz-admin deletions` adapter. + +pub use buzz_deletion::Command as DeletionsCommand; + +/// Delegate to the shared durable deletion engine. +pub async fn run(command: DeletionsCommand) -> anyhow::Result { + buzz_deletion::run(command).await +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + #[test] + fn continuous_worker_command_is_not_exposed() { + let command = crate::Cli::try_parse_from(["buzz-admin", "deletions", "worker"]); + assert!(command.is_err()); + } +} diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index bb30ddfae4f..42a7de84f7c 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -20,6 +20,8 @@ //! newest timestamp and collide on the bumped second. run.sh serialization is //! the guard against parallel adds (e.g. `xargs -P`). +mod deletions; + use std::sync::Arc; use anyhow::Result; @@ -81,12 +83,22 @@ enum Command { #[command(subcommand)] command: ProductFeedbackCommand, }, - /// Emit kind:39000/39002 events for channels missing them. + /// Durable CLI-only whole-community deletion control plane. + Deletions { + #[command(subcommand)] + command: deletions::DeletionsCommand, + }, + /// Emit missing kind:39000/39001/39002 channel discovery events, or + /// republish only a targeted channel's kind:39002 roster. /// - /// Channels created via direct SQL (seed scripts, pre-migration data) won't - /// have Nostr discovery events. This command creates them so pure-nostr - /// clients can see those channels. Idempotent — safe to run multiple times. + /// Without `--channel`, only channels missing discovery metadata are + /// reconciled. With `--channel`, only that channel's member snapshot is + /// replaced; canonical metadata and admin events remain untouched. ReconcileChannels { + /// Optional channel UUID to force-republish. + #[arg(long)] + channel: Option, + /// Relay private key (hex) for signing events. Falls back to /// BUZZ_RELAY_PRIVATE_KEY env var. If neither is set, generates /// an ephemeral key (events will be unverifiable after restart). @@ -148,8 +160,9 @@ async fn run(cli: Cli) -> Result { Command::ProductFeedback { command: ProductFeedbackCommand::List { limit }, } => cmd_list_product_feedback(limit).await, - Command::ReconcileChannels { relay_key } => { - reconcile_channels(relay_key).await?; + Command::Deletions { command } => deletions::run(command).await, + Command::ReconcileChannels { channel, relay_key } => { + reconcile_channels(channel, relay_key).await?; Ok(0) } } @@ -458,14 +471,26 @@ async fn resolve_admin_tenant(db: &Db) -> Result { Ok(TenantContext::resolved(record.id, record.host)) } -async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { +async fn reconcile_channels( + channel_arg: Option, + relay_key_arg: Option, +) -> Result<()> { use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; use buzz_db::event::EventQuery; let db = connect_db().await?; - // Resolve relay signing key: arg > env > ephemeral - let relay_keys = match relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()) { + // Resolve relay signing key: arg > env > ephemeral. Force-republish must + // never use an ephemeral key because it replaces an existing authoritative + // snapshot. + let configured_relay_key = + relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()); + if channel_arg.is_some() && configured_relay_key.is_none() { + return Err(anyhow::anyhow!( + "--channel requires --relay-key or BUZZ_RELAY_PRIVATE_KEY" + )); + } + let relay_keys = match configured_relay_key { Some(key_hex) => { Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))? } @@ -482,7 +507,21 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { }; let tenant = resolve_admin_tenant(&db).await?; - let channels = db.list_channels(tenant.community(), None).await?; + let target_channel = channel_arg + .as_deref() + .map(uuid::Uuid::parse_str) + .transpose() + .map_err(|e| anyhow::anyhow!("invalid --channel UUID: {e}"))?; + let channels = if let Some(target) = target_channel { + vec![db + .get_channel(tenant.community(), target) + .await + .map_err(|_| { + anyhow::anyhow!("channel {target} not found in community {}", tenant.host()) + })?] + } else { + db.list_channels(tenant.community(), None).await? + }; if channels.is_empty() { println!("No channels in database."); return Ok(()); @@ -505,57 +544,64 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { .await .unwrap_or_default(); - if !existing.is_empty() { + if !existing.is_empty() && target_channel.is_none() { skipped += 1; continue; } let members = db.get_members(tenant.community(), channel.id).await?; - // kind:39000 — channel metadata - { - let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; - tags.push(Tag::parse(["name", &channel.name])?); - if let Some(ref desc) = channel.description { - if !desc.is_empty() { - tags.push(Tag::parse(["about", desc])?); + // A targeted repair is deliberately roster-only. kind:39000 metadata + // is richer than this legacy backfill builder, and kind:39001 is not + // part of the stale-roster incident; replacing either can destroy + // canonical state. Full backfill still creates all three event kinds + // for channels with no discovery metadata. + if target_channel.is_none() { + // kind:39000 — channel metadata + { + let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; + tags.push(Tag::parse(["name", &channel.name])?); + if let Some(ref desc) = channel.description { + if !desc.is_empty() { + tags.push(Tag::parse(["about", desc])?); + } } + if channel.visibility == "private" { + tags.push(Tag::parse(["private"])?); + } else { + tags.push(Tag::parse(["public"])?); + } + if channel.channel_type == "dm" { + tags.push(Tag::parse(["hidden"])?); + } + tags.push(Tag::parse(["closed"])?); + tags.push(Tag::parse(["t", &channel.channel_type])?); + + let event = EventBuilder::new(Kind::Custom(39000), "") + .tags(tags) + .sign_with_keys(&relay_keys) + .map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?; + db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) + .await?; } - if channel.visibility == "private" { - tags.push(Tag::parse(["private"])?); - } else { - tags.push(Tag::parse(["public"])?); - } - if channel.channel_type == "dm" { - tags.push(Tag::parse(["hidden"])?); - } - tags.push(Tag::parse(["closed"])?); - tags.push(Tag::parse(["t", &channel.channel_type])?); - - let event = EventBuilder::new(Kind::Custom(39000), "") - .tags(tags) - .sign_with_keys(&relay_keys) - .map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) - .await?; - } - // kind:39001 — admins - { - let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; - for m in members - .iter() - .filter(|m| m.role == "owner" || m.role == "admin") + // kind:39001 — admins { - let pk = hex::encode(&m.pubkey); - tags.push(Tag::parse(["p", &pk, &m.role])?); + let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; + for m in members + .iter() + .filter(|m| m.role == "owner" || m.role == "admin") + { + let pk = hex::encode(&m.pubkey); + tags.push(Tag::parse(["p", &pk, &m.role])?); + } + let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "") + .tags(tags) + .sign_with_keys(&relay_keys) + .map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?; + db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) + .await?; } - let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "") - .tags(tags) - .sign_with_keys(&relay_keys) - .map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) - .await?; } // kind:39002 — members diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index f1a10890460..fabf75754e1 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -41,6 +41,7 @@ axum = { workspace = true } base64 = "0.22" hex = { workspace = true } sha2 = { workspace = true } +url = { workspace = true } urlencoding = "2" webbrowser = "1" dirs = "6" diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 5d942777d5e..0bc03db7813 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -153,7 +153,8 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. | | `BUZZ_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. | | `BUZZ_AGENT_MAX_ROUNDS` | `0` | Tool-loop iteration cap. 0 = unlimited. | -| `BUZZ_AGENT_MAX_OUTPUT_TOKENS` | `32768` | Per LLM call. Headroom for large tool-call inputs (e.g. file writes via heredoc); Sonnet 4 / Opus 4 cap at 64K. | +| `BUZZ_AGENT_MAX_OUTPUT_TOKENS` | `65536` | Desired per-call ceiling. Set this at or below the served model's output limit for each agent deployment. Proactive handoff is independently based on 90% of `BUZZ_AGENT_MAX_CONTEXT_TOKENS`. | +| `BUZZ_AGENT_MAX_TOKEN_RECOVERIES` | `3` | Retries after a successful response is truncated at the output-token limit. `0` disables recovery; the finite value and `BUZZ_AGENT_MAX_ROUNDS` prevent infinite retries. | | `BUZZ_AGENT_MAX_CONTEXT_TOKENS` | `200000` | Provider context window used by the handoff gate. | | `BUZZ_AGENT_MAX_HANDOFFS` | `10` | Max context handoffs per session before falling back to truncation. | | `BUZZ_AGENT_LLM_TIMEOUT_SECS` | `240` | Max seconds with no response bytes before abandoning an LLM call (per-read inactivity, not wall-clock). | diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index f3fbabdcda3..9258ce449f3 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -6,7 +6,9 @@ use tokio::task::JoinSet; use tracing::Instrument as _; use crate::builtin; -use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES}; +use crate::config::{ + pricing_authority, Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES, +}; use crate::handoff::{ContextRecovery, HandoffOutcome}; use crate::hints::SkillEntry; use crate::llm::Llm; @@ -14,8 +16,9 @@ use crate::mcp::McpRegistry; use crate::mcp::ResultBudget; use crate::types::{ - AgentError, ContentBlock, HistoryItem, ProviderStop, SessionUsageBaseline, StopReason, - ToolCall, ToolResult, ToolResultContent, TurnTotalState, + AgentError, CacheTotalState, ContentBlock, HistoryItem, PricingIdentity, ProviderStop, + SessionUsageBaseline, StopReason, ToolCall, ToolResult, ToolResultContent, TurnIOState, + TurnTotalState, }; use crate::wire::{self, WireSender}; @@ -28,12 +31,7 @@ const UNSUPPORTED_IMAGE_TOOL_MESSAGE: &str = "The current model does not support /// its output-token limit. This is a user message rather than a synthetic tool /// result because truncation can happen without a tool call (and an unpaired /// tool result is invalid on every provider wire format). -const MAX_TOKENS_RECOVERY_MESSAGE: &str = "Your previous response exceeded the model's output token limit and was truncated. Any incomplete tool call was not run. Continue the task, breaking the work or tool call into smaller steps and keeping the response concise."; - -/// A provider can repeatedly spend its entire output allowance without making -/// progress, while `max_rounds` is unbounded by default. Keep the in-turn rescue -/// finite so a persistently truncating model eventually surfaces `max_tokens`. -const MAX_TOKENS_RECOVERIES_PER_RUN: u32 = 2; +const MAX_TOKENS_RECOVERY_MESSAGE: &str = "Your previous response reached the model's output token limit and was truncated. Any incomplete tool calls were discarded and were not run. Stop prolonged internal reasoning now. Use the available tools immediately: write a script or artifact to a file and run it in small, verifiable steps instead of emitting the entire solution inline. Continue the task concisely from the preserved text."; /// Remove image blocks that the provider has explicitly rejected while keeping /// their surrounding tool result (and therefore the tool-call/result pairing) @@ -175,16 +173,38 @@ pub struct RunCtx<'a> { /// preserved in lockstep with `last_request_input_tokens`. pub last_request_history_bytes: &'a mut Option, /// Accumulated input tokens across all LLM rounds in this turn, for - /// NIP-AM metric publishing. Reset to `None` at turn start in `run()`. - pub turn_input_tokens: &'a mut Option, + /// NIP-AM metric publishing. Reset to `Unseen` at turn start in `run()`. + pub turn_input_tokens: &'a mut TurnIOState, /// Accumulated output tokens across all LLM rounds in this turn, for - /// NIP-AM metric publishing. Reset to `None` at turn start in `run()`. - pub turn_output_tokens: &'a mut Option, + /// NIP-AM metric publishing. Reset to `Unseen` at turn start in `run()`. + pub turn_output_tokens: &'a mut TurnIOState, /// The cache-served subset of `turn_input_tokens`, accumulated across all - /// LLM rounds in this turn. Reset to `None` at turn start in `run()`. + /// LLM rounds in this turn. Reset to `Unseen` at turn start in `run()`. /// Consumers price this slice at the provider's cached rate; without it /// every round of a growing conversation is billed at full price. - pub turn_cached_input_tokens: &'a mut Option, + /// + /// `CacheTotalState` enforces the D1 rule: any usage-bearing round that + /// omits this category poisons the accumulator permanently for the turn. + pub turn_cached_input_tokens: &'a mut CacheTotalState, + /// The cache-written subset of `turn_input_tokens`, accumulated across all + /// LLM rounds in this turn. Reset to `Unseen` at turn start in `run()`. + /// Consumers need this to price cache-creation at the provider's write rate + /// (distinct from both the standard input rate and the cached-read rate). + /// + /// Same D1 tri-state contract as `turn_cached_input_tokens`. + pub turn_cache_write_tokens: &'a mut CacheTotalState, + /// Per-turn billing identity accumulator. + /// + /// - `None`: no usage-bearing response observed yet this turn (initial state). + /// - `Some(Some(pi))`: all usage-bearing responses so far carry the same + /// proven identity `pi`. If a subsequent response carries a different or + /// unproven identity, this transitions to `Some(None)` (poisoned). + /// - `Some(None)`: poisoned — mixed identities, unproven response, mesh + /// retry across models, or no identity derived. Never heals within the turn. + /// + /// Reset to `None` at turn start in `run()`. The wire payload emits the + /// proven identity when `Some(Some(pi))`, omits it otherwise. + pub turn_pricing_identity: &'a mut Option>, /// Tri-state total-token accumulator for this turn. /// /// - `Unseen`: no usage-bearing response observed yet this turn (initial state). @@ -202,6 +222,41 @@ pub struct RunCtx<'a> { pub usage_baseline: SessionUsageBaseline, } +/// Fold one round's proven identity into the per-turn identity accumulator. +/// +/// Accumulator tri-state (NIP-AM §pricingIdentity): +/// - `None`: no usage-bearing round observed yet this turn. +/// - `Some(Some(pi))`: every usage-bearing round so far carries the same +/// proven identity `pi`. +/// - `Some(None)`: poisoned — mixed identities or unproven round seen. +/// Never heals within the turn. +/// +/// `round`: `Some(pi)` when this round's `(base_url, request_model)` resolve +/// to a proven identity; `None` when the endpoint is unallowlisted or the +/// model is unknown. +#[inline] +fn fold_pricing_identity( + acc: Option>, + round: Option, +) -> Option> { + match acc { + // First usage-bearing round: record whatever was derived. + None => Some(round), + // Already consistent: keep only if this round matches exactly. + Some(Some(ref existing)) => { + if Some(existing) == round.as_ref() { + Some(round) + } else { + // Mismatch (different model, different authority, + // or this round had no proven identity) → poison. + Some(None) + } + } + // Already poisoned: stays poisoned forever this turn. + poisoned @ Some(None) => poisoned, + } +} + impl RunCtx<'_> { /// Send a session-cumulative `usage_update` reflecting everything observed /// up to and including the most recent LLM response. @@ -213,15 +268,30 @@ impl RunCtx<'_> { /// everything but its final in-flight request. async fn emit_usage_update(&self) { let base = self.usage_baseline; + // Combine session baseline CacheTotalState with the per-turn delta: + // merge_session produces Exact when both sides are Exact, Unknown when + // either is Unknown, and leaves Unseen when both sides are Unseen. + let cached_total = base + .cached_input_tokens + .merge_session(*self.turn_cached_input_tokens); + let write_total = base + .cache_write_tokens + .merge_session(*self.turn_cache_write_tokens); let payload = wire::usage_update_payload( base.input_tokens - .saturating_add(self.turn_input_tokens.unwrap_or(0)), + .merge_session(*self.turn_input_tokens) + .exact_value(), base.output_tokens - .saturating_add(self.turn_output_tokens.unwrap_or(0)), - base.cached_input_tokens - .saturating_add(self.turn_cached_input_tokens.unwrap_or(0)), + .merge_session(*self.turn_output_tokens) + .exact_value(), + cached_total.exact_value(), + write_total.exact_value(), base.total_state.merge_session(*self.turn_total_state), self.effective_model, + // Extract the proven identity if this turn is consistent so far. + self.turn_pricing_identity + .as_ref() + .and_then(|inner| inner.as_ref()), ); wire::send( self.wire, @@ -243,9 +313,11 @@ impl RunCtx<'_> { self.history.push(HistoryItem::User(user_text)); // Reset per-turn token accumulators for this prompt. - *self.turn_input_tokens = None; - *self.turn_output_tokens = None; - *self.turn_cached_input_tokens = None; + *self.turn_input_tokens = TurnIOState::Unseen; + *self.turn_output_tokens = TurnIOState::Unseen; + *self.turn_cached_input_tokens = CacheTotalState::Unseen; + *self.turn_cache_write_tokens = CacheTotalState::Unseen; + *self.turn_pricing_identity = None; *self.turn_total_state = TurnTotalState::Unseen; // Per-turn handoff-attempt counter. Scoped here (not persisted in the // session) so `BUZZ_AGENT_MAX_HANDOFFS` bounds compactions per @@ -423,7 +495,17 @@ impl RunCtx<'_> { // a response omits usage (`None`) rather than clobbering — a // one-off missing field shouldn't blind the gate or zero the // growth baseline. - if let Some(tokens) = response.input_tokens { + if response.input_tokens_overflowed { + // The Anthropic-style inclusive sum (input_tokens + + // cache_read_input_tokens + cache_creation_input_tokens) + // overflowed u64::MAX during parsing. Permanently poison the + // turn accumulator so wire emission omits this value and ACP + // marks the delta unreliable. Do NOT update + // last_request_input_tokens — freeze the context-gate + // baseline at its prior reading rather than poisoning it with + // a clamped value, exactly as the absent-usage path does. + *self.turn_input_tokens = TurnIOState::Poisoned; + } else if let Some(tokens) = response.input_tokens { *self.last_request_input_tokens = Some(tokens); *self.last_request_history_bytes = Some( self.history @@ -432,29 +514,30 @@ impl RunCtx<'_> { .sum(), ); // Accumulate per-turn input tokens for NIP-AM metric publishing. - *self.turn_input_tokens = - Some(self.turn_input_tokens.unwrap_or(0).saturating_add(tokens)); + // fold_round uses checked_add; overflow permanently poisons the + // turn accumulator (and, via merge_session, the session cumulative). + *self.turn_input_tokens = self.turn_input_tokens.fold_round(tokens); } // Accumulate per-turn output tokens for NIP-AM metric publishing. if let Some(out) = response.output_tokens { - *self.turn_output_tokens = - Some(self.turn_output_tokens.unwrap_or(0).saturating_add(out)); + *self.turn_output_tokens = self.turn_output_tokens.fold_round(out); } - // Accumulate the cache-served subset of this turn's input. Tracked - // separately from `turn_input_tokens` rather than subtracted from - // it: the input total must stay inclusive for the handoff gate, - // which cares how much context was sent, not what it cost. - if let Some(cached) = response.cached_input_tokens { - *self.turn_cached_input_tokens = Some( - self.turn_cached_input_tokens - .unwrap_or(0) - .saturating_add(cached), - ); - } - // Fold the provider-reported total into the turn tri-state, but only - // when this response was usage-bearing (had input or output tokens). - // A response with no usage at all is not evidence of a missing total - // and must not poison the accumulator. + // Fold the provider-reported total, cache subsets, and billing + // identity — only when this response was usage-bearing (had input + // or output tokens). A response with no usage at all is not + // evidence of a missing cache field or total and must not poison + // either accumulator. + // + // `input_tokens_overflowed` counts as usage-bearing: the provider + // reported an input total (which overflowed) so cache fields and + // the total are meaningful and must be folded. + // + // D1: absent cache field on a usage-bearing round permanently + // poisons the turn accumulator. Some(0) stays Exact(0) (explicit + // zero is distinct from absent). Cache-read and cache-write are + // tracked separately from `turn_input_tokens` rather than + // subtracted from it: the input total must stay inclusive for the + // handoff gate, which cares how much context was sent, not cost. // // Shape assumption: documented OpenAI-compatible responses that carry // `total_tokens` always co-report at least one of `prompt_tokens` / @@ -462,8 +545,45 @@ impl RunCtx<'_> { // with neither category is therefore not a supported shape and would // be silently ignored here. If that shape is ever encountered, extend // this gate rather than representing absent categories as zero. - if response.input_tokens.is_some() || response.output_tokens.is_some() { + if response.input_tokens.is_some() + || response.input_tokens_overflowed + || response.output_tokens.is_some() + { *self.turn_total_state = self.turn_total_state.fold(response.total_tokens); + // Cache-read: the cache-served subset of input tokens. + *self.turn_cached_input_tokens = self + .turn_cached_input_tokens + .fold(response.cached_input_tokens); + // Cache-write: the cache-creation subset of input tokens. + *self.turn_cache_write_tokens = self + .turn_cache_write_tokens + .fold(response.cache_write_tokens); + + // Derive billing identity for this round and fold it into the + // per-turn accumulator. Rules (NIP-AM §pricingIdentity): + // + // 1. Attempt to derive identity from (base_url, request_model). + // - base_url must canonically match an official allowlisted host. + // - request_model must be Some (mesh-auto with unknown model + // cannot prove identity). + // 2. Fold the round identity into the turn accumulator: + // - Unseen (None): record this round's identity (or poison if None). + // - Consistent: if it matches, keep; otherwise poison. + // - Poisoned: stays poisoned forever this turn. + { + let round_identity: Option = + response.request_model.as_deref().and_then(|model| { + pricing_authority(&self.cfg.base_url).map(|auth| PricingIdentity { + authority: auth.to_string(), + model: model.to_string(), + cache_class: None, // no cache-class derivation yet + }) + }); + + *self.turn_pricing_identity = + fold_pricing_identity(self.turn_pricing_identity.take(), round_identity); + } + // Report what the turn has burned SO FAR, before running the // next round. A turn is many provider round-trips over many // minutes, and until this point the only report was the one @@ -542,9 +662,10 @@ impl RunCtx<'_> { tool_calls: Vec::new(), reasoning_details: response.reasoning_details, }); - if max_tokens_recoveries >= MAX_TOKENS_RECOVERIES_PER_RUN { + if max_tokens_recoveries >= self.cfg.max_token_recoveries { tracing::warn!( recoveries = max_tokens_recoveries, + max_recoveries = self.cfg.max_token_recoveries, "provider repeatedly hit output token limit; recovery budget exhausted" ); return Ok(StopReason::MaxTokens); @@ -552,8 +673,7 @@ impl RunCtx<'_> { max_tokens_recoveries = max_tokens_recoveries.saturating_add(1); tracing::warn!( recovery = max_tokens_recoveries, - max_recoveries = MAX_TOKENS_RECOVERIES_PER_RUN, - discarded_tool_calls = response.tool_calls.len(), + max_recoveries = self.cfg.max_token_recoveries, "provider hit output token limit; asking model to continue in smaller steps" ); self.history @@ -1378,4 +1498,99 @@ mod tests { "under budget must not evict anything" ); } + + // ── fold_pricing_identity: turn discipline ──────────────────────────────── + + fn pi(authority: &str, model: &str) -> PricingIdentity { + PricingIdentity { + authority: authority.to_string(), + model: model.to_string(), + cache_class: None, + } + } + + /// Case 1: two usage-bearing rounds with different proven identities in one + /// turn must poison the accumulator. The wire payload omits `pricingIdentity` + /// when `Some(None)`. + #[test] + fn fold_pricing_identity_mismatch_poisons() { + let round_a = Some(pi("api.anthropic.com", "claude-opus-4-5")); + let round_b = Some(pi("api.openai.com", "gpt-4o")); + + // Start: unseen. + let acc = None; + // After round A: consistent — Some(Some(claude-opus-4-5)). + let acc = fold_pricing_identity(acc, round_a); + assert!( + matches!(acc, Some(Some(_))), + "after one round must be consistent" + ); + // After round B (different authority + model): poisoned. + let acc = fold_pricing_identity(acc, round_b); + assert_eq!( + acc, + Some(None), + "different proven identities in one turn must poison" + ); + } + + /// Case 2: proven identity followed by a usage-bearing round with no proven + /// identity (request_model absent or non-allowlisted endpoint) must poison. + /// An unpaired cumulative snapshot also produces round=None (no model known) + /// and hits this same path — case 4 collapses into case 2. + #[test] + fn fold_pricing_identity_unproven_round_poisons() { + let round_a = Some(pi("api.anthropic.com", "claude-3-7-sonnet")); + let round_unproven: Option = None; // absent request_model or custom endpoint + + let acc = None; + let acc = fold_pricing_identity(acc, round_a); + assert!( + matches!(acc, Some(Some(_))), + "after one proven round must be consistent" + ); + let acc = fold_pricing_identity(acc, round_unproven); + assert_eq!( + acc, + Some(None), + "an unproven round after a proven round must poison (no-model / custom-endpoint path)" + ); + } + + /// Case 3: a poisoned accumulator must not heal, even if a later round + /// carries an identity matching the original. + #[test] + fn fold_pricing_identity_poisoned_never_heals() { + let round_a = Some(pi("api.openai.com", "gpt-4o")); + let round_unproven: Option = None; + let round_a_again = Some(pi("api.openai.com", "gpt-4o")); // identical to round_a + + let acc = None; + let acc = fold_pricing_identity(acc, round_a); + let acc = fold_pricing_identity(acc, round_unproven); // poisons + assert_eq!(acc, Some(None), "must be poisoned before heal attempt"); + let acc = fold_pricing_identity(acc, round_a_again); // must not heal + assert_eq!( + acc, + Some(None), + "poisoned accumulator must stay poisoned even when the next round matches the original identity" + ); + } + + /// Baseline: a turn where every round carries the same proven identity + /// stays consistent and emits the identity on the wire. + #[test] + fn fold_pricing_identity_consistent_rounds_stay_proven() { + let identity = pi("api.openrouter.ai", "meta-llama/llama-4-scout"); + + let acc = None; + let acc = fold_pricing_identity(acc, Some(identity.clone())); + let acc = fold_pricing_identity(acc, Some(identity.clone())); + let acc = fold_pricing_identity(acc, Some(identity.clone())); + assert_eq!( + acc, + Some(Some(identity)), + "three identical rounds must remain consistently proven" + ); + } } diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index 3f43925de36..a78a499bdd1 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -16,7 +16,8 @@ //! calls hit the cache and silently refresh when expired. use std::fs; -use std::path::PathBuf; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -188,15 +189,16 @@ impl PkceOAuthTokenSource { } /// Persist a token to disk and the in-memory cell. + /// + /// The cache holds both the access and refresh tokens, so the on-disk + /// file is written owner-only (`0o600` on Unix) via an atomic + /// inode-swapping rename — see [`write_private_cache`]. fn save(&self, state: &mut Option, token: CachedToken) -> Result<(), AgentError> { let body = serde_json::to_vec_pretty(&token) .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; - // Atomic rename so a concurrent reader never sees a partial write. - let tmp = self.cache_path.with_extension("json.tmp"); - fs::write(&tmp, &body) - .map_err(|e| AgentError::Llm(format!("oauth cache write {tmp:?}: {e}")))?; - fs::rename(&tmp, &self.cache_path) - .map_err(|e| AgentError::Llm(format!("oauth cache rename: {e}")))?; + write_private_cache(&self.cache_path, &body).map_err(|e| { + AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) + })?; *state = Some(token); Ok(()) } @@ -463,11 +465,162 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { Ok(dir.join(format!("{hash}.json"))) } -fn read_cache(path: &PathBuf) -> Option { - let body = fs::read(path).ok()?; +/// Load a cached token, enforcing the owner-only invariant on load. +/// +/// Owner-only permissions are a cache *lifecycle* invariant, not just a +/// write-path property: a world-readable cache left by an older buzz-agent +/// (or any tampering) must be tightened the moment we touch it, before the +/// tokens are used — otherwise a file that never expires stays exposed until +/// some future refresh happens to rewrite it. Every load path (initial and +/// cross-process re-reads) funnels through here, so the repair covers them +/// all. Returns `None` when the cache is absent, unreadable, unparseable, or +/// cannot be secured; the caller then falls through to refresh/browser. +fn read_cache(path: &Path) -> Option { + let body = read_private_cache(path).ok()?; serde_json::from_slice(&body).ok() } +/// Open the cache, reject symlinks, tighten loose permissions to `0o600`, and +/// return its bytes. +/// +/// On Unix `O_NOFOLLOW` rejects a symlinked cache path at the kernel level +/// (no stat/open TOCTOU), and `fchmod` on the already-open handle repairs a +/// loose mode against the pinned inode rather than re-resolving the path. +/// A cache that exists but cannot be secured is an error, so the caller fails +/// closed instead of using an exposed file. +#[cfg(unix)] +fn read_private_cache(path: &Path) -> io::Result> { + use std::io::Read; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(path)?; + + let meta = file.metadata()?; + if !meta.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "oauth cache is not a regular file", + )); + } + // Tighten in place on the open fd if any group/other bit is set. fchmod + // targets the inode we already hold, so no attacker can swap the path + // between the check and the repair. + if meta.permissions().mode() & 0o077 != 0 { + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + + let mut body = Vec::new(); + file.read_to_end(&mut body)?; + Ok(body) +} + +/// Non-Unix fallback: read the cache as-is. Owner-only enforcement is the +/// Windows DACL work deferred behind the [`create_private_temp_file`] seam. +#[cfg(not(unix))] +fn read_private_cache(path: &Path) -> io::Result> { + fs::read(path) +} + +/// Removes a temp file on drop unless it was already renamed away. Keeps a +/// failed/partial write from leaving a stray token file behind. +struct TmpFileGuard<'a>(&'a Path); + +impl Drop for TmpFileGuard<'_> { + fn drop(&mut self) { + let _ = fs::remove_file(self.0); + } +} + +/// A per-write-unique temp suffix so concurrent savers — sibling threads or +/// separate processes sharing `$HOME` — never collide on one temp path. +/// Falls back to a timestamp if the RNG is unavailable rather than panicking +/// mid-auth. +fn unique_suffix() -> String { + let mut bytes = [0u8; 8]; + if getrandom::fill(&mut bytes).is_ok() { + return hex::encode(bytes); + } + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +/// Write `body` to `path` as an owner-only file via an atomic rename. +/// +/// The cache holds both the refresh and access tokens, so it must never be +/// readable by other users. We create a uniquely-named temp file in the same +/// directory with owner-only protection at creation time — mode `0o600` on +/// Unix (see [`create_private_temp_file`]) — so it is never briefly +/// world/other readable, write and fsync it, then rename over the +/// destination. The rename swaps the inode/entry wholesale, so a pre-existing +/// cache file with loose permissions is *replaced* by the new private one; +/// its old mode never survives. `fs::rename` maps to +/// `MOVEFILE_REPLACE_EXISTING` on Windows, so the atomic replace holds on +/// both platforms; the Windows owner-only DACL is pending the unsafe-FFI +/// decision noted at the seam. +fn write_private_cache(path: &Path, body: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "oauth cache path has no parent directory", + ) + })?; + fs::create_dir_all(parent)?; + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("oauth-cache"); + let tmp = parent.join(format!(".{file_name}.{}.tmp", unique_suffix())); + let guard = TmpFileGuard(&tmp); + + let mut f = create_private_temp_file(&tmp)?; + f.write_all(body)?; + f.sync_all()?; + drop(f); + + fs::rename(&tmp, path)?; + // The rename consumed the temp path; nothing left to clean up. + std::mem::forget(guard); + Ok(()) +} + +/// Create `tmp` for writing with owner-only permissions from the moment it +/// exists. Fails if the file already exists (`create_new`), which the +/// per-write-unique suffix makes effectively impossible. +#[cfg(unix)] +fn create_private_temp_file(tmp: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(tmp) +} + +/// Non-Unix fallback: create the temp file if it does not already exist. +/// +/// On Windows the owner-only equivalent is an explicit DACL set at creation +/// (`CreateFileW` with SDDL `D:P(A;;FA;;;OW)`, matching goose's +/// `private_file.rs`), but that FFI needs `unsafe`, which this crate forbids. +/// Reconciling the two — an isolated helper crate, a vetted safe dependency, +/// or descoping Windows — is an open decision escalated to the maintainer, so +/// this interim relies on the default per-user ACLs and drops the owner-only +/// implementation in behind this seam once the decision lands. `create_new` +/// fails if the file already exists. +#[cfg(not(unix))] +fn create_private_temp_file(tmp: &Path) -> io::Result { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(tmp) +} + /// Parse a token-endpoint JSON response. Fails loudly when `access_token` /// is missing or empty — without this, a malformed server response would /// be cached and `bearer()` would silently return `""` until the entry @@ -518,6 +671,47 @@ fn random_state() -> Result { Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) } +/// Decide the OAuth callback result and the HTML page to serve. +/// +/// Returns `(result, page)`: `result` carries the auth code (or a detail +/// string on failure) to the waiting flow via the oneshot channel; `page` is +/// the *static* HTML shown in the browser. The page never embeds any request +/// parameter — the `error` query value is attacker-influenceable, so +/// reflecting it would be an XSS sink on the localhost callback. Failure +/// detail travels only through `result`, which surfaces in the process error +/// and logs, never in the served markup. +fn callback_outcome( + params: &std::collections::HashMap, + expected_state: &str, +) -> (Result, String) { + let result = match (params.get("code"), params.get("state")) { + (Some(code), Some(st)) if st == expected_state => Ok(code.clone()), + (Some(_), Some(_)) => Err("state mismatch".to_string()), + _ => Err(params + .get("error") + .map(|e| sanitize_callback_detail(e)) + .unwrap_or_else(|| "missing code".into())), + }; + let page = match result { + Ok(_) => "

Buzz: signed in

You can close this window.

", + Err(_) => "

Buzz auth failed

You can close this window and try again.

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

Buzz: signed in

You can close this window.

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

Buzz auth failed

{e}
")), + let _ = sender.send(result); } + Html(page) } }), ); @@ -844,4 +1026,320 @@ mod tests { ), } } + + // ---- callback HTML must never reflect input -------------------------- + + #[test] + fn test_callback_failure_page_omits_reflected_error_param() { + // A hostile `error` query value carrying markup must not appear in + // the served HTML — otherwise the localhost callback is an XSS sink. + let payload = ""; + let mut params = std::collections::HashMap::new(); + params.insert("error".to_string(), payload.to_string()); + + let (result, page) = callback_outcome(¶ms, "expected-state"); + + // The failure detail still reaches the waiting flow via `result`... + assert_eq!(result.as_ref().err().map(String::as_str), Some(payload)); + // ...but the browser page is static and inert. + assert!( + !page.contains(payload), + "callback page reflected the raw error param: {page}" + ); + assert!( + !page.contains(""; - let result = validate_file_content(html, &config); + // Sanity: this fixture is exactly the shape `infer` classifies as HTML. + assert_eq!(infer::get(html).map(|k| k.mime_type()), Some("text/html")); + let (mime, ext) = validate_file_content(html, &config).unwrap(); + assert_eq!(mime, "text/html"); + assert_eq!(ext, "html"); + assert!( + !serve_inline(&mime), + "text/html must never be served inline — it must force download" + ); + } + + #[test] + fn test_validate_file_executable_still_rejected() { + // Removing HTML from the deny-list must not weaken the executable + // block. `infer` classifies an ELF header as `application/x-executable`, + // which the generic path must still reject via the deny-list. + let config = test_config(); + // `infer`'s ELF matcher requires the magic plus >52 bytes of header. + let mut elf = b"\x7fELF".to_vec(); + elf.extend_from_slice(&[0u8; 60]); + assert_eq!( + infer::get(&elf).map(|k| k.mime_type()), + Some("application/x-executable") + ); assert!( - matches!(result, Err(MediaError::DisallowedContentType(ref m)) if m == "text/html"), - "expected DisallowedContentType(text/html), got {result:?}" + matches!(validate_file_content(&elf, &config), Err(MediaError::DisallowedContentType(ref m)) if m == "application/x-executable"), + "ELF executable must still be rejected by the generic file path" ); } + #[test] + fn test_generic_deny_list_keeps_active_content_and_executables() { + // Static guard on the deny-list itself: HTML is intentionally gone, but + // SVG, JavaScript, XHTML, and the native-executable types remain. These + // are the entries that keep the inert-download boundary honest even if a + // future `infer` upgrade starts classifying more of them by content. + assert!(!BLOCKED_FILE_MIME_TYPES.contains(&"text/html")); + for kept in [ + "image/svg+xml", + "application/xhtml+xml", + "application/javascript", + "text/javascript", + "application/x-msdownload", + "application/x-executable", + "application/vnd.microsoft.portable-executable", + "application/x-mach-binary", + "application/x-msi", + "application/x-apple-diskimage", + ] { + assert!( + BLOCKED_FILE_MIME_TYPES.contains(&kept), + "{kept} must remain in the generic-file deny-list" + ); + } + } + #[test] fn test_validate_file_too_large_rejected() { let mut config = test_config(); diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 65fe32b6b31..deb2e7e16a5 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -19,6 +19,8 @@ path = "src/main.rs" buzz-core = { workspace = true } buzz-conformance = { workspace = true } buzz-db = { workspace = true } +buzz-datastore-tracing = { workspace = true } +buzz-deletion = { workspace = true } buzz-auth = { workspace = true } buzz-pubsub = { workspace = true } buzz-audit = { workspace = true } @@ -84,8 +86,8 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } # Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): # the relay client for discovery notes and the exact ed25519 the mesh owner # keys use for binding verification. diff --git a/crates/buzz-relay/examples/mesh_admission_smoke.rs b/crates/buzz-relay/examples/mesh_admission_smoke.rs index a541f35bbcd..94c86c2a68e 100644 --- a/crates/buzz-relay/examples/mesh_admission_smoke.rs +++ b/crates/buzz-relay/examples/mesh_admission_smoke.rs @@ -54,16 +54,11 @@ fn env(name: &str) -> anyhow::Result { std::env::var(name).map_err(|_| anyhow::anyhow!("{name} is required for this role")) } +/// Installs the signed release runtime when none is cached — the same path the +/// desktop takes. Deliberately no "is it installed?" precondition: this runs in +/// three separate processes, and a guard here would refuse before reaching the +/// call that does the installing. async fn init_native_runtime() -> anyhow::Result<()> { - let cache = mesh_llm_sdk::native_runtime::native_runtime_cache(None)?; - let current = mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION; - if !cache - .installed()? - .iter() - .any(|runtime| runtime.mesh_version == current) - { - anyhow::bail!("MeshLLM native runtime for MeshLLM {current} is not installed; run `just mesh-e2e-hardware` once to prepare it"); - } mesh_llm_host_runtime::initialize_host_runtime() .await .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error}")) diff --git a/crates/buzz-relay/examples/mesh_agent_e2e.rs b/crates/buzz-relay/examples/mesh_agent_e2e.rs index 345ca4c746c..db0fc6090be 100644 --- a/crates/buzz-relay/examples/mesh_agent_e2e.rs +++ b/crates/buzz-relay/examples/mesh_agent_e2e.rs @@ -112,10 +112,12 @@ async fn run() -> anyhow::Result<()> { Err(e) => record("P1 explicit-model chat", false, e.to_string()), } - // P2: auto — router picks the model. + // P2: the virtual `mesh` model — exactly what apply_relay_mesh_env now puts + // on the wire for shared compute. With one served model there is no + // committee, so this proves MeshLLM's degrade_to_single_model path. let r = agent_chat( &base, - "auto", + "mesh", None, "Reply with exactly one word: PONG", &[], @@ -123,11 +125,15 @@ async fn run() -> anyhow::Result<()> { .await; match r { Ok(text) => record( - "P2 auto-model chat", + "P2 virtual-mesh chat (degrades to single model)", text.to_uppercase().contains("PONG"), text, ), - Err(e) => record("P2 auto-model chat", false, e.to_string()), + Err(e) => record( + "P2 virtual-mesh chat (degrades to single model)", + false, + e.to_string(), + ), } // P3: regression — an output budget no served model's context can hold @@ -139,7 +145,7 @@ async fn run() -> anyhow::Result<()> { // GLM-4.7-Flash. let r = agent_chat( &base, - &served_id, + "mesh", Some("150000"), "Reply with exactly one word: PONG", &[], @@ -169,7 +175,7 @@ async fn run() -> anyhow::Result<()> { ); let mcp = vec![("dev".to_string(), repo_bin("buzz-dev-mcp")?)]; let (r, marker) = - agent_chat_with_marker(&base, &served_id, None, &prompt, &mcp, &marker_name).await; + agent_chat_with_marker(&base, "mesh", None, &prompt, &mcp, &marker_name).await; let file_ok = std::fs::read_to_string(&marker) .map(|c| c.contains("BUZZ_OK")) .unwrap_or(false); @@ -270,7 +276,9 @@ async fn agent_chat_in_isolated_home( .env_clear() .env("PATH", std::env::var("PATH").unwrap_or_default()) .env("HOME", &home) - // Exactly the environment apply_relay_mesh_env() supplies. + // The transport subset of apply_relay_mesh_env(): provider, base URL, + // model, key, and chat API. Not BUZZ_AGENT_REQUIRE_REPLY, which needs + // Buzz's publish tools to mean anything. .env("BUZZ_AGENT_PROVIDER", "openai") .env("BUZZ_AGENT_MODEL", model) .env("OPENAI_COMPAT_BASE_URL", base) diff --git a/crates/buzz-relay/examples/mesh_serve_client_smoke.rs b/crates/buzz-relay/examples/mesh_serve_client_smoke.rs index 903d1a73c0e..19e6c775d74 100644 --- a/crates/buzz-relay/examples/mesh_serve_client_smoke.rs +++ b/crates/buzz-relay/examples/mesh_serve_client_smoke.rs @@ -41,15 +41,11 @@ const CLIENT_CONSOLE_PORT: u16 = 13132; async fn main() -> anyhow::Result<()> { let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); eprintln!("[smoke] model: {model}"); - let cache = mesh_llm_sdk::native_runtime::native_runtime_cache(None)?; - let current = mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION; - if !cache - .installed()? - .iter() - .any(|runtime| runtime.mesh_version == current) - { - anyhow::bail!("MeshLLM native runtime for MeshLLM {current} is not installed; run `just mesh-e2e-hardware` to prepare it"); - } + // No cache precondition: `initialize_host_runtime` installs the signed + // release runtime itself when none is present, which is how the desktop + // gets one too. The guard that used to stand here refused before reaching + // this line and told the reader to run the very recipe that runs this + // example. mesh_llm_host_runtime::initialize_host_runtime() .await .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error}"))?; diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453fd..0856c85cf36 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -21,7 +21,7 @@ use crate::state::AppState; use super::{api_error, internal_error, not_found}; -async fn enforce_http_admission( +pub(crate) async fn enforce_http_admission( state: &AppState, tenant: &TenantContext, pubkey: &nostr::PublicKey, @@ -654,12 +654,13 @@ pub async fn submit_event( submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; match &outcome { - SubmitOutcome::Ok { accepted, .. } => { + SubmitOutcome::Ok { accepted, kind, .. } => { tracing::info!( pubkey = %pubkey_hex, route = "/events", status = 200u16, accepted, + kind, "HTTP bridge request" ); } @@ -713,6 +714,7 @@ enum SubmitOutcome { /// Ingest pipeline ran and returned a result (accepted or not). Ok { accepted: bool, + kind: u32, response: Json, }, /// JSON parse failure before ingest — log category/line/column, not msg. @@ -843,6 +845,7 @@ async fn submit_event_authed( })); SubmitOutcome::Ok { accepted: result.accepted, + kind: kind_u32, response, } } @@ -1935,7 +1938,10 @@ pub async fn workflow_webhook( buzz_db::workflow::RunStatus::Failed, 0, &serde_json::json!([]), - Some(&format!("definition parse error: {e}")), + Some(buzz_db::workflow::WorkflowRunFailure { + code: "invalid_definition", + message: &format!("definition parse error: {e}"), + }), ) .await { diff --git a/crates/buzz-relay/src/api/git/manifest.rs b/crates/buzz-relay/src/api/git/manifest.rs index baf109c1ade..0dfbdb35a4d 100644 --- a/crates/buzz-relay/src/api/git/manifest.rs +++ b/crates/buzz-relay/src/api/git/manifest.rs @@ -474,6 +474,17 @@ mod tests { m.validate().expect("no parent is fine (first push)"); } + #[test] + fn pointer_writer_is_covered_by_deletion_taxonomy() { + let community = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); + let owner = "a".repeat(64); + let key = pointer_key(community, &owner, "repo"); + let prefixes = buzz_media::tenant_prefixes(*community.as_uuid()); + + assert!(prefixes.iter().any(|prefix| key.starts_with(prefix))); + assert!(buzz_media::is_tenant_owned_key(*community.as_uuid(), &key)); + } + #[test] fn pointer_key_strips_dot_git() { let c = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 53e3f59463c..3b2241046a3 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -1783,6 +1783,21 @@ pub(crate) struct PushContext { pub repo_handle: HydratedRepo, } +#[derive(Default)] +struct FinalizePushHooks { + #[cfg(test)] + post_cas_gate: Option>, + #[cfg(test)] + fail_ref_state_insert: bool, +} + +#[cfg(test)] +#[derive(Default)] +struct PostCasGate { + reached: tokio::sync::Notify, + resume: tokio::sync::Notify, +} + /// Finalize a push request: CAS-commit the new state into the object /// store, derive kind:30618 from the committed manifest, and only then /// build the success response. @@ -1793,6 +1808,17 @@ pub(crate) struct PushContext { /// constructor of a push 2xx, so the seam is structural (not by /// convention). async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { + finalize_push_inner(state, ctx, &FinalizePushHooks::default()).await +} + +async fn finalize_push_inner( + state: &Arc, + ctx: PushContext, + hooks: &FinalizePushHooks, +) -> Response { + #[cfg(not(test))] + let _ = hooks; + // The push fence, part 0 — **a rejected push publishes nothing.** // // `ctx.pack.ok` is false when git aborted the ref updates: either the @@ -1823,10 +1849,41 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { return response; } + // An already-running receive-pack may cross the durable fence after + // request admission. Revalidate immediately before object-store CAS; DB + // trigger fencing alone cannot roll back an S3 pointer mutation. + let serving_write = match buzz_deletion::acquire_serving_write( + &state.db, + ctx.tenant.community(), + "git_publish", + ) + .await + { + Ok(guard) => guard, + Err(error) => { + warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push rejected by community deletion fence"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + "community writes are fenced", + ) + .into_response(); + } + }; + + if let Err(error) = serving_write.verify().await { + warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push lost community serving lease"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + "community write lease lost", + ) + .into_response(); + } + // Step 7 (CAS). The PushContext binds `parent_state` (observed at // hydrate) to the CAS predicate here — no re-reading of the pointer - // between hydrate and CAS. - let success = match cas_publish( + // between hydrate and CAS. Observe serving-lease loss throughout the + // potentially long upload/CAS operation, not only at its boundaries. + let publish = cas_publish( &state.git_store, &ctx.tenant, ctx.repo_handle.path(), @@ -1838,72 +1895,87 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { max_pack_bytes: state.config.git_max_pack_bytes, max_repo_bytes: state.config.git_max_repo_bytes, }, - ) - .await - { - Ok(s) => s, - Err(CasError::Conflict { - winner_manifest_key, - .. - }) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - winner = %winner_manifest_key, - "push lost CAS race; tempdir dropped, returning 409" - ); - return ( - StatusCode::CONFLICT, - "push superseded by a concurrent writer; pull and retry", - ) - .into_response(); - } - Err(CasError::ManifestInvalid(e)) => { - // 4xx-class: the workspace produced refs/HEAD/oids the - // manifest validator rejects (unsafe refname, malformed oid, - // empty head, malformed parent). Pre-CAS — no pointer was - // written. - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - error = %e, - "push rejected: manifest validation failed" - ); - return ( - StatusCode::BAD_REQUEST, - "push produced invalid manifest state", - ) - .into_response(); - } - Err(CasError::ResourceLimit(e)) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - error = %e, - "push rejected: repo exceeds relay resource limits" - ); + ); + let success = match serving_write.protect(publish).await { + Ok(result) => match result { + Ok(s) => s, + Err(CasError::Conflict { + winner_manifest_key, + .. + }) => { + warn!( + owner = %ctx.owner, + repo = %ctx.repo, + winner = %winner_manifest_key, + "push lost CAS race; tempdir dropped, returning 409" + ); + return ( + StatusCode::CONFLICT, + "push superseded by a concurrent writer; pull and retry", + ) + .into_response(); + } + Err(CasError::ManifestInvalid(e)) => { + // 4xx-class: the workspace produced refs/HEAD/oids the + // manifest validator rejects (unsafe refname, malformed oid, + // empty head, malformed parent). Pre-CAS — no pointer was + // written. + warn!( + owner = %ctx.owner, + repo = %ctx.repo, + error = %e, + "push rejected: manifest validation failed" + ); + return ( + StatusCode::BAD_REQUEST, + "push produced invalid manifest state", + ) + .into_response(); + } + Err(CasError::ResourceLimit(e)) => { + warn!( + owner = %ctx.owner, + repo = %ctx.repo, + error = %e, + "push rejected: repo exceeds relay resource limits" + ); + return ( + StatusCode::PAYLOAD_TOO_LARGE, + "repository exceeds relay resource limits", + ) + .into_response(); + } + Err(e) => { + // 5xx-class: ManifestReadFailed (parent corruption), + // Backend, PackCapture. The tempdir drops on scope exit; no + // pointer was written (or, on rare ManifestReadFailed during + // winner-fetch, the winner is already installed and the + // loser's data is unrelated). + error!( + owner = %ctx.owner, + repo = %ctx.repo, + error = %e, + "push failed pre-response" + ); + return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error").into_response(); + } + }, + Err(error) => { + warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push lost community serving lease during CAS publish"); return ( - StatusCode::PAYLOAD_TOO_LARGE, - "repository exceeds relay resource limits", + StatusCode::SERVICE_UNAVAILABLE, + "community write lease lost", ) .into_response(); } - Err(e) => { - // 5xx-class: ManifestReadFailed (parent corruption), - // Backend, PackCapture. The tempdir drops on scope exit; no - // pointer was written (or, on rare ManifestReadFailed during - // winner-fetch, the winner is already installed and the - // loser's data is unrelated). - error!( - owner = %ctx.owner, - repo = %ctx.repo, - error = %e, - "push failed pre-response" - ); - return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error").into_response(); - } }; + #[cfg(test)] + if let Some(gate) = &hooks.post_cas_gate { + gate.reached.notify_one(); + gate.resume.notified().await; + } + // Derived after CAS: kind:30618 ref-state event over the *committed* // manifest's refs/head. Spec §Implementation Correspondence: // "kind:30618 is derived after CAS, never the commit." We emit only @@ -1927,7 +1999,7 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { (Some(before), Some(after)) => before != after, _ => true, // first push (parent None) or impossible-shape after key → publish }; - if manifest_changed { + let publication_result: Result<(), String> = if manifest_changed { let inputs = RefStateInputs { repo_id: &ctx.repo_id, head: &success.manifest.head, @@ -1938,11 +2010,23 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { Ok(event) => { // Relay-signed kind:30618 belongs to the same server-resolved // tenant as the git request that committed the pointer. - match state + #[cfg(test)] + let insert_result = if hooks.fail_ref_state_insert { + Err(buzz_db::DbError::InvalidData( + "injected kind:30618 insert failure".to_string(), + )) + } else { + state + .db + .insert_event_with_serving_write_guard(serving_write.lease(), &event, None) + .await + }; + #[cfg(not(test))] + let insert_result = state .db - .insert_event(ctx.tenant.community(), &event, None) - .await - { + .insert_event_with_serving_write_guard(serving_write.lease(), &event, None) + .await; + match insert_result { Ok((stored, true)) => { // Routed through the guarded send path for uniformity; // the access gate no-ops for this globally-scoped @@ -1959,6 +2043,7 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { manifest = %success.manifest_key, "kind:30618 published (derived after CAS)" ); + Ok(()) } Ok((_, false)) => { info!( @@ -1966,26 +2051,41 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { repo = %ctx.repo_id, "kind:30618 deduplicated by relay db" ); + Ok(()) } - Err(e) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, - error = %e, - "kind:30618 insert failed; push remains durable in object store" - ); - } + Err(error) => Err(format!("kind:30618 insert failed: {error}")), } } - Err(e) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, - error = %e, - "kind:30618 build failed; push remains durable in object store" - ); - } + Err(error) => Err(format!("kind:30618 build failed: {error}")), } + } else { + Ok(()) + }; + + // The admitted serving write spans the complete publication attempt. Fence + // acquisition cannot overtake the pointer CAS, durable 30618 insert, or + // local fan-out attempt; only now may the lease be released. + if let Err(error) = serving_write.finish().await { + warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "failed to release community serving lease after push publication"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + "community write lease lost during publication", + ) + .into_response(); + } + if let Err(error) = publication_result { + error!( + owner = %ctx.owner, + repo = %ctx.repo_id, + manifest = %success.manifest_key, + %error, + "push pointer committed but kind:30618 publication failed" + ); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "push committed but ref-state publication failed; retry", + ) + .into_response(); } // Only now — after CAS commit and (optional) 30618 emission — build @@ -2014,12 +2114,14 @@ pub fn git_router(state: Arc) -> Router { #[cfg(test)] mod track_c_tests { use super::*; + use crate::api::git::hydrate::{hydrate_for_write, HydrationOptions}; use crate::api::git::manifest::Manifest; use buzz_core::CommunityId; use nostr::{EventBuilder, Keys, Kind, Tag}; use std::collections::BTreeMap; use std::io::Write; use std::process::Output; + use tempfile::TempDir; fn oid_sha1() -> String { "cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string() @@ -2160,6 +2262,303 @@ mod track_c_tests { assert!(remote.join("refs/heads/master").exists()); } + async fn run_finalize_git(repo: &Path, args: &[&str]) -> std::process::Output { + let mut command = Command::new("git"); + command.current_dir(repo).args(args); + harden_git_env(&mut command); + let output = command.output().await.expect("spawn git"); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + output + } + + async fn finalize_test_state() -> (Arc, sqlx::PgPool) { + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&config.database_url) + .await + .expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test DB"); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + (Arc::new(state), pool) + } + + async fn approved_deletion( + state: &AppState, + host: &str, + ) -> ( + buzz_db::deletion::DeletionRequest, + buzz_db::deletion::ClaimedDeletion, + ) { + use buzz_db::deletion::{ + FrozenInventory, KeyStreamDigest, PrefixManifest, StorageManifest, + DEFAULT_LEASE_DURATION, + }; + + let store = state.db.deletion_store(); + let request = store + .submit(host, "git-finalize-test", Some("post-CAS lease regression")) + .await + .expect("submit deletion"); + let inventory = FrozenInventory { + schema: store + .inventory_schema(request.community_id) + .await + .expect("schema inventory"), + storage: StorageManifest { + version: 4, + prefixes: buzz_media::tenant_prefixes(*request.community_id.as_uuid()) + .into_iter() + .map(|prefix| PrefixManifest { + prefix, + object_count: 0, + total_bytes: 0, + keys_digest: KeyStreamDigest::new().finish().0, + }) + .collect(), + }, + }; + store + .freeze_inventory(request.id, &inventory) + .await + .expect("freeze inventory"); + store + .approve(request.id, "git-finalize-test", None) + .await + .expect("approve deletion"); + let claim = store + .claim_specific(request.id, "git-finalize-test", DEFAULT_LEASE_DURATION) + .await + .expect("claim deletion") + .expect("won deletion claim"); + (request, claim) + } + + async fn pushed_context( + state: &AppState, + community: CommunityId, + host: &str, + owner: String, + repo: String, + pusher: nostr::PublicKey, + scratch: &Path, + ) -> PushContext { + let tenant = TenantContext::resolved(community, host); + let (hydrated, parent_state) = hydrate_for_write( + &state.git_store, + &tenant, + &owner, + &repo, + HydrationOptions { + pack_cache: &state.git_pack_cache, + scratch_dir: scratch, + max_pack_bytes: 1024 * 1024, + max_repo_bytes: 2 * 1024 * 1024, + }, + ) + .await + .expect("hydrate empty test repo"); + let source = scratch.join("source"); + tokio::fs::create_dir(&source) + .await + .expect("source directory"); + run_finalize_git(&source, &["init", "--quiet", "--initial-branch=main"]).await; + run_finalize_git(&source, &["config", "user.email", "finalize@test"]).await; + run_finalize_git(&source, &["config", "user.name", "finalize"]).await; + tokio::fs::write(source.join("file.txt"), b"committed\n") + .await + .expect("write source file"); + run_finalize_git(&source, &["add", "file.txt"]).await; + run_finalize_git(&source, &["commit", "--quiet", "-m", "committed"]).await; + let remote = hydrated.path().to_str().expect("hydrated path utf8"); + run_finalize_git(&source, &["push", "--quiet", remote, "main"]).await; + + PushContext { + pack: PackOutput { + stdout: b"push-ok".to_vec(), + ok: true, + }, + parent_state, + owner, + repo: repo.clone(), + repo_id: repo, + pusher, + tenant, + repo_handle: hydrated, + } + } + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn finalize_push_holds_serving_lease_through_post_cas_publication() { + let (state, pool) = finalize_test_state().await; + let host = format!("git-finalize-{}.example", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let (request, claim) = approved_deletion(&state, &host).await; + let scratch = TempDir::new().expect("scratch"); + let owner = format!("owner-{}", uuid::Uuid::new_v4().simple()); + let repo = format!("repo-{}", uuid::Uuid::new_v4().simple()); + let ctx = pushed_context( + &state, + community, + &host, + owner, + repo.clone(), + Keys::generate().public_key(), + scratch.path(), + ) + .await; + let gate = Arc::new(PostCasGate::default()); + let hooks = FinalizePushHooks { + post_cas_gate: Some(Arc::clone(&gate)), + fail_ref_state_insert: false, + }; + let finalize_state = Arc::clone(&state); + let finalize = + tokio::spawn(async move { finalize_push_inner(&finalize_state, ctx, &hooks).await }); + + gate.reached.notified().await; + state + .db + .deletion_store() + .begin_quiescing(&claim.lease) + .await + .expect("quiesce after CAS"); + let error = state + .db + .deletion_store() + .fence(&claim.lease) + .await + .expect_err("post-CAS serving lease must block fence"); + assert!(matches!( + error, + buzz_db::DbError::ServingWritesNotDrained { .. } + )); + assert!(!state + .db + .deletion_store() + .is_serving_active(community) + .await + .expect("quiescing rejects new serving work")); + + gate.resume.notify_one(); + let response = finalize.await.expect("finalize task"); + assert_eq!(response.status(), StatusCode::OK); + let mut query = buzz_db::event::EventQuery::for_community(community); + query.kinds = Some(vec![30_618]); + query.d_tag = Some(repo); + let events = state.db.query_events(&query).await.expect("query 30618"); + assert_eq!(events.len(), 1, "kind:30618 must be durable before release"); + assert!(state + .db + .deletion_store() + .serving_writes_drained(community) + .await + .expect("serving lease released")); + let generation = state + .db + .deletion_store() + .fence(&claim.lease) + .await + .expect("fence after publication"); + assert_eq!(generation, 1); + assert_eq!( + state + .db + .deletion_store() + .get(request.id) + .await + .expect("fenced request") + .stage, + buzz_db::deletion::DeletionStage::Fenced + ); + drop(state); + pool.close().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn finalize_push_db_failure_after_cas_is_not_success_and_releases_lease() { + let (state, pool) = finalize_test_state().await; + let host = format!( + "git-finalize-fail-{}.example", + uuid::Uuid::new_v4().simple() + ); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let scratch = TempDir::new().expect("scratch"); + let ctx = pushed_context( + &state, + community, + &host, + format!("owner-{}", uuid::Uuid::new_v4().simple()), + format!("repo-{}", uuid::Uuid::new_v4().simple()), + Keys::generate().public_key(), + scratch.path(), + ) + .await; + let hooks = FinalizePushHooks { + post_cas_gate: None, + fail_ref_state_insert: true, + }; + + let response = finalize_push_inner(&state, ctx, &hooks).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(state + .db + .deletion_store() + .serving_writes_drained(community) + .await + .expect("serving lease released on failure")); + drop(state); + pool.close().await; + } + /// A gzip-encoded request body is transparently inflated before it /// reaches the git subprocess. Git's smart-HTTP client gzips the /// upload-pack/receive-pack request body past a size threshold (fires diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171ccad..d09c7fc6119 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -260,6 +260,19 @@ async fn authenticate( Ok((tenant, pubkey)) } +fn map_mint_error(error: buzz_db::DbError) -> (StatusCode, Json) { + match error { + buzz_db::DbError::InvalidData(message) | buzz_db::DbError::DeletionSafety(message) => { + api_error(StatusCode::BAD_REQUEST, &message) + } + buzz_db::DbError::AccessDenied(_) => api_error( + StatusCode::SERVICE_UNAVAILABLE, + "community writes are temporarily unavailable", + ), + error => internal_error(&format!("invite mint: {error}")), + } +} + /// Mint an invite code — `POST /api/invites`, NIP-98 signed by an owner/admin. /// /// Returns the code, its expiry, and a shareable landing-page URL on the @@ -304,10 +317,7 @@ pub async fn mint_invite( .db .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) .await - .map_err(|error| match error { - buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), - error => internal_error(&format!("invite mint: {error}")), - })?; + .map_err(map_mint_error)?; // Same TLS-posture logic as nip98_expected_url: wss deployments get an // https landing page URL, ws dev/test deployments get http. @@ -895,6 +905,19 @@ mod tests { } } + #[test] + fn mint_fence_errors_map_to_temporary_unavailability() { + let (status, body) = super::map_mint_error(buzz_db::DbError::AccessDenied( + "community is write-fenced".to_string(), + )); + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + body.0.get("error").and_then(Value::as_str), + Some("community writes are temporarily unavailable") + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn malformed_and_unknown_v2_codes_are_forbidden_without_v1_fallback() { diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index a2f3640bde5..3b6e07bad66 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -283,6 +283,19 @@ async fn upload_attribution( }) } +fn serving_write_error(error: anyhow::Error) -> MediaError { + if buzz_deletion::ServingWriteGuard::acquisition_is_fenced(&error) { + MediaError::CommunityWriteFenced + } else { + MediaError::ServiceUnavailable + } +} + +fn serving_lease_lost(error: anyhow::Error) -> MediaError { + tracing::warn!(%error, "media serving-write lease lost"); + MediaError::ServiceUnavailable +} + /// PUT `/upload` or the temporary media-only `/media/upload` alias. /// /// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body @@ -310,6 +323,11 @@ pub async fn upload_blob( ) -> Result, MediaError> { let attribution = upload_attribution(&state, &auth, &headers).await; + let serving_write = + buzz_deletion::acquire_serving_write(&state.db, auth.tenant.community(), "media_upload") + .await + .map_err(serving_write_error)?; + if auth.route_mode == UploadRouteMode::LegacyMedia { metrics::counter!("buzz_media_legacy_upload_route_total").increment(1); } @@ -335,69 +353,86 @@ pub async fn upload_blob( } let replay = futures_util::stream::iter(replay_chunks.into_iter().map(Ok)).chain(source); - let mut descriptor = if should_stream_as_video(&sniff) { - // Video path: stream body directly to disk — never fully buffered in RAM. - let content_length = headers - .get("content-length") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()); - buzz_media::process_video_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - replay, - content_length, - attribution, - ) - .await? - } else { - // Non-video path: buffer the body (bounded by the larger of the image - // and generic-file caps), then decide image-vs-generic by sniffed MIME. - // Images go through the thumbnailing pipeline; non-media attachments - // (docs, archives, text, data) take the generic file path and are - // served as downloads. Recognized audio/video cannot fall through it. - let max = state - .config - .media - .max_image_bytes - .max(state.config.media.max_file_bytes); - let bytes = axum::body::to_bytes(axum::body::Body::from_stream(replay), max as usize) - .await - .map_err(|_| MediaError::FileTooLarge { size: 0, max })?; - - let is_image = matches!( - infer::get(&bytes).map(|t| t.mime_type()), - Some("image/jpeg" | "image/png" | "image/gif" | "image/webp") - ); - - if is_image { - buzz_media::process_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - bytes, - attribution, - ) - .await? - } else if auth.route_mode == UploadRouteMode::LegacyMedia { - let mime = infer::get(&bytes) - .map(|kind| kind.mime_type().to_string()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - return Err(MediaError::DisallowedContentType(mime)); - } else { - buzz_media::process_file_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - bytes, - attribution, - ) - .await? - } - }; + serving_write.verify().await.map_err(serving_lease_lost)?; + + let mut descriptor = serving_write + .protect(async { + Ok(if should_stream_as_video(&sniff) { + // Video path: stream body directly to disk — never fully buffered in RAM. + let content_length = headers + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + buzz_media::process_video_upload( + &state.media_storage, + &state.config.media, + &auth.tenant, + &auth.auth_event, + replay, + content_length, + attribution, + ) + .await? + } else { + // Non-video path: buffer the body (bounded by the larger of the image + // and generic-file caps), then decide image-vs-generic by sniffed MIME. + // Images go through the thumbnailing pipeline; non-media attachments + // (docs, archives, text, data) take the generic file path and are + // served as downloads. Recognized audio/video cannot fall through it. + let max = state + .config + .media + .max_image_bytes + .max(state.config.media.max_file_bytes); + let bytes = + axum::body::to_bytes(axum::body::Body::from_stream(replay), max as usize) + .await + .map_err(|_| MediaError::FileTooLarge { size: 0, max })?; + + let is_image = matches!( + infer::get(&bytes).map(|t| t.mime_type()), + Some("image/jpeg" | "image/png" | "image/gif" | "image/webp") + ); + + if is_image { + buzz_media::process_upload( + &state.media_storage, + &state.config.media, + &auth.tenant, + &auth.auth_event, + bytes, + attribution, + ) + .await? + } else if auth.route_mode == UploadRouteMode::LegacyMedia { + let mime = infer::get(&bytes) + .map(|kind| kind.mime_type().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + return Err(MediaError::DisallowedContentType(mime)); + } else { + buzz_media::process_file_upload( + &state.media_storage, + &state.config.media, + &auth.tenant, + &auth.auth_event, + bytes, + attribution, + ) + .await? + } + }) + }) + .await + .map_err(|error| { + if buzz_deletion::ServingWriteGuard::is_lease_lost(&error) { + serving_lease_lost(error) + } else { + match error.downcast::() { + Ok(error) => error, + Err(_) => MediaError::Internal, + } + } + })??; rewrite_descriptor_urls_for_tenant( &mut descriptor, @@ -441,6 +476,7 @@ pub async fn upload_blob( } } + serving_write.finish().await.map_err(serving_lease_lost)?; Ok(Json(descriptor)) } @@ -913,6 +949,20 @@ mod tests { const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + #[test] + fn serving_write_error_taxonomy_separates_fence_from_backend_failure() { + let fenced = anyhow::Error::from(buzz_db::DbError::AccessDenied("fenced".to_string())); + assert!(matches!( + serving_write_error(fenced), + MediaError::CommunityWriteFenced + )); + let backend = anyhow::Error::from(buzz_db::DbError::Sqlx(sqlx::Error::PoolTimedOut)); + assert!(matches!( + serving_write_error(backend), + MediaError::ServiceUnavailable + )); + } + #[test] fn upload_routes_distinguish_standard_and_legacy_modes() { assert_eq!( diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index d9f829433b1..2a942bc8039 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -9,6 +9,7 @@ pub mod media; pub mod mesh_demo; pub mod nip05; pub mod operator; +pub mod workflows; // Re-export imeta helpers used by ingest pipeline. pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs}; diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs new file mode 100644 index 00000000000..a3d5a6c729e --- /dev/null +++ b/crates/buzz-relay/src/api/workflows.rs @@ -0,0 +1,264 @@ +//! Authorized structured reads for workflow execution state. +//! +//! Runs and approvals are relay-owned database rows, not Nostr events. These +//! endpoints expose those read models without inventing synthetic events. + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, RawQuery, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use serde_json::Value; +use uuid::Uuid; + +use buzz_core::TenantContext; + +use crate::{ + api::{api_error, bridge, internal_error}, + state::AppState, +}; + +const DEFAULT_RUN_LIMIT: i64 = 20; +const MAX_RUN_LIMIT: i64 = 100; + +/// Pagination query for workflow run history. +#[derive(Debug, Deserialize, Default)] +pub struct RunsQuery { + before: Option>, + before_id: Option, + limit: Option, +} + +fn request_path(path: &str, raw_query: Option<&str>) -> String { + match raw_query { + Some(query) if !query.is_empty() => format!("{path}?{query}"), + _ => path.to_string(), + } +} + +async fn authorize_workflow_read( + state: &Arc, + headers: &HeaderMap, + path: &str, + raw_query: Option<&str>, + workflow_id: Uuid, +) -> Result)> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let path_with_query = request_path(path, raw_query); + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let (pubkey, event_id_bytes) = + bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + bridge::enforce_http_admission(state, &tenant, &pubkey).await?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + + let pubkey_bytes = pubkey.to_bytes().to_vec(); + let auth_tag = headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()); + super::relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey_bytes, + auth_tag, + ) + .await?; + + let workflow = state + .db + .get_workflow(tenant.community(), workflow_id) + .await + .map_err(|error| match error { + buzz_db::error::DbError::NotFound(_) => { + api_error(StatusCode::NOT_FOUND, "workflow not found") + } + other => internal_error(&format!("get workflow for run read: {other}")), + })?; + let channel_id = workflow + .channel_id + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped"))?; + let accessible = state + .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) + .await + .map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?; + if !accessible.contains(&channel_id) { + return Err(api_error( + StatusCode::FORBIDDEN, + "workflow is not accessible", + )); + } + + Ok(tenant) +} + +/// `GET /workflows/{workflow_id}/runs` — one authorized, keyset-paginated page. +pub async fn workflow_runs( + State(state): State>, + Path(workflow_id): Path, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + if query.before.is_some() != query.before_id.is_some() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "before and before_id must be supplied together", + )); + } + let limit = query.limit.unwrap_or(DEFAULT_RUN_LIMIT); + if !(1..=MAX_RUN_LIMIT).contains(&limit) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "limit must be between 1 and 100", + )); + } + + let path = format!("/workflows/{workflow_id}/runs"); + let tenant = + authorize_workflow_read(&state, &headers, &path, raw_query.as_deref(), workflow_id).await?; + let mut rows = state + .db + .list_workflow_runs_page( + tenant.community(), + workflow_id, + query.before, + query.before_id, + limit + 1, + ) + .await + .map_err(|error| internal_error(&format!("list workflow runs: {error}")))?; + + let has_more = rows.len() > limit as usize; + rows.truncate(limit as usize); + let next = if has_more { + rows.last().map(|last| { + serde_json::json!({ + "before": last.created_at, + "before_id": last.id, + }) + }) + } else { + None + }; + + Ok(Json(serde_json::json!({ + "runs": rows.iter().map(run_json).collect::>(), + "next": next, + }))) +} + +/// `GET /workflows/{workflow_id}/runs/{run_id}/approvals` — approvals for a run. +pub async fn run_approvals( + State(state): State>, + Path((workflow_id, run_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + let path = format!("/workflows/{workflow_id}/runs/{run_id}/approvals"); + let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id).await?; + + let run = state + .db + .get_workflow_run(tenant.community(), run_id) + .await + .map_err(|error| match error { + buzz_db::error::DbError::NotFound(_) => { + api_error(StatusCode::NOT_FOUND, "workflow run not found") + } + other => internal_error(&format!("get workflow run for approval read: {other}")), + })?; + if run.workflow_id != workflow_id { + return Err(api_error(StatusCode::NOT_FOUND, "workflow run not found")); + } + + let approvals = state + .db + .get_run_approvals(tenant.community(), workflow_id, run_id) + .await + .map_err(|error| internal_error(&format!("list run approvals: {error}")))?; + Ok(Json(serde_json::json!({ + "approvals": approvals.iter().map(approval_json).collect::>(), + }))) +} + +fn run_json(run: &buzz_db::workflow::WorkflowRunRecord) -> Value { + serde_json::json!({ + "id": run.id, + "workflow_id": run.workflow_id, + "status": run.status, + "current_step": run.current_step, + "execution_trace": run.execution_trace, + "started_at": run.started_at.map(|value| value.timestamp()), + "completed_at": run.completed_at.map(|value| value.timestamp()), + "error_code": run.error_code, + "error_message": run.error_message, + "created_at": run.created_at.timestamp(), + }) +} + +fn approval_json(approval: &buzz_db::workflow::ApprovalRecord) -> Value { + serde_json::json!({ + "approval_ref": hex::encode(&approval.token), + "workflow_id": approval.workflow_id, + "run_id": approval.run_id, + "step_id": approval.step_id, + "step_index": approval.step_index, + "approver_spec": approval.approver_spec, + "status": approval.status, + "approver_pubkey": approval.approver_pubkey.as_ref().map(hex::encode), + "note": approval.note, + "expires_at": approval.expires_at, + "created_at": approval.created_at.timestamp(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_path_preserves_signed_query_verbatim() { + assert_eq!( + request_path("/workflows/id/runs", Some("limit=20&before_id=abc")), + "/workflows/id/runs?limit=20&before_id=abc" + ); + assert_eq!( + request_path("/workflows/id/runs", None), + "/workflows/id/runs" + ); + } + + #[test] + fn approval_wire_does_not_expose_hash_as_token() { + let approval = buzz_db::workflow::ApprovalRecord { + token: vec![0xab; 32], + workflow_id: Uuid::new_v4(), + run_id: Uuid::new_v4(), + step_id: "review".to_string(), + step_index: 1, + approver_spec: "any".to_string(), + status: buzz_db::workflow::ApprovalStatus::Pending, + approver_pubkey: None, + note: None, + expires_at: Utc::now(), + created_at: Utc::now(), + }; + let wire = approval_json(&approval); + assert!(wire.get("token").is_none()); + assert_eq!(wire["approval_ref"], hex::encode([0xab; 32])); + } +} diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 16cd56209c7..4c158eab0c4 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -25,7 +25,7 @@ use bytes::Bytes; use futures_util::{SinkExt, StreamExt}; use nostr::{EventBuilder, Kind, Tag}; use serde::Deserialize; -use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore}; +use tokio::sync::{mpsc, watch, OwnedSemaphorePermit, Semaphore}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use uuid::Uuid; @@ -38,7 +38,7 @@ use buzz_core::StoredEvent; use buzz_pubsub::EventTopic; use crate::audio::room::PeerCtrl; -use crate::state::{run_registered_community_connection, AppState}; +use crate::state::{run_registered_community_connection, AppState, CommunityConnectionControl}; /// Maximum binary frame size: 4 KB is generous for a single Opus packet. const MAX_AUDIO_FRAME_BYTES: usize = 4096; @@ -149,6 +149,7 @@ async fn handle_audio_connection( _permit: OwnedSemaphorePermit, ) { let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel); let community_id = tenant.community(); let registry = Arc::clone(&state.community_connections); let check_state = Arc::clone(&state); @@ -157,9 +158,11 @@ async fn handle_audio_connection( ®istry, Uuid::new_v4(), community_id, - cancel.clone(), + control, move || async move { check_state.db.is_community_active(community_id).await }, - move || handle_active_audio_connection(socket, run_state, tenant, channel_id, cancel), + move |control| { + handle_active_audio_connection(socket, run_state, tenant, channel_id, control) + }, ) .await; } @@ -169,8 +172,10 @@ async fn handle_active_audio_connection( state: Arc, tenant: TenantContext, channel_id: Uuid, - cancel: CancellationToken, + control: CommunityConnectionControl, ) { + let cancel = control.cancellation_token(); + let disconnect_reason = control.disconnect_reason(); let (mut ws_send, mut ws_recv) = socket.split(); let challenge = generate_challenge(); @@ -660,7 +665,13 @@ async fn handle_active_audio_connection( let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); let send_cancel = cancel.child_token(); - let send_task = tokio::spawn(send_loop(ws_send, data_rx, ctrl_rx, send_cancel)); + let send_task = tokio::spawn(send_loop( + ws_send, + data_rx, + ctrl_rx, + send_cancel, + disconnect_reason, + )); let hb_cancel = cancel.clone(); let hb_missed = Arc::clone(&missed_pongs); @@ -1056,12 +1067,15 @@ async fn recv_loop( /// /// Control frames (Ping, Pong, Close, control JSON) are drained first on every /// iteration, so heartbeat pings are never starved by audio backpressure. -async fn send_loop( - mut ws_send: futures_util::stream::SplitSink, +async fn send_loop( + mut ws_send: S, mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, cancel: CancellationToken, -) { + disconnect_reason: watch::Receiver>, +) where + S: futures_util::Sink + Unpin, +{ loop { // Priority: drain all pending control frames before data. while let Ok(ctrl_msg) = ctrl_rx.try_recv() { @@ -1073,7 +1087,10 @@ async fn send_loop( tokio::select! { biased; _ = cancel.cancelled() => { - let _ = ws_send.send(WsMessage::Close(None)).await; + let close = disconnect_reason + .borrow() + .map_or(WsMessage::Close(None), |reason| reason.close_message()); + let _ = ws_send.send(close).await; break; } Some(ctrl_msg) = ctrl_rx.recv() => { @@ -1416,6 +1433,74 @@ mod tests { received } + #[tokio::test] + async fn audio_send_loop_sends_policy_close_when_community_is_deleted() { + use futures_util::Sink; + + struct MockSink { + messages: Arc>>, + } + + impl Sink for MockSink { + type Error = std::io::Error; + + fn poll_ready( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn start_send( + self: std::pin::Pin<&mut Self>, + item: WsMessage, + ) -> Result<(), Self::Error> { + self.messages.lock().expect("mock sink poisoned").push(item); + Ok(()) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.poll_flush(cx) + } + } + + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let disconnect_reason = control.disconnect_reason(); + let registry = crate::state::CommunityConnectionRegistry::new(); + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let _guard = registry.register(Uuid::new_v4(), community, control); + assert_eq!(registry.disconnect_community(community), 1); + let messages = Arc::new(Mutex::new(Vec::new())); + let sink = MockSink { + messages: Arc::clone(&messages), + }; + + send_loop(sink, data_rx, ctrl_rx, cancel, disconnect_reason).await; + + let messages = messages.lock().expect("mock sink poisoned"); + assert_eq!(messages.len(), 1); + match &messages[0] { + WsMessage::Close(Some(close)) => { + assert_eq!(close.code, axum::extract::ws::close_code::POLICY); + assert_eq!(close.reason.as_str(), "community deleted"); + } + other => panic!("expected one 1008 deletion close, got {other:?}"), + } + } + #[tokio::test] async fn audio_websocket_parser_rejects_oversized_messages_before_handler_reads_them() { assert!( diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 72a7eb91269..c37421e7e80 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -8,7 +8,7 @@ use std::time::Duration; use axum::extract::ws::{Message as WsMessage, WebSocket}; use futures_util::{Sink, SinkExt, StreamExt}; -use tokio::sync::{mpsc, Mutex, RwLock}; +use tokio::sync::{mpsc, watch, Mutex, RwLock}; use tokio_util::sync::CancellationToken; use tracing::Instrument as _; use tracing::{debug, info, trace, warn}; @@ -20,7 +20,10 @@ use nostr::Filter; use crate::handlers; use crate::protocol::{ClientMessage, RelayMessage}; -use crate::state::{run_registered_community_connection, AppState}; +use crate::state::{ + run_registered_community_connection, AppState, CommunityConnectionControl, + CommunityDisconnectReason, +}; use buzz_pubsub::EventTopic; /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. @@ -128,6 +131,7 @@ pub async fn handle_connection( ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel); let community_id = tenant.community(); let registry = Arc::clone(&state.community_connections); let check_state = Arc::clone(&state); @@ -136,9 +140,9 @@ pub async fn handle_connection( ®istry, conn_id, community_id, - cancel.clone(), + control, move || async move { check_state.db.is_community_active(community_id).await }, - move || handle_active_connection(socket, run_state, addr, tenant, conn_id, cancel), + move |control| handle_active_connection(socket, run_state, addr, tenant, conn_id, control), ) .await; } @@ -149,8 +153,10 @@ async fn handle_active_connection( addr: SocketAddr, tenant: TenantContext, conn_id: Uuid, - cancel: CancellationToken, + control: CommunityConnectionControl, ) { + let cancel = control.cancellation_token(); + let disconnect_reason = control.disconnect_reason(); let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { @@ -226,7 +232,14 @@ async fn handle_active_connection( let (ws_send, ws_recv) = socket.split(); let send_cancel = cancel.child_token(); - let send_task = tokio::spawn(send_loop(ws_send, rx, ctrl_rx, restart_rx, send_cancel)); + let send_task = tokio::spawn(send_loop( + ws_send, + rx, + ctrl_rx, + restart_rx, + send_cancel, + disconnect_reason, + )); let missed_pongs = Arc::new(AtomicU8::new(0)); let heartbeat_cancel = cancel.clone(); @@ -310,8 +323,17 @@ async fn send_loop( ctrl_rx: mpsc::Receiver, restart_rx: mpsc::Receiver, cancel: CancellationToken, + disconnect_reason: watch::Receiver>, ) { - send_loop_inner(ws_send, data_rx, ctrl_rx, restart_rx, cancel).await; + send_loop_inner( + ws_send, + data_rx, + ctrl_rx, + restart_rx, + cancel, + disconnect_reason, + ) + .await; } async fn send_loop_inner( @@ -320,6 +342,7 @@ async fn send_loop_inner( mut ctrl_rx: mpsc::Receiver, mut restart_rx: mpsc::Receiver, cancel: CancellationToken, + disconnect_reason: watch::Receiver>, ) where S: Sink + Unpin, { @@ -359,7 +382,10 @@ async fn send_loop_inner( break; } } - let _ = ws_send.send(WsMessage::Close(None)).await; + let close = disconnect_reason + .borrow() + .map_or(WsMessage::Close(None), |reason| reason.close_message()); + let _ = ws_send.send(close).await; break; } Some(ctrl_msg) = ctrl_rx.recv() => { @@ -787,6 +813,17 @@ mod tests { } } + fn ordinary_disconnect_reason() -> watch::Receiver> { + let (_tx, rx) = watch::channel(None); + rx + } + + fn deleted_community_disconnect_reason() -> watch::Receiver> { + let (tx, rx) = watch::channel(None); + tx.send_replace(Some(CommunityDisconnectReason::CommunityDeleted)); + rx + } + fn text_payloads(messages: &[WsMessage]) -> Vec { messages .iter() @@ -823,7 +860,15 @@ mod tests { let (sink, state) = MockSink::new(Some(1)); let (_restart_tx, restart_rx) = mpsc::channel(1); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 1); @@ -844,7 +889,15 @@ mod tests { let (sink, state) = MockSink::new(Some(1)); let (_restart_tx, restart_rx) = mpsc::channel(1); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 1); @@ -870,7 +923,15 @@ mod tests { let (sink, state) = MockSink::new(Some(2)); let (_restart_tx, restart_rx) = mpsc::channel(1); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 2); @@ -894,7 +955,15 @@ mod tests { .expect("queue restart close"); let (sink, state) = MockSink::new(None); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; assert_eq!(flushed_rx.await, Ok(true)); let state = state.lock().expect("mock sink poisoned"); @@ -923,7 +992,15 @@ mod tests { .expect("queue restart close"); let (sink, state) = MockSink::new(Some(1)); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + CancellationToken::new(), + ordinary_disconnect_reason(), + ) + .await; assert_eq!(flushed_rx.await, Ok(false)); let state = state.lock().expect("mock sink poisoned"); @@ -931,6 +1008,59 @@ mod tests { assert_eq!(state.messages.len(), 1, "no fallback close is appended"); } + #[tokio::test] + async fn send_loop_sends_policy_close_when_community_is_deleted() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + cancel.cancel(); + + let (sink, state) = MockSink::new(None); + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + cancel, + deleted_community_disconnect_reason(), + ) + .await; + + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.messages.len(), 1); + match &state.messages[0] { + WsMessage::Close(Some(close)) => { + assert_eq!(close.code, axum::extract::ws::close_code::POLICY); + assert_eq!(close.reason.as_str(), "community deleted"); + } + other => panic!("expected one 1008 deletion close, got {other:?}"), + } + } + + #[tokio::test] + async fn send_loop_sends_bare_close_for_ordinary_cancellation() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + cancel.cancel(); + + let (sink, state) = MockSink::new(None); + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + cancel, + ordinary_disconnect_reason(), + ) + .await; + + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.messages.as_slice(), [WsMessage::Close(None)]); + } + #[tokio::test] async fn send_loop_flushes_queued_control_before_close_on_cancel() { // A ban disconnect queues its `OK false "blocked: …"` reason frame on @@ -951,7 +1081,15 @@ mod tests { let (sink, state) = MockSink::new(None); let (_restart_tx, restart_rx) = mpsc::channel(1); - send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, cancel).await; + send_loop_inner( + sink, + data_rx, + ctrl_rx, + restart_rx, + cancel, + ordinary_disconnect_reason(), + ) + .await; let state = state.lock().expect("mock sink poisoned"); assert_eq!( @@ -966,8 +1104,8 @@ mod tests { other => panic!("expected the ban reason frame first, got {other:?}"), } assert!( - matches!(state.messages[1], WsMessage::Close(_)), - "Close is sent only after the reason frame is flushed" + matches!(state.messages[1], WsMessage::Close(None)), + "ordinary cancellation retains the bare Close after the reason frame" ); } } diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2d82736807a..29abe9f27d4 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -19,6 +19,7 @@ use uuid::Uuid; use buzz_core::kind::*; use buzz_core::tenant::{CommunityId, TenantContext}; +use buzz_datastore_tracing::datastore_span; use buzz_db::workflow::{ApprovalStatus, RunStatus}; use buzz_db::DbError; use buzz_workflow::executor::TriggerContext; @@ -97,6 +98,7 @@ enum PersistResult { /// persists without the event record. On retry, the event INSERT succeeds /// (no conflict), and the mutation re-executes — which is safe for idempotent /// operations (open_dm, hide_dm, update_approval, upsert_workflow). +#[datastore_span(name = "persist_command_event", system = "postgresql")] async fn persist_command_event( state: &Arc, tenant: &TenantContext, @@ -110,6 +112,12 @@ async fn persist_command_event( .begin_transaction() .await .map_err(|e| IngestError::Internal(format!("error: begin transaction: {e}")))?; + buzz_deletion::store(&state.db) + .guard_transaction(&mut tx, tenant.community()) + .await + .map_err(|error| { + IngestError::Rejected(format!("restricted: community writes are fenced: {error}")) + })?; // INSERT with ON CONFLICT DO NOTHING — idempotency guard. let id_bytes = event.id.as_bytes(); @@ -956,7 +964,10 @@ async fn handle_workflow_trigger( RunStatus::Failed, 0, &serde_json::json!([]), - Some(&format!("definition parse error: {e}")), + Some(buzz_db::workflow::WorkflowRunFailure { + code: "invalid_definition", + message: &format!("definition parse error: {e}"), + }), ) .await { @@ -1253,7 +1264,10 @@ async fn handle_approval_deny( RunStatus::Cancelled, run.current_step, &run.execution_trace, - Some(&cancel_msg), + Some(buzz_db::workflow::WorkflowRunFailure { + code: "approval_denied", + message: &cancel_msg, + }), ) .await { @@ -1321,7 +1335,10 @@ async fn resume_workflow_after_approval( RunStatus::Failed, run.current_step, &run.execution_trace, - Some(&format!("definition parse error: {e}")), + Some(buzz_db::workflow::WorkflowRunFailure { + code: "invalid_definition", + message: &format!("definition parse error: {e}"), + }), ) .await { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a67797385b8..ccba40f3282 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -705,16 +705,49 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc {} + Ok(false) => { + reject("restricted"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "restricted: community writes are fenced", + )); + return; + } + Err(error) => { + reject("error"); + tracing::warn!(%error, event_id = %event_id_hex, "failed to check ephemeral-event community lifecycle"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "error: internal server error", + )); + return; + } + } + match handle_ephemeral_event( event, conn_id, - &event_id_hex, pubkey_bytes, auth_pubkey, - conn, + Arc::clone(&conn), state, ) - .await; + .await + { + Ok(()) => { + conn.send(RelayMessage::ok(&event_id_hex, true, "")); + } + Err(message) => { + reject("invalid"); + conn.send(RelayMessage::ok(&event_id_hex, false, &message)); + } + } return; } @@ -762,33 +795,19 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, auth_pubkey: nostr::PublicKey, conn: Arc, state: Arc, -) { +) -> Result<(), String> { let event_clone = event.clone(); + let event_id = event.id.to_hex(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; match verify_result { Ok(Ok(())) => {} - Ok(Err(e)) => { - conn.send(RelayMessage::ok( - event_id_hex, - false, - &format!("invalid: {e}"), - )); - return; - } - Err(_) => { - conn.send(RelayMessage::ok( - event_id_hex, - false, - "error: internal error", - )); - return; - } + Ok(Err(e)) => return Err(format!("invalid: {e}")), + Err(_) => return Err("error: internal error".to_string()), } // Special handling for presence events (kind:20001). @@ -829,18 +848,8 @@ async fn handle_ephemeral_event( // Check channel membership before publishing other ephemeral events. if let Some(ch_id) = super::ingest::extract_channel_id(&event) { - if let Err(msg) = super::ingest::check_channel_membership( - &conn.tenant, - &state, - ch_id, - &pubkey_bytes, - None, - ) - .await - { - conn.send(RelayMessage::ok(event_id_hex, false, &msg)); - return; - } + super::ingest::check_channel_membership(&conn.tenant, &state, ch_id, &pubkey_bytes, None) + .await?; // Mark as local before Redis publish to prevent double-delivery when // the event comes back through the Redis subscriber loop. @@ -854,7 +863,7 @@ async fn handle_ephemeral_event( state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); - warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral publish failed: {e}"); + warn!(conn_id = %conn_id, event_id = %event_id, "Ephemeral publish failed: {e}"); } // Direct fan-out to local WS subscribers, through the guarded send path @@ -882,7 +891,7 @@ async fn handle_ephemeral_event( state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); - warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral global publish failed: {e}"); + warn!(conn_id = %conn_id, event_id = %event_id, "Ephemeral global publish failed: {e}"); } // Direct fan-out to local WS subscribers through the guarded send path. @@ -893,7 +902,7 @@ async fn handle_ephemeral_event( fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; } - conn.send(RelayMessage::ok(event_id_hex, true, "")); + Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 7aea2ed02e4..5ba9650e91e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -299,6 +299,24 @@ pub enum IngestError { Internal(String), } +/// Map the durable community write-fence lookup onto the ingest error taxonomy. +/// +/// An inactive community is an authorization decision and keeps the exact +/// `restricted:` wire text the ephemeral path uses. A lookup outage is a +/// server fault and fails closed as `error:`/500 — a Postgres blip can +/// neither admit a write past the fence nor read as a client mistake. +fn map_serving_fence_state(active: Result) -> Result<(), IngestError> { + match active { + Ok(true) => Ok(()), + Ok(false) => Err(IngestError::Rejected( + "restricted: community writes are fenced".into(), + )), + Err(error) => Err(IngestError::Internal(format!( + "error: checking community write fence: {error}" + ))), + } +} + fn map_relay_admin_error(error: super::relay_admin::RelayAdminError) -> IngestError { use super::relay_admin::RelayAdminError; match error { @@ -1931,6 +1949,17 @@ async fn ingest_event_inner( let kind_u32 = event_kind_u32(&event); debug!(event_id = %event_id_hex, kind = kind_u32, "ingest_event"); + // Durable community write fence: persistent ingest is a DB write the + // deletion engine cannot exclude via serving-write leases (those cover + // external side effects only), so the shared WS/HTTP seam must refuse + // writes once the community leaves the active lifecycle state. Row churn + // inside the remaining race window is swept by the destructive DB stage. + map_serving_fence_state( + buzz_deletion::store(&state.db) + .is_serving_active(tenant.community()) + .await, + )?; + if kind_u32 == KIND_AUTH { return Err(IngestError::Rejected( "invalid: AUTH events cannot be submitted".into(), @@ -2837,24 +2866,29 @@ async fn ingest_event_inner( }; let pubkey_hex = auth.pubkey().to_hex(); - // Spec WriteInsert (line 514) / WriteDuplicate (line 606): emit - // the abstract write action. The persist API returns - // `was_inserted` (true → Insert, false → Duplicate). This branch - // is the reaction path; channel_id is always Some here, so - // WriteInsertGlobal does not apply. + // Spec WriteInsert (line 514) / WriteDuplicate (line 606) / + // WriteInsertGlobal (line 559): emit the abstract write action. The + // persist API returns `was_inserted` (true → Insert/Global, false → + // Duplicate). Reactions on project events (issue/PR roots and their + // comments) carry no `h` tag, so `channel_id` can be `None` here — + // mirror the message write's three-way split instead of asserting a + // channel, which panicked the ingest worker on those events. let claimed = claimed_community_from_event(&event); - let action = if was_inserted { - TraceAction::WriteInsert { + let action = match (channel_id, was_inserted) { + (Some(ch), true) => TraceAction::WriteInsert { msg_id: msg_id_label(event.id.as_bytes()), - channel: channel_label(channel_id.expect("reaction path has channel")), + channel: channel_label(ch), claimed_community: claimed, - } - } else { - TraceAction::WriteDuplicate { + }, + (Some(ch), false) => TraceAction::WriteDuplicate { msg_id: msg_id_label(event.id.as_bytes()), - channel: channel_label(channel_id.expect("reaction path has channel")), + channel: channel_label(ch), claimed_community: claimed, - } + }, + (None, _) => TraceAction::WriteInsertGlobal { + msg_id: msg_id_label(event.id.as_bytes()), + claimed_community: claimed, + }, }; emit(tracer, action, state_for_request(tenant, auth.pubkey())); dispatch_persistent_event( @@ -3166,6 +3200,123 @@ mod tests { } } + /// An active community passes the durable write fence untouched. + #[test] + fn serving_fence_active_community_admits_write() { + assert!(map_serving_fence_state(Ok(true)).is_ok()); + } + + /// A fenced/tombstoned/archived community is an authorization decision: + /// `restricted:` and (via `bridge.rs`) HTTP 400 — with the exact wire text + /// the ephemeral WS path uses, so clients see one refusal vocabulary. + #[test] + fn serving_fence_inactive_community_maps_to_restricted() { + match map_serving_fence_state(Ok(false)) { + Err(IngestError::Rejected(msg)) => { + assert_eq!(msg, "restricted: community writes are fenced"); + } + other => panic!("fenced community must map to Rejected, got {other:?}"), + } + } + + /// A fence-lookup outage is a server fault and must fail closed as + /// `error:`/500 — a Postgres blip can neither admit a write past the + /// fence nor be reported to an innocent client as a bad request. + #[test] + fn serving_fence_lookup_outage_fails_closed_as_internal() { + let outage = buzz_db::DbError::Sqlx(sqlx::Error::PoolTimedOut); + match map_serving_fence_state(Err(outage)) { + Err(IngestError::Internal(msg)) => { + assert!( + msg.starts_with("error: "), + "fence outages need the `error:` NIP-01 prefix, got {msg:?}" + ); + } + other => panic!("fence lookup failure must map to Internal, got {other:?}"), + } + } + + /// Production-path regression: the exact predicate `ingest_event_inner` + /// consults must admit writes while a community is active and refuse them + /// once the community deletion lifecycle fences it. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ingest_write_fence_follows_community_deletion_lifecycle() { + use buzz_db::deletion::{ + FrozenInventory, KeyStreamDigest, PrefixManifest, StorageManifest, + DEFAULT_LEASE_DURATION, + }; + + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool); + db.migrate().await.expect("migrate test DB"); + let store = buzz_deletion::store(&db); + + let host = format!("lane3-fence-{}.example", Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + + assert!( + map_serving_fence_state(store.is_serving_active(community).await).is_ok(), + "active community must admit persistent ingest" + ); + + let submitted = store + .submit( + &host, + "test-operator", + Some("lane3 ingest fence regression"), + ) + .await + .expect("submit"); + let inventory = FrozenInventory { + schema: store + .inventory_schema(community) + .await + .expect("schema inventory"), + storage: StorageManifest { + version: 4, + prefixes: buzz_media::tenant_prefixes(*community.as_uuid()) + .into_iter() + .map(|prefix| PrefixManifest { + prefix, + object_count: 0, + total_bytes: 0, + keys_digest: KeyStreamDigest::new().finish().0, + }) + .collect(), + }, + }; + let request = store + .freeze_inventory(submitted.id, &inventory) + .await + .expect("freeze inventory"); + store + .approve(request.id, "approver", None) + .await + .expect("approve"); + let claim = store + .claim_specific(request.id, "executor", DEFAULT_LEASE_DURATION) + .await + .expect("claim") + .expect("won claim"); + store.begin_quiescing(&claim.lease).await.expect("quiesce"); + store.fence(&claim.lease).await.expect("fence"); + + match map_serving_fence_state(store.is_serving_active(community).await) { + Err(IngestError::Rejected(msg)) => { + assert_eq!(msg, "restricted: community writes are fenced"); + } + other => panic!("fenced community must refuse persistent ingest, got {other:?}"), + } + } + #[derive(Debug, Default)] struct VecTracer { steps: Mutex>, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 98f8a9aa847..0dc6cbd5039 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -358,23 +358,22 @@ pub async fn validate_admin_event( let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; - // PUT_USER: open channels allow any authenticated user. Private - // channels only let owners/admins add another identity; otherwise - // any compromised member could extend access to channel history. - // - // A self-targeted add skips this check so an idempotent re-add - // still works. That is not a way into a private channel: ingest's - // `check_channel_membership` rejects a non-member (and a - // soft-removed member) before this validator runs, and `add_member` - // independently requires the self-inviter to hold an active role. - // Self-promotion is caught by the role-change guard below. - if channel.visibility == "private" - && target_pubkey != actor_bytes - && !actor_role.is_some_and(|r| r.is_elevated()) - { - return Err(anyhow::anyhow!( - "only owners/admins may add private-channel members" - )); + // PUT_USER: open channels allow any authenticated user; private channels + // require the actor to be an existing active member. Any active member may + // add an ordinary member, guest, or bot, but only owners/admins may grant + // an elevated role. + if channel.visibility == "private" { + if actor_role.is_none() { + return Err(anyhow::anyhow!("actor not authorized")); + } + + if requested_role.is_some_and(|role| role.is_elevated()) + && !actor_role.is_some_and(|role| role.is_elevated()) + { + return Err(anyhow::anyhow!( + "only owners/admins may grant elevated roles" + )); + } } // Changing an ACTIVE existing member's role is privileged in both @@ -1034,6 +1033,18 @@ async fn emit_addressable_discovery_event( Ok(()) } +fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Result> { + let mut tags: Vec = Vec::with_capacity(members.len() + 1); + tags.push(Tag::parse(["d", group_id])?); + for member in members { + let pubkey_hex = hex::encode(&member.pubkey); + // NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url + // because the canonical relay is implicit (this event is signed by it). + tags.push(Tag::parse(["p", &pubkey_hex, "", &member.role])?); + } + Ok(tags) +} + /// Emit NIP-29 group discovery events (39000, 39001, 39002) signed by the relay keypair. /// Called after group creation, metadata changes, or membership changes. /// Events are stored channel-scoped (`channel_id = Some(...)`) so that existing @@ -1137,13 +1148,7 @@ pub async fn emit_group_discovery_events( } { - let mut tags: Vec = vec![Tag::parse(["d", &group_id])?]; - for m in &members { - let pubkey_hex = hex::encode(&m.pubkey); - // NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url - // because the canonical relay is implicit (this event is signed by it). - tags.push(Tag::parse(["p", &pubkey_hex, "", &m.role])?); - } + let tags = group_members_tags(&group_id, &members)?; emit_addressable_discovery_event( tenant, state, @@ -3373,6 +3378,33 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { mod tests { use super::*; + #[test] + fn group_members_snapshot_keeps_members_past_one_thousand() { + let channel_id = Uuid::new_v4(); + let members: Vec = (0_u16..1_501) + .map(|index| MemberRecord { + channel_id, + pubkey: vec![(index >> 8) as u8, index as u8], + role: if index == 1_500 { "owner" } else { "member" }.to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }) + .collect(); + + let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags"); + assert_eq!(tags.len(), 1_502, "d tag plus every member p tag"); + + let late_pubkey = hex::encode(&members[1_500].pubkey); + assert!(tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.len() == 4 + && fields[0] == "p" + && fields[1] == late_pubkey + && fields[3] == "owner" + })); + } + #[test] fn delete_tombstone_omits_absent_moderation_metadata() { let content = diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 34dc2dfcf80..3584e1849d1 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -201,6 +201,12 @@ async fn main() -> anyhow::Result<()> { error!("Failed to ensure partitions: {e}"); } + db.validate_deletion_serving_catalog().await.map_err(|e| { + error!("Community deletion serving-fence validation failed: {e}"); + anyhow::anyhow!("Community deletion serving fence is unsafe: {e}") + })?; + info!("Community deletion serving fences verified"); + // Freshness fence probe: cursor pages route to the replica only for // history the probe has verified as fully replayed. Deliberately AFTER // the migration decision: spawn_fence_probe first verifies the @@ -469,6 +475,7 @@ async fn main() -> anyhow::Result<()> { if let Some(handle) = buzz_relay::mesh_boot::boot_mesh( &state.config, state.redis_pool.clone(), + state.db.clone(), &state.relay_keypair, Arc::clone(&state.shutting_down), ) @@ -1018,6 +1025,24 @@ async fn main() -> anyhow::Result<()> { metrics::gauge!("buzz_redis_pool_size").set(rs.size as f64); metrics::gauge!("buzz_redis_pool_max").set(rs.max_size as f64); metrics::gauge!("buzz_redis_pool_waiting").set(rs.waiting as f64); + + let deletion_store = pool_state.db.deletion_store(); + match deletion_store.reap_expired_serving_write_leases(1000).await { + Ok(reaped) => metrics::counter!("buzz_deletion_serving_leases_reaped_total") + .increment(reaped), + Err(error) => tracing::warn!(%error, "serving-lease reaper failed"), + } + match deletion_store.serving_lease_stats().await { + Ok(stats) => { + metrics::gauge!("buzz_deletion_serving_leases_active") + .set(stats.active as f64); + metrics::gauge!("buzz_deletion_serving_leases_expired") + .set(stats.expired as f64); + metrics::gauge!("buzz_deletion_serving_leases_dead_tuples") + .set(stats.dead_tuples as f64); + } + Err(error) => tracing::warn!(%error, "serving-lease metrics failed"), + } } }); } diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index 20e550aa08a..cd7c427c72e 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -411,6 +411,7 @@ fn advertise_addrs(endpoint: &MeshEndpoint) -> Vec { pub async fn boot_mesh( config: &Config, redis_pool: deadpool_redis::Pool, + db: buzz_db::Db, relay_keypair: &nostr::Keys, shutting_down: Arc, ) -> anyhow::Result> { @@ -508,7 +509,7 @@ pub async fn boot_mesh( transport.set_inbound(Box::new(dispatcher.clone())); Ok(Some(MeshHandle { - directory: SessionDirectory::new(redis_pool), + directory: SessionDirectory::with_db(redis_pool, db), transport, membership: membership_arc, local_runtime_id: runtime_id, @@ -535,7 +536,13 @@ mod tests { .create_pool(Some(deadpool_redis::Runtime::Tokio1)) .unwrap(); let keys = nostr::Keys::generate(); - let handle = boot_mesh(&config, pool, &keys, Arc::new(AtomicBool::new(false))) + let db = buzz_db::Db::from_pool( + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://unused:unused@127.0.0.1:1/unused") + .expect("lazy database pool"), + ); + let handle = boot_mesh(&config, pool, db, &keys, Arc::new(AtomicBool::new(false))) .await .expect("off path is never an error"); assert!(handle.is_none()); diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 49845067eac..4946b248c65 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -418,6 +418,23 @@ async fn deliver_one( return; } }; + let serving_write = match buzz_deletion::acquire_serving_write( + &state.db, + outcome.community, + "push_delivery", + ) + .await + { + Ok(guard) => guard, + Err(error) => { + warn!(wake=%outcome.id, %error, "push delivery suppressed by community deletion fence"); + let _ = state + .db + .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await; + return; + } + }; let Some(url) = state.config.push_gateway_delivery_url.as_ref() else { return; }; @@ -429,7 +446,20 @@ async fn deliver_one( return; } }; - let response = send_gateway_request(http, url, body, auth).await; + if let Err(error) = serving_write.verify().await { + warn!(wake=%outcome.id, %error, "push serving lease lost before delivery"); + return; + } + let response = match serving_write + .protect(send_gateway_request(http, url, body, auth)) + .await + { + Ok(response) => response, + Err(error) => { + warn!(wake=%outcome.id, %error, "push serving lease lost during delivery"); + return; + } + }; match response { Ok(r) if r.status().is_success() => match r.json::().await { Ok(DeliveryResponse::Accepted) => { @@ -502,6 +532,9 @@ async fn deliver_one( .await; } } + if let Err(error) = serving_write.finish().await { + warn!(wake=%outcome.id, %error, "failed to release community serving lease after push delivery"); + } } fn delivery_body(endpoint_grant: &str, request_id: uuid::Uuid, expires_at: i64) -> Vec { diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe34..1dce66e91e4 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -72,6 +72,14 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + .route( + "/workflows/{workflow_id}/runs", + get(api::workflows::workflow_runs), + ) + .route( + "/workflows/{workflow_id}/runs/{run_id}/approvals", + get(api::workflows::run_approvals), + ) .route( "/operator/communities", get(api::operator::list_owned_communities).post(api::operator::provision_community), @@ -376,22 +384,30 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo } let check = async { - let (pg_ok, redis_ok) = tokio::join!(state.db.ping(), async { - state.redis_pool.get().await.is_ok() - },); - (pg_ok, redis_ok) + let (pg_ok, redis_ok, deletion_catalog_ok) = tokio::join!( + state.db.ping(), + async { state.redis_pool.get().await.is_ok() }, + async { state.db.validate_deletion_serving_catalog().await.is_ok() }, + ); + (pg_ok, redis_ok, deletion_catalog_ok) }; - let (pg_ok, redis_ok) = tokio::time::timeout(Duration::from_secs(2), check) - .await - .unwrap_or((false, false)); + let (pg_ok, redis_ok, deletion_catalog_ok) = + tokio::time::timeout(Duration::from_secs(2), check) + .await + .unwrap_or((false, false, false)); - if pg_ok && redis_ok { + if pg_ok && redis_ok && deletion_catalog_ok { (StatusCode::OK, Json(json!({"status": "ready"}))).into_response() } else { ( StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"status": "not_ready", "postgres": pg_ok, "redis": redis_ok})), + Json(json!({ + "status": "not_ready", + "postgres": pg_ok, + "redis": redis_ok, + "deletion_catalog": deletion_catalog_ok + })), ) .into_response() } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 14a50df7b77..2f544e188c0 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -10,8 +10,7 @@ use axum::body::Bytes; use axum::extract::ws::{Message as WsMessage, Utf8Bytes as WsUtf8Bytes}; use dashmap::DashMap; use futures_util::future::join_all; -use tokio::sync::mpsc; -use tokio::sync::Semaphore; +use tokio::sync::{mpsc, watch, Semaphore}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -37,6 +36,55 @@ use crate::subscription::SubscriptionRegistry; pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); +/// Why a community-bound socket is being asked to stop. +/// +/// Only deletion is externally attributed today. Ordinary lifecycle exits keep +/// using cancellation alone and therefore retain the existing bare-close +/// behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommunityDisconnectReason { + CommunityDeleted, +} + +impl CommunityDisconnectReason { + pub(crate) fn close_message(self) -> WsMessage { + match self { + Self::CommunityDeleted => WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: WsUtf8Bytes::from_static("community deleted"), + })), + } + } +} + +/// Per-socket lifecycle controls shared by the registry and the writer. +#[derive(Clone)] +pub(crate) struct CommunityConnectionControl { + cancel: CancellationToken, + reason_tx: watch::Sender>, +} + +impl CommunityConnectionControl { + pub(crate) fn new(cancel: CancellationToken) -> Self { + let (reason_tx, _reason_rx) = watch::channel(None); + Self { cancel, reason_tx } + } + + pub(crate) fn cancellation_token(&self) -> CancellationToken { + self.cancel.clone() + } + + pub(crate) fn disconnect_reason(&self) -> watch::Receiver> { + self.reason_tx.subscribe() + } + + fn disconnect_community(&self) { + self.reason_tx + .send_replace(Some(CommunityDisconnectReason::CommunityDeleted)); + self.cancel.cancel(); + } +} + /// Leaves headroom under the process-wide drain deadline for a stalled writer. const RESTART_CLOSE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); type SlidingWindowCounter = (u32, Instant); @@ -68,7 +116,7 @@ struct ConnEntry { /// registration cancels the token; archival before registration is observed by /// the revalidation. The returned guard removes the entry on every exit path. pub struct CommunityConnectionRegistry { - connections: Arc>, + connections: Arc>, } impl Default for CommunityConnectionRegistry { @@ -86,26 +134,27 @@ impl CommunityConnectionRegistry { } /// Registers one socket and returns a guard that deregisters it on drop. - pub fn register( + pub(crate) fn register( &self, connection_id: Uuid, community_id: CommunityId, - cancel: CancellationToken, + control: CommunityConnectionControl, ) -> CommunityConnectionGuard { self.connections - .insert(connection_id, (community_id, cancel)); + .insert(connection_id, (community_id, control)); CommunityConnectionGuard { connection_id, connections: Arc::clone(&self.connections), } } - /// Cancels every socket type currently bound to `community_id`. + /// Disconnects every socket type currently bound to `community_id` and + /// attributes the close to community deletion. pub fn disconnect_community(&self, community_id: CommunityId) -> usize { let mut closed = 0; for entry in self.connections.iter() { if entry.value().0 == community_id { - entry.value().1.cancel(); + entry.value().1.disconnect_community(); closed += 1; } } @@ -124,7 +173,7 @@ impl CommunityConnectionRegistry { /// Removes a socket lifecycle registration on every handler exit path. pub struct CommunityConnectionGuard { connection_id: Uuid, - connections: Arc>, + connections: Arc>, } impl Drop for CommunityConnectionGuard { @@ -137,20 +186,21 @@ impl Drop for CommunityConnectionGuard { /// /// The ordering is the archival admission invariant: archive-before-query is /// observed by the query, while archive-after-registration sees the token. -pub async fn run_registered_community_connection( +pub(crate) async fn run_registered_community_connection( registry: &CommunityConnectionRegistry, connection_id: Uuid, community_id: CommunityId, - cancel: CancellationToken, + control: CommunityConnectionControl, check_active: Check, run: Run, ) where Check: FnOnce() -> CheckFuture, CheckFuture: Future>, - Run: FnOnce() -> RunFuture, + Run: FnOnce(CommunityConnectionControl) -> RunFuture, RunFuture: Future, { - let _guard = registry.register(connection_id, community_id, cancel.clone()); + let cancel = control.cancel.clone(); + let _guard = registry.register(connection_id, community_id, control.clone()); if !matches!(check_active().await, Ok(true)) { cancel.cancel(); return; @@ -158,7 +208,7 @@ pub async fn run_registered_community_connection, } /// Active session ownership lease read from Redis. @@ -179,6 +180,9 @@ pub enum DirectoryError { /// Lease TTL cannot be represented in Redis milliseconds. #[error("lease ttl must be at least 1ms and fit in i64 milliseconds")] InvalidLeaseTtl, + /// Durable community deletion fence rejected a Redis mutation. + #[error("community write fenced: {0}")] + CommunityWriteFenced(String), } impl SessionDirectory { @@ -187,9 +191,36 @@ impl SessionDirectory { Self::with_lease_ttl(pool, DEFAULT_LEASE_TTL) } + /// Create a serving directory whose Redis mutations use durable, + /// heartbeat-backed community write leases. + pub fn with_db(pool: deadpool_redis::Pool, db: buzz_db::Db) -> Self { + Self { + pool, + lease_ttl: DEFAULT_LEASE_TTL, + db: Some(db), + } + } + /// Create a directory backed by `pool` with an explicit lease TTL. pub fn with_lease_ttl(pool: deadpool_redis::Pool, lease_ttl: Duration) -> Self { - Self { pool, lease_ttl } + Self { + pool, + lease_ttl, + db: None, + } + } + + async fn begin_serving_write( + &self, + community_id: CommunityId, + ) -> Result, DirectoryError> { + match &self.db { + Some(db) => buzz_deletion::acquire_serving_write(db, community_id, "session_directory") + .await + .map(Some) + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string())), + None => Ok(None), + } } /// Attempt to create/take over the session lease. @@ -204,10 +235,11 @@ impl SessionDirectory { owner_runtime_id: RuntimeId, profile: Profile, ) -> Result { + let serving_write = self.begin_serving_write(community_id).await?; let keys = SessionKeys::new(community_id, session_id); let ttl_ms = ttl_ms(self.lease_ttl)?; let mut conn = self.pool.get().await?; - let (status, value, _known_generation): (String, String, String) = + let mutation = async { Script::new(ACQUIRE_SCRIPT) .key(&keys.lease) .key(&keys.generation) @@ -215,8 +247,22 @@ impl SessionDirectory { .arg(profile.as_wire_str()) .arg(ttl_ms) .invoke_async(&mut *conn) - .await?; + .await + }; + let (status, value, _known_generation): (String, String, String) = match &serving_write { + Some(guard) => guard + .protect(mutation) + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))??, + None => mutation.await?, + }; let lease = parse_lease(community_id, session_id, &value)?; + if let Some(guard) = serving_write { + guard + .finish() + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))?; + } match status.as_str() { "acquired" => Ok(AcquireResult::Acquired(lease)), "exists" => Ok(AcquireResult::Exists(lease)), @@ -244,18 +290,34 @@ impl SessionDirectory { /// Renew a lease only if the current Redis value exactly matches the /// caller's owner runtime and generation. pub async fn renew(&self, lease: &SessionLease) -> Result { + let serving_write = self.begin_serving_write(lease.community_id).await?; let keys = SessionKeys::new(lease.community_id, lease.session_id); let ttl_ms = ttl_ms(self.lease_ttl)?; let mut conn = self.pool.get().await?; - let (status, value, known_generation): (String, String, String) = Script::new(RENEW_SCRIPT) - .key(&keys.lease) - .key(&keys.generation) - .arg(lease.owner_runtime_id.to_hex()) - .arg(lease.generation) - .arg(ttl_ms) - .invoke_async(&mut *conn) - .await?; + let mutation = async { + Script::new(RENEW_SCRIPT) + .key(&keys.lease) + .key(&keys.generation) + .arg(lease.owner_runtime_id.to_hex()) + .arg(lease.generation) + .arg(ttl_ms) + .invoke_async(&mut *conn) + .await + }; + let (status, value, known_generation): (String, String, String) = match &serving_write { + Some(guard) => guard + .protect(mutation) + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))??, + None => mutation.await?, + }; let current = parse_optional_lease(lease.community_id, lease.session_id, &value)?; + if let Some(guard) = serving_write { + guard + .finish() + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))?; + } match status.as_str() { "renewed" => Ok(RenewResult::Renewed( current.expect("renewed returns lease"), @@ -275,17 +337,32 @@ impl SessionDirectory { /// Release a lease only if the current Redis value exactly matches the /// caller's owner runtime and generation. pub async fn release(&self, lease: &SessionLease) -> Result { + let serving_write = self.begin_serving_write(lease.community_id).await?; let keys = SessionKeys::new(lease.community_id, lease.session_id); let mut conn = self.pool.get().await?; - let (status, value, known_generation): (String, String, String) = + let mutation = async { Script::new(RELEASE_SCRIPT) .key(&keys.lease) .key(&keys.generation) .arg(lease.owner_runtime_id.to_hex()) .arg(lease.generation) .invoke_async(&mut *conn) - .await?; + .await + }; + let (status, value, known_generation): (String, String, String) = match &serving_write { + Some(guard) => guard + .protect(mutation) + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))??, + None => mutation.await?, + }; let current = parse_optional_lease(lease.community_id, lease.session_id, &value)?; + if let Some(guard) = serving_write { + guard + .finish() + .await + .map_err(|error| DirectoryError::CommunityWriteFenced(error.to_string()))?; + } match status.as_str() { "released" => Ok(ReleaseResult::Released( current.expect("released returns lease"), diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 948fa775f51..30311ddcf46 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1121,6 +1121,131 @@ pub fn build_git_issue( Ok(EventBuilder::new(Kind::Custom(KIND_GIT_ISSUE as u16), content).tags(tags)) } +/// Build an issue assignment note (kind:1) — a labeled comment whose `p` +/// tags are the assignees, mirroring the Desktop app's assignment events. +/// +/// Tag layout: `["e", , "", "root"]`, `["a", ]`, one `["p", ..]` +/// per assignee, and `["t", "assignment"]`. +/// +/// Clients only trust assignments signed by the issue author or the repo +/// owner (who may assign anyone), or a self-assignment whose sole assignee +/// is the signer. Assignments from other signers are ignored on read. +pub fn build_git_issue_assignment( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, +) -> Result { + build_git_issue_assignment_with_prior(repo, issue_id, assignees, content, None) +} + +/// Build an issue assignment note with an optional causal assignment-operation +/// event ID in a `["prior", ]` tag. +/// +/// `prior`, when present, must be a 64-character hexadecimal event ID. +pub fn build_git_issue_assignment_with_prior( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + prior: Option<&str>, +) -> Result { + build_git_issue_assignee_operation( + repo, + issue_id, + assignees, + content, + GitIssueAssigneeOperation::Assign, + prior, + ) +} + +/// Build an issue unassignment note (kind:1) whose `p` tags name the people +/// being removed and whose operation label is `t: unassignment`. +/// +/// Clients trust unassignments signed by the issue author or repository owner, +/// or a self-unassignment whose sole `p` tag is the signer. +pub fn build_git_issue_unassignment( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, +) -> Result { + build_git_issue_unassignment_with_prior(repo, issue_id, assignees, content, None) +} + +/// Build an issue unassignment note with an optional causal +/// assignment-operation event ID in a `["prior", ]` tag. +/// +/// `prior`, when present, must be a 64-character hexadecimal event ID. +pub fn build_git_issue_unassignment_with_prior( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + prior: Option<&str>, +) -> Result { + build_git_issue_assignee_operation( + repo, + issue_id, + assignees, + content, + GitIssueAssigneeOperation::Unassign, + prior, + ) +} + +#[derive(Clone, Copy)] +enum GitIssueAssigneeOperation { + Assign, + Unassign, +} + +impl GitIssueAssigneeOperation { + fn label(self) -> &'static str { + match self { + Self::Assign => "assignment", + Self::Unassign => "unassignment", + } + } +} + +fn build_git_issue_assignee_operation( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + operation: GitIssueAssigneeOperation, + prior: Option<&str>, +) -> Result { + check_content(content, 64 * 1024)?; + let issue = check_hex_exact(issue_id, 64, "issue")?; + let a_value = repo.to_a_tag_value()?; + if assignees.is_empty() || assignees.len() > 50 { + return Err(SdkError::InvalidInput( + "between 1 and 50 assignees are required".into(), + )); + } + let mut normalized = assignees + .iter() + .map(|assignee| check_pubkey_hex(assignee, "assignee")) + .collect::, _>>()?; + normalized.sort(); + normalized.dedup(); + + let mut tags = vec![tag(&["e", &issue, "", "root"])?, tag(&["a", &a_value])?]; + for assignee in &normalized { + tags.push(tag(&["p", assignee])?); + } + tags.push(tag(&["t", operation.label()])?); + if let Some(prior) = prior { + let prior = check_hex_exact(prior, 64, "prior assignment operation")?; + tags.push(tag(&["prior", &prior])?); + } + + Ok(EventBuilder::new(Kind::Custom(1), content).tags(tags)) +} + /// Status to apply to a patch or issue root (kind:1630/1631/1632/1633, NIP-34). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GitStatus { @@ -3481,6 +3606,149 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + #[test] + fn git_issue_assignment_happy_path() { + let owner = "a".repeat(64); + let repo = GitRepoCoord { + owner: owner.clone(), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + // Duplicates (case-insensitive) collapse to a single p tag. + let assignees = vec!["C".repeat(64), "c".repeat(64), "d".repeat(64)]; + let ev = sign( + build_git_issue_assignment(&repo, &issue, &assignees, "Assigned this issue to Thomas") + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 1); + assert_eq!(ev.content, "Assigned this issue to Thomas"); + assert!(has_tag(&ev, "e", &issue)); + assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo"))); + assert!(has_tag(&ev, "p", &"c".repeat(64))); + assert!(has_tag(&ev, "p", &"d".repeat(64))); + assert!(has_tag(&ev, "t", "assignment")); + let p_count = ev + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("p")) + .count(); + assert_eq!(p_count, 2); + } + + #[test] + fn git_issue_assignment_rejects_bad_input() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + // No assignees. + let err = build_git_issue_assignment(&repo, &issue, &[], "x").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + // Malformed assignee pubkey. + let err = + build_git_issue_assignment(&repo, &issue, &["nope".to_string()], "x").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + // Malformed issue id. + let err = build_git_issue_assignment(&repo, "short", &["c".repeat(64)], "x").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn git_issue_assignment_with_prior_emits_valid_causal_tag() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignee = "c".repeat(64); + let prior = "d".repeat(64); + let ev = sign( + build_git_issue_assignment_with_prior( + &repo, + &issue, + &[assignee], + "Assigned this issue", + Some(&prior), + ) + .unwrap(), + ); + + assert!(has_tag(&ev, "prior", &prior)); + let unassignment = sign( + build_git_issue_unassignment_with_prior( + &repo, + &issue, + &["c".repeat(64)], + "Unassigned this issue", + Some(&prior), + ) + .unwrap(), + ); + assert!(has_tag(&unassignment, "prior", &prior)); + assert!(build_git_issue_assignment_with_prior( + &repo, + &issue, + &["c".repeat(64)], + "Assigned this issue", + Some("invalid"), + ) + .is_err()); + } + + #[test] + fn git_issue_unassignment_happy_path() { + let owner = "a".repeat(64); + let repo = GitRepoCoord { + owner: owner.clone(), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignee = "c".repeat(64); + let ev = sign( + build_git_issue_unassignment( + &repo, + &issue, + std::slice::from_ref(&assignee), + "Unassigned Thomas from this issue", + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 1); + assert_eq!(ev.content, "Unassigned Thomas from this issue"); + assert!(has_tag(&ev, "e", &issue)); + assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo"))); + assert!(has_tag(&ev, "p", &assignee)); + assert!(has_tag(&ev, "t", "unassignment")); + assert!(!has_tag(&ev, "t", "assignment")); + } + + #[test] + fn legacy_issue_assignment_builders_omit_prior() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignees = vec!["c".repeat(64)]; + let assignment = sign( + build_git_issue_assignment(&repo, &issue, &assignees, "Assigned this issue").unwrap(), + ); + let unassignment = sign( + build_git_issue_unassignment(&repo, &issue, &assignees, "Unassigned this issue") + .unwrap(), + ); + + assert!(!assignment + .tags + .iter() + .any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") })); + assert!(!unassignment + .tags + .iter() + .any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") })); + } + #[test] fn git_status_open_happy_path() { let root = event_id().to_hex(); diff --git a/crates/buzz-search/Cargo.toml b/crates/buzz-search/Cargo.toml index e42bcc8041b..e28c5b68409 100644 --- a/crates/buzz-search/Cargo.toml +++ b/crates/buzz-search/Cargo.toml @@ -9,9 +9,11 @@ description = "Postgres full-text search for Buzz, scoped by community" [dependencies] buzz-core = { workspace = true } +buzz-datastore-tracing = { workspace = true } sqlx = { workspace = true } uuid = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index 7f33b660c43..bd95e8cdbbc 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -8,10 +8,12 @@ //! //! See conformance row 50. -use buzz_core::CommunityId; use sqlx::{PgPool, QueryBuilder, Row}; use uuid::Uuid; +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; + use crate::error::SearchError; /// Channel-scope filter for a community-scoped FTS query. @@ -213,6 +215,7 @@ fn normalized_search_text(q: &str) -> Option { /// /// `community_id = $ctx` is the first predicate and is non-negotiable. There /// is no code path through this function that omits it. +#[datastore_span(name = "search", system = "postgresql")] pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result { let Some(search_text) = normalized_search_text(&query.q) else { return Ok(SearchResult { @@ -229,6 +232,14 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result = QueryBuilder::new( "SELECT id, kind, pubkey, channel_id, \ @@ -292,7 +303,13 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result"; + let resp = upload(&client, &keys, html).await; + let status = resp.status().as_u16(); + assert_eq!( + status, 200, + "HTML should upload via file path, got {status}" + ); + let desc: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(desc["type"].as_str().unwrap(), "text/html"); + let url = desc["url"].as_str().unwrap(); + assert!( + url.ends_with(".html"), + "served URL must carry the .html extension, got {url}" + ); + let sha256 = desc["sha256"].as_str().unwrap(); + + let get_resp = client + .get(url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) + .send() + .await + .expect("GET request"); + assert_eq!(get_resp.status(), 200, "HTML GET roundtrip should succeed"); + + let header = |name: &str| { + get_resp + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + assert_eq!(header("content-type"), "text/html"); + assert_eq!( + header("content-disposition"), + "attachment", + "HTML must be forced to download, never rendered inline" + ); + assert_eq!( + header("x-content-type-options"), + "nosniff", + "nosniff must prevent MIME re-sniffing to an executable type" + ); + assert_eq!( + header("content-security-policy"), + "default-src 'none'", + "restrictive CSP must neutralise any active content" + ); + println!("✅ HTML → 200, served as inert attachment (disposition+nosniff+CSP)"); +} + #[tokio::test] #[ignore] async fn test_upload_pdf_accepted() { diff --git a/crates/buzz-test-client/tests/e2e_mesh_llm.rs b/crates/buzz-test-client/tests/e2e_mesh_llm.rs index 4b1bfb3d213..21a8ec29d79 100644 --- a/crates/buzz-test-client/tests/e2e_mesh_llm.rs +++ b/crates/buzz-test-client/tests/e2e_mesh_llm.rs @@ -11,7 +11,9 @@ //! # Running (manual / runbook) //! //! ```text -//! # 1. prepare the matching native runtime with `scripts/ensure-mesh-native-runtime.sh` +//! # 1. the mesh-enabled desktop installs the signed native runtime itself on +//! # first init; stale pre-0.75 cache entries are skipped rather than +//! # fatal (MeshLLM >= 0.75.1), so no manual cleanup is needed //! # 2. start the normal membership-gated relay and a mesh-enabled desktop //! # 3. have that desktop publish status, then run the trust assertions: //! RELAY_URL=ws://localhost:3000 \ diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 5b9a50b5b17..5d5ad8916c3 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -120,7 +120,12 @@ async fn seed_relay_member(host: &str, keys: &Keys, role: &str) { } async fn seed_relay_owner(keys: &Keys) { - seed_relay_member("localhost:3000", keys, "owner").await; + seed_relay_member(&relay_authority(), keys, "owner").await; +} + +fn relay_authority() -> String { + let url = url::Url::parse(&relay_http_url()).expect("relay HTTP URL"); + url[url::Position::BeforeHost..url::Position::AfterPort].to_string() } fn http_origin_for_host(host: &str) -> String { @@ -315,7 +320,7 @@ async fn test_invite_claim_rejects_invalid_code() { #[ignore] async fn test_invite_mint_requires_owner_or_admin() { let member = Keys::generate(); - seed_relay_member("localhost:3000", &member, "member").await; + seed_relay_member(&relay_authority(), &member, "member").await; let response = invite_post(&member, "/api/invites", "{}").await; assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); @@ -791,10 +796,10 @@ async fn test_auth_event_kind_rejected() { /// NIP-11 max_subscriptions must be enforced; (limit+1)th REQ gets CLOSED. /// -/// The relay's MAX_SUBSCRIPTIONS is 1024. Opening 1024 subs in a test is slow, -/// so we open a smaller batch and verify the NIP-11 advertised limit matches -/// the actual enforcement constant. The full-limit test is covered by the -/// NIP-11 assertion below (which verifies the advertised value is 1024). +/// This is a protocol-cap test, not an admission-throughput test. Open one REQ +/// at a time and wait out any shared fixed-window quota before retrying a REQ +/// rejected specifically as `rate-limited`, so production admission remains +/// enabled while the test deterministically reaches the independent 1024 cap. #[tokio::test] #[ignore] async fn test_subscription_limit_enforced() { @@ -802,60 +807,75 @@ async fn test_subscription_limit_enforced() { let keys = Keys::generate(); let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); - // Open 1024 subscriptions (the relay's MAX_SUBSCRIPTIONS). for i in 0..1024 { let sid = format!("limit-sub-{i}"); - let filter = Filter::new().kind(Kind::Custom(9)); - client - .subscribe(&sid, vec![filter]) - .await - .expect("subscribe"); - // Drain EOSE to avoid buffer buildup. - client - .collect_until_eose(&sid, Duration::from_secs(5)) - .await - .expect("EOSE"); + let filter = Filter::new().kind(Kind::Custom(49_999)); + subscribe_until_eose(&mut client, &sid, filter).await; } let overflow_sid = sub_id("overflow"); - // Use a kind that no other test writes, so we don't receive stale events. - let filter = Filter::new().kind(Kind::Custom(49999)); - client - .subscribe(&overflow_sid, vec![filter]) - .await - .expect("send REQ"); - - // Drain EOSE and stale events from the 100 earlier subscriptions - // until we receive the CLOSED for the overflow subscription. - let msg = loop { - let m = client - .recv_event(Duration::from_secs(5)) + let filter = Filter::new().kind(Kind::Custom(49_999)); + loop { + client + .subscribe(&overflow_sid, vec![filter.clone()]) .await - .expect("recv CLOSED (or timeout)"); - match &m { - RelayMessage::Eose { .. } => continue, - RelayMessage::Event { .. } => continue, // stale event from earlier subs - _ => break m, - } - }; + .expect("send overflow REQ"); - match msg { - RelayMessage::Closed { - subscription_id, - message, - } => { - assert_eq!(subscription_id, overflow_sid); - assert!( - message.to_lowercase().contains("too many"), - "Expected 'too many' in CLOSED message, got: {message}" - ); + match client + .recv_event(Duration::from_secs(6)) + .await + .expect("recv overflow CLOSED") + { + RelayMessage::Closed { + subscription_id, + message, + } if subscription_id == overflow_sid && message.starts_with("rate-limited:") => { + tokio::time::sleep(Duration::from_secs(5)).await; + } + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, overflow_sid); + assert!( + message.to_lowercase().contains("too many"), + "Expected 'too many' in CLOSED message, got: {message}" + ); + break; + } + other => panic!("Expected CLOSED for overflow subscription, got {other:?}"), } - other => panic!("Expected CLOSED for overflow subscription, got {other:?}"), } client.disconnect().await.expect("disconnect"); } +async fn subscribe_until_eose(client: &mut BuzzTestClient, sid: &str, filter: Filter) { + loop { + client + .subscribe(sid, vec![filter.clone()]) + .await + .expect("subscribe"); + match client + .recv_event(Duration::from_secs(6)) + .await + .expect("EOSE or rate-limit CLOSED") + { + RelayMessage::Eose { subscription_id } => { + assert_eq!(subscription_id, sid); + return; + } + RelayMessage::Closed { + subscription_id, + message, + } if subscription_id == sid && message.starts_with("rate-limited:") => { + tokio::time::sleep(Duration::from_secs(5)).await; + } + other => panic!("unexpected response while opening {sid}: {other:?}"), + } + } +} + #[tokio::test] #[ignore] async fn test_nip11_relay_info() { @@ -2249,14 +2269,17 @@ async fn add_member_with_role_ws( (ok.accepted, ok.message) } -/// Only owners/admins can add another identity to a private channel. +/// Any active member can add any ordinary role to a private channel. #[tokio::test] #[ignore] -async fn test_private_channel_member_cannot_invite() { +async fn test_private_channel_any_member_can_invite() { let url = relay_url(); let owner_keys = Keys::generate(); - let member_keys = Keys::generate(); - let invitee_keys = Keys::generate(); + let actors = [ + ("member", Keys::generate()), + ("guest", Keys::generate()), + ("bot", Keys::generate()), + ]; // Connect as owner and create a private channel. let mut owner_client = BuzzTestClient::connect(&url, &owner_keys) @@ -2264,54 +2287,70 @@ async fn test_private_channel_member_cannot_invite() { .expect("connect as owner"); let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await; - // Owner adds member_keys as a regular member. - let (accepted, msg) = add_member_ws( - &mut owner_client, - &channel_id, - &member_keys.public_key().to_hex(), - &owner_keys, - ) - .await; - assert!(accepted, "owner should add member, got: {msg}"); + // Seed one actor for each ordinary active role. + for (role, keys) in &actors { + let (accepted, msg) = add_member_with_role_ws( + &mut owner_client, + &channel_id, + &keys.public_key().to_hex(), + role, + &owner_keys, + ) + .await; + assert!(accepted, "owner should add {role} actor, got: {msg}"); + } - // Connect as the regular member. - let mut member_client = BuzzTestClient::connect(&url, &member_keys) - .await - .expect("connect as member"); + // Exercise the full ordinary-role target matrix. Relay and DB authorization + // both run here, unlike the Desktop/mobile policy-unit-test mirrors. + for (actor_role, actor_keys) in &actors { + let mut actor_client = BuzzTestClient::connect(&url, actor_keys) + .await + .unwrap_or_else(|err| panic!("connect as {actor_role}: {err}")); + + for target_role in ["member", "guest", "bot"] { + let target_keys = Keys::generate(); + let target_pubkey_hex = target_keys.public_key().to_hex(); + let (accepted, msg) = add_member_with_role_ws( + &mut actor_client, + &channel_id, + &target_pubkey_hex, + target_role, + actor_keys, + ) + .await; + assert!( + accepted, + "private-channel {actor_role} should add {target_role}, got: {msg}" + ); + assert_eq!( + member_role(&url, &owner_keys, &channel_id, &target_pubkey_hex).await, + Some(target_role.to_string()), + "private-channel {actor_role} add must persist the {target_role} role" + ); + } - // Regular member tries to invite a third user. - let (accepted, msg) = add_member_ws( - &mut member_client, - &channel_id, - &invitee_keys.public_key().to_hex(), - &member_keys, - ) - .await; - assert!( - !accepted, - "regular member must not add another private-channel identity: {msg}" - ); - assert!( - msg.contains("owners/admins"), - "rejection should name the owner/admin requirement, got: {msg}" - ); + // Re-adding oneself stays idempotent — the huddle bot-add and kind:9021 + // paths depend on a self-targeted PUT_USER working. + let (accepted, msg) = add_member_with_role_ws( + &mut actor_client, + &channel_id, + &actor_keys.public_key().to_hex(), + actor_role, + actor_keys, + ) + .await; + assert!( + accepted, + "self-targeted {actor_role} re-add must stay idempotent, got: {msg}" + ); - // The same member re-adding *themselves* stays idempotent — the huddle - // bot-add and kind:9021 paths depend on a self-targeted PUT_USER working. - let (accepted, msg) = add_member_ws( - &mut member_client, - &channel_id, - &member_keys.public_key().to_hex(), - &member_keys, - ) - .await; - assert!( - accepted, - "self-targeted re-add must stay idempotent, got: {msg}" - ); + actor_client + .disconnect() + .await + .unwrap_or_else(|err| panic!("disconnect {actor_role}: {err}")); + } owner_client.disconnect().await.expect("disconnect owner"); - member_client.disconnect().await.expect("disconnect member"); } /// An admin — not just the owner — can still add to a private channel. diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs index 0c6174a8dcc..e23bf2a516c 100644 --- a/crates/buzz-voice/src/pocket.rs +++ b/crates/buzz-voice/src/pocket.rs @@ -40,6 +40,16 @@ pub const VOICE_FILE_EXT: &str = "wav"; const TTS_NUM_THREADS: usize = 1; +/// EXPERIMENTAL (latency): override ONNX intra-op threads for the Pocket +/// sessions via `BUZZ_TTS_THREADS`. Default preserves production's 1. +fn tts_num_threads() -> usize { + std::env::var("BUZZ_TTS_THREADS") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&n| n >= 1) + .unwrap_or(TTS_NUM_THREADS) +} + /// Loaded reference voice samples and their original sample rate. #[derive(Debug, Clone)] pub struct VoiceStyle { @@ -83,13 +93,13 @@ pub fn load_text_to_speech(model_dir: &str) -> Result { } } Ok(PocketTts { - inner: Mutex::new(AprilPocketTts::load(&dir, TTS_NUM_THREADS)?), + 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. + /// Split text into model-safe synthesis units that satisfy the bundle's + /// exact 50-token input limit, packing sentences whenever they fit. pub fn split_text_into_chunks(&self, text: &str) -> Result, String> { let Some(prepared) = prepare_april_prompt(text) else { return Ok(Vec::new()); @@ -100,6 +110,23 @@ impl PocketTts { .split_prompt(&prepared) } + /// Split text into ordered playback units, keeping the first sentence + /// separate so it reaches synthesis before the remainder is packed. + /// + /// Units are contiguous substrings of the prepared model prompt and may + /// retain boundary whitespace. Concatenating them with `chunks.concat()` + /// reconstructs that prompt exactly, and each unit's prepared token count + /// is at most 50. + pub fn split_text_for_playback(&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_playback_prompt(&prepared) + } + /// Synthesize text with the supplied reference voice. /// /// Pocket detects language from text and this model uses one synthesis @@ -127,6 +154,36 @@ impl PocketTts { } Ok(samples) } + + /// EXPERIMENTAL (latency): streaming synthesis. Invokes `on_audio` with + /// PCM deltas as soon as roughly `emit_frames` Flow LM frames (80 ms of + /// audio each) have been generated and decoded. Concatenated deltas equal + /// one `synth_chunk` result. The callback runs on the caller thread and + /// returns `false` to cancel; the function then returns Ok(false). + pub fn synth_chunk_streaming( + &self, + text: &str, + style: &VoiceStyle, + emit_frames: usize, + on_audio: &mut dyn FnMut(Vec) -> bool, + ) -> Result { + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(true); + }; + let mut engine = self + .inner + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())?; + let chunks = engine.split_prompt(&prepared)?; + for chunk in chunks { + let prepared = prepare_april_prompt(&chunk) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + if !engine.synth_chunk_streaming(&prepared, style, emit_frames, on_audio)? { + return Ok(false); + } + } + Ok(true) + } } #[cfg(test)] @@ -148,6 +205,92 @@ mod tests { .any(|artifact| artifact.filename == "flow_lm_main.onnx")); } + /// Which splitter each production function delegates to, across the whole + /// file rather than one hand-picked window. + /// + /// A wrong delegation can reinstate either shipped defect in one token: + /// removing first-sentence priority from playback, or re-isolating sentence + /// one inside units that already fit. Asserting the whole map means a new + /// delegation must be declared here to compile green. + fn splitter_delegations(source: &str) -> Vec<(String, Vec)> { + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(production, _)| production); + // Scan code only. Prose cannot call a splitter, but it can contain + // ` fn `, which would end a body early and hide a call after it, and it + // can name a splitter, which would report a call the code never makes. + let production: String = production + .lines() + .map(|line| line.split_once("//").map_or(line, |(code, _)| code)) + .collect::>() + .join("\n"); + let mut out = Vec::new(); + let mut rest = production.as_str(); + while let Some((_, after)) = rest.split_once(" fn ") { + let (name, body) = after + .split_once('(') + .expect("a function signature has an argument list"); + // End at this function's own closing brace, not at the next ` fn `: + // a body provably stops where its braces balance, so no later + // function's calls are attributed here and none of this one's are + // dropped. + let inner = body.split_once('{').map_or("", |(_, inner)| inner); + let mut depth = 1usize; + let body = inner + .char_indices() + .find(|&(_, ch)| { + depth = match ch { + '{' => depth + 1, + '}' => depth - 1, + _ => depth, + }; + depth == 0 + }) + .map_or(inner, |(end, _)| &inner[..end]); + let mut calls = Vec::new(); + // Check the isolating spelling first: ".split_prompt(" is a + // substring of neither, but a naive contains() on the shorter name + // would also match the longer one. + for _ in 0..body.matches(".split_playback_prompt(").count() { + calls.push("split_playback_prompt".to_string()); + } + let plain = body.matches(".split_prompt(").count(); + for _ in 0..plain { + calls.push("split_prompt".to_string()); + } + if !calls.is_empty() { + out.push((name.trim().to_string(), calls)); + } + rest = after; + } + out + } + + #[test] + fn every_production_splitter_delegation_is_declared() { + let source = include_str!("pocket.rs"); + let actual = splitter_delegations(source); + let expected: Vec<(String, Vec)> = vec![ + // Model units: pack sentences, never isolate. + ("split_text_into_chunks".into(), vec!["split_prompt".into()]), + // Playback units: isolate sentence one for time-to-first-audio. + ( + "split_text_for_playback".into(), + vec!["split_playback_prompt".into()], + ), + // Synthesis receives an already-packed unit: re-isolating here + // re-adds the per-sentence seam this PR removes. + ("synth_chunk".into(), vec!["split_prompt".into()]), + ("synth_chunk_streaming".into(), vec!["split_prompt".into()]), + ]; + assert_eq!( + actual, expected, + "a production function changed which splitter it calls (or a new \ + one appeared); isolating outside split_text_for_playback delays \ + first audio, packing inside it removes the guarantee" + ); + } + #[test] #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] fn production_api_emits_non_silent_april_int8_pcm() { diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs index 43826df5c99..9ace5001daa 100644 --- a/crates/buzz-voice/src/pocket_april.rs +++ b/crates/buzz-voice/src/pocket_april.rs @@ -36,6 +36,13 @@ const DECODER_CHUNK_FRAMES: usize = 12; const TOKENS_PER_SECOND_ESTIMATE: f32 = 3.0; const GENERATION_SECONDS_PADDING: f32 = 2.0; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TextBoundary { + Sentence, + Clause, + Word, +} + #[derive(Debug, Deserialize)] struct Bundle { schema_version: u32, @@ -89,13 +96,129 @@ struct StateValue { value: DynValue, } -struct CachedVoice { - samples_ptr: usize, +/// Stable identity for a reference voice: a content hash of the sample +/// buffer plus its length and rate. Buffer addresses are NOT part of the +/// key — voice switching clones and drops sample buffers, so the allocator +/// can hand a different voice the same address, and an address-based key +/// would then restore the previous voice's cached state. +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +struct VoiceKey { + content_hash: u64, samples_len: usize, sample_rate: i32, +} + +fn voice_key(style: &VoiceStyle) -> VoiceKey { + use std::hash::Hasher; + let mut hasher = std::hash::DefaultHasher::new(); + for sample in &style.samples { + hasher.write_u32(sample.to_bits()); + } + VoiceKey { + content_hash: hasher.finish(), + samples_len: style.samples.len(), + sample_rate: style.sample_rate, + } +} + +struct CachedVoice { + key: VoiceKey, embeddings: Vec, } +/// EXPERIMENTAL (latency): a dtype-tagged copy of one recurrent state tensor, +/// used to snapshot the Flow LM state right after voice conditioning so +/// subsequent chunks skip the ~160 ms `condition_voice` pass entirely. +enum SnapshotTensor { + F32(Vec, Vec), + I64(Vec, Vec), + Bool(Vec, Vec), +} + +struct CachedConditioning { + key: VoiceKey, + state: Vec<(StateSpec, SnapshotTensor)>, +} + +fn snapshot_state(state: &[StateValue]) -> Result, String> { + state + .iter() + .map(|value| { + let tensor = match value.spec.dtype { + StateDtype::Float32 => { + let (shape, data) = value + .value + .try_extract_tensor::() + .map_err(ort_error("snapshot f32 state"))?; + SnapshotTensor::F32(shape.to_vec(), data.to_vec()) + } + StateDtype::Int64 => { + let (shape, data) = value + .value + .try_extract_tensor::() + .map_err(ort_error("snapshot i64 state"))?; + SnapshotTensor::I64(shape.to_vec(), data.to_vec()) + } + StateDtype::Bool => { + let (shape, data) = value + .value + .try_extract_tensor::() + .map_err(ort_error("snapshot bool state"))?; + SnapshotTensor::Bool(shape.to_vec(), data.to_vec()) + } + }; + Ok((value.spec.clone(), tensor)) + }) + .collect() +} + +fn restore_state(snapshot: &[(StateSpec, SnapshotTensor)]) -> Result, String> { + snapshot + .iter() + .map(|(spec, tensor)| { + let value = match tensor { + SnapshotTensor::F32(shape, data) => { + if data.is_empty() { + Tensor::::new(&ort::memory::Allocator::default(), shape.clone()) + .map_err(ort_error("restore empty f32 state"))? + .into_dyn() + } else { + Tensor::from_array((shape.clone(), data.clone().into_boxed_slice())) + .map_err(ort_error("restore f32 state"))? + .into_dyn() + } + } + SnapshotTensor::I64(shape, data) => { + if data.is_empty() { + Tensor::::new(&ort::memory::Allocator::default(), shape.clone()) + .map_err(ort_error("restore empty i64 state"))? + .into_dyn() + } else { + Tensor::from_array((shape.clone(), data.clone().into_boxed_slice())) + .map_err(ort_error("restore i64 state"))? + .into_dyn() + } + } + SnapshotTensor::Bool(shape, data) => { + if data.is_empty() { + Tensor::::new(&ort::memory::Allocator::default(), shape.clone()) + .map_err(ort_error("restore empty bool state"))? + .into_dyn() + } else { + Tensor::from_array((shape.clone(), data.clone().into_boxed_slice())) + .map_err(ort_error("restore bool state"))? + .into_dyn() + } + } + }; + Ok(StateValue { + spec: spec.clone(), + value, + }) + }) + .collect() +} + pub(crate) struct AprilPocketTts { bundle: Bundle, tokenizer: Tokenizer, @@ -106,6 +229,10 @@ pub(crate) struct AprilPocketTts { flow: Session, mimi_decoder: Session, cached_voice: Option, + /// EXPERIMENTAL (latency): post-`condition_voice` Flow LM state, cached + /// per reference voice. Restoring it replaces the ~160 ms conditioning + /// pass on every chunk after the first for a given voice. + cached_conditioning: Option, } #[derive(Debug, Clone, PartialEq)] @@ -239,6 +366,7 @@ impl AprilPocketTts { tokenizer, bos_embedding, cached_voice: None, + cached_conditioning: None, }) } @@ -246,62 +374,23 @@ impl AprilPocketTts { &self, prepared: &AprilPreparedPrompt, ) -> Result, String> { - if self.token_count(&prepared.text)? <= self.bundle.max_token_per_chunk { + if self.prepared_token_count(&prepared.text)? <= self.bundle.max_token_per_chunk { return Ok(vec![prepared.text.clone()]); } + split_model_at_natural_boundaries(&prepared.text, self.bundle.max_token_per_chunk, |text| { + self.prepared_token_count(text) + }) + } - 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 split_playback_prompt( + &self, + prepared: &AprilPreparedPrompt, + ) -> Result, String> { + split_playback_at_natural_boundaries( + &prepared.text, + self.bundle.max_token_per_chunk, + |text| self.prepared_token_count(text), + ) } pub(crate) fn synth_chunk( @@ -309,8 +398,11 @@ impl AprilPocketTts { prepared: &AprilPreparedPrompt, style: &VoiceStyle, ) -> Result, String> { - let voice_embeddings = self.voice_embeddings(style)?; - let mut flow_state = self.condition_voice(&voice_embeddings)?; + // EXPERIMENTAL (latency bench): phase timing, enabled by BUZZ_TTS_PHASE_LOG=1. + let phase_log = std::env::var("BUZZ_TTS_PHASE_LOG").is_ok_and(|v| v == "1"); + let t0 = std::time::Instant::now(); + let mut flow_state = self.conditioned_flow_state(style)?; + let t_condition = t0.elapsed(); let token_ids = self .tokenizer .encode(prepared.text.as_str(), false) @@ -334,10 +426,244 @@ impl AprilPocketTts { 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 t_prefix = t0.elapsed(); 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) + let t_generate = t0.elapsed(); + let audio = self.decode_latents(&latents)?; + if phase_log { + eprintln!( + "tts-phase: condition={:.0}ms prefix={:.0}ms generate={:.0}ms decode={:.0}ms frames={} audio_s={:.2}", + t_condition.as_secs_f64() * 1e3, + (t_prefix - t_condition).as_secs_f64() * 1e3, + (t_generate - t_prefix).as_secs_f64() * 1e3, + (t0.elapsed() - t_generate).as_secs_f64() * 1e3, + latents.len() / self.bundle.latent_dim, + audio.len() as f64 / self.bundle.sample_rate as f64, + ); + } + Ok(audio) + } + + /// EXPERIMENTAL (latency): return a fresh Flow LM state conditioned on + /// the reference voice, restoring a cached snapshot when the same voice + /// samples were conditioned before. Keyed by voice content, like + /// `cached_voice` — never by buffer address. + fn conditioned_flow_state(&mut self, style: &VoiceStyle) -> Result, String> { + let key = voice_key(style); + if let Some(cached) = &self.cached_conditioning { + if cached.key == key { + return restore_state(&cached.state); + } + } + let voice_embeddings = self.voice_embeddings(style)?; + let state = self.condition_voice(&voice_embeddings)?; + self.cached_conditioning = Some(CachedConditioning { + key, + state: snapshot_state(&state)?, + }); + Ok(state) + } + + /// EXPERIMENTAL (latency): streaming synthesis — interleaves the Flow LM + /// frame loop with incremental stateful Mimi decoding, invoking + /// `on_audio` with each decoded delta as soon as ~`emit_frames` latent + /// frames exist (80 ms of audio per frame). The Mimi decoder carries its + /// recurrent state across deltas, so the concatenated deltas are the same + /// audio `synth_chunk` would return. Returns Ok(false) when the callback + /// requested cancellation. + pub(crate) fn synth_chunk_streaming( + &mut self, + prepared: &AprilPreparedPrompt, + style: &VoiceStyle, + emit_frames: usize, + on_audio: &mut dyn FnMut(Vec) -> bool, + ) -> Result { + let mut flow_state = self.conditioned_flow_state(style)?; + 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(true); + } + 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 emit_frames = emit_frames.max(1); + + let mut mimi_state = initialize_state(&self.bundle.mimi_state_manifest)?; + let mut pending: Vec = Vec::with_capacity(emit_frames * self.bundle.latent_dim); + let mut current = vec![f32::NAN; 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, &flow_state); + // Scoped: `outputs` borrows `self.flow_main`; it must drop before + // `decode_frames` takes `&mut self` below. + let (conditioning, eos_logit) = { + 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(&mut flow_state, &mut outputs)?; + (conditioning, eos_logit) + }; + + if eos_logit > EOS_LOGIT_THRESHOLD && eos_step.is_none() { + eos_step = Some(step); + } + if eos_step.is_some_and(|eos| step >= eos + prepared.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; + } + drop(outputs); + current.clone_from(&noise); + pending.extend_from_slice(&noise); + + if pending.len() >= emit_frames * self.bundle.latent_dim { + let audio = self.decode_frames(&pending, &mut mimi_state)?; + pending.clear(); + if !audio.is_empty() && !on_audio(audio) { + return Ok(false); + } + } + } + if !pending.is_empty() { + let audio = self.decode_frames(&pending, &mut mimi_state)?; + if !audio.is_empty() && !on_audio(audio) { + return Ok(false); + } + } + Ok(true) + } + + /// EXPERIMENTAL (latency): decode a batch of latent frames with a + /// caller-held Mimi state, so successive calls continue one stream. + fn decode_frames( + &mut self, + latents: &[f32], + state: &mut [StateValue], + ) -> 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 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(state, &mut outputs)?; + } + Ok(audio) } fn prepared_token_count(&self, text: &str) -> Result { @@ -356,13 +682,9 @@ impl AprilPocketTts { } fn voice_embeddings(&mut self, style: &VoiceStyle) -> Result, String> { - let key = ( - style.samples.as_ptr() as usize, - style.samples.len(), - style.sample_rate, - ); + let key = voice_key(style); if let Some(cached) = &self.cached_voice { - if (cached.samples_ptr, cached.samples_len, cached.sample_rate) == key { + if cached.key == key { return Ok(cached.embeddings.clone()); } } @@ -403,9 +725,7 @@ impl AprilPocketTts { 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, + key, embeddings: embeddings.clone(), }); Ok(embeddings) @@ -638,6 +958,180 @@ impl AprilPocketTts { } } +fn split_model_at_natural_boundaries( + text: &str, + max_tokens: usize, + token_count: F, +) -> Result, String> +where + F: FnMut(&str) -> Result, +{ + split_at_natural_boundaries(text, max_tokens, false, token_count) +} + +fn split_playback_at_natural_boundaries( + text: &str, + max_tokens: usize, + token_count: F, +) -> Result, String> +where + F: FnMut(&str) -> Result, +{ + split_at_natural_boundaries(text, max_tokens, true, token_count) +} + +fn split_at_natural_boundaries( + text: &str, + max_tokens: usize, + isolate_first_sentence: bool, + mut token_count: F, +) -> Result, String> +where + F: FnMut(&str) -> Result, +{ + if text.is_empty() { + return Ok(Vec::new()); + } + + let mut chunks = Vec::new(); + let mut start = 0; + while start < text.len() { + while text[start..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + start += text[start..] + .chars() + .next() + .expect("checked above") + .len_utf8(); + } + if start == text.len() { + break; + } + + let mut first_sentence_end = None; + let mut sentence_end = None; + let mut clause_end = None; + let mut word_end = None; + for (offset, ch) in text[start..].char_indices() { + let end = start + offset + ch.len_utf8(); + let at_word_end = + end == text.len() || text[end..].chars().next().is_some_and(char::is_whitespace); + let at_clause_end = matches!(ch, '—' | '–') + && !text[end..] + .chars() + .next() + .is_some_and(is_closing_punctuation); + if !at_word_end && !at_clause_end { + continue; + } + // Prepared token counts are monotonic in prefix length, so once a + // candidate overflows the limit no longer candidate can fit. Stop + // scanning instead of tokenizing every remaining boundary: that + // kept this loop superlinear in prompt length, and the cost landed + // before the first chunk reached synthesis. + if token_count(&text[start..end])? > max_tokens { + break; + } + + word_end = Some(end); + match natural_boundary(&text[start..end], end == text.len()) { + TextBoundary::Sentence => { + first_sentence_end.get_or_insert(end); + sentence_end = Some(end); + } + TextBoundary::Clause => clause_end = Some(end), + TextBoundary::Word => {} + } + } + + let preferred_end = if isolate_first_sentence && chunks.is_empty() { + first_sentence_end.or(clause_end).or(word_end) + } else { + sentence_end.or(clause_end).or(word_end) + }; + let end = if let Some(end) = preferred_end { + end + } else { + // A single word can itself exceed the model limit. Preserve a + // scalar boundary as the final safety case without losing UTF-8. + let mut scalar_end = None; + for (offset, ch) in text[start..].char_indices() { + if ch.is_whitespace() { + break; + } + let end = start + offset + ch.len_utf8(); + if token_count(&text[start..end])? <= max_tokens { + scalar_end = Some(end); + } + } + scalar_end.ok_or_else(|| { + format!( + "Pocket TTS prompt cannot fit one character within the {max_tokens}-token limit" + ) + })? + }; + + let mut next_start = end; + while text[next_start..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + next_start += text[next_start..] + .chars() + .next() + .expect("checked above") + .len_utf8(); + } + chunks.push(text[start..next_start].to_string()); + start = next_start; + } + + debug_assert_eq!(chunks.concat(), text); + Ok(chunks) +} + +fn natural_boundary(candidate: &str, is_end_of_text: bool) -> TextBoundary { + if is_end_of_text { + return TextBoundary::Sentence; + } + + let mut chars = candidate.chars().rev(); + let mut last = chars.next(); + while last.is_some_and(is_closing_punctuation) { + last = chars.next(); + } + match last { + Some('.' | '!' | '?') if !looks_like_abbreviation(candidate) => TextBoundary::Sentence, + Some(',' | ';' | ':' | '—' | '–') => TextBoundary::Clause, + _ => TextBoundary::Word, + } +} + +fn is_closing_punctuation(ch: char) -> bool { + matches!(ch, '"' | '\'' | '”' | '’' | ')' | ']' | '}') +} + +fn looks_like_abbreviation(candidate: &str) -> bool { + const ABBREVIATIONS: &[&str] = &[ + "Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.", "Jr.", "St.", "Ave.", "Rd.", "Blvd.", "Dept.", + "Inc.", "Ltd.", "Co.", "Corp.", "etc.", "vs.", "i.e.", "e.g.", "Ph.D.", + ]; + + let candidate = candidate.trim_end_matches(is_closing_punctuation); + let last_word = candidate + .rsplit_once(char::is_whitespace) + .map_or(candidate, |(_, word)| word); + ABBREVIATIONS.contains(&last_word) + || (last_word.ends_with('.') + && last_word[..last_word.len() - 1] + .chars() + .all(|ch| ch.is_ascii_digit())) +} + fn load_session(path: PathBuf, num_threads: usize) -> Result { if !path.is_file() { return Err(format!("missing Pocket TTS file: {}", path.display())); @@ -861,6 +1355,215 @@ mod tests { assert_eq!(shape_len(&[2, 1, 8, 1000, 64]).expect("shape"), 1_024_000); } + /// The two engine splitters must keep OPPOSITE isolation polarity. + /// + /// The guards in `pocket.rs` pin which engine method each public API calls, + /// but they cannot see what the method itself does: pointing + /// `split_playback_prompt` at the model wrapper leaves every call site's + /// source text untouched while first-sentence isolation silently stops + /// happening, so the first playback unit becomes the whole utterance and + /// first audio waits on generating all of it. + #[test] + fn engine_splitters_keep_opposite_isolation_polarity() { + let source = include_str!("pocket_april.rs"); + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(production, _)| production); + + // A method's own code, and nothing else. Ending at the method's own + // closing brace keeps the NEXT method's doc comment out, and stripping + // `//` to end of line keeps prose out: neither can call a splitter, so + // scanning either reports drift in a method that has not changed. + let method_code = |name: &str| -> String { + let (_, body) = production + .split_once(name) + .unwrap_or_else(|| panic!("{name} exists")); + let (body, _) = body + .split_once("\n }\n") + .unwrap_or_else(|| panic!("{name} has a closing brace")); + body.lines() + .map(|line| line.split_once("//").map_or(line, |(code, _)| code)) + .collect::>() + .join("\n") + }; + let model = method_code("fn split_prompt"); + let model = model.as_str(); + let playback = method_code("fn split_playback_prompt"); + let playback = playback.as_str(); + + assert_eq!( + ( + model.matches("split_model_at_natural_boundaries(").count(), + model + .matches("split_playback_at_natural_boundaries(") + .count(), + ), + (1, 0), + "split_prompt must pack sentences: isolating here peels sentence \ + one off every already-packed unit" + ); + assert_eq!( + ( + playback + .matches("split_playback_at_natural_boundaries(") + .count(), + playback + .matches("split_model_at_natural_boundaries(") + .count(), + ), + (1, 0), + "split_playback_prompt must isolate sentence one: packing here \ + makes the first playback unit the whole utterance and delays \ + first audio by the full generation" + ); + + // Calling the isolating splitter is necessary but not sufficient: a + // short circuit before the call can return the whole utterance as one + // unit while leaving the delegated splitter unchanged. Playback must + // delegate unconditionally so sentence one remains the first unit. + for control_flow in ["if ", "match ", "else", "return"] { + assert!( + !playback.contains(control_flow), + "split_playback_prompt must delegate unconditionally, found \ + `{control_flow}`: a branch before the split can return the \ + whole utterance as the first playback unit, delaying first \ + audio by the full generation" + ); + } + } + + fn whitespace_token_count(text: &str) -> Result { + Ok(text.split_whitespace().count()) + } + + #[test] + fn playback_split_keeps_first_sentence_separate_then_packs_the_remainder() { + let text = "One two. Three four. Five six."; + let chunks = split_playback_at_natural_boundaries(text, 4, whitespace_token_count).unwrap(); + assert_eq!(chunks, ["One two. ", "Three four. Five six."]); + assert_eq!(chunks.concat(), text); + } + + #[test] + fn model_split_packs_multiple_sentences_within_limit() { + let text = "One two. Three four. Five six."; + let chunks = split_model_at_natural_boundaries(text, 4, whitespace_token_count).unwrap(); + assert_eq!(chunks, ["One two. Three four. ", "Five six."]); + assert_eq!(chunks.concat(), text); + } + + #[test] + fn playback_then_model_split_does_not_isolate_later_sentences_again() { + let text = "Alpha one. Beta two. Gamma three."; + let playback = + split_playback_at_natural_boundaries(text, 50, whitespace_token_count).unwrap(); + assert_eq!(playback, ["Alpha one. ", "Beta two. Gamma three."]); + + let model: Vec<_> = playback + .iter() + .flat_map(|chunk| { + split_model_at_natural_boundaries(chunk.trim(), 50, whitespace_token_count).unwrap() + }) + .collect(); + assert_eq!(model, ["Alpha one.", "Beta two. Gamma three."]); + } + + #[test] + fn natural_split_prefers_preceding_sentence_boundary() { + let text = "One two. Three four five six."; + let chunks = split_at_natural_boundaries(text, 5, true, whitespace_token_count).unwrap(); + assert_eq!(chunks, ["One two. ", "Three four five six."]); + assert_eq!(chunks.concat(), text); + } + + #[test] + fn oversized_sentence_uses_clause_then_word_fallback() { + let clause_text = "One two three, four five six seven."; + let clause_chunks = + split_at_natural_boundaries(clause_text, 5, true, whitespace_token_count).unwrap(); + assert_eq!(clause_chunks, ["One two three, ", "four five six seven."]); + assert_eq!(clause_chunks.concat(), clause_text); + + let word_text = "One two three four five six."; + let word_chunks = + split_at_natural_boundaries(word_text, 4, true, whitespace_token_count).unwrap(); + assert_eq!(word_chunks, ["One two three four ", "five six."]); + assert_eq!(word_chunks.concat(), word_text); + } + + #[test] + fn natural_split_preserves_unicode_punctuation_and_abbreviations() { + let text = "“Café naïve?” Maybe—yes, definitely; 東京 speaks."; + let chunks = split_at_natural_boundaries(text, 3, true, whitespace_token_count).unwrap(); + assert_eq!( + chunks, + ["“Café naïve?” ", "Maybe—yes, definitely; ", "東京 speaks."] + ); + assert_eq!(chunks.concat(), text); + + let abbreviation = "Dr. Smith waits. Then leaves."; + let chunks = + split_at_natural_boundaries(abbreviation, 3, true, whitespace_token_count).unwrap(); + assert_eq!(chunks, ["Dr. Smith waits. ", "Then leaves."]); + assert_eq!(chunks.concat(), abbreviation); + + let unspaced_clause = "alpha beta—gamma delta"; + let chunks = + split_at_natural_boundaries(unspaced_clause, 2, true, whitespace_token_count).unwrap(); + assert_eq!(chunks, ["alpha beta—", "gamma delta"]); + assert_eq!(chunks.concat(), unspaced_clause); + } + + #[test] + fn natural_split_does_not_treat_numeric_punctuation_as_unspaced_clauses() { + let text = "Meet at 12:30 with 1,000 guests onward."; + let chunks = split_at_natural_boundaries(text, 3, true, whitespace_token_count).unwrap(); + assert_eq!(chunks, ["Meet at 12:30 ", "with 1,000 guests ", "onward."]); + assert_eq!(chunks.concat(), text); + } + + #[test] + fn oversized_word_uses_utf8_scalar_boundary_without_loss() { + let text = "éééé"; + let chunks = + split_at_natural_boundaries(text, 3, true, |chunk| Ok(chunk.chars().count())).unwrap(); + assert_eq!(chunks, ["ééé", "é"]); + assert_eq!(chunks.concat(), text); + } + + #[test] + fn natural_split_stops_counting_tokens_past_the_limit() { + // Each boundary scan must stop at the first overflowing candidate + // rather than tokenizing every remaining boundary. Scanning to + // end-of-text makes tokenizer input grow superlinearly in prompt + // length, and that cost is paid before the first chunk reaches + // synthesis, taxing time-to-first-audio on long prompts. + let sentence = "The relay finished its migration and the channel list refreshed. "; + let tokenized_bytes = |repeats: usize| -> usize { + let text = sentence.repeat(repeats).trim_end().to_string(); + let total = std::cell::Cell::new(0_usize); + let chunks = split_at_natural_boundaries(&text, 50, true, |chunk| { + total.set(total.get() + chunk.len()); + whitespace_token_count(chunk) + }) + .expect("split repeated sentences"); + assert_eq!(chunks.concat(), text); + assert!(chunks.len() > 1); + total.get() + }; + + // Doubling the prompt must not multiply tokenizer work superlinearly. + // Bounded scans grow ~2x here; scanning to end-of-text grows ~5.5x. + let single = tokenized_bytes(12); + let double = tokenized_bytes(24); + assert!( + double < single * 3, + "doubling the prompt grew tokenizer input from {single} to {double} bytes \ + ({:.1}x); bounded scans stay near 2x", + double as f64 / single as f64, + ); + } + #[test] fn normal_noise_has_requested_length() { let mut rng = rand::rng(); @@ -874,6 +1577,234 @@ mod tests { assert_eq!(estimate_max_frames(300, 12.5), 1_275); } + /// Regression (review finding): the voice caches must key on CONTENT. + /// Voice switching clones and drops sample buffers, so a new voice with + /// the same length and rate can land at a recycled address — an + /// address-based key would then restore the previous voice's state and + /// speak with the wrong voice. + #[test] + fn voice_key_is_content_based_not_address_based() { + let style_a = VoiceStyle { + samples: vec![0.1, -0.2, 0.3, -0.4], + sample_rate: 24_000, + }; + // Same length, same rate, different content — MUST key differently, + // regardless of what address the allocator hands out. + let style_b = VoiceStyle { + samples: vec![0.4, -0.3, 0.2, -0.1], + sample_rate: 24_000, + }; + assert_ne!(voice_key(&style_a), voice_key(&style_b)); + + // Same content in a fresh allocation — MUST key identically, so the + // cache still hits across clones of the same voice. + let style_a_clone = VoiceStyle { + samples: style_a.samples.clone(), + sample_rate: style_a.sample_rate, + }; + assert_ne!( + style_a.samples.as_ptr(), + style_a_clone.samples.as_ptr(), + "clone must be a distinct allocation for this test to mean anything" + ); + assert_eq!(voice_key(&style_a), voice_key(&style_a_clone)); + + // Same content at a different rate is a different voice identity. + let style_a_resampled = VoiceStyle { + samples: style_a.samples.clone(), + sample_rate: 16_000, + }; + assert_ne!(voice_key(&style_a), voice_key(&style_a_resampled)); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn switching_between_equal_length_voices_reconditions_the_flow_state() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let style_a = + crate::pocket::load_voice_style(&Path::new(&dir).join("reference_sample.wav")) + .expect("load reference voice"); + // Voice B: same length, same rate, different content (reversed + // samples) — the exact shape an address-recycling collision takes. + let style_b = VoiceStyle { + samples: style_a.samples.iter().rev().copied().collect(), + sample_rate: style_a.sample_rate, + }; + assert_eq!(style_a.samples.len(), style_b.samples.len()); + + // Engine 1: condition A (primes both caches), then switch to B. + let mut engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle"); + let state_a = snapshot_state( + &engine + .conditioned_flow_state(&style_a) + .expect("condition A"), + ) + .expect("snapshot A"); + let state_b_after_switch = snapshot_state( + &engine + .conditioned_flow_state(&style_b) + .expect("condition B"), + ) + .expect("snapshot B after switch"); + // Warm hit on the SAME voice: the cached restore must reproduce the + // original conditioning bit-for-bit (cache warm == cache cold). + let state_b_warm_hit = snapshot_state( + &engine + .conditioned_flow_state(&style_b) + .expect("condition B warm"), + ) + .expect("snapshot B warm hit"); + + // Engine 2: fresh process conditions B with no cache in play. + let mut fresh = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle"); + let state_b_fresh = snapshot_state( + &fresh + .conditioned_flow_state(&style_b) + .expect("condition B fresh"), + ) + .expect("snapshot B fresh"); + + // The switched state must equal a from-scratch conditioning of B and + // must NOT be A's cached state. + assert!( + snapshots_equal(&state_b_after_switch, &state_b_fresh), + "switching voices must recondition, not replay the cache" + ); + assert!( + !snapshots_equal(&state_b_after_switch, &state_a), + "equal-length distinct voices must produce distinct conditioning" + ); + // And the warm cache hit must be indistinguishable from recomputing. + assert!( + snapshots_equal(&state_b_warm_hit, &state_b_fresh), + "a warm conditioning-cache hit must equal a cold recompute" + ); + } + + fn snapshots_equal( + a: &[(StateSpec, SnapshotTensor)], + b: &[(StateSpec, SnapshotTensor)], + ) -> bool { + // f32 compares bitwise: state tensors legitimately contain NaN fill, + // and NaN != NaN under float equality would make identical states + // compare unequal. + a.len() == b.len() + && a.iter().zip(b).all(|((_, ta), (_, tb))| match (ta, tb) { + (SnapshotTensor::F32(sa, da), SnapshotTensor::F32(sb, db)) => { + sa == sb + && da.len() == db.len() + && da.iter().zip(db).all(|(x, y)| x.to_bits() == y.to_bits()) + } + (SnapshotTensor::I64(sa, da), SnapshotTensor::I64(sb, db)) => sa == sb && da == db, + (SnapshotTensor::Bool(sa, da), SnapshotTensor::Bool(sb, db)) => { + sa == sb && da == db + } + _ => false, + }) + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn incremental_stateful_decode_matches_batch_decode() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let mut engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle"); + let style = crate::pocket::load_voice_style(&Path::new(&dir).join("reference_sample.wav")) + .expect("load reference voice"); + + // Generate one real latent sequence (the RNG makes repeat synths + // differ, so both decode paths must consume the SAME latents). + let prepared = + prepare_april_prompt("The relay deploy finished and every check passed cleanly.") + .expect("prepare prompt"); + let mut flow_state = engine + .conditioned_flow_state(&style) + .expect("condition voice"); + let token_ids = engine + .tokenizer + .encode(prepared.text.as_str(), false) + .expect("tokenize") + .get_ids() + .iter() + .copied() + .map(i64::from) + .collect::>(); + let token_count = token_ids.len(); + let text_embeddings = engine.text_embeddings(token_ids).expect("text embeddings"); + engine + .run_flow_main_prefix(&text_embeddings, &mut flow_state) + .expect("prefix"); + let max_frames = estimate_max_frames(token_count, engine.bundle.frame_rate); + let latents = engine + .generate_latents(max_frames, prepared.frames_after_eos, &mut flow_state) + .expect("generate latents"); + let frame_count = latents.len() / engine.bundle.latent_dim; + assert!( + frame_count > DECODER_CHUNK_FRAMES, + "need a multi-chunk case" + ); + + // Batch: the production decode (fresh state, 12-frame steps). + let batch = engine.decode_latents(&latents).expect("batch decode"); + + // Incremental chunkings: 12-frame deltas through one carried Mimi + // state must be bit-exact (the production batch path itself steps by + // DECODER_CHUNK_FRAMES=12 through one state). Sub-12 chunkings are + // measured for the record but are NOT exact — the decoder has + // intra-chunk lookahead — so streaming must emit at >= 12 frames. + for delta_frames in [6usize, 4, 2, 1] { + let mut state = + initialize_state(&engine.bundle.mimi_state_manifest).expect("mimi state"); + let mut streamed = Vec::new(); + for chunk in latents.chunks(delta_frames * engine.bundle.latent_dim) { + streamed.extend( + engine + .decode_frames(chunk, &mut state) + .expect("delta decode"), + ); + } + assert_eq!(batch.len(), streamed.len(), "sample count must match"); + let max_diff = batch + .iter() + .zip(&streamed) + .map(|(a, b)| (a - b).abs()) + .fold(0.0f32, f32::max); + let rms_batch = (batch.iter().map(|s| s * s).sum::() / batch.len() as f32).sqrt(); + let rms_err = (batch + .iter() + .zip(&streamed) + .map(|(a, b)| (a - b) * (a - b)) + .sum::() + / batch.len() as f32) + .sqrt(); + eprintln!( + "delta_frames={delta_frames}: max|diff|={max_diff:.6} rms_err={rms_err:.6} snr_db={:.1}", + 20.0 * (rms_batch / rms_err.max(1e-12)).log10() + ); + } + let mut state = initialize_state(&engine.bundle.mimi_state_manifest).expect("mimi state"); + let mut streamed = Vec::new(); + for chunk in latents.chunks(DECODER_CHUNK_FRAMES * engine.bundle.latent_dim) { + streamed.extend( + engine + .decode_frames(chunk, &mut state) + .expect("delta decode"), + ); + } + + assert_eq!(batch.len(), streamed.len(), "sample count must match"); + let max_diff = batch + .iter() + .zip(&streamed) + .map(|(a, b)| (a - b).abs()) + .fold(0.0f32, f32::max); + assert!( + max_diff <= 1.0e-4, + "incremental decode diverged from batch decode: max |diff| = {max_diff}" + ); + } + #[test] #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] fn tokenizer_matches_sentencepiece_reference_including_unknown_words() { @@ -910,8 +1841,10 @@ mod tests { assert!(chunks.len() > 1); assert!(chunks.iter().all(|chunk| { - engine.token_count(chunk).expect("tokenize chunk") <= engine.bundle.max_token_per_chunk + engine.prepared_token_count(chunk).expect("tokenize chunk") + <= engine.bundle.max_token_per_chunk })); + assert_eq!(chunks.concat(), prepared.text); } #[test] @@ -925,16 +1858,20 @@ mod tests { 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")) + .map(|chunk| engine.prepared_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]); + assert!(token_counts + .iter() + .all(|&count| count <= engine.bundle.max_token_per_chunk)); + assert_eq!(chunks.concat(), prepared.text); + assert!(chunks.len() > 1); + assert!(chunks[..chunks.len() - 1].iter().all(|chunk| { + chunk + .trim_end() + .chars() + .last() + .is_some_and(|ch| ['.', '!', '?', ',', ';', ':', '—', '–'].contains(&ch)) + })); } } diff --git a/crates/buzz-workflow/Cargo.toml b/crates/buzz-workflow/Cargo.toml index d4813e56d42..7d361b1477a 100644 --- a/crates/buzz-workflow/Cargo.toml +++ b/crates/buzz-workflow/Cargo.toml @@ -10,6 +10,7 @@ description = "YAML-as-code workflow engine for Buzz" [dependencies] buzz-core = { workspace = true } buzz-db = { workspace = true } +buzz-deletion = { workspace = true } hex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-workflow/src/error.rs b/crates/buzz-workflow/src/error.rs index 292f8dd027c..109d4a2cb3d 100644 --- a/crates/buzz-workflow/src/error.rs +++ b/crates/buzz-workflow/src/error.rs @@ -65,8 +65,50 @@ pub enum WorkflowError { NotImplemented(String), } +impl WorkflowError { + /// Stable run-level classification. Diagnostics remain in `Display` output. + pub const fn code(&self) -> &'static str { + match self { + Self::InvalidYaml(_) => "invalid_yaml", + Self::InvalidDefinition(_) => "invalid_definition", + Self::ConditionError(_) => "condition_evaluation_failed", + Self::TemplateError(_) => "template_resolution_failed", + Self::StepTimeout { .. } => "step_timeout", + Self::WebhookError(_) => "webhook_failed", + Self::CapacityExceeded => "capacity_exceeded", + Self::Database(_) => "database_error", + Self::Unauthorized(_) => "owner_unauthorized", + Self::NotImplemented(_) => "action_not_implemented", + } + } +} + impl From for WorkflowError { fn from(e: buzz_db::error::DbError) -> Self { WorkflowError::Database(e.to_string()) } } + +#[cfg(test)] +mod tests { + use super::WorkflowError; + + #[test] + fn workflow_error_codes_are_stable_and_separate_from_diagnostics() { + let timeout = WorkflowError::StepTimeout { + step_id: "notify".to_owned(), + timeout_secs: 30, + }; + assert_eq!(timeout.code(), "step_timeout"); + assert!(timeout.to_string().contains("notify")); + + let webhook = WorkflowError::WebhookError("secret-bearing detail".to_owned()); + assert_eq!(webhook.code(), "webhook_failed"); + assert!(!webhook.code().contains("secret-bearing detail")); + + assert_eq!( + WorkflowError::NotImplemented("SendDm".to_owned()).code(), + "action_not_implemented" + ); + } +} diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index e30541377e4..dffa4927168 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -526,165 +526,202 @@ pub async fn dispatch_action( ) -> Result { use ActionDef::*; - match action { - SendMessage { text, channel } => { - // Look up workflow metadata for destination validation and - // attribution, scoped to the run's community — the same run/workflow - // UUID may exist in another community, so a bare-id lookup could - // load the wrong row and drive a side effect under it. - let wf_run = engine - .db - .get_workflow_run(community_id, run_id) - .await - .map_err(|e| { - WorkflowError::WebhookError(format!( - "SendMessage: failed to load workflow run {run_id}: {e}" - )) - })?; - let workflow = engine - .db - .get_workflow(community_id, wf_run.workflow_id) - .await - .map_err(|e| { - WorkflowError::WebhookError(format!( - "SendMessage: failed to load workflow {}: {e}", - wf_run.workflow_id - )) - })?; - let channel_id = resolve_send_message_channel( - channel.as_deref(), - &trigger_ctx.channel_id, - workflow.channel_id, - )?; - let owner_pubkey_hex = hex::encode(&workflow.owner_pubkey); - - info!( - run_id = %run_id, - step = step_id, - channel = %channel_id, - "SendMessage → {channel_id}: {text}" - ); - - let event_id = engine - .action_sink()? - .send_message(community_id, &channel_id, text, &owner_pubkey_hex) - .await - .map_err(WorkflowError::from)?; - - Ok(StepResult::Completed(serde_json::json!({ - "sent": true, - "event_id": event_id, - }))) - } - - SendDm { to, text: _ } => { - warn!(run_id = %run_id, step = step_id, "SendDm not yet implemented (to={to})"); - // TODO (WF-07): emit DM event. - Err(WorkflowError::NotImplemented("SendDm".into())) - } - - SetChannelTopic { topic: _ } => { - warn!(run_id = %run_id, step = step_id, "SetChannelTopic not yet implemented"); - // TODO (WF-07): update channel topic via DB. - Err(WorkflowError::NotImplemented("SetChannelTopic".into())) - } - - AddReaction { emoji } => { - info!(run_id = %run_id, step = step_id, "AddReaction → :{emoji}:"); - if trigger_ctx.message_id.is_empty() { - return Err(WorkflowError::InvalidDefinition( - "AddReaction: no trigger.message_id available".into(), - )); - } - - #[cfg(feature = "reqwest")] - { - let result = add_reaction_impl(&trigger_ctx.message_id, emoji).await?; - Ok(StepResult::Completed(result)) - } - - #[cfg(not(feature = "reqwest"))] - { - warn!( - run_id = %run_id, - step = step_id, - "AddReaction: reqwest feature not enabled, skipping HTTP call" - ); - Ok(StepResult::Completed( - serde_json::json!({ "added": false, "skipped": true }), + // The workflow engine can outlive the serving request that spawned it. + // Revalidate the durable community fence immediately before every external + // side effect (message publish, webhook, delay/resume). A storage failure is + // a denial, never permission to continue. + let serving_write = + buzz_deletion::acquire_serving_write(&engine.db, community_id, "workflow_action") + .await + .map_err(|error| { + WorkflowError::WebhookError(format!( + "community write fence rejected workflow side effect: {error}" )) - } - } + })?; - CallWebhook { - url, - method, - headers, - body, - } => { - let method_str = method.as_deref().unwrap_or("POST"); - info!(run_id = %run_id, step = step_id, "CallWebhook → {method_str} {url}"); + serving_write.verify().await.map_err(|error| { + WorkflowError::WebhookError(format!("community write lease lost: {error}")) + })?; - #[cfg(feature = "reqwest")] - { - let result = call_webhook_impl(url, method_str, headers, body).await?; - Ok(StepResult::Completed(result)) - } + let result = serving_write + .protect(async { + match action { + SendMessage { text, channel } => { + // Look up workflow metadata for destination validation and + // attribution, scoped to the run's community — the same run/workflow + // UUID may exist in another community, so a bare-id lookup could + // load the wrong row and drive a side effect under it. + let wf_run = engine + .db + .get_workflow_run(community_id, run_id) + .await + .map_err(|e| { + WorkflowError::WebhookError(format!( + "SendMessage: failed to load workflow run {run_id}: {e}" + )) + })?; + let workflow = engine + .db + .get_workflow(community_id, wf_run.workflow_id) + .await + .map_err(|e| { + WorkflowError::WebhookError(format!( + "SendMessage: failed to load workflow {}: {e}", + wf_run.workflow_id + )) + })?; + let channel_id = resolve_send_message_channel( + channel.as_deref(), + &trigger_ctx.channel_id, + workflow.channel_id, + )?; + let owner_pubkey_hex = hex::encode(&workflow.owner_pubkey); + + info!( + run_id = %run_id, + step = step_id, + channel = %channel_id, + "SendMessage → {channel_id}: {text}" + ); + + let event_id = engine + .action_sink()? + .send_message(community_id, &channel_id, text, &owner_pubkey_hex) + .await + .map_err(WorkflowError::from)?; + + Ok(StepResult::Completed(serde_json::json!({ + "sent": true, + "event_id": event_id, + }))) + } - #[cfg(not(feature = "reqwest"))] - { - // reqwest not enabled — log and return placeholder. - warn!( - run_id = %run_id, step = step_id, - "CallWebhook: reqwest feature not enabled, skipping HTTP call" - ); - let _ = (headers, body); // suppress unused warnings - Ok(StepResult::Completed(serde_json::json!({ - "status": 0, - "body": null, - "skipped": true - }))) - } - } + SendDm { to, text: _ } => { + warn!(run_id = %run_id, step = step_id, "SendDm not yet implemented (to={to})"); + // TODO (WF-07): emit DM event. + Err(WorkflowError::NotImplemented("SendDm".into())) + } - RequestApproval { - from, - message, - timeout, - } => { - let timeout_str = timeout.as_deref().unwrap_or("24h"); - info!( - run_id = %run_id, step = step_id, - "RequestApproval from={from} timeout={timeout_str}: {message}" - ); + SetChannelTopic { topic: _ } => { + warn!(run_id = %run_id, step = step_id, "SetChannelTopic not yet implemented"); + // TODO (WF-07): update channel topic via DB. + Err(WorkflowError::NotImplemented("SetChannelTopic".into())) + } + + AddReaction { emoji } => { + info!(run_id = %run_id, step = step_id, "AddReaction → :{emoji}:"); + if trigger_ctx.message_id.is_empty() { + Err(WorkflowError::InvalidDefinition( + "AddReaction: no trigger.message_id available".into(), + )) + } else { + #[cfg(feature = "reqwest")] + { + let result = add_reaction_impl(&trigger_ctx.message_id, emoji).await?; + Ok(StepResult::Completed(result)) + } + + #[cfg(not(feature = "reqwest"))] + { + warn!( + run_id = %run_id, + step = step_id, + "AddReaction: reqwest feature not enabled, skipping HTTP call" + ); + Ok(StepResult::Completed( + serde_json::json!({ "added": false, "skipped": true }), + )) + } + } + } - let token = generate_approval_token(run_id, step_id); + CallWebhook { + url, + method, + headers, + body, + } => { + let method_str = method.as_deref().unwrap_or("POST"); + info!(run_id = %run_id, step = step_id, "CallWebhook → {method_str} {url}"); + + #[cfg(feature = "reqwest")] + { + let result = call_webhook_impl(url, method_str, headers, body).await?; + Ok(StepResult::Completed(result)) + } - // TODO (WF-08): create approval record in DB, emit kind:46010. - // For now, return Suspended with the token so the caller can persist state. + #[cfg(not(feature = "reqwest"))] + { + // reqwest not enabled — log and return placeholder. + warn!( + run_id = %run_id, step = step_id, + "CallWebhook: reqwest feature not enabled, skipping HTTP call" + ); + let _ = (headers, body); // suppress unused warnings + Ok(StepResult::Completed(serde_json::json!({ + "status": 0, + "body": null, + "skipped": true + }))) + } + } - Ok(StepResult::Suspended { - approval_token: token, - }) - } + RequestApproval { + from, + message, + timeout, + } => { + let timeout_str = timeout.as_deref().unwrap_or("24h"); + info!( + run_id = %run_id, step = step_id, + "RequestApproval from={from} timeout={timeout_str}: {message}" + ); + + let token = generate_approval_token(run_id, step_id); + + // TODO (WF-08): create approval record in DB, emit kind:46010. + // For now, return Suspended with the token so the caller can persist state. + + Ok(StepResult::Suspended { + approval_token: token, + }) + } - Delay { duration } => { - let secs = parse_duration_secs(duration)?; - // Cap delay at 270 seconds (4.5 minutes) — must be less than default_timeout_secs (300s) - // to avoid non-deterministic StepTimeout. Long delays (hours/days) - // should use the scheduled resume pattern (future work: WF-09). - const MAX_DELAY_SECS: u64 = 270; - if secs > MAX_DELAY_SECS { - return Err(WorkflowError::InvalidDefinition(format!( - "delay exceeds maximum of {MAX_DELAY_SECS} seconds (got {secs}s); \ + Delay { duration } => { + let secs = parse_duration_secs(duration)?; + // Cap delay at 270 seconds (4.5 minutes) — must be less than default_timeout_secs (300s) + // to avoid non-deterministic StepTimeout. Long delays (hours/days) + // should use the scheduled resume pattern (future work: WF-09). + const MAX_DELAY_SECS: u64 = 270; + if secs > MAX_DELAY_SECS { + return Err(WorkflowError::InvalidDefinition(format!( + "delay exceeds maximum of {MAX_DELAY_SECS} seconds (got {secs}s); \ use the scheduled resume pattern for long delays" - ))); + ))); + } + info!(run_id = %run_id, step = step_id, "Delay {duration} ({secs}s)"); + tokio::time::sleep(std::time::Duration::from_secs(secs)).await; + Ok(StepResult::Completed( + serde_json::json!({ "slept_secs": secs }), + )) + } } - info!(run_id = %run_id, step = step_id, "Delay {duration} ({secs}s)"); - tokio::time::sleep(std::time::Duration::from_secs(secs)).await; - Ok(StepResult::Completed( - serde_json::json!({ "slept_secs": secs }), - )) + }) + .await + .map_err(|error| { + WorkflowError::WebhookError(format!("community write lease lost: {error}")) + })?; + let release = serving_write.finish().await.map_err(|error| { + WorkflowError::WebhookError(format!("community write lease release failed: {error}")) + }); + match result { + Ok(value) => { + release?; + Ok(value) + } + Err(error) => { + let _ = release; + Err(error) } } } diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index e1422211690..fe8b477ba40 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -242,7 +242,10 @@ impl WorkflowEngine { RunStatus::Failed, step_count, &trace_json, - Some("approval gates not yet implemented — see WF-08"), + Some(buzz_db::workflow::WorkflowRunFailure { + code: "approval_not_supported", + message: "approval gates not yet implemented — see WF-08", + }), ) .await { @@ -285,7 +288,10 @@ impl WorkflowEngine { RunStatus::Failed, progress.step_index as i32, &trace_json, - Some(&e.to_string()), + Some(buzz_db::workflow::WorkflowRunFailure { + code: e.code(), + message: &e.to_string(), + }), ) .await { diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index b2778df28b5..86989676604 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -62,9 +62,15 @@ Buzz uses one URL style for both media and Git/CAS object-store requests: | `virtual` | `https://bucket.endpoint/key` | AWS-style providers and new Railway Storage Buckets | The chart always renders `s3.addressingStyle` as -`BUZZ_S3_ADDRESSING_STYLE`. It renders `s3.region` as `BUZZ_S3_REGION` only -when explicitly set, preserving the relay's existing `AWS_REGION` fallback for -upgrades. Only `path` and `virtual` addressing styles are accepted; invalid +`BUZZ_S3_ADDRESSING_STYLE` and `s3.region` as `BUZZ_S3_REGION`. The region +defaults to `us-east-1`, keeping bundled MinIO and the in-pod +`buzz-admin deletions` workflow operable without an ambient `AWS_REGION`. +Production providers must set their credential region explicitly when it +differs. Existing releases that previously omitted `s3.region` will begin +rendering `BUZZ_S3_REGION=us-east-1` after upgrade, even if an image or +`relay.extraEnv` entry supplied `AWS_REGION`; set `s3.region` to the provider's +actual credential region before upgrading. Only `path` and `virtual` addressing +styles are accepted; invalid values fail chart rendering and relay startup. The bundled MinIO quickstart deliberately keeps `path` because its Service DNS resolves one endpoint hostname, not arbitrary `.` names. diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 0ad41ac4611..451ebb1cded 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -170,9 +170,7 @@ spec: - { name: BUZZ_S3_ENDPOINT, value: {{ $s3Endpoint | quote }} } {{- end }} - { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} } - {{- if .Values.s3.region }} - { name: BUZZ_S3_REGION, value: {{ .Values.s3.region | quote }} } - {{- end }} - { name: BUZZ_S3_ADDRESSING_STYLE, value: {{ .Values.s3.addressingStyle | quote }} } # ── Secrets (from chart-managed or existing) ───────────── diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 196a4a53032..10a1a34d1fd 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -30,11 +30,11 @@ tests: path: kind value: Service template: templates/service.yaml - - notContains: + - contains: path: spec.template.spec.containers[0].env content: name: BUZZ_S3_REGION - any: true + value: "us-east-1" template: templates/deployment.yaml - contains: path: spec.template.spec.containers[0].env diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index d3670595b5b..94d369c8903 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -200,7 +200,8 @@ "bucket": { "type": "string", "minLength": 1 }, "region": { "type": "string", - "description": "Optional S3 region used for SigV4 signing. When empty, BUZZ_S3_REGION is omitted so the relay can use AWS_REGION or its own default." + "minLength": 1, + "description": "S3 region used for SigV4 signing by the relay and deletion operator. Defaults to us-east-1 for bundled MinIO/local deployments; set the provider region explicitly when it differs." }, "addressingStyle": { "type": "string", diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 8131aef4321..ca3403a633f 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -342,9 +342,10 @@ externalRedis: s3: endpoint: "" bucket: "buzz-media" - # Optional SigV4 signing region. Leave empty to preserve the relay's - # AWS_REGION fallback; set the provider's credential value when needed. - region: "" + # SigV4 signing region shared by the relay and `buzz-admin deletions`. + # Keep the MinIO/local default operable; production providers should set + # their credential region explicitly when it differs. + region: "us-east-1" # path: https://endpoint/bucket/key (bundled MinIO-compatible default) # virtual: https://bucket.endpoint/key (standard S3; required by new Railway buckets) addressingStyle: path diff --git a/desktop/.gitignore b/desktop/.gitignore index 4d3e0c5ac5a..5dda9a099b5 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -14,6 +14,7 @@ dist-ssr playwright-report playwright-report.json test-results +playwright-release-smoke-report *.local playwright-report test-results diff --git a/desktop/package.json b/desktop/package.json index 14c412ff1de..39e93d8a98d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.8", + "version": "0.5.14", "type": "module", "scripts": { "dev": "vite", @@ -20,6 +20,7 @@ "test:e2e": "pnpm build:e2e && playwright test", "test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke", "test:e2e:integration": "pnpm build:e2e && playwright test --project=integration", + "test:e2e:release-smoke": "pnpm build:e2e && playwright test --config=playwright.release-smoke.config.ts", "test:e2e:report": "playwright show-report", "tauri:build": "tauri build" }, @@ -66,6 +67,7 @@ "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", "lucide-react": "^1.0.0", + "mdast-util-from-markdown": "^2.0.3", "motion": "^12.38.0", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b9..7d06c4da91b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,8 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/sidebar-offcanvas-rail.spec.ts", + "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", @@ -62,6 +64,7 @@ export default defineConfig({ "**/video-attachment.spec.ts", "**/spoiler.spec.ts", "**/composer-link-shortcut.spec.ts", + "**/entity-link-recipient-cards.spec.ts", "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", @@ -75,6 +78,7 @@ export default defineConfig({ "**/relay-connectivity.spec.ts", "**/unread-pill.spec.ts", "**/sidebar-more-unread-overlap.spec.ts", + "**/sidebar-snapshot.spec.ts", "**/home-collapsed-top-chrome.spec.ts", "**/top-chrome-zoom-clearance.spec.ts", "**/thread-unread.spec.ts", @@ -106,6 +110,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-inbox.spec.ts", + "**/projects-v3-screenshots.spec.ts", "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", @@ -127,6 +132,7 @@ export default defineConfig({ "**/profile-nsec-reveal.spec.ts", "**/profile-backup-settings.spec.ts", "**/signout-confirmation.spec.ts", + "**/settings-section-layout.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", "**/agent-access-warning.spec.ts", diff --git a/desktop/playwright.release-smoke.config.ts b/desktop/playwright.release-smoke.config.ts new file mode 100644 index 00000000000..19ac3cbf06d --- /dev/null +++ b/desktop/playwright.release-smoke.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from "@playwright/test"; + +const webPort = process.env.BUZZ_RELEASE_SMOKE_WEB_PORT ?? "4173"; +const webUrl = `http://127.0.0.1:${webPort}`; + +export default defineConfig({ + testDir: "./tests/e2e", + testMatch: [ + "**/release-smoke.spec.ts", + "**/dm-history-live-regression.spec.ts", + "**/foreground-responsiveness-regression.spec.ts", + ], + timeout: 10 * 60_000, + retries: 0, + workers: 1, + reporter: [ + ["list"], + ["json", { outputFile: "test-results/release-smoke/playwright.json" }], + [ + "html", + { open: "never", outputFolder: "playwright-release-smoke-report" }, + ], + ], + use: { + ...devices["Desktop Chrome"], + baseURL: webUrl, + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + webServer: { + command: `python3 -m http.server ${webPort} -d dist`, + cwd: ".", + reuseExistingServer: false, + url: webUrl, + }, +}); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index c66bf4cb540..887e1282ffa 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -210,7 +210,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -221,14 +221,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -447,20 +447,21 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +checksum = "2c713e1f14c7b82e32ea159af1c6e2f070cfadbdf23fb2512acce9af0a26f1a2" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", "tokio", + "tokio-happy-eyeballs", "tokio-rustls", "tokio-socks", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.28.0", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -507,12 +508,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -762,6 +757,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "beef" version = "0.5.2" @@ -774,7 +775,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "serde", "unicode-normalization", ] @@ -815,7 +816,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.5.0", ] [[package]] @@ -827,6 +828,12 @@ dependencies = [ "hex-conservative 0.3.2", ] +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + [[package]] name = "bitcoin-io" version = "0.1.101" @@ -847,6 +854,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals 0.6.0", + "hex-conservative 1.2.0", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1034,6 +1053,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "url", "urlencoding", "webbrowser", ] @@ -1046,7 +1066,7 @@ dependencies = [ "chrono", "hex", "hmac 0.13.0", - "nostr", + "nostr 0.44.7", "percent-encoding", "rand 0.10.2", "serde", @@ -1061,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.8" +version = "0.5.14" dependencies = [ "anyhow", "arboard", @@ -1100,13 +1120,14 @@ dependencies = [ "mesh-llm-sdk", "mesh-llm-system", "neteq", - "nostr", + "nostr 0.44.7", "notify-rust", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-user-notifications", "opus", + "percent-encoding", "plist", "png 0.18.1", "portable-pty", @@ -1168,7 +1189,7 @@ dependencies = [ "imagesize", "infer", "mp4", - "nostr", + "nostr 0.44.7", "rust-s3", "serde", "serde_json", @@ -1197,7 +1218,7 @@ name = "buzz-sdk" version = "0.1.0" dependencies = [ "buzz-core", - "nostr", + "nostr 0.44.7", "serde", "serde_json", "thiserror 2.0.18", @@ -1603,7 +1624,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -2270,7 +2291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -2448,7 +2469,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2729,7 +2750,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2819,6 +2840,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3237,7 +3268,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", + "windows-link 0.1.3", "windows-result 0.4.1", ] @@ -3562,6 +3593,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3620,6 +3660,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -3663,29 +3713,12 @@ dependencies = [ ] [[package]] -name = "hf-hub" -version = "1.0.0-rc.1" +name = "hex-conservative" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", + "arrayvec", ] [[package]] @@ -4448,7 +4481,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.1", + "lru", "n0-error", "n0-future", "noq", @@ -4930,12 +4963,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - [[package]] name = "lru" version = "0.18.1" @@ -5133,8 +5160,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "mesh-llm-client", @@ -5143,8 +5170,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5154,13 +5181,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-llm-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5191,8 +5218,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5207,8 +5234,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5217,8 +5244,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "clap", @@ -5229,12 +5256,9 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ - "anyhow", - "cc", - "libc", "serde", "serde_json", "tracing", @@ -5242,8 +5266,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -5251,16 +5275,45 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "mesh-llm-native-runtime", ] +[[package]] +name = "mesh-llm-hf-hub" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43088a838cf0c6715c65f65a5ac99045fd6d6e90949a8a4183b8104ab791e96b" +dependencies = [ + "base64 0.22.1", + "bon", + "bytes", + "futures", + "getrandom 0.2.17", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + [[package]] name = "mesh-llm-host-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "argon2", @@ -5278,7 +5331,6 @@ dependencies = [ "flate2", "futures-util", "hex", - "hf-hub", "http", "http-body-util", "httparse", @@ -5293,6 +5345,7 @@ dependencies = [ "mesh-llm-config", "mesh-llm-events", "mesh-llm-guardrails", + "mesh-llm-hf-hub", "mesh-llm-identity", "mesh-llm-native-runtime", "mesh-llm-node", @@ -5305,6 +5358,7 @@ dependencies = [ "mesh-llm-types", "mesh-llm-ui", "mesh-mixture-of-agents", + "mesh-native-serving-plugin-host", "model-artifact", "model-hf", "model-package", @@ -5352,8 +5406,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "argon2", "base64 0.22.1", @@ -5374,8 +5428,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "serde", @@ -5385,8 +5439,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-types", @@ -5399,8 +5453,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5416,8 +5470,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5435,8 +5489,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "hex", @@ -5446,18 +5500,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "mesh-llm-release-footer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "hex", + "sha2 0.10.9", +] + [[package]] name = "mesh-llm-routing" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5479,8 +5542,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5494,8 +5557,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5505,8 +5568,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "chrono", @@ -5514,8 +5577,12 @@ dependencies = [ "dirs", "hex", "libc", + "libloading 0.8.9", "mesh-llm-build-info", "mesh-llm-gpu-bench", + "mesh-llm-native-runtime", + "mesh-llm-release-footer", + "mesh-llm-runtime-install", "reqwest 0.12.28", "semver", "serde", @@ -5528,8 +5595,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "serde", @@ -5539,13 +5606,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-mixture-of-agents" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5556,6 +5623,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "mesh-native-serving-plugin-api" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" + +[[package]] +name = "mesh-native-serving-plugin-host" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "anyhow", + "libloading 0.8.9", + "mesh-native-serving-plugin-api", + "skippy-server", +] + [[package]] name = "miette" version = "7.6.0" @@ -5643,13 +5726,13 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "model-artifact" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5659,14 +5742,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", - "hf-hub", + "mesh-llm-hf-hub", "model-artifact", "model-ref", "serde", @@ -5677,14 +5760,14 @@ dependencies = [ [[package]] name = "model-package" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "bytes", "chrono", "futures", - "hf-hub", + "mesh-llm-hf-hub", "model-hf", "model-ref", "reqwest 0.12.28", @@ -5697,16 +5780,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "model-artifact", @@ -5802,7 +5885,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6160,6 +6243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", + "aws-lc-rs", "bytes", "derive_more", "enum-assoc", @@ -6200,9 +6284,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.11.1", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -6210,7 +6294,7 @@ dependencies = [ "hex", "instant", "scrypt", - "secp256k1", + "secp256k1 0.29.1", "serde", "serde_json", "unicode-normalization", @@ -6218,56 +6302,71 @@ dependencies = [ ] [[package]] -name = "nostr-database" -version = "0.44.0" +name = "nostr" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +checksum = "5dde8c76076d334409d86c2e1db3e97abe5deb8cb92744f939cbc1fa45bd69e7" dependencies = [ - "lru 0.16.4", - "nostr", - "tokio", + "base64 0.22.1", + "bech32 0.12.0", + "bip39", + "bitcoin_hashes 1.2.0", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "faster-hex", + "opaquerr", + "rand 0.10.2", + "secp256k1 0.30.0", + "serde", + "serde_json", + "unicode-normalization", + "universal-time", + "url", + "zeroize", ] [[package]] -name = "nostr-gossip" -version = "0.44.0" +name = "nostr-database" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf" dependencies = [ - "nostr", + "nostr 0.45.1", + "opaquerr", ] [[package]] -name = "nostr-relay-pool" -version = "0.44.3" +name = "nostr-gossip" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336" dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru 0.16.4", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", + "nostr 0.45.1", + "opaquerr", ] [[package]] name = "nostr-sdk" -version = "0.44.1" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +checksum = "26c86342f367bd9b173ec4a697e936e3a82d6dad5b4aa06c0d35d9b4f88a8e72" dependencies = [ "async-utility", - "nostr", + "async-wsocket", + "faster-hex", + "futures", + "lru", + "negentropy", + "nostr 0.45.1", "nostr-database", "nostr-gossip", - "nostr-relay-pool", + "opaquerr", + "rand 0.10.2", "tokio", + "tokio-stream", "tracing", + "universal-time", ] [[package]] @@ -6299,7 +6398,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6772,6 +6871,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaquerr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" + [[package]] name = "open" version = "5.3.6" @@ -6785,8 +6890,8 @@ dependencies = [ [[package]] name = "openai-frontend" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "axum", @@ -6997,7 +7102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -8023,7 +8128,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8179,7 +8284,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools", "kasuari", - "lru 0.18.1", + "lru", "palette", "serde", "strum", @@ -8754,7 +8859,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8824,7 +8929,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9009,6 +9114,17 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.101", + "rand 0.8.6", + "secp256k1-sys", +] + [[package]] name = "secp256k1-sys" version = "0.10.1" @@ -9080,7 +9196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9565,8 +9681,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "blake3", @@ -9575,40 +9691,41 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "libloading 0.8.9", ] [[package]] name = "skippy-metrics" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "skippy-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "prost 0.14.4", "prost-build 0.14.4", "protoc-bin-vendored", "serde", + "skippy-tokenizer", ] [[package]] name = "skippy-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "libc", @@ -9621,8 +9738,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "ahash", "anyhow", @@ -9633,6 +9750,8 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-native-serving-plugin-api", + "model-artifact", "openai-frontend", "opentelemetry-proto", "serde", @@ -9642,16 +9761,25 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-tokenizer", "socket2", "tokio", "tokio-stream", "tonic", ] +[[package]] +name = "skippy-tokenizer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "serde", +] + [[package]] name = "skippy-topology" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -9687,7 +9815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10701,10 +10829,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10726,7 +10854,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook 0.3.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10972,6 +11100,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-happy-eyeballs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8564c32dfb6f4257f8bc6edfc178a34af97520e0b7b9815500c55eb3d092f29f" +dependencies = [ + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -11040,9 +11177,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -11050,7 +11187,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.26.2", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -11090,6 +11227,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" dependencies = [ + "aws-lc-rs", "base64 0.22.1", "bytes", "futures-core", @@ -11458,7 +11596,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11480,9 +11618,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -11559,7 +11697,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11700,6 +11838,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -12164,15 +12308,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "core-foundation 0.10.1", "jni 0.22.4", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys", @@ -12404,7 +12548,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9b2de6a575f..527690df14f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.8" +version = "0.5.14" description = "Buzz desktop app" authors = ["you"] edition = "2021" @@ -98,6 +98,7 @@ nostr = { version = "0.44", features = ["nip44", "nip49"] } # transitive dependency; pinned here for direct use). getrandom = "0.2" zeroize = "1" +percent-encoding = "2" reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } url = "2" @@ -109,14 +110,14 @@ buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" } portable-pty = "0.9" iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" diff --git a/desktop/src-tauri/src/archive/agent_usage.rs b/desktop/src-tauri/src/archive/agent_usage.rs new file mode 100644 index 00000000000..587970aac65 --- /dev/null +++ b/desktop/src-tauri/src/archive/agent_usage.rs @@ -0,0 +1,886 @@ +//! Pure NIP-AM usage accounting: request validation, wire types, and the +//! per-field cumulative/direct-fallback ladder. +//! +//! No Tauri or filesystem dependency — every function here takes already +//! loaded rows (or plain values) and returns plain values. The caller +//! (`mod.rs`'s `get_agent_usage_series` command, Phase 2) owns SQLite access +//! (`metric_store.rs`) and glues the two together: load window rows, compute +//! [`window_probe_keys`], load those exact-key rows, then call +//! [`compute_series`]. +//! +//! Accounting contract (Rev 3, frozen plan + amendments A1/A2/A4/A9/A11–A13): +//! see `docs/nips/NIP-AM.md:119-160` for the NIP itself. + +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use super::metric_store::AgentMetricIndexRow; + +// ── Request ────────────────────────────────────────────────────────────────── + +/// Request for [`compute_series`]'s caller. `bucket_boundaries` are exact +/// local-midnight Unix-second boundaries built by the frontend (inclusive +/// start / exclusive end per adjacent pair): N entries = N - 1 buckets, so +/// 2 entries = 1 bucket (`1d`), 8 = 7 buckets, 31 = 30 buckets, and an +/// arbitrary custom range falls anywhere in between up to +/// [`MAX_BOUNDARIES`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsageSeriesRequest { + pub bucket_boundaries: Vec, + pub agent_pubkey: Option, +} + +/// Widest interval NIP-AM query validation admits (A9): wide enough to admit +/// every real civil-day transition (ordinary DST, 30-minute-offset zones, +/// historical calendar skips) while still rejecting arbitrary bins. +const MAX_INTERVAL_SECS: i64 = 48 * 3600; + +/// Largest boundary count a request may carry: 367 boundaries = 366 daily +/// buckets = one leap year, the product ceiling on the custom date-range +/// picker. Bounds the SQLite window scan and keeps the rendered bar chart +/// legible; the frontend clamps the picker to the same span so a user never +/// reaches this check, which stays as fail-closed defense in depth. +const MAX_BOUNDARIES: usize = 367; + +/// Smallest boundary count that describes a real window: 2 boundaries = +/// 1 bucket, the `1d` case. +const MIN_BOUNDARIES: usize = 2; + +/// Validate a request per the frozen contract + A9 (drops the 23–25h band +/// for a `> 0 && <= 48h` sanity band) and A13 pubkey normalization. +/// +/// Fails closed before any SQLite work. Returns the normalized (lowercased) +/// agent pubkey, if one was supplied. +pub(super) fn validate_request(req: &AgentUsageSeriesRequest) -> Result, String> { + let n = req.bucket_boundaries.len(); + if !(MIN_BOUNDARIES..=MAX_BOUNDARIES).contains(&n) { + return Err(format!( + "bucket_boundaries must have between {MIN_BOUNDARIES} and {MAX_BOUNDARIES} entries, got {n}" + )); + } + + for i in 0..n - 1 { + let (a, b) = (req.bucket_boundaries[i], req.bucket_boundaries[i + 1]); + if b <= a { + return Err(format!( + "bucket_boundaries must be strictly increasing (index {i}: {a} >= {b})" + )); + } + let interval = b - a; + if interval > MAX_INTERVAL_SECS { + return Err(format!( + "bucket_boundaries interval at index {i} is {interval}s, exceeds {MAX_INTERVAL_SECS}s" + )); + } + } + + // Finite Unix-second bounds: must be representable as an RFC 3339 + // instant (chrono's timestamp range), independent of local timezone. + for &t in &req.bucket_boundaries { + if chrono::DateTime::from_timestamp(t, 0).is_none() { + return Err(format!("bucket boundary {t} is out of representable range")); + } + } + + let normalized_pubkey = match &req.agent_pubkey { + None => None, + Some(pk) => { + if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("agent_pubkey must be exactly 64 hex characters".to_string()); + } + Some(pk.to_lowercase()) + } + }; + + Ok(normalized_pubkey) +} + +// ── Wire types ─────────────────────────────────────────────────────────────── + +/// Per-field completeness (A2): `value: null` means no known increment in +/// scope; `incomplete: true` on a non-null value means the value is a +/// reported lower bound, not full coverage. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageField { + pub value: Option, + pub incomplete: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CostField { + pub value: Option, + pub incomplete: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportedUsage { + pub input_tokens: UsageField, + pub output_tokens: UsageField, + pub total_tokens: UsageField, + pub estimated_cost_usd: CostField, + /// Cache-read (served) token count. `None` value + `incomplete: false` + /// means no events in this scope reported the field; in practice this + /// only happens for empty scopes. A non-empty scope where ALL events had + /// absent `turn_cache_read_tokens` (old harness) produces `incomplete: true` + /// (unknown, not zero). + pub cache_read_tokens: UsageField, + /// Cache-write (creation) token count. Same absence semantics as + /// `cache_read_tokens`. + pub cache_write_tokens: UsageField, + /// Input tokens minus cache-served and cache-write subsets. Computed only + /// when all three inputs are complete and the arithmetic succeeds + /// (`cacheRead + cacheWrite ≤ input`). Otherwise `incomplete: true`. + pub fresh_input_tokens: UsageField, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesBucket { + pub start: i64, + pub end: i64, + pub usage: ReportedUsage, + pub report_count: i64, + pub has_unknown_usage: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelUsage { + pub harness: Option, + pub model: Option, + pub usage: ReportedUsage, + pub report_count: i64, + pub has_unknown_usage: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsage { + pub agent_pubkey: String, + pub usage: ReportedUsage, + pub buckets: Vec, + pub models: Vec, + pub report_count: i64, + pub has_unknown_usage: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Coverage { + pub first_archived_at: Option, + pub last_archived_at: Option, + pub first_reported_at: Option, + pub last_reported_at: Option, + pub report_count: i64, + pub invalid_report_count: i64, + pub has_unknown_usage: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsageSeries { + pub collection_enabled: bool, + pub buckets: Vec, + pub agents: Vec, + pub coverage: Coverage, + /// A13: `null` when the request had no `agentPubkey` filter; otherwise + /// `true` iff at least one surviving `agent_metric_index` row (either + /// `parse_status`) exists for that author, with no bucket-boundary + /// restriction. Callers compute this (DB access) and pass it through. + pub has_archived_evidence: Option, +} + +// ── Per-event field ladder (A1, A4, A11, A12) ─────────────────────────────── + +#[derive(Debug, Clone, Copy)] +enum FieldValue { + Known(T), + Unknown, +} + +struct EventOutcome { + input: FieldValue, + output: FieldValue, + total: FieldValue, + cost: FieldValue, + cache_read: FieldValue, + cache_write: FieldValue, +} + +/// Per-field ladder for token counters (A1): adjacent nondecreasing cumulative +/// pair → diff; adjacent decreasing pair → unknown, terminal, no fallback; +/// no usable baseline for this field → `deltaReliable` direct value or +/// unknown. +fn ladder_token( + baseline_cumulative: Option, + current_cumulative: Option, + current_turn: Option, + delta_reliable: bool, +) -> FieldValue { + if let (Some(prev), Some(cur)) = (baseline_cumulative, current_cumulative) { + return if cur >= prev { + FieldValue::Known(cur - prev) + } else { + FieldValue::Unknown + }; + } + if delta_reliable { + if let Some(v) = current_turn { + return FieldValue::Known(v); + } + } + FieldValue::Unknown +} + +/// Same ladder for `costUsd` (f64). +fn ladder_cost( + baseline_cumulative: Option, + current_cumulative: Option, + current_turn: Option, + delta_reliable: bool, +) -> FieldValue { + if let (Some(prev), Some(cur)) = (baseline_cumulative, current_cumulative) { + return if cur >= prev { + FieldValue::Known(cur - prev) + } else { + FieldValue::Unknown + }; + } + if delta_reliable { + if let Some(v) = current_turn { + return FieldValue::Known(v); + } + } + FieldValue::Unknown +} + +/// Resolve the exact-`S-1` baseline row for `row`, or `None` if no usable +/// baseline exists (A11/A12): missing session/sequence key, duplicate row at +/// `row`'s own sequence (A4 — no cumulative delta for any row at a +/// duplicated sequence), sequence `0` (no predecessor, `checked_sub` +/// underflow), no predecessor row (gap), or duplicate rows at the +/// predecessor sequence (ambiguous baseline). +/// +/// `probe_by_key` must contain, for every key queried, ALL valid rows at +/// that exact `(agent, session, turnSeq)` — used for both this baseline +/// lookup and the A4/A11 duplicate-cardinality check, which is why absence +/// of a key from the map is treated identically to an empty group. +fn resolve_baseline<'a>( + row: &AgentMetricIndexRow, + probe_by_key: &HashMap<(String, String, u64), Vec<&'a AgentMetricIndexRow>>, +) -> Option<&'a AgentMetricIndexRow> { + let (agent, session, seq) = row.accounting_key()?; + + let own_group = probe_by_key.get(&(agent.clone(), session.clone(), seq))?; + if own_group.len() > 1 { + return None; // A4: duplicate at own sequence — no cumulative delta. + } + + let pred_seq = seq.checked_sub(1)?; // A12: seq == 0 has no baseline. + let pred_group = probe_by_key.get(&(agent, session, pred_seq))?; + if pred_group.len() != 1 { + return None; // Missing (gap) or ambiguous (duplicate) predecessor. + } + Some(pred_group[0]) +} + +fn compute_event_outcome( + row: &AgentMetricIndexRow, + probe_by_key: &HashMap<(String, String, u64), Vec<&AgentMetricIndexRow>>, +) -> EventOutcome { + let baseline = resolve_baseline(row, probe_by_key); + let delta_reliable = row.delta_reliable.unwrap_or(false); + + EventOutcome { + input: ladder_token( + baseline.and_then(|b| b.cumulative_input_tokens), + row.cumulative_input_tokens, + row.turn_input_tokens, + delta_reliable, + ), + output: ladder_token( + baseline.and_then(|b| b.cumulative_output_tokens), + row.cumulative_output_tokens, + row.turn_output_tokens, + delta_reliable, + ), + total: ladder_token( + baseline.and_then(|b| b.cumulative_total_tokens), + row.cumulative_total_tokens, + row.turn_total_tokens, + delta_reliable, + ), + cost: ladder_cost( + baseline.and_then(|b| b.cumulative_cost_usd), + row.cumulative_cost_usd, + row.turn_cost_usd, + delta_reliable, + ), + cache_read: ladder_token( + baseline.and_then(|b| b.cumulative_cache_read_tokens), + row.cumulative_cache_read_tokens, + row.turn_cache_read_tokens, + delta_reliable, + ), + cache_write: ladder_token( + baseline.and_then(|b| b.cumulative_cache_write_tokens), + row.cumulative_cache_write_tokens, + row.turn_cache_write_tokens, + delta_reliable, + ), + } +} + +/// The exact `(agent, session, turnSeq)` keys the caller must load via +/// `metric_store::load_rows_at_exact_keys` before calling [`compute_series`] +/// (A11): each in-window row's own key, plus its checked predecessor key +/// (`turnSeq - 1`) when one exists. Pure and DB-free so it is unit-testable +/// without SQLite. +pub(super) fn window_probe_keys( + window_rows: &[AgentMetricIndexRow], +) -> HashSet<(String, String, u64)> { + let mut keys = HashSet::new(); + for row in window_rows { + if let Some((agent, session, seq)) = row.accounting_key() { + if let Some(pred) = seq.checked_sub(1) { + keys.insert((agent.clone(), session.clone(), pred)); + } + keys.insert((agent, session, seq)); + } + } + keys +} + +// ── Accumulators ───────────────────────────────────────────────────────────── + +/// Sums known per-event increments with `checked_add`; an event with an +/// unknown value, or an overflow, marks the scope `incomplete` (A2) without +/// ever wrapping (overflow freezes the sum at its last valid value). +#[derive(Debug, Default, Clone)] +struct TokenAccumulator { + value: Option, + incomplete: bool, + overflowed: bool, +} + +impl TokenAccumulator { + fn add(&mut self, v: FieldValue) { + match v { + FieldValue::Unknown => self.incomplete = true, + FieldValue::Known(x) => { + if self.overflowed { + self.incomplete = true; + return; + } + self.value = Some(match self.value { + None => x, + Some(cur) => match cur.checked_add(x) { + Some(sum) => sum, + None => { + self.overflowed = true; + self.incomplete = true; + cur + } + }, + }); + } + } + } + + fn has_unknown(&self) -> bool { + self.incomplete + } + + fn finish(self) -> UsageField { + UsageField { + value: self.value.map(|v| v.to_string()), + incomplete: self.incomplete, + } + } +} + +/// Same contract as [`TokenAccumulator`] for `f64` costs: "checked finite +/// addition" means a sum that would become non-finite is rejected and the +/// scope freezes at its last valid value, flagged incomplete. +#[derive(Debug, Default, Clone)] +struct CostAccumulator { + value: Option, + incomplete: bool, + overflowed: bool, +} + +impl CostAccumulator { + fn add(&mut self, v: FieldValue) { + match v { + FieldValue::Unknown => self.incomplete = true, + FieldValue::Known(x) => { + if self.overflowed { + self.incomplete = true; + return; + } + let candidate = match self.value { + None => x, + Some(cur) => cur + x, + }; + if candidate.is_finite() { + self.value = Some(candidate); + } else { + self.overflowed = true; + self.incomplete = true; + } + } + } + } + + fn has_unknown(&self) -> bool { + self.incomplete + } + + fn finish(self) -> CostField { + CostField { + value: self.value, + incomplete: self.incomplete, + } + } +} + +#[derive(Debug, Default, Clone)] +struct UsageAccumulator { + input: TokenAccumulator, + output: TokenAccumulator, + total: TokenAccumulator, + cost: CostAccumulator, + cache_read: TokenAccumulator, + cache_write: TokenAccumulator, +} + +impl UsageAccumulator { + fn add(&mut self, outcome: &EventOutcome) { + self.input.add(outcome.input); + self.output.add(outcome.output); + self.total.add(outcome.total); + self.cost.add(outcome.cost); + self.cache_read.add(outcome.cache_read); + self.cache_write.add(outcome.cache_write); + } + + fn has_unknown(&self) -> bool { + self.input.has_unknown() + || self.output.has_unknown() + || self.total.has_unknown() + || self.cost.has_unknown() + || self.cache_read.has_unknown() + || self.cache_write.has_unknown() + || self.fresh_input_incomplete() + } + + /// Returns true when `derive_fresh_input()` would produce `incomplete: true`. + /// + /// Mirrors the fail-closed conditions of `derive_fresh_input()` without + /// consuming `self`, so `has_unknown()` can be called before `finish()` at + /// all six call sites. The two functions must stay in sync. + fn fresh_input_incomplete(&self) -> bool { + // Input unknown or incomplete → fresh_input incomplete. + if self.input.incomplete { + return true; + } + // No input events at all (value = None, not incomplete) → no fresh_input + // to derive; not a failure, not incomplete. + let Some(input) = self.input.value else { + return false; + }; + // Any cache accumulator incomplete → fresh_input incomplete. + if self.cache_read.incomplete || self.cache_write.incomplete { + return true; + } + let cache_read = self.cache_read.value.unwrap_or(0); + let cache_write = self.cache_write.value.unwrap_or(0); + // Subset sum overflow → incomplete. + let Some(subset_sum) = cache_read.checked_add(cache_write) else { + return true; + }; + // Subsets exceed input → incomplete. + input < subset_sum + } + + /// D6 sort value: provider `totalTokens` when known, else `input+output` + /// when BOTH are known (no overflow), else `None` (unknown-last). This is + /// the sole sort key; provenance (whether total came from a single field vs + /// two) is not distinguished — both are "known" and rank before unknown. + fn sort_value(&self) -> Option { + // Prefer the reported total if present and exact. + if let Some(t) = self.total.value { + if !self.total.incomplete { + return Some(t); + } + } + // Fall back to input+output when both are complete and neither + // overflowed. An incomplete accumulator may still carry a partial + // value; we must not use it (treat as unknown). + match (self.input.value, self.output.value) { + (Some(i), Some(o)) if !self.input.incomplete && !self.output.incomplete => { + i.checked_add(o) + } + _ => None, + } + } + + /// Derive `freshInputTokens` from accumulated input, cache-read, and + /// cache-write: checked arithmetic — fail-closed on any unknown input or + /// cache delta, overflow, or subsets-exceed-input condition. + /// + /// Cache-read and cache-write follow the same Unknown semantics as the + /// main token ladder: a turn or cumulative value that was absent (not + /// reported by the harness) produces `FieldValue::Unknown` in + /// `compute_event_outcome`, which poisons the accumulator as `incomplete`. + /// An absent cache field is therefore "unknown, not zero," and always + /// produces `incomplete: true` here. + /// + /// The only case where the cache accumulator can be `{ value: None, + /// incomplete: false }` without any events is an empty scope, which + /// cannot occur in practice (scopes are created on first event). + /// + /// Conditions that produce `incomplete: true`: + /// - input is unknown (None value or incomplete = true) + /// - any cache category's accumulator is incomplete + /// - checked_sub(input - (cacheRead + cacheWrite)) would underflow + /// (cacheRead + cacheWrite > input) + /// - overflow in cacheRead + cacheWrite sum + fn derive_fresh_input(&self) -> UsageField { + // If input is unknown or incomplete, fresh_input is incomplete too. + if self.input.incomplete { + return UsageField { + value: None, + incomplete: true, + }; + } + let input = match self.input.value { + Some(v) => v, + None => { + // No input events observed — no fresh_input to compute. + return UsageField { + value: None, + incomplete: false, + }; + } + }; + + // Cache-read and cache-write: absent means zero (no provider reported it), + // but incomplete (unknown delta) means fail-closed. + if self.cache_read.incomplete || self.cache_write.incomplete { + return UsageField { + value: None, + incomplete: true, + }; + } + let cache_read = self.cache_read.value.unwrap_or(0); + let cache_write = self.cache_write.value.unwrap_or(0); + + // Sum of subsets must not overflow and must not exceed input. + let subset_sum = match cache_read.checked_add(cache_write) { + Some(s) => s, + None => { + return UsageField { + value: None, + incomplete: true, + }; + } + }; + match input.checked_sub(subset_sum) { + Some(fresh) => UsageField { + value: Some(fresh.to_string()), + incomplete: false, + }, + None => UsageField { + value: None, + incomplete: true, + }, + } + } + + fn finish(self) -> ReportedUsage { + let fresh_input = self.derive_fresh_input(); + ReportedUsage { + input_tokens: self.input.finish(), + output_tokens: self.output.finish(), + total_tokens: self.total.finish(), + estimated_cost_usd: self.cost.finish(), + cache_read_tokens: self.cache_read.finish(), + cache_write_tokens: self.cache_write.finish(), + fresh_input_tokens: fresh_input, + } + } +} + +// ── Bucket assignment ──────────────────────────────────────────────────────── + +/// Which `[boundaries[i], boundaries[i+1])` bucket `t` falls in, or `None` +/// if outside every bucket (defensive; callers scope their row query to +/// `[boundaries[0], boundaries[last])` so this should never miss). +fn assign_bucket_index(boundaries: &[i64], t: i64) -> Option { + (0..boundaries.len().saturating_sub(1)).find(|&i| t >= boundaries[i] && t < boundaries[i + 1]) +} + +// ── Sort helpers ───────────────────────────────────────────────────────────── + +/// D6 sort comparator: known values descending, unknown (`None`) last; equal +/// within unknowns (callers chain further tiebreaks). Value is derived by +/// [`UsageAccumulator::sort_value`]: provider total when available, else +/// `input+output`, else `None`. +fn cmp_sort_value(a: Option, b: Option) -> std::cmp::Ordering { + match (a, b) { + (Some(av), Some(bv)) => bv.cmp(&av), // descending + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } +} + +/// Ordinal (byte-order) comparison of two optional strings, with `None` +/// sorting after any `Some` value — used as a stable tiebreak for harness and +/// model names so ordering is locale-independent and matches the Rust backend. +fn cmp_option_str_none_last(a: &Option, b: &Option) -> std::cmp::Ordering { + match (a, b) { + (Some(av), Some(bv)) => av.cmp(bv), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } +} + +// ── compute_series ─────────────────────────────────────────────────────────── + +/// Per-agent accumulation scope, built while walking `window_rows` once. +struct AgentScope { + buckets: Vec, + bucket_counts: Vec, + total: UsageAccumulator, + report_count: i64, + /// Keyed by `(harness, model)` — same model via two harnesses → two rows. + models: HashMap<(Option, Option), (UsageAccumulator, i64)>, +} + +/// Compute the full [`AgentUsageSeries`] from already-loaded rows. +/// +/// - `window_rows`: valid rows with `reported_at` in `[boundaries[0], +/// boundaries[last])` (optionally pre-filtered to one agent), from +/// `metric_store::load_window_valid_rows`. +/// - `probe_rows`: valid rows at the exact keys from [`window_probe_keys`], +/// from `metric_store::load_rows_at_exact_keys` — used for baseline +/// resolution and A4/A11 duplicate-cardinality checks, unrestricted by the +/// window. +/// - `invalid_report_count`: from `metric_store::count_invalid_rows_in_window`. +/// - `has_archived_evidence`: from `metric_store::has_archived_evidence`, +/// already resolved to `None` when the request has no `agentPubkey` filter +/// (A13) — this function does not decide that; it only carries the value. +pub(super) fn compute_series( + window_rows: &[AgentMetricIndexRow], + probe_rows: &[AgentMetricIndexRow], + invalid_report_count: i64, + boundaries: &[i64], + has_archived_evidence: Option, + collection_enabled: bool, +) -> AgentUsageSeries { + let bucket_count = boundaries.len().saturating_sub(1); + + let mut probe_by_key: HashMap<(String, String, u64), Vec<&AgentMetricIndexRow>> = + HashMap::new(); + for r in probe_rows { + if let Some(key) = r.accounting_key() { + probe_by_key.entry(key).or_default().push(r); + } + } + + let mut overall_buckets: Vec = (0..bucket_count) + .map(|_| UsageAccumulator::default()) + .collect(); + let mut overall_bucket_counts: Vec = vec![0; bucket_count]; + + let mut agents: HashMap = HashMap::new(); + + let mut first_reported_at: Option = None; + let mut last_reported_at: Option = None; + let mut first_archived_at: Option = None; + let mut last_archived_at: Option = None; + + for row in window_rows { + // Defensive: the loader already scopes to reported_at in-window and + // parse_status = 'valid'; a miss here means the caller passed rows + // it should not have, so skip rather than panic or miscount. + let Some(reported_at) = row.reported_at else { + continue; + }; + let Some(bucket_idx) = assign_bucket_index(boundaries, reported_at) else { + continue; + }; + + first_reported_at = Some(first_reported_at.map_or(reported_at, |v| v.min(reported_at))); + last_reported_at = Some(last_reported_at.map_or(reported_at, |v| v.max(reported_at))); + first_archived_at = + Some(first_archived_at.map_or(row.archived_at, |v| v.min(row.archived_at))); + last_archived_at = + Some(last_archived_at.map_or(row.archived_at, |v| v.max(row.archived_at))); + + let outcome = compute_event_outcome(row, &probe_by_key); + + overall_buckets[bucket_idx].add(&outcome); + overall_bucket_counts[bucket_idx] += 1; + + let scope = agents + .entry(row.agent_pubkey.clone()) + .or_insert_with(|| AgentScope { + buckets: (0..bucket_count) + .map(|_| UsageAccumulator::default()) + .collect(), + bucket_counts: vec![0; bucket_count], + total: UsageAccumulator::default(), + report_count: 0, + models: HashMap::new(), + }); + scope.buckets[bucket_idx].add(&outcome); + scope.bucket_counts[bucket_idx] += 1; + scope.total.add(&outcome); + scope.report_count += 1; + + let model_entry = scope + .models + .entry((row.harness.clone(), row.model.clone())) + .or_insert_with(|| (UsageAccumulator::default(), 0i64)); + model_entry.0.add(&outcome); + model_entry.1 += 1; + } + + let overall_report_count: i64 = overall_bucket_counts.iter().sum(); + + let buckets: Vec = overall_buckets + .into_iter() + .zip(overall_bucket_counts) + .enumerate() + .map(|(i, (acc, count))| { + let has_unknown_usage = acc.has_unknown(); + SeriesBucket { + start: boundaries[i], + end: boundaries[i + 1], + usage: acc.finish(), + report_count: count, + has_unknown_usage, + } + }) + .collect(); + let any_overall_bucket_unknown = buckets.iter().any(|b| b.has_unknown_usage); + + // Build agent rows, then apply the D6 ranking rule: known sort value + // (provider total, else input+output) descending, unknown-value agents + // after, pubkey as the final tiebreak for determinism. + let mut agent_rows: Vec<(Option, String, AgentUsage)> = agents + .into_iter() + .map(|(agent_pubkey, scope)| { + let sort_value = scope.total.sort_value(); + let has_unknown_usage = scope.total.has_unknown(); + + let buckets: Vec = scope + .buckets + .into_iter() + .zip(scope.bucket_counts) + .enumerate() + .map(|(i, (acc, count))| { + let has_unknown_usage = acc.has_unknown(); + SeriesBucket { + start: boundaries[i], + end: boundaries[i + 1], + usage: acc.finish(), + report_count: count, + has_unknown_usage, + } + }) + .collect(); + + // Named sort key so the 4-tuple doesn't exceed clippy's type_complexity + // threshold and the ordering intent reads at a glance. + struct ModelSortKey { + sort_val: Option, + harness: Option, + model: Option, + usage: ModelUsage, + } + let mut model_rows: Vec = scope + .models + .into_iter() + .map(|((harness, model), (acc, count))| { + let sort_val = acc.sort_value(); + let has_unknown_usage = acc.has_unknown(); + ModelSortKey { + sort_val, + harness: harness.clone(), + model: model.clone(), + usage: ModelUsage { + harness, + model, + usage: acc.finish(), + report_count: count, + has_unknown_usage, + }, + } + }) + .collect(); + // D6 ranking: known sort value (provider total, else input+output) + // descending, unknown-value rows after; tiebreak: harness ascending + // (None last), then model ascending (None last), for determinism. + model_rows.sort_by(|a, b| { + cmp_sort_value(a.sort_val, b.sort_val) + .then_with(|| cmp_option_str_none_last(&a.harness, &b.harness)) + .then_with(|| cmp_option_str_none_last(&a.model, &b.model)) + }); + let models = model_rows.into_iter().map(|k| k.usage).collect(); + + ( + sort_value, + agent_pubkey.clone(), + AgentUsage { + agent_pubkey, + usage: scope.total.finish(), + buckets, + models, + report_count: scope.report_count, + has_unknown_usage, + }, + ) + }) + .collect(); + agent_rows.sort_by(|a, b| cmp_sort_value(a.0, b.0).then_with(|| a.1.cmp(&b.1))); + let any_agent_unknown = agent_rows.iter().any(|(_, _, a)| a.has_unknown_usage); + let agents: Vec = agent_rows.into_iter().map(|(_, _, a)| a).collect(); + + AgentUsageSeries { + collection_enabled, + buckets, + agents, + coverage: Coverage { + first_archived_at, + last_archived_at, + first_reported_at, + last_reported_at, + report_count: overall_report_count, + invalid_report_count, + has_unknown_usage: any_overall_bucket_unknown + || any_agent_unknown + || invalid_report_count > 0, + }, + has_archived_evidence, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[path = "agent_usage_tests.rs"] +mod agent_usage_tests; + +#[cfg(test)] +#[path = "agent_usage_p4a_tests.rs"] +mod agent_usage_p4a_tests; diff --git a/desktop/src-tauri/src/archive/agent_usage_p4a_tests.rs b/desktop/src-tauri/src/archive/agent_usage_p4a_tests.rs new file mode 100644 index 00000000000..7f89ecc2227 --- /dev/null +++ b/desktop/src-tauri/src/archive/agent_usage_p4a_tests.rs @@ -0,0 +1,512 @@ +//! Tests for P4a additions: cache-read/write ladder (§S-1 extension), +//! freshInputTokens derivation, and D6 comparator. Split from +//! `agent_usage_tests.rs` to keep both files under the 1 000-line ratchet. + +use super::*; +use crate::archive::metric_store::{AgentMetricIndexRow, ParseStatus}; + +// ── Row builder ─────────────────────────────────────────────────────────────── + +/// Minimal valid row for P4a test scenarios. +fn row(id: &str, agent: &str, session: &str, seq: u64, reported_at: i64) -> AgentMetricIndexRow { + AgentMetricIndexRow { + id: id.to_string(), + agent_pubkey: agent.to_string(), + event_created_at: reported_at, + archived_at: reported_at, + reported_at: Some(reported_at), + session_id: Some(session.to_string()), + turn_seq: Some(seq), + harness: None, + model: None, + delta_reliable: Some(true), + turn_input_tokens: None, + turn_output_tokens: None, + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: None, + cumulative_input_tokens: None, + cumulative_output_tokens: None, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, + turn_cache_write_tokens: None, + pricing_authority: None, + pricing_model: None, + pricing_cache_class: None, + parse_status: ParseStatus::Valid, + } +} + +/// Standard 8-boundary window covering one day, seconds since epoch. +const DAY: i64 = 86_400; +fn boundaries_7() -> Vec { + (0..=7).map(|i| i * DAY).collect() +} + +// ── Cache-read / cache-write ladder (P4a.1) ────────────────────────────────── + +#[test] +fn cache_read_ladder_uses_direct_turn_value_when_no_baseline() { + // A single event with only turn_cache_read_tokens; no cumulative, no + // baseline → direct turn value flows through as Known. + let r = AgentMetricIndexRow { + turn_cache_read_tokens: Some(80), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].usage.cache_read_tokens, + UsageField { + value: Some("80".to_string()), + incomplete: false + } + ); +} + +#[test] +fn cache_write_ladder_uses_direct_turn_value_when_no_baseline() { + let r = AgentMetricIndexRow { + turn_cache_write_tokens: Some(40), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].usage.cache_write_tokens, + UsageField { + value: Some("40".to_string()), + incomplete: false + } + ); +} + +#[test] +fn omitted_cache_field_stays_unknown_not_zero_through_pipeline() { + // Old-harness compat: a row that never reports cache_write_tokens + // must produce incomplete=true (unknown), NOT value=Some("0"). + // The ladder returns Unknown for any absent turn/cumulative field, which + // poisons the TokenAccumulator as incomplete — absence is NOT treated as zero. + let r = AgentMetricIndexRow { + turn_input_tokens: Some(100), + delta_reliable: Some(true), + turn_cache_write_tokens: None, // explicitly omitted + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].usage.cache_write_tokens, + UsageField { + value: None, + incomplete: true + }, + "absent cache_write_tokens must produce incomplete=true (unknown), not zero" + ); +} + +#[test] +fn explicit_zero_cache_field_survives_as_zero_not_absent() { + // A row reporting cache_write = 0 (explicit confirmed-zero) must produce + // value=Some("0"), not None. + let r = AgentMetricIndexRow { + turn_cache_write_tokens: Some(0), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].usage.cache_write_tokens, + UsageField { + value: Some("0".to_string()), + incomplete: false + }, + "explicit zero must survive as '0' not absent" + ); +} + +#[test] +fn cache_read_adjacent_cumulative_preferred_over_direct() { + // Two adjacent events with cumulative_cache_read_tokens: the second + // event's diff (200 - 100 = 100) is used, not the direct turn value. + let baseline = AgentMetricIndexRow { + cumulative_cache_read_tokens: Some(100), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let current = AgentMetricIndexRow { + turn_cache_read_tokens: Some(999), // direct — should NOT be used + cumulative_cache_read_tokens: Some(200), + delta_reliable: Some(true), + ..row("e2", "agent1", "s1", 2, 1) + }; + let boundaries = boundaries_7(); + let rows = vec![baseline, current]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + // baseline contributes Unknown (no baseline before it, no turn value) + // then current contributes cumulative diff = 100 + // Total = Unknown accumulation → incomplete + assert!(series.agents[0].usage.cache_read_tokens.incomplete); +} + +// ── freshInputTokens derivation (P4a.2) ────────────────────────────────────── + +#[test] +fn fresh_input_is_input_minus_cache_subsets() { + // input=100, cache_read=30, cache_write=20 → fresh=50 + let r = AgentMetricIndexRow { + turn_input_tokens: Some(100), + turn_cache_read_tokens: Some(30), + turn_cache_write_tokens: Some(20), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].usage.fresh_input_tokens, + UsageField { + value: Some("50".to_string()), + incomplete: false + } + ); +} + +#[test] +fn fresh_input_fail_closed_when_cache_fields_absent() { + // cache_read and cache_write absent → ladder returns Unknown → accumulator + // is incomplete → fresh_input fails closed (incomplete: true). + // Old-harness compat: absent cache fields are unknown, not zero. + let r = AgentMetricIndexRow { + turn_input_tokens: Some(100), + turn_cache_read_tokens: None, + turn_cache_write_tokens: None, + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].usage.fresh_input_tokens, + UsageField { + value: None, + incomplete: true + }, + "absent cache fields produce unknown fresh_input, not input-verbatim" + ); +} + +#[test] +fn fresh_input_fail_closed_when_subsets_exceed_input() { + // cache_read=60 + cache_write=60 = 120 > input=100 → incomplete + let r = AgentMetricIndexRow { + turn_input_tokens: Some(100), + turn_cache_read_tokens: Some(60), + turn_cache_write_tokens: Some(60), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].usage.fresh_input_tokens, + UsageField { + value: None, + incomplete: true + }, + "cacheRead+cacheWrite > input must produce incomplete fresh_input" + ); +} + +#[test] +fn fresh_input_fail_closed_when_subset_sum_overflows() { + // Both cache fields are u64::MAX/2 + 1 → their sum overflows u64 → incomplete + let half_plus_one = u64::MAX / 2 + 1; + let r = AgentMetricIndexRow { + turn_input_tokens: Some(u64::MAX), + turn_cache_read_tokens: Some(half_plus_one), + turn_cache_write_tokens: Some(half_plus_one), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].usage.fresh_input_tokens, + UsageField { + value: None, + incomplete: true + }, + "cache subset sum overflow must produce incomplete fresh_input" + ); +} + +#[test] +fn fresh_input_fail_closed_when_input_unknown() { + // Unknown input (unreliable delta, no cumulative) → fresh_input incomplete + let r = AgentMetricIndexRow { + turn_input_tokens: Some(100), + delta_reliable: Some(false), // unreliable, no cumulative → Unknown + turn_cache_read_tokens: Some(10), + turn_cache_write_tokens: Some(5), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].usage.fresh_input_tokens, + UsageField { + value: None, + incomplete: true + }, + "unknown input must make fresh_input incomplete" + ); +} + +// ── D6 comparator test vector (P4a.3) ──────────────────────────────────────── +// +// This vector pins the Rust sort order that the TS layer must match for +// model-breakdown rendering (P5). Each scenario documents the expected +// position in the sorted slice. + +#[test] +fn d6_agent_with_total_ranks_before_input_output_only() { + // Agent A: total=200 (known from provider) + // Agent B: input=150, output=100 (total unknown) → sort value = 250 + // D6: A has total=200 < 250, so B ranks first (higher sort value) + let a = AgentMetricIndexRow { + turn_total_tokens: Some(200), + delta_reliable: Some(true), + ..row("e1", "agent_a", "s1", 1, 0) + }; + let b = AgentMetricIndexRow { + turn_input_tokens: Some(150), + turn_output_tokens: Some(100), + delta_reliable: Some(true), + ..row("e2", "agent_b", "s2", 1, 1) + }; + let boundaries = boundaries_7(); + let rows = vec![a, b]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + // B: input+output = 250 > A: total = 200 → B ranks first + assert_eq!(series.agents[0].agent_pubkey, "agent_b"); + assert_eq!(series.agents[1].agent_pubkey, "agent_a"); +} + +#[test] +fn d6_input_output_fallback_used_when_total_unknown() { + // Agent A: total unknown, input=100 only (output=None) → sort value = None + // Agent B: total unknown, input=80, output=40 → sort value = 120 + // D6: B has a sort value, A does not → B ranks first + let a = AgentMetricIndexRow { + turn_input_tokens: Some(100), + delta_reliable: Some(true), + ..row("e1", "agent_a", "s1", 1, 0) + }; + let b = AgentMetricIndexRow { + turn_input_tokens: Some(80), + turn_output_tokens: Some(40), + delta_reliable: Some(true), + ..row("e2", "agent_b", "s2", 1, 1) + }; + let boundaries = boundaries_7(); + let rows = vec![a, b]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!( + series.agents[0].agent_pubkey, "agent_b", + "input+output fallback must rank before unknown-sort-value agent" + ); + assert_eq!(series.agents[1].agent_pubkey, "agent_a"); +} + +#[test] +fn d6_model_total_ranks_before_input_output_fallback_within_agent() { + // Same agent, two model rows: + // model-a: total=300 (known) + // model-b: input=200, output=150, total unknown → sort value = 350 + // D6: model-b sort value 350 > model-a total 300 → model-b ranks first + let ra = AgentMetricIndexRow { + model: Some("model-a".to_string()), + turn_total_tokens: Some(300), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let rb = AgentMetricIndexRow { + model: Some("model-b".to_string()), + turn_input_tokens: Some(200), + turn_output_tokens: Some(150), + delta_reliable: Some(true), + ..row("e2", "agent1", "s2", 1, 1) + }; + let boundaries = boundaries_7(); + let rows = vec![ra, rb]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + let models = &series.agents[0].models; + assert_eq!(models[0].model.as_deref(), Some("model-b")); + assert_eq!(models[1].model.as_deref(), Some("model-a")); +} + +#[test] +fn d6_unknown_sort_value_ranks_last() { + // Three agents: + // agent_c: total=100 (known) + // agent_b: input=50, output=60 (total unknown) → sort value = 110 + // agent_a: input only, no output, no total → sort value = None (unknown) + // Expected order: agent_b (110), agent_c (100), agent_a (None) + let c = AgentMetricIndexRow { + turn_total_tokens: Some(100), + delta_reliable: Some(true), + ..row("e1", "agent_c", "s1", 1, 0) + }; + let b = AgentMetricIndexRow { + turn_input_tokens: Some(50), + turn_output_tokens: Some(60), + delta_reliable: Some(true), + ..row("e2", "agent_b", "s2", 1, 1) + }; + let a = AgentMetricIndexRow { + turn_input_tokens: Some(200), // large input but no output → no sort value + delta_reliable: Some(true), + ..row("e3", "agent_a", "s3", 1, 2) + }; + let boundaries = boundaries_7(); + let rows = vec![c, b, a]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!(series.agents[0].agent_pubkey, "agent_b"); + assert_eq!(series.agents[1].agent_pubkey, "agent_c"); + assert_eq!(series.agents[2].agent_pubkey, "agent_a"); +} + +// ── hasUnknownUsage propagation (P4b: Wes's CHANGES_REQUESTED fixes) ───────── + +/// Wes's escape (a): `derive_fresh_input()` returns `incomplete: true` when +/// subsets exceed input, but `hasUnknownUsage` was false because the four +/// original checked fields (input, output, total, cost) were all complete. +/// This pins that `hasUnknownUsage` is true at bucket, agent, model, and +/// top-level-coverage levels whenever derivation fails. +#[test] +fn has_unknown_usage_true_when_fresh_input_derivation_fails() { + // All four fields that the original has_unknown() checked are complete: + // input=100, output=50, total=150, cost=0.01 — no incomplete values there. + // But cache_read=60 + cache_write=60 = 120 > input=100 → derive fails. + // Before the fix, has_unknown() returned false (none of the four original + // fields were incomplete). After the fix it returns true. + let r = AgentMetricIndexRow { + turn_input_tokens: Some(100), + turn_output_tokens: Some(50), + turn_total_tokens: Some(150), + turn_cost_usd: Some(0.01), + turn_cache_read_tokens: Some(60), + turn_cache_write_tokens: Some(60), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + + // Bucket-level flag. + assert!( + series.buckets[0].has_unknown_usage, + "bucket hasUnknownUsage must be true when fresh_input derivation fails" + ); + // Agent-level flag. + assert!( + series.agents[0].has_unknown_usage, + "agent hasUnknownUsage must be true when fresh_input derivation fails" + ); + // Model-level flag. + assert!( + series.agents[0].models[0].has_unknown_usage, + "model hasUnknownUsage must be true when fresh_input derivation fails" + ); + // Top-level coverage flag. + assert!( + series.coverage.has_unknown_usage, + "coverage hasUnknownUsage must be true when fresh_input derivation fails" + ); + // Confirm the fresh_input value reflects the derivation failure. + assert_eq!( + series.agents[0].usage.fresh_input_tokens, + super::UsageField { + value: None, + incomplete: true, + }, + "fresh_input_tokens must be incomplete when subsets exceed input" + ); +} + +/// Wes's escape (b): old harness omits cache fields → cache/fresh accumulators +/// are incomplete while `hasUnknownUsage` was false (the four original checked +/// fields were complete). This pins that `hasUnknownUsage` is true whenever +/// cache fields are absent. +#[test] +fn has_unknown_usage_true_when_cache_fields_absent() { + // All four fields that the original has_unknown() checked are complete: + // input=100, output=50, total=150, cost=0.01 — no incomplete values there. + // But cache_read and cache_write are absent (old harness) → cache + // accumulators are incomplete → fresh_input is incomplete → has_unknown true. + // Before the fix, has_unknown() returned false because input/output/total/cost + // were complete. After the fix it returns true (cache accumulators are incomplete). + let r = AgentMetricIndexRow { + turn_input_tokens: Some(100), + turn_output_tokens: Some(50), + turn_total_tokens: Some(150), + turn_cost_usd: Some(0.01), + turn_cache_read_tokens: None, // absent — old harness + turn_cache_write_tokens: None, // absent — old harness + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + + // Bucket-level flag. + assert!( + series.buckets[0].has_unknown_usage, + "bucket hasUnknownUsage must be true when cache fields absent" + ); + // Agent-level flag. + assert!( + series.agents[0].has_unknown_usage, + "agent hasUnknownUsage must be true when cache fields absent" + ); + // Model-level flag. + assert!( + series.agents[0].models[0].has_unknown_usage, + "model hasUnknownUsage must be true when cache fields absent" + ); + // Top-level coverage flag. + assert!( + series.coverage.has_unknown_usage, + "coverage hasUnknownUsage must be true when cache fields absent" + ); + // Sanity: the cache and fresh_input fields are all incomplete. + assert!( + series.agents[0].usage.cache_read_tokens.incomplete, + "cache_read_tokens must be incomplete when absent" + ); + assert!( + series.agents[0].usage.cache_write_tokens.incomplete, + "cache_write_tokens must be incomplete when absent" + ); + assert!( + series.agents[0].usage.fresh_input_tokens.incomplete, + "fresh_input_tokens must be incomplete when cache fields absent" + ); +} diff --git a/desktop/src-tauri/src/archive/agent_usage_tests.rs b/desktop/src-tauri/src/archive/agent_usage_tests.rs new file mode 100644 index 00000000000..4440a92a12f --- /dev/null +++ b/desktop/src-tauri/src/archive/agent_usage_tests.rs @@ -0,0 +1,837 @@ +//! Tests for the pure NIP-AM accounting ladder, accumulators, and request +//! validation. No SQLite — rows are constructed directly. + +use super::*; +use crate::archive::metric_store::ParseStatus; + +// ── Row builder ────────────────────────────────────────────────────────────── + +/// Build a fully-specified valid row for test scenarios. Defaults every +/// optional field to `None`/appropriate zero; callers override what a +/// scenario needs via struct-update syntax. +fn row(id: &str, agent: &str, session: &str, seq: u64, reported_at: i64) -> AgentMetricIndexRow { + AgentMetricIndexRow { + id: id.to_string(), + agent_pubkey: agent.to_string(), + event_created_at: reported_at, + archived_at: reported_at, + reported_at: Some(reported_at), + session_id: Some(session.to_string()), + turn_seq: Some(seq), + harness: None, + model: None, + delta_reliable: Some(true), + turn_input_tokens: None, + turn_output_tokens: None, + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: None, + cumulative_input_tokens: None, + cumulative_output_tokens: None, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, + turn_cache_write_tokens: None, + pricing_authority: None, + pricing_model: None, + pricing_cache_class: None, + parse_status: ParseStatus::Valid, + } +} + +/// Standard 8-boundary window covering one day, seconds since epoch. +const DAY: i64 = 86_400; +fn boundaries_7() -> Vec { + (0..=7).map(|i| i * DAY).collect() +} + +fn probe_map( + rows: &[AgentMetricIndexRow], +) -> std::collections::HashMap<(String, String, u64), Vec<&AgentMetricIndexRow>> { + let mut m = std::collections::HashMap::new(); + for r in rows { + if let Some(key) = r.accounting_key() { + m.entry(key).or_insert_with(Vec::new).push(r); + } + } + m +} + +// ── Ladder: direct / cumulative / fallback ────────────────────────────────── + +#[test] +fn direct_turn_value_used_when_no_baseline() { + let r = AgentMetricIndexRow { + turn_input_tokens: Some(100), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let __probe_rows = [r.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&r, &probes); + assert!(matches!(outcome.input, FieldValue::Known(100))); +} + +#[test] +fn adjacent_cumulative_preferred_over_direct() { + let prev = AgentMetricIndexRow { + cumulative_input_tokens: Some(1000), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(1300), + turn_input_tokens: Some(999), // deliberately wrong direct value + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(300))); +} + +#[test] +fn direct_fallback_when_baseline_missing() { + // seq 5 has no row at seq 4 in the probe set → gap, direct-reliable only. + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(500), + turn_input_tokens: Some(42), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 5, 0) + }; + let __probe_rows = [cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(42))); +} + +#[test] +fn unreliable_delta_with_no_baseline_is_unknown() { + let cur = AgentMetricIndexRow { + turn_input_tokens: Some(42), + delta_reliable: Some(false), + ..row("e1", "agent1", "s1", 5, 0) + }; + let __probe_rows = [cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Unknown)); +} + +#[test] +fn sequence_gap_no_diff_but_direct_reliable_may_count() { + // predecessor exists at seq 1 but current is seq 3 (gap at seq 2) — + // predecessor lookup requires exact S-1, so seq 3's predecessor probe is + // for seq 2, which is absent. Direct fallback applies. + let baseline = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(400), + turn_input_tokens: Some(50), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 3, 10) + }; + let __probe_rows = [baseline, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(50))); +} + +// ── A1: counter decrease is terminal, no fallback ─────────────────────────── + +#[test] +fn adjacent_decrease_with_reliable_direct_present_is_unknown() { + // Required A1 test: decreasing cumulative pair + deltaReliable true + + // direct turn value present → field unknown, NOT the direct value. + let prev = AgentMetricIndexRow { + cumulative_input_tokens: Some(1000), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(600), // decreased + turn_input_tokens: Some(77), // present, but must NOT be used + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Unknown)); +} + +#[test] +fn decrease_taints_only_the_affected_field() { + // Interpretation note in A1: a decrease on one field must not zero out + // sibling fields whose own adjacent pair is nondecreasing. + let prev = AgentMetricIndexRow { + cumulative_input_tokens: Some(1000), + cumulative_output_tokens: Some(200), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(600), // decreased + cumulative_output_tokens: Some(250), // increased, still valid + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Unknown)); + assert!(matches!(outcome.output, FieldValue::Known(50))); +} + +// ── Cost ladder (mirrors the token ladder tests above, f64-specific) ─────── + +#[test] +fn ladder_cost_direct_value_used_when_no_baseline() { + let r = AgentMetricIndexRow { + turn_cost_usd: Some(0.05), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let __probe_rows = [r.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&r, &probes); + assert!(matches!(outcome.cost, FieldValue::Known(v) if v == 0.05)); +} + +#[test] +fn ladder_cost_adjacent_cumulative_preferred_over_direct() { + let prev = AgentMetricIndexRow { + cumulative_cost_usd: Some(1.0), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_cost_usd: Some(1.3), + turn_cost_usd: Some(999.0), // deliberately wrong direct value + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.cost, FieldValue::Known(v) if (v - 0.3).abs() < f64::EPSILON)); +} + +#[test] +fn ladder_cost_adjacent_decrease_is_unknown_not_direct() { + // A1 applies identically to the cost field: a decreasing cumulative + // pair is terminal-unknown even with a present direct value. + let prev = AgentMetricIndexRow { + cumulative_cost_usd: Some(5.0), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_cost_usd: Some(3.0), // decreased + turn_cost_usd: Some(1.0), // present, but must NOT be used + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.cost, FieldValue::Unknown)); +} + +// ── A4/A11: duplicate sequence quarantine ─────────────────────────────────── + +#[test] +fn duplicate_at_sequence_quarantines_successor() { + // Two rows at N, one at N+1: N+1 must not cumulative-diff against + // either N candidate. + let dup_a = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + ..row("e0a", "agent1", "s1", 5, 0) + }; + let dup_b = AgentMetricIndexRow { + cumulative_input_tokens: Some(150), + ..row("e0b", "agent1", "s1", 5, 1) + }; + let successor = AgentMetricIndexRow { + cumulative_input_tokens: Some(400), + turn_input_tokens: Some(30), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 6, 10) + }; + let __probe_rows = [dup_a, dup_b, successor.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&successor, &probes); + // No usable baseline (ambiguous predecessor) → direct-reliable fallback. + assert!(matches!(outcome.input, FieldValue::Known(30))); +} + +#[test] +fn duplicate_row_itself_gets_no_cumulative_delta() { + let baseline = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + ..row("e_base", "agent1", "s1", 4, 0) + }; + let dup_a = AgentMetricIndexRow { + cumulative_input_tokens: Some(200), + turn_input_tokens: Some(99), + delta_reliable: Some(true), + ..row("e5a", "agent1", "s1", 5, 1) + }; + let dup_b = AgentMetricIndexRow { + cumulative_input_tokens: Some(250), + ..row("e5b", "agent1", "s1", 5, 2) + }; + let __probe_rows = [baseline, dup_a.clone(), dup_b]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&dup_a, &probes); + // Own sequence has >1 row → no cumulative delta; direct-reliable value + // for dup_a specifically still counts (A4: "only an independently + // reliable direct turn value may count"). + assert!(matches!(outcome.input, FieldValue::Known(99))); +} + +#[test] +fn duplicate_out_of_window_peer_still_quarantines_in_window_row() { + // A11: an out-of-window duplicate at the same sequence still poisons the + // in-window row's cumulative eligibility, because the probe set has no + // reported_at restriction. + let out_of_window_dup = AgentMetricIndexRow { + cumulative_input_tokens: Some(500), + ..row("e_old", "agent1", "s1", 5, -1000) + }; + let baseline = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + ..row("e_base", "agent1", "s1", 4, 0) + }; + let in_window = AgentMetricIndexRow { + cumulative_input_tokens: Some(200), + turn_input_tokens: Some(11), + delta_reliable: Some(true), + ..row("e5", "agent1", "s1", 5, 1) + }; + let __probe_rows = [out_of_window_dup, baseline, in_window.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&in_window, &probes); + assert!(matches!(outcome.input, FieldValue::Known(11))); +} + +// ── A12: checked_sub sequence arithmetic ──────────────────────────────────── + +#[test] +fn adjacent_pair_at_u64_max_computes_normally() { + let prev = AgentMetricIndexRow { + cumulative_input_tokens: Some(1000), + ..row("e_prev", "agent1", "s1", u64::MAX - 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(1500), + ..row("e_max", "agent1", "s1", u64::MAX, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(500))); +} + +#[test] +fn duplicate_at_u64_max_needs_no_successor_probe() { + // u64::MAX has no successor sequence to quarantine — verify duplicate + // handling at MAX itself still works (own-sequence cardinality check). + let dup_a = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + turn_input_tokens: Some(5), + delta_reliable: Some(true), + ..row("e_max_a", "agent1", "s1", u64::MAX, 0) + }; + let dup_b = AgentMetricIndexRow { + cumulative_input_tokens: Some(150), + ..row("e_max_b", "agent1", "s1", u64::MAX, 1) + }; + let __probe_rows = [dup_a.clone(), dup_b]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&dup_a, &probes); + assert!(matches!(outcome.input, FieldValue::Known(5))); +} + +#[test] +fn seq_zero_has_no_baseline_underflow() { + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + turn_input_tokens: Some(100), + delta_reliable: Some(true), + ..row("e0", "agent1", "s1", 0, 0) + }; + let __probe_rows = [cur.clone()]; + let probes = probe_map(&__probe_rows); + // Must not panic (checked_sub) and must fall back to direct. + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(100))); +} + +// ── Null total / per-field independence ───────────────────────────────────── + +#[test] +fn null_total_tokens_stays_unknown_even_when_input_output_known() { + let r = AgentMetricIndexRow { + turn_input_tokens: Some(10), + turn_output_tokens: Some(20), + turn_total_tokens: None, + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let __probe_rows = [r.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&r, &probes); + assert!(matches!(outcome.input, FieldValue::Known(10))); + assert!(matches!(outcome.output, FieldValue::Known(20))); + assert!(matches!(outcome.total, FieldValue::Unknown)); +} + +// ── Cross-session / cross-agent isolation ─────────────────────────────────── + +#[test] +fn cumulative_diff_never_crosses_session_boundary() { + let other_session = AgentMetricIndexRow { + cumulative_input_tokens: Some(999_999), + ..row("e_other", "agent1", "s_other", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(50), + turn_input_tokens: Some(50), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 10) + }; + let __probe_rows = [other_session, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + // seq 1 has no predecessor (seq 0) in ANY session — direct fallback. + assert!(matches!(outcome.input, FieldValue::Known(50))); +} + +#[test] +fn cumulative_diff_never_crosses_agent_boundary() { + let other_agent = AgentMetricIndexRow { + cumulative_input_tokens: Some(999_999), + ..row("e_other", "agent2", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(60), + turn_input_tokens: Some(60), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [other_agent, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + // seq 2's predecessor (seq 1) does not exist for agent1 — direct. + assert!(matches!(outcome.input, FieldValue::Known(60))); +} + +// ── window_probe_keys ──────────────────────────────────────────────────────── + +#[test] +fn window_probe_keys_includes_own_and_predecessor() { + let r = row("e1", "agent1", "s1", 5, 0); + let keys = window_probe_keys(&[r]); + assert!(keys.contains(&("agent1".to_string(), "s1".to_string(), 5))); + assert!(keys.contains(&("agent1".to_string(), "s1".to_string(), 4))); + assert_eq!(keys.len(), 2); +} + +#[test] +fn window_probe_keys_skips_predecessor_at_seq_zero() { + let r = row("e1", "agent1", "s1", 0, 0); + let keys = window_probe_keys(&[r]); + assert_eq!(keys.len(), 1); + assert!(keys.contains(&("agent1".to_string(), "s1".to_string(), 0))); +} + +#[test] +fn window_probe_keys_skips_rows_without_session_or_seq() { + let r = AgentMetricIndexRow { + session_id: None, + turn_seq: None, + ..row("e1", "agent1", "s1", 5, 0) + }; + let keys = window_probe_keys(&[r]); + assert!(keys.is_empty()); +} + +// ── assign_bucket_index: boundary edges ───────────────────────────────────── + +#[test] +fn assign_bucket_index_start_is_inclusive_end_is_exclusive() { + let boundaries = boundaries_7(); + // Exactly on bucket 1's start boundary → bucket 1, not bucket 0. + assert_eq!(assign_bucket_index(&boundaries, DAY), Some(1)); + // One second before bucket 1's start → still bucket 0 (end-exclusive). + assert_eq!(assign_bucket_index(&boundaries, DAY - 1), Some(0)); +} + +#[test] +fn assign_bucket_index_returns_none_outside_every_bucket() { + let boundaries = boundaries_7(); + assert_eq!(assign_bucket_index(&boundaries, -1), None); + assert_eq!(assign_bucket_index(&boundaries, boundaries[7]), None); // last boundary is exclusive end +} + +// ── compute_series: bucketing, overflow, ranking, models ──────────────────── + +#[test] +fn compute_series_buckets_by_reported_at_not_created_at() { + let r = AgentMetricIndexRow { + turn_input_tokens: Some(10), + delta_reliable: Some(true), + event_created_at: 999_999_999, // deliberately wrong/misleading + ..row("e1", "agent1", "s1", 1, DAY + 100) // reported_at lands in bucket 1 + }; + let boundaries = boundaries_7(); + let series = compute_series( + std::slice::from_ref(&r), + std::slice::from_ref(&r), + 0, + &boundaries, + None, + true, + ); + assert_eq!(series.buckets[0].report_count, 0); + assert_eq!(series.buckets[1].report_count, 1); +} + +#[test] +fn compute_series_checked_add_overflow_marks_incomplete_without_wrapping() { + let r1 = AgentMetricIndexRow { + turn_input_tokens: Some(u64::MAX), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 10, 0) + }; + let r2 = AgentMetricIndexRow { + turn_input_tokens: Some(5), + delta_reliable: Some(true), + ..row("e2", "agent1", "s2", 10, 1) // different session avoids adjacency + }; + let boundaries = boundaries_7(); + let rows = vec![r1, r2]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + let bucket = &series.buckets[0]; + assert!(bucket.has_unknown_usage); + // Value freezes at the last valid sum (u64::MAX) rather than wrapping. + assert_eq!(bucket.usage.input_tokens.value, Some(u64::MAX.to_string())); + assert!(bucket.usage.input_tokens.incomplete); +} + +#[test] +fn compute_series_cost_non_finite_marks_incomplete() { + let r1 = AgentMetricIndexRow { + turn_cost_usd: Some(f64::MAX), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 10, 0) + }; + let r2 = AgentMetricIndexRow { + turn_cost_usd: Some(f64::MAX), + delta_reliable: Some(true), + ..row("e2", "agent1", "s2", 10, 1) + }; + let boundaries = boundaries_7(); + let rows = vec![r1, r2]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert!(series.buckets[0].usage.estimated_cost_usd.incomplete); +} + +#[test] +fn compute_series_ranks_known_total_before_unknown_total() { + let known = AgentMetricIndexRow { + turn_total_tokens: Some(500), + delta_reliable: Some(true), + ..row("e1", "agent_known", "s1", 1, 0) + }; + let unknown = AgentMetricIndexRow { + turn_total_tokens: None, + delta_reliable: Some(true), + ..row("e2", "agent_unknown", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![known, unknown]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!(series.agents[0].agent_pubkey, "agent_known"); + assert_eq!(series.agents[1].agent_pubkey, "agent_unknown"); +} + +#[test] +fn compute_series_ties_on_known_total_break_by_pubkey_ascending() { + // Equal totalTokens for two agents: the A2 tiebreak must be + // deterministic pubkey order, not insertion/hash order. + let agent_z = AgentMetricIndexRow { + turn_total_tokens: Some(100), + delta_reliable: Some(true), + ..row("e1", "agent_zzz", "s1", 1, 0) + }; + let agent_a = AgentMetricIndexRow { + turn_total_tokens: Some(100), + delta_reliable: Some(true), + ..row("e2", "agent_aaa", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![agent_z, agent_a]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!(series.agents[0].agent_pubkey, "agent_aaa"); + assert_eq!(series.agents[1].agent_pubkey, "agent_zzz"); +} + +#[test] +fn compute_series_model_breakdown_attributes_per_event_model() { + let r1 = AgentMetricIndexRow { + model: Some("model-a".to_string()), + turn_input_tokens: Some(10), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let r2 = AgentMetricIndexRow { + model: Some("model-b".to_string()), + turn_input_tokens: Some(20), + delta_reliable: Some(true), + ..row("e2", "agent1", "s2", 1, 1) + }; + let boundaries = boundaries_7(); + let rows = vec![r1, r2]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!(series.agents.len(), 1); + assert_eq!(series.agents[0].models.len(), 2); +} + +#[test] +fn compute_series_same_model_two_harnesses_produces_two_rows() { + // Collapse-fix requirement: same model via two different harnesses must + // NOT collapse into one row — (harness, model) is the grouping key. + let r1 = AgentMetricIndexRow { + harness: Some("goose".to_string()), + model: Some("claude-sonnet".to_string()), + turn_input_tokens: Some(100), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let r2 = AgentMetricIndexRow { + harness: Some("claude-code".to_string()), + model: Some("claude-sonnet".to_string()), + turn_input_tokens: Some(200), + delta_reliable: Some(true), + ..row("e2", "agent1", "s2", 1, 1) + }; + let boundaries = boundaries_7(); + let rows = vec![r1, r2]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!(series.agents.len(), 1); + // Two distinct harnesses → two rows, not one collapsed row. + assert_eq!( + series.agents[0].models.len(), + 2, + "same model under two harnesses must produce two rows" + ); + let harneses: Vec> = series.agents[0] + .models + .iter() + .map(|m| m.harness.as_deref()) + .collect(); + assert!( + harneses.contains(&Some("claude-code")), + "claude-code row must be present" + ); + assert!( + harneses.contains(&Some("goose")), + "goose row must be present" + ); +} + +#[test] +fn compute_series_single_harness_data_looks_unchanged_with_harness_label() { + // Single-harness data: still exactly one row per model, harness label present. + let r = AgentMetricIndexRow { + harness: Some("goose".to_string()), + model: Some("claude-sonnet".to_string()), + turn_total_tokens: Some(500), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![r]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!(series.agents[0].models.len(), 1); + assert_eq!( + series.agents[0].models[0].harness, + Some("goose".to_string()) + ); + assert_eq!( + series.agents[0].models[0].model, + Some("claude-sonnet".to_string()) + ); +} + +#[test] +fn compute_series_invalid_report_count_passed_through_and_not_bucketed() { + let boundaries = boundaries_7(); + let series = compute_series(&[], &[], 3, &boundaries, None, true); + assert_eq!(series.coverage.invalid_report_count, 3); + assert_eq!(series.coverage.report_count, 0); + assert!(series.coverage.has_unknown_usage); +} + +#[test] +fn compute_series_zero_invalid_and_no_unknown_rows_is_not_unknown() { + let boundaries = boundaries_7(); + let series = compute_series(&[], &[], 0, &boundaries, None, true); + assert_eq!(series.coverage.invalid_report_count, 0); + assert!(!series.coverage.has_unknown_usage); +} + +#[test] +fn compute_series_collection_enabled_passthrough() { + let boundaries = boundaries_7(); + let series = compute_series(&[], &[], 0, &boundaries, None, false); + assert!(!series.collection_enabled); +} + +#[test] +fn compute_series_has_archived_evidence_passthrough() { + let boundaries = boundaries_7(); + let series = compute_series(&[], &[], 0, &boundaries, Some(true), true); + assert_eq!(series.has_archived_evidence, Some(true)); + let series_none = compute_series(&[], &[], 0, &boundaries, None, true); + assert_eq!(series_none.has_archived_evidence, None); +} + +// ── validate_request ───────────────────────────────────────────────────────── + +fn req(boundaries: Vec, agent_pubkey: Option) -> AgentUsageSeriesRequest { + AgentUsageSeriesRequest { + bucket_boundaries: boundaries, + agent_pubkey, + } +} + +#[test] +fn validate_request_accepts_boundary_counts_across_the_supported_range() { + // 2 boundaries = the `1d` single-bucket case. + assert!(validate_request(&req(vec![0, DAY], None)).is_ok()); + assert!(validate_request(&req(boundaries_7(), None)).is_ok()); + let b31: Vec = (0..=30).map(|i| i * DAY).collect(); + assert!(validate_request(&req(b31, None)).is_ok()); + // 367 boundaries = 366 daily buckets = one leap year, the ceiling. + let b367: Vec = (0..=366).map(|i| i * DAY).collect(); + assert_eq!(b367.len(), 367); + assert!(validate_request(&req(b367, None)).is_ok()); +} + +#[test] +fn validate_request_rejects_boundary_count_outside_the_supported_range() { + // A single boundary describes no bucket at all. + assert!(validate_request(&req(vec![0], None)).is_err()); + assert!(validate_request(&req(vec![], None)).is_err()); + // 368 boundaries = 367 buckets, one past the one-leap-year ceiling. + let b368: Vec = (0..=367).map(|i| i * DAY).collect(); + assert_eq!(b368.len(), 368); + assert!(validate_request(&req(b368, None)).is_err()); +} + +#[test] +fn validate_request_rejects_non_increasing_boundaries() { + let mut b = boundaries_7(); + b[3] = b[2]; // zero-width interval + assert!(validate_request(&req(b, None)).is_err()); +} + +#[test] +fn validate_request_rejects_interval_over_48h() { + let mut b = boundaries_7(); + b[1] = b[0] + 49 * 3600; + assert!(validate_request(&req(b, None)).is_err()); +} + +#[test] +fn validate_request_rejects_boundary_out_of_chrono_representable_range() { + // All 7 intervals stay well within the 48h band (exactly one day each) + // so only the finite-range check can be responsible for the rejection; + // the window is shifted to straddle chrono's actual max representable + // instant rather than a hardcoded guess. + let max_ts = chrono::DateTime::::MAX_UTC.timestamp(); + let base = max_ts - 6 * DAY; + let b: Vec = (0..=7).map(|i| base + i * DAY).collect(); + assert!( + b[7] > max_ts, + "test setup must push the last boundary out of range" + ); + assert!(validate_request(&req(b, None)).is_err()); +} + +#[test] +fn validate_request_accepts_30_minute_dst_interval() { + // Lord Howe Island: 30-minute DST offset. A day-boundary pair differing + // by 23.5h must be accepted under the 48h sanity band (A9). + let mut b = boundaries_7(); + b[1] = b[0] + 23 * 3600 + 1800; + assert!(validate_request(&req(b, None)).is_ok()); +} + +#[test] +fn validate_request_normalizes_pubkey_to_lowercase() { + let pk = "AB".repeat(32); + let result = validate_request(&req(boundaries_7(), Some(pk.clone()))); + assert_eq!(result.unwrap(), Some(pk.to_lowercase())); +} + +#[test] +fn validate_request_rejects_malformed_pubkey() { + let short = "ab".repeat(10); + assert!(validate_request(&req(boundaries_7(), Some(short))).is_err()); + let non_hex = "zz".repeat(32); + assert!(validate_request(&req(boundaries_7(), Some(non_hex))).is_err()); +} + +// ── Tauri camelCase key-shape contract ──────────────────────────────────────── + +/// Verify that a fully-populated `ReportedUsage` serializes to exactly the +/// seven camelCase keys the TS `ReportedUsage` type declares. Any Rust-side +/// rename drift will fail this test before reaching the TypeScript consumer. +#[test] +fn reported_usage_serializes_with_all_seven_camel_case_keys() { + let field = || UsageField { + value: Some("1".to_string()), + incomplete: false, + }; + let usage = ReportedUsage { + input_tokens: field(), + output_tokens: field(), + total_tokens: field(), + estimated_cost_usd: CostField { + value: Some(0.001), + incomplete: false, + }, + cache_read_tokens: field(), + cache_write_tokens: field(), + fresh_input_tokens: field(), + }; + + let obj = serde_json::to_value(&usage).expect("ReportedUsage must serialize"); + let keys: std::collections::BTreeSet<&str> = obj + .as_object() + .expect("must be a JSON object") + .keys() + .map(String::as_str) + .collect(); + + let expected: std::collections::BTreeSet<&str> = [ + "inputTokens", + "outputTokens", + "totalTokens", + "estimatedCostUsd", + "cacheReadTokens", + "cacheWriteTokens", + "freshInputTokens", + ] + .iter() + .copied() + .collect(); + + assert_eq!( + keys, expected, + "ReportedUsage camelCase key contract changed — update tauriArchive.ts to match" + ); +} diff --git a/desktop/src-tauri/src/archive/metric_store.rs b/desktop/src-tauri/src/archive/metric_store.rs new file mode 100644 index 00000000000..9595e4d3323 --- /dev/null +++ b/desktop/src-tauri/src/archive/metric_store.rs @@ -0,0 +1,630 @@ +//! Parsed index of kind 44200 (NIP-AM agent turn metric) archive rows. +//! +//! `agent_metric_index` is a derived, rebuildable cache of parsed NIP-AM +//! payload fields, keyed by `(identity_pubkey, relay_url, id)` exactly like +//! `archived_events`. It exists so the accounting algorithm in +//! `agent_usage.rs` can query parsed columns instead of re-parsing JSON on +//! every render. The canonical source of truth remains +//! `archived_events.raw_json`; every row here is reproducible from it alone +//! via [`AgentMetricIndexRow::from_payload`]. +//! +//! Kept in a sibling file (not `store.rs`) to keep that file under the +//! 1000-line gate, per the existing `pipeline.rs` precedent. + +use rusqlite::{params, Connection, OptionalExtension}; + +use buzz_core_pkg::agent_turn_metric::AgentTurnMetricPayload; + +// ── u64-safe sortable encoding ─────────────────────────────────────────────── + +/// Fixed-width digit count for the lexicographically order-preserving decimal +/// encoding of a `u64`. `u64::MAX` = 18446744073709551615 is 20 digits. +const U64_SORTABLE_WIDTH: usize = 20; + +/// Encode a `u64` as a fixed-width zero-padded decimal string so SQLite TEXT +/// ordering matches numeric ordering, and so the full `u64` range survives +/// SQLite's signed-`i64` INTEGER column type (rusqlite has no unsigned +/// binding). Used for both token counters and `turn_seq`. +pub(super) fn encode_u64_sortable(value: u64) -> String { + format!("{value:0U64_SORTABLE_WIDTH$}") +} + +/// Decode a value written by [`encode_u64_sortable`]. Returns `None` if the +/// string is not a well-formed same-width decimal `u64` — defensive only; +/// every value written by this module is always well-formed. +pub(super) fn decode_u64_sortable(text: &str) -> Option { + if text.len() != U64_SORTABLE_WIDTH { + return None; + } + text.parse::().ok() +} + +fn parse_rfc3339_secs(timestamp: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(timestamp) + .ok() + .map(|dt| dt.timestamp()) +} + +// ── Row type ────────────────────────────────────────────────────────────── + +/// Parse status of a stored `agent_metric_index` row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ParseStatus { + Valid, + Invalid, +} + +impl ParseStatus { + fn as_str(self) -> &'static str { + match self { + ParseStatus::Valid => "valid", + ParseStatus::Invalid => "invalid", + } + } + + fn from_str(s: &str) -> Self { + match s { + "valid" => ParseStatus::Valid, + _ => ParseStatus::Invalid, + } + } +} + +/// One fully parsed `agent_metric_index` row. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct AgentMetricIndexRow { + pub id: String, + pub agent_pubkey: String, + pub event_created_at: i64, + pub archived_at: i64, + pub reported_at: Option, + pub session_id: Option, + pub turn_seq: Option, + pub harness: Option, + pub model: Option, + pub delta_reliable: Option, + pub turn_input_tokens: Option, + pub turn_output_tokens: Option, + pub turn_total_tokens: Option, + pub turn_cost_usd: Option, + pub turn_cache_read_tokens: Option, + pub turn_cache_write_tokens: Option, + pub cumulative_input_tokens: Option, + pub cumulative_output_tokens: Option, + pub cumulative_total_tokens: Option, + pub cumulative_cost_usd: Option, + pub cumulative_cache_read_tokens: Option, + pub cumulative_cache_write_tokens: Option, + /// Billing identity fields. All three are `None` when the publisher did + /// not include a `pricingIdentity` object (unrecognised endpoint, mixed + /// identities, old harness). Stored as separate columns for indexing. + pub pricing_authority: Option, + pub pricing_model: Option, + pub pricing_cache_class: Option, + pub parse_status: ParseStatus, +} + +impl AgentMetricIndexRow { + /// Parse a decrypted NIP-AM payload (the plaintext JSON already stored in + /// `archived_events.raw_json` for kind 44200 rows) into an index row. + /// + /// New ingest and backfill share this single parser so validity rules + /// cannot drift between the two paths (frozen plan requirement). + /// + /// "Invalid" per Rev 2 A5: the payload's JSON decodes but fails a + /// semantic check this layer owns: unparseable RFC3339 `timestamp`, or + /// `cumulative` present without both `sessionId` and `turnSeq` (NIP-AM + /// REQUIREs both whenever `cumulative` is present — a row missing either + /// cannot supply the complete `(agent, session, seq)` key needed to + /// compete as a cumulative snapshot). The upstream fail-closed ingest + /// path (`pipeline.rs::commit_archive`) has already decrypted, + /// deserialized, and numeric-validated (non-negative/finite `costUsd`) + /// before this row is ever produced — a raw-JSON parse failure here + /// would indicate on-disk corruption, not a normal producer error, but + /// is still handled fail-closed rather than panicking. + pub(super) fn from_payload( + raw_json: &str, + id: &str, + agent_pubkey: &str, + event_created_at: i64, + archived_at: i64, + ) -> Self { + let Ok(payload) = serde_json::from_str::(raw_json) else { + return Self::invalid(id, agent_pubkey, event_created_at, archived_at); + }; + + let reported_at = parse_rfc3339_secs(&payload.timestamp); + let cumulative_requires_session_seq = payload.cumulative.is_some(); + let has_session_and_seq = payload.session_id.is_some() && payload.turn_seq.is_some(); + + if reported_at.is_none() || (cumulative_requires_session_seq && !has_session_and_seq) { + return Self::invalid(id, agent_pubkey, event_created_at, archived_at); + } + + let turn = payload.turn.as_ref(); + let cumulative = payload.cumulative.as_ref(); + + Self { + id: id.to_string(), + agent_pubkey: agent_pubkey.to_string(), + event_created_at, + archived_at, + reported_at, + session_id: payload.session_id, + turn_seq: payload.turn_seq, + harness: Some(payload.harness), + model: payload.model, + delta_reliable: Some(payload.delta_reliable), + turn_input_tokens: turn.and_then(|t| t.input_tokens), + turn_output_tokens: turn.and_then(|t| t.output_tokens), + turn_total_tokens: turn.and_then(|t| t.total_tokens), + turn_cost_usd: turn.and_then(|t| t.cost_usd), + turn_cache_read_tokens: turn.and_then(|t| t.cache_read_tokens), + turn_cache_write_tokens: turn.and_then(|t| t.cache_write_tokens), + cumulative_input_tokens: cumulative.and_then(|c| c.input_tokens), + cumulative_output_tokens: cumulative.and_then(|c| c.output_tokens), + cumulative_total_tokens: cumulative.and_then(|c| c.total_tokens), + cumulative_cost_usd: cumulative.and_then(|c| c.cost_usd), + cumulative_cache_read_tokens: cumulative.and_then(|c| c.cache_read_tokens), + cumulative_cache_write_tokens: cumulative.and_then(|c| c.cache_write_tokens), + pricing_authority: payload + .pricing_identity + .as_ref() + .map(|pi| pi.authority.clone()), + pricing_model: payload.pricing_identity.as_ref().map(|pi| pi.model.clone()), + pricing_cache_class: payload + .pricing_identity + .as_ref() + .and_then(|pi| pi.cache_class.clone()), + parse_status: ParseStatus::Valid, + } + } + + fn invalid(id: &str, agent_pubkey: &str, event_created_at: i64, archived_at: i64) -> Self { + Self { + id: id.to_string(), + agent_pubkey: agent_pubkey.to_string(), + event_created_at, + archived_at, + reported_at: None, + session_id: None, + turn_seq: None, + harness: None, + model: None, + delta_reliable: None, + turn_input_tokens: None, + turn_output_tokens: None, + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: None, + turn_cache_write_tokens: None, + cumulative_input_tokens: None, + cumulative_output_tokens: None, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, + pricing_authority: None, + pricing_model: None, + pricing_cache_class: None, + parse_status: ParseStatus::Invalid, + } + } + + /// The `(agent_pubkey, session_id, turn_seq)` cumulative-accounting key, + /// or `None` if this row cannot participate in cumulative delta + /// recomputation (missing session/sequence). + pub(super) fn accounting_key(&self) -> Option<(String, String, u64)> { + match (&self.session_id, self.turn_seq) { + (Some(sid), Some(seq)) => Some((self.agent_pubkey.clone(), sid.clone(), seq)), + _ => None, + } + } +} + +fn row_from_sql(row: &rusqlite::Row) -> rusqlite::Result { + let turn_seq_text: Option = row.get("turn_seq")?; + let turn_input_text: Option = row.get("turn_input_tokens")?; + let turn_output_text: Option = row.get("turn_output_tokens")?; + let turn_total_text: Option = row.get("turn_total_tokens")?; + let turn_cache_read_text: Option = row.get("turn_cache_read_tokens")?; + let turn_cache_write_text: Option = row.get("turn_cache_write_tokens")?; + let cum_input_text: Option = row.get("cumulative_input_tokens")?; + let cum_output_text: Option = row.get("cumulative_output_tokens")?; + let cum_total_text: Option = row.get("cumulative_total_tokens")?; + let cum_cache_read_text: Option = row.get("cumulative_cache_read_tokens")?; + let cum_cache_write_text: Option = row.get("cumulative_cache_write_tokens")?; + let delta_reliable_int: Option = row.get("delta_reliable")?; + let parse_status_str: String = row.get("parse_status")?; + + Ok(AgentMetricIndexRow { + id: row.get("id")?, + agent_pubkey: row.get("agent_pubkey")?, + event_created_at: row.get("event_created_at")?, + archived_at: row.get("archived_at")?, + reported_at: row.get("reported_at")?, + session_id: row.get("session_id")?, + turn_seq: turn_seq_text.as_deref().and_then(decode_u64_sortable), + harness: row.get("harness")?, + model: row.get("model")?, + delta_reliable: delta_reliable_int.map(|v| v != 0), + turn_input_tokens: turn_input_text.as_deref().and_then(decode_u64_sortable), + turn_output_tokens: turn_output_text.as_deref().and_then(decode_u64_sortable), + turn_total_tokens: turn_total_text.as_deref().and_then(decode_u64_sortable), + turn_cost_usd: row.get("turn_cost_usd")?, + turn_cache_read_tokens: turn_cache_read_text + .as_deref() + .and_then(decode_u64_sortable), + turn_cache_write_tokens: turn_cache_write_text + .as_deref() + .and_then(decode_u64_sortable), + cumulative_input_tokens: cum_input_text.as_deref().and_then(decode_u64_sortable), + cumulative_output_tokens: cum_output_text.as_deref().and_then(decode_u64_sortable), + cumulative_total_tokens: cum_total_text.as_deref().and_then(decode_u64_sortable), + cumulative_cost_usd: row.get("cumulative_cost_usd")?, + cumulative_cache_read_tokens: cum_cache_read_text.as_deref().and_then(decode_u64_sortable), + cumulative_cache_write_tokens: cum_cache_write_text + .as_deref() + .and_then(decode_u64_sortable), + pricing_authority: row.get("pricing_authority")?, + pricing_model: row.get("pricing_model")?, + pricing_cache_class: row.get("pricing_cache_class")?, + parse_status: ParseStatus::from_str(&parse_status_str), + }) +} + +const ROW_COLUMNS: &str = "id, agent_pubkey, event_created_at, archived_at, reported_at, \ + session_id, turn_seq, harness, model, delta_reliable, turn_input_tokens, turn_output_tokens, \ + turn_total_tokens, turn_cost_usd, turn_cache_read_tokens, turn_cache_write_tokens, \ + cumulative_input_tokens, cumulative_output_tokens, cumulative_total_tokens, \ + cumulative_cost_usd, cumulative_cache_read_tokens, cumulative_cache_write_tokens, \ + pricing_authority, pricing_model, pricing_cache_class, parse_status"; + +// ── Write path ─────────────────────────────────────────────────────────────── + +/// Insert one metric index row inside the caller's transaction. +/// +/// Called from `pipeline::commit_archive` ONLY when the corresponding +/// `archived_events` row was newly inserted (never for a duplicate), and +/// from the backfill driver for pre-existing unindexed rows. `INSERT OR +/// IGNORE` on the shared PK makes a second call for the same +/// `(identity, relay, id)` a safe no-op (defensive; callers already guard +/// against re-indexing). +pub(super) fn insert_metric_index_row( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + row: &AgentMetricIndexRow, +) -> Result { + let turn_seq = row.turn_seq.map(encode_u64_sortable); + let turn_input = row.turn_input_tokens.map(encode_u64_sortable); + let turn_output = row.turn_output_tokens.map(encode_u64_sortable); + let turn_total = row.turn_total_tokens.map(encode_u64_sortable); + let turn_cache_read = row.turn_cache_read_tokens.map(encode_u64_sortable); + let turn_cache_write = row.turn_cache_write_tokens.map(encode_u64_sortable); + let cum_input = row.cumulative_input_tokens.map(encode_u64_sortable); + let cum_output = row.cumulative_output_tokens.map(encode_u64_sortable); + let cum_total = row.cumulative_total_tokens.map(encode_u64_sortable); + let cum_cache_read = row.cumulative_cache_read_tokens.map(encode_u64_sortable); + let cum_cache_write = row.cumulative_cache_write_tokens.map(encode_u64_sortable); + let delta_reliable = row.delta_reliable.map(|b| b as i64); + + let affected = conn + .execute( + "INSERT INTO agent_metric_index + (identity_pubkey, relay_url, id, agent_pubkey, event_created_at, + archived_at, reported_at, session_id, turn_seq, harness, model, + delta_reliable, turn_input_tokens, turn_output_tokens, + turn_total_tokens, turn_cost_usd, turn_cache_read_tokens, + turn_cache_write_tokens, cumulative_input_tokens, cumulative_output_tokens, + cumulative_total_tokens, cumulative_cost_usd, + cumulative_cache_read_tokens, cumulative_cache_write_tokens, + pricing_authority, pricing_model, pricing_cache_class, parse_status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, + ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, + ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28) + ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING", + params![ + identity_pubkey, + relay_url, + row.id, + row.agent_pubkey, + row.event_created_at, + row.archived_at, + row.reported_at, + row.session_id, + turn_seq, + row.harness, + row.model, + delta_reliable, + turn_input, + turn_output, + turn_total, + row.turn_cost_usd, + turn_cache_read, + turn_cache_write, + cum_input, + cum_output, + cum_total, + row.cumulative_cost_usd, + cum_cache_read, + cum_cache_write, + row.pricing_authority, + row.pricing_model, + row.pricing_cache_class, + row.parse_status.as_str(), + ], + ) + .map_err(|e| format!("failed to insert agent_metric_index row: {e}"))?; + Ok(affected > 0) +} + +// ── Backfill ───────────────────────────────────────────────────────────────── + +/// Backfill existing `archived_events` kind-44200 rows that have no matching +/// `agent_metric_index` row yet, for the given identity + relay. +/// +/// Runs in bounded chunks (~500 rows per transaction) so a large existing +/// archive never holds one unbounded write lock; each chunk is independently +/// atomic and the whole backfill is idempotent (anti-join + index PK is the +/// source of truth) and restartable — interruption between chunks loses +/// nothing, and a later run simply resumes against the still-missing rows. +/// +/// Returns the total number of newly indexed rows. +pub(super) fn backfill_agent_metric_index( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result { + const CHUNK_SIZE: i64 = 500; + let mut total = 0usize; + + loop { + let mut stmt = conn + .prepare( + "SELECT ae.id, ae.pubkey, ae.created_at, ae.archived_at, ae.raw_json + FROM archived_events ae + WHERE ae.identity_pubkey = ?1 + AND ae.relay_url = ?2 + AND ae.kind = 44200 + AND ae.id NOT IN ( + SELECT id FROM agent_metric_index + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + ) + ORDER BY ae.created_at ASC, ae.id ASC + LIMIT ?3", + ) + .map_err(|e| format!("prepare backfill_agent_metric_index select: {e}"))?; + + let chunk: Vec<(String, String, i64, i64, String)> = stmt + .query_map(params![identity_pubkey, relay_url, CHUNK_SIZE], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + )) + }) + .map_err(|e| format!("query backfill_agent_metric_index select: {e}"))? + .collect::, _>>() + .map_err(|e| format!("read backfill_agent_metric_index row: {e}"))?; + drop(stmt); + + if chunk.is_empty() { + break; + } + let chunk_len = chunk.len(); + + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("failed to begin backfill chunk transaction: {e}"))?; + for (id, pubkey, created_at, archived_at, raw_json) in &chunk { + let parsed = + AgentMetricIndexRow::from_payload(raw_json, id, pubkey, *created_at, *archived_at); + insert_metric_index_row(&tx, identity_pubkey, relay_url, &parsed)?; + } + tx.commit() + .map_err(|e| format!("failed to commit backfill chunk: {e}"))?; + + total += chunk_len; + if (chunk_len as i64) < CHUNK_SIZE { + break; + } + } + + Ok(total) +} + +// ── GC / orphan repair ─────────────────────────────────────────────────────── + +/// Delete `agent_metric_index` rows whose canonical `archived_events` row no +/// longer exists. Called from `store::gc_orphaned_events` inside the SAME +/// SQLite transaction as the canonical delete (A6) so the index can never +/// observe a canonical row as gone while its own row survives. +pub(super) fn delete_orphaned_metric_index_rows( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result { + let affected = conn + .execute( + "DELETE FROM agent_metric_index + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND id NOT IN ( + SELECT id FROM archived_events + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + )", + params![identity_pubkey, relay_url], + ) + .map_err(|e| format!("failed to gc orphaned agent_metric_index rows: {e}"))?; + Ok(affected) +} + +/// Read-time orphan repair: same anti-join delete as +/// [`delete_orphaned_metric_index_rows`], run defensively before every read +/// so a planted/legacy orphan is self-healed even if a future deletion path +/// forgets to call the GC cascade. Defense in depth, not an atomicity +/// substitute for A6. +pub(super) fn repair_orphaned_metric_index_rows( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result { + delete_orphaned_metric_index_rows(conn, identity_pubkey, relay_url) +} + +// ── Read path ──────────────────────────────────────────────────────────────── + +/// Load all VALID rows whose `reported_at` falls in `[start, end)`, optionally +/// filtered to one agent author. This is the exact set of rows that may ever +/// be counted into a bucket (A11 step 1). +pub(super) fn load_window_valid_rows( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + start: i64, + end: i64, + agent_pubkey: Option<&str>, +) -> Result, String> { + let sql = format!( + "SELECT {ROW_COLUMNS} FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'valid' + AND reported_at >= ?3 AND reported_at < ?4 + AND (?5 IS NULL OR agent_pubkey = ?5) + ORDER BY reported_at ASC, id ASC" + ); + let mut stmt = stmt_prepare(conn, &sql)?; + let rows = stmt + .query_map( + params![identity_pubkey, relay_url, start, end, agent_pubkey], + row_from_sql, + ) + .map_err(|e| format!("query load_window_valid_rows: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("read load_window_valid_rows row: {e}")) +} + +/// Count INVALID rows whose `event_created_at` falls in `[start, end)` +/// (invalid rows have no trustworthy `reported_at`, so coarse signed +/// `created_at` is the only available time signal), optionally filtered to +/// one agent author. +pub(super) fn count_invalid_rows_in_window( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + start: i64, + end: i64, + agent_pubkey: Option<&str>, +) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'invalid' + AND event_created_at >= ?3 AND event_created_at < ?4 + AND (?5 IS NULL OR agent_pubkey = ?5)", + params![identity_pubkey, relay_url, start, end, agent_pubkey], + |row| row.get(0), + ) + .map_err(|e| format!("count_invalid_rows_in_window: {e}")) +} + +/// For the given set of exact `(agent_pubkey, session_id, turn_seq)` keys, +/// load ALL valid rows matching those exact keys with NO `reported_at` +/// restriction (A11). Used both for duplicate-cardinality checks at a +/// sequence and for exact-predecessor baseline lookups — a single probe +/// covers both, since the predecessor key is included in the request set +/// alongside each window row's own key. +/// +/// Keys are grouped by `(agent_pubkey, session_id)` and queried with a +/// `turn_seq IN (...)` clause per group (typically few groups per window), +/// served by `idx_agent_metric_session`. +pub(super) fn load_rows_at_exact_keys( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + keys: &std::collections::HashSet<(String, String, u64)>, +) -> Result, String> { + use std::collections::HashMap; + + // Group by (agent, session) so each group becomes one IN-list query. + let mut groups: HashMap<(String, String), Vec> = HashMap::new(); + for (agent, session, seq) in keys { + groups + .entry((agent.clone(), session.clone())) + .or_default() + .push(*seq); + } + + let mut out = Vec::new(); + for ((agent, session), seqs) in groups { + let encoded: Vec = seqs.into_iter().map(encode_u64_sortable).collect(); + let sql = format!( + "SELECT {ROW_COLUMNS} FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'valid' + AND agent_pubkey = ?3 AND session_id = ?4 + AND turn_seq IN ({})", + encoded + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", i + 5)) + .collect::>() + .join(",") + ); + let mut stmt = stmt_prepare(conn, &sql)?; + let mut bound: Vec> = vec![ + Box::new(identity_pubkey.to_owned()), + Box::new(relay_url.to_owned()), + Box::new(agent.clone()), + Box::new(session.clone()), + ]; + for e in &encoded { + bound.push(Box::new(e.clone())); + } + let refs: Vec<&dyn rusqlite::ToSql> = bound.iter().map(|b| b.as_ref()).collect(); + let rows = stmt + .query_map(refs.as_slice(), row_from_sql) + .map_err(|e| format!("query load_rows_at_exact_keys: {e}"))?; + for r in rows { + out.push(r.map_err(|e| format!("read load_rows_at_exact_keys row: {e}"))?); + } + } + + Ok(out) +} + +/// `hasArchivedEvidence` (A13): does at least one surviving `agent_metric_index` +/// row (either `parse_status`) exist for this author under the active +/// identity+relay, with NO bucket-boundary restriction? Computed after +/// backfill + orphan repair by the caller. +pub(super) fn has_archived_evidence( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + agent_pubkey: &str, +) -> Result { + let exists: Option = conn + .query_row( + "SELECT 1 FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND agent_pubkey = ?3 + LIMIT 1", + params![identity_pubkey, relay_url, agent_pubkey], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("has_archived_evidence: {e}"))?; + Ok(exists.is_some()) +} + +fn stmt_prepare<'a>(conn: &'a Connection, sql: &str) -> Result, String> { + conn.prepare(sql) + .map_err(|e| format!("prepare failed: {e} — sql: {sql}")) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[path = "metric_store_tests.rs"] +mod metric_store_tests; diff --git a/desktop/src-tauri/src/archive/metric_store_tests.rs b/desktop/src-tauri/src/archive/metric_store_tests.rs new file mode 100644 index 00000000000..93fd3f90987 --- /dev/null +++ b/desktop/src-tauri/src/archive/metric_store_tests.rs @@ -0,0 +1,864 @@ +//! Unit tests for `archive/metric_store.rs`. +//! +//! Kept in a sibling file so `metric_store.rs` stays under the file-size +//! gate; `#[path]`-included from there. + +use super::*; +use crate::archive::store::{self, SCHEMA}; + +fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn +} + +fn valid_payload_json(session_id: &str, seq: u64, timestamp: &str) -> String { + format!( + r#"{{"harness":"goose","model":"claude","channelId":null,"sessionId":"{session_id}","turnId":null,"turnSeq":{seq},"timestamp":"{timestamp}","turn":{{"inputTokens":10,"outputTokens":20,"totalTokens":30,"costUsd":0.01}},"cumulative":{{"inputTokens":100,"outputTokens":200,"totalTokens":300,"costUsd":0.1}},"deltaReliable":true,"stopReason":"end_turn"}}"# + ) +} + +#[allow(clippy::too_many_arguments)] +fn insert_archived_event( + conn: &Connection, + identity: &str, + relay: &str, + id: &str, + kind: i64, + pubkey: &str, + created_at: i64, + raw_json: &str, + archived_at: i64, +) { + store::upsert_archived_event( + conn, + identity, + relay, + id, + kind, + pubkey, + created_at, + raw_json, + archived_at, + ) + .unwrap(); +} + +// ── u64 sortable encoding ──────────────────────────────────────────────────── + +#[test] +fn u64_sortable_round_trips_zero_and_max() { + for v in [0u64, 1, 12345, u64::MAX - 1, u64::MAX] { + let encoded = encode_u64_sortable(v); + assert_eq!(encoded.len(), U64_SORTABLE_WIDTH); + assert_eq!(decode_u64_sortable(&encoded), Some(v)); + } +} + +#[test] +fn u64_sortable_encoding_preserves_numeric_order_across_i64_max() { + let below = i64::MAX as u64; + let above = (i64::MAX as u64) + 1; + let e_below = encode_u64_sortable(below); + let e_above = encode_u64_sortable(above); + assert!( + e_below < e_above, + "lexicographic order must match numeric order" + ); +} + +#[test] +fn decode_rejects_wrong_width() { + assert_eq!(decode_u64_sortable("123"), None); + assert_eq!(decode_u64_sortable(""), None); +} + +// ── from_payload parsing ───────────────────────────────────────────────────── + +#[test] +fn from_payload_parses_valid_row() { + let json = valid_payload_json("s1", 7, "2026-07-01T20:11:03.213Z"); + let row = AgentMetricIndexRow::from_payload(&json, "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Valid); + assert_eq!(row.session_id, Some("s1".to_string())); + assert_eq!(row.turn_seq, Some(7)); + assert_eq!(row.turn_input_tokens, Some(10)); + assert_eq!(row.cumulative_input_tokens, Some(100)); + assert_eq!(row.model, Some("claude".to_string())); + assert_eq!(row.harness, Some("goose".to_string())); +} + +#[test] +fn from_payload_parses_harness_field() { + let json = r#"{"harness":"claude-code","model":"claude-sonnet","timestamp":"2026-07-01T20:11:03Z","turn":{"inputTokens":5,"outputTokens":null,"totalTokens":null,"costUsd":null}}"#; + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Valid); + assert_eq!(row.harness, Some("claude-code".to_string())); +} + +#[test] +fn from_payload_marks_unparseable_json_invalid() { + let row = AgentMetricIndexRow::from_payload("not json", "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Invalid); + assert_eq!(row.turn_input_tokens, None); +} + +#[test] +fn from_payload_marks_unparseable_timestamp_invalid() { + let json = r#"{"harness":"goose","timestamp":"not-a-timestamp"}"#; + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Invalid); +} + +#[test] +fn from_payload_marks_cumulative_without_session_seq_invalid() { + // cumulative present but sessionId/turnSeq missing — semantic-invalid per A5. + let json = r#"{"harness":"goose","timestamp":"2026-07-01T20:11:03Z","cumulative":{"inputTokens":1,"outputTokens":null,"totalTokens":null,"costUsd":null}}"#; + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Invalid); +} + +#[test] +fn from_payload_accepts_missing_cumulative_without_session_seq() { + // No cumulative object at all — session/seq are not required. + let json = r#"{"harness":"goose","timestamp":"2026-07-01T20:11:03Z","turn":{"inputTokens":5,"outputTokens":null,"totalTokens":null,"costUsd":null}}"#; + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Valid); + assert_eq!(row.turn_input_tokens, Some(5)); +} + +// ── insert / idempotence ────────────────────────────────────────────────────── + +#[test] +fn insert_metric_index_row_is_idempotent_on_pk() { + let conn = in_memory(); + let row = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eid1", + "agent1", + 100, + 200, + ); + let first = insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + let second = insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + assert!(first); + assert!(!second, "second insert of the same PK must be a no-op"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn insert_metric_index_row_round_trips_u64_max() { + let conn = in_memory(); + let row = AgentMetricIndexRow { + turn_seq: Some(u64::MAX), + turn_input_tokens: Some(u64::MAX), + cumulative_input_tokens: Some(u64::MAX), + ..AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eid1", + "agent1", + 100, + 200, + ) + }; + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + let loaded = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].turn_seq, Some(u64::MAX)); + assert_eq!(loaded[0].turn_input_tokens, Some(u64::MAX)); + assert_eq!(loaded[0].cumulative_input_tokens, Some(u64::MAX)); +} + +#[test] +fn insert_invalid_row_preserves_null_parsed_columns() { + let conn = in_memory(); + let row = AgentMetricIndexRow::from_payload("bad json", "eid1", "agent1", 100, 200); + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + let count = count_invalid_rows_in_window(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(count, 1); +} + +// ── Identity/relay isolation ───────────────────────────────────────────────── + +#[test] +fn load_window_valid_rows_scoped_to_identity_and_relay() { + let conn = in_memory(); + let row_a = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eidA", + "agent1", + 100, + 200, + ); + let row_b = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eidB", + "agent1", + 100, + 200, + ); + insert_metric_index_row(&conn, "identityA", "relay1", &row_a).unwrap(); + insert_metric_index_row(&conn, "identityB", "relay1", &row_b).unwrap(); + + let loaded_a = load_window_valid_rows(&conn, "identityA", "relay1", 0, i64::MAX, None).unwrap(); + assert_eq!(loaded_a.len(), 1); + assert_eq!(loaded_a[0].id, "eidA"); + + let loaded_b = load_window_valid_rows(&conn, "identityB", "relay1", 0, i64::MAX, None).unwrap(); + assert_eq!(loaded_b.len(), 1); + assert_eq!(loaded_b[0].id, "eidB"); +} + +#[test] +fn load_window_valid_rows_filters_by_agent_pubkey() { + let conn = in_memory(); + let row_a = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eidA", + "agentA", + 100, + 200, + ); + let row_b = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eidB", + "agentB", + 100, + 200, + ); + insert_metric_index_row(&conn, "id", "relay", &row_a).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &row_b).unwrap(); + + let loaded = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, Some("agentA")).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].agent_pubkey, "agentA"); +} + +#[test] +fn load_window_valid_rows_excludes_out_of_window_reported_at() { + let conn = in_memory(); + let in_window = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-01-02T00:00:00Z"), + "eid_in", + "agent1", + 0, + 0, + ); + let out_of_window = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 2, "2020-01-01T00:00:00Z"), + "eid_out", + "agent1", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &in_window).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &out_of_window).unwrap(); + + let start = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .timestamp(); + let end = chrono::DateTime::parse_from_rfc3339("2026-01-03T00:00:00Z") + .unwrap() + .timestamp(); + let loaded = load_window_valid_rows(&conn, "id", "relay", start, end, None).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "eid_in"); +} + +// ── load_rows_at_exact_keys ─────────────────────────────────────────────────── + +#[test] +fn load_rows_at_exact_keys_matches_multiple_groups() { + let conn = in_memory(); + let r1 = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 5, "2026-07-01T00:00:00Z"), + "e1", + "agent1", + 0, + 0, + ); + let r2 = AgentMetricIndexRow::from_payload( + &valid_payload_json("s2", 9, "2026-07-01T00:00:00Z"), + "e2", + "agent1", + 0, + 0, + ); + let r3_not_requested = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 99, "2026-07-01T00:00:00Z"), + "e3", + "agent1", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &r1).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &r2).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &r3_not_requested).unwrap(); + + let mut keys = std::collections::HashSet::new(); + keys.insert(("agent1".to_string(), "s1".to_string(), 5u64)); + keys.insert(("agent1".to_string(), "s2".to_string(), 9u64)); + + let loaded = load_rows_at_exact_keys(&conn, "id", "relay", &keys).unwrap(); + let mut ids: Vec<&str> = loaded.iter().map(|r| r.id.as_str()).collect(); + ids.sort(); + assert_eq!(ids, vec!["e1", "e2"]); +} + +#[test] +fn load_rows_at_exact_keys_returns_all_duplicates_at_one_key() { + let conn = in_memory(); + let dup_a = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 5, "2026-07-01T00:00:00Z"), + "eA", + "agent1", + 0, + 0, + ); + let dup_b = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 5, "2026-07-01T00:00:01Z"), + "eB", + "agent1", + 0, + 1, + ); + insert_metric_index_row(&conn, "id", "relay", &dup_a).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &dup_b).unwrap(); + + let mut keys = std::collections::HashSet::new(); + keys.insert(("agent1".to_string(), "s1".to_string(), 5u64)); + let loaded = load_rows_at_exact_keys(&conn, "id", "relay", &keys).unwrap(); + assert_eq!(loaded.len(), 2); +} + +// ── has_archived_evidence ───────────────────────────────────────────────────── + +#[test] +fn has_archived_evidence_true_for_either_parse_status() { + let conn = in_memory(); + let invalid_row = AgentMetricIndexRow::from_payload("bad", "eid1", "agentX", 0, 0); + insert_metric_index_row(&conn, "id", "relay", &invalid_row).unwrap(); + + assert!(has_archived_evidence(&conn, "id", "relay", "agentX").unwrap()); + assert!(!has_archived_evidence(&conn, "id", "relay", "agentY").unwrap()); +} + +#[test] +fn has_archived_evidence_ignores_bucket_boundaries() { + let conn = in_memory(); + // A very old row (outside any realistic window) still counts as evidence. + let old_row = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "1999-01-01T00:00:00Z"), + "eid1", + "agentX", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &old_row).unwrap(); + assert!(has_archived_evidence(&conn, "id", "relay", "agentX").unwrap()); +} + +// ── Backfill ────────────────────────────────────────────────────────────────── + +#[test] +fn backfill_indexes_existing_unindexed_kind_44200_rows() { + let conn = in_memory(); + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200, + ); + + let indexed = backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + assert_eq!(indexed, 1); + + let loaded = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "eid1"); +} + +#[test] +fn backfill_is_idempotent_anti_join() { + let conn = in_memory(); + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200, + ); + + backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + let second_run = backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + assert_eq!(second_run, 0, "second backfill run must index nothing new"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn backfill_processes_chunks_larger_than_500_rows() { + let conn = in_memory(); + // 501 rows exercises the CHUNK_SIZE=500 boundary. + for i in 0..501 { + let json = valid_payload_json(&format!("s{i}"), 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, + "id", + "relay", + &format!("eid{i}"), + 44200, + "agent1", + 100 + i as i64, + &json, + 200, + ); + } + let indexed = backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + assert_eq!(indexed, 501); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 501); +} + +#[test] +fn backfill_ignores_non_44200_rows() { + let conn = in_memory(); + insert_archived_event(&conn, "id", "relay", "eid1", 1, "author1", 100, "{}", 200); + let indexed = backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + assert_eq!(indexed, 0); +} + +// ── GC / orphan repair ──────────────────────────────────────────────────────── + +#[test] +fn delete_orphaned_metric_index_rows_removes_rows_with_no_canonical_event() { + let conn = in_memory(); + // Planted orphan: index row with no matching archived_events row. + let orphan = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "orphan_id", + "agent1", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &orphan).unwrap(); + + let deleted = delete_orphaned_metric_index_rows(&conn, "id", "relay").unwrap(); + assert_eq!(deleted, 1); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); +} + +#[test] +fn delete_orphaned_metric_index_rows_preserves_rows_with_canonical_event() { + let conn = in_memory(); + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200, + ); + let row = AgentMetricIndexRow::from_payload(&json, "eid1", "agent1", 100, 200); + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + let deleted = delete_orphaned_metric_index_rows(&conn, "id", "relay").unwrap(); + assert_eq!(deleted, 0); +} + +#[test] +fn repair_orphaned_metric_index_rows_self_heals_planted_orphan_before_read() { + let conn = in_memory(); + let orphan = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "orphan_id", + "agent1", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &orphan).unwrap(); + + repair_orphaned_metric_index_rows(&conn, "id", "relay").unwrap(); + let loaded = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert!( + loaded.is_empty(), + "planted orphan must never be reported after repair" + ); +} + +#[test] +fn gc_orphaned_events_cascades_to_metric_index_atomically() { + let conn = in_memory(); + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200, + ); + let row = AgentMetricIndexRow::from_payload(&json, "eid1", "agent1", 100, 200); + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + // Remove the last scope row so the event becomes orphaned, then GC. + // (No scope row was ever added in this test, so the event is already + // orphaned by construction — gc_orphaned_events should delete both the + // canonical row and its index row in one transaction.) + store::gc_orphaned_events(&conn, "id", "relay").unwrap(); + + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let index_count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 0); + assert_eq!( + index_count, 0, + "index row must not outlive its canonical event" + ); +} + +// ── EXPLAIN QUERY PLAN index assertions (A7) ───────────────────────────────── + +fn query_plan(conn: &Connection, sql: &str, params: &[&dyn rusqlite::ToSql]) -> String { + let explain_sql = format!("EXPLAIN QUERY PLAN {sql}"); + let mut stmt = conn.prepare(&explain_sql).unwrap(); + let mut rows = stmt.query(params).unwrap(); + let mut plan = String::new(); + while let Some(row) = rows.next().unwrap() { + let detail: String = row.get(3).unwrap(); + plan.push_str(&detail); + plan.push('\n'); + } + plan +} + +#[test] +fn backfill_anti_join_uses_partial_index() { + let conn = in_memory(); + let plan = query_plan( + &conn, + "SELECT ae.id FROM archived_events ae + WHERE ae.identity_pubkey = ?1 AND ae.relay_url = ?2 AND ae.kind = 44200 + AND ae.id NOT IN (SELECT id FROM agent_metric_index WHERE identity_pubkey = ?1 AND relay_url = ?2)", + &[&"id", &"relay"], + ); + assert!( + plan.contains("idx_archived_events_agent_metric"), + "backfill anti-join must use the partial index, plan was:\n{plan}" + ); +} + +#[test] +fn window_scan_uses_reported_index() { + let conn = in_memory(); + let plan = query_plan( + &conn, + "SELECT * FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'valid' + AND reported_at >= ?3 AND reported_at < ?4", + &[&"id", &"relay", &0i64, &100i64], + ); + assert!( + plan.contains("idx_agent_metric_reported"), + "window scan must use the reported-time index, plan was:\n{plan}" + ); +} + +#[test] +fn predecessor_lookup_uses_session_index() { + let conn = in_memory(); + let plan = query_plan( + &conn, + "SELECT * FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'valid' + AND agent_pubkey = ?3 AND session_id = ?4 AND turn_seq IN (?5)", + &[&"id", &"relay", &"agent1", &"s1", &"00000000000000000005"], + ); + assert!( + plan.contains("idx_agent_metric_session"), + "predecessor lookup must use the session index, plan was:\n{plan}" + ); +} + +// ── Migration: old-shape rows get harness populated on rebuild ──────────────── + +/// Simulate a pre-harness archive row by inserting an archived_event and an +/// index row with harness = NULL (as the old schema would have stored it), +/// then running the backfill rebuild path to verify harness is populated. +#[test] +fn migration_old_shape_rows_get_harness_populated_after_rebuild() { + let conn = in_memory(); + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + // Seed an archived event. + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200, + ); + // Seed an index row with harness explicitly NULL (simulates pre-migration row). + conn.execute( + "INSERT INTO agent_metric_index + (identity_pubkey, relay_url, id, agent_pubkey, event_created_at, + archived_at, reported_at, session_id, turn_seq, harness, model, + delta_reliable, turn_input_tokens, turn_output_tokens, + turn_total_tokens, turn_cost_usd, cumulative_input_tokens, + cumulative_output_tokens, cumulative_total_tokens, + cumulative_cost_usd, parse_status) + VALUES ('id','relay','eid1','agent1',100,200,1751414400000, + 's1','00000000000000000001',NULL,'claude', + 1,'00000000000000000010','00000000000000000020', + '00000000000000000030',0.01, + '00000000000000000100','00000000000000000200', + '00000000000000000300',0.1,'valid')", + [], + ) + .unwrap(); + + // Verify the row is there with NULL harness. + let harness_before: Option = conn + .query_row( + "SELECT harness FROM agent_metric_index WHERE id = 'eid1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + harness_before, None, + "old-shape row should start with NULL harness" + ); + + // Simulate the migration: delete index rows and re-run backfill. + conn.execute_batch("DELETE FROM agent_metric_index") + .unwrap(); + backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + + // The rebuilt row must have harness populated. + let harness_after: Option = conn + .query_row( + "SELECT harness FROM agent_metric_index WHERE id = 'eid1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + harness_after, + Some("goose".to_string()), + "rebuilt row must have harness populated from raw_json" + ); +} + +// ── cache_read_tokens parse and persist ─────────────────────────────────────── + +/// A payload carrying `cacheReadTokens` in both `turn` and `cumulative` must +/// round-trip through `from_payload` → `insert_metric_index_row` → `row_from_sql`. +#[test] +fn from_payload_parses_cache_read_tokens_when_present() { + let json = r#"{"harness":"goose","model":"claude","channelId":null,"sessionId":"s1","turnId":null,"turnSeq":1,"timestamp":"2026-07-01T00:00:00Z","turn":{"inputTokens":100,"outputTokens":20,"totalTokens":120,"costUsd":0.01,"cacheReadTokens":80},"cumulative":{"inputTokens":500,"outputTokens":100,"totalTokens":600,"costUsd":0.05,"cacheReadTokens":400},"deltaReliable":true,"stopReason":"end_turn"}"#; + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + assert_eq!( + row.turn_cache_read_tokens, + Some(80), + "turn_cache_read_tokens must be parsed from turn.cacheReadTokens" + ); + assert_eq!( + row.cumulative_cache_read_tokens, + Some(400), + "cumulative_cache_read_tokens must be parsed from cumulative.cacheReadTokens" + ); +} + +/// A payload WITHOUT `cacheReadTokens` must produce `None` in both fields. +#[test] +fn from_payload_cache_read_tokens_none_when_absent() { + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + let row = AgentMetricIndexRow::from_payload(&json, "eid1", "agent1", 100, 200); + assert_eq!( + row.turn_cache_read_tokens, None, + "turn_cache_read_tokens must be None when cacheReadTokens absent" + ); + assert_eq!( + row.cumulative_cache_read_tokens, None, + "cumulative_cache_read_tokens must be None when cacheReadTokens absent" + ); +} + +/// `insert_metric_index_row` persists nonzero cache columns; `row_from_sql` +/// (via `load_window_valid_rows`) reads them back correctly. +#[test] +fn insert_and_load_round_trips_cache_read_tokens() { + let conn = in_memory(); + let json = r#"{"harness":"goose","model":"claude","channelId":null,"sessionId":"s1","turnId":null,"turnSeq":1,"timestamp":"2026-07-01T00:00:00Z","turn":{"inputTokens":100,"outputTokens":20,"totalTokens":120,"costUsd":0.01,"cacheReadTokens":80},"cumulative":{"inputTokens":500,"outputTokens":100,"totalTokens":600,"costUsd":0.05,"cacheReadTokens":400},"deltaReliable":true,"stopReason":"end_turn"}"#; + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, json, 200, + ); + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + // Use load_window_valid_rows to exercise the full read path. + let rows = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].turn_cache_read_tokens, + Some(80), + "turn_cache_read_tokens must survive insert + load round trip" + ); + assert_eq!( + rows[0].cumulative_cache_read_tokens, + Some(400), + "cumulative_cache_read_tokens must survive insert + load round trip" + ); +} + +/// A row inserted without cache tokens loads back with `None` for both fields — +/// columns default to NULL and the decoder handles NULL correctly. +#[test] +fn insert_and_load_cache_read_tokens_null_when_absent() { + let conn = in_memory(); + let json = &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, json, 200, + ); + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + let rows = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].turn_cache_read_tokens, None, + "turn_cache_read_tokens must be None when not present in payload" + ); + assert_eq!( + rows[0].cumulative_cache_read_tokens, None, + "cumulative_cache_read_tokens must be None when not present in payload" + ); +} + +// ── M3 cache-write and pricing columns ──────────────────────────────────────── + +/// M3 positive full-row round trip: a payload carrying nonzero cache-write +/// tokens and a pricingIdentity must survive `from_payload` → `insert` → `load` +/// with all five M3 columns intact. +#[test] +fn m3_insert_and_load_round_trips_cache_write_and_pricing() { + let conn = in_memory(); + let json = r#"{"harness":"buzz-agent","model":"claude-opus-4-5","channelId":null,"sessionId":"s1","turnId":null,"turnSeq":1,"timestamp":"2026-07-01T00:00:00Z","turn":{"inputTokens":200,"outputTokens":50,"totalTokens":250,"costUsd":0.02,"cacheReadTokens":120,"cacheWriteTokens":30},"cumulative":{"inputTokens":600,"outputTokens":150,"totalTokens":750,"costUsd":0.06,"cacheReadTokens":360,"cacheWriteTokens":90},"deltaReliable":true,"stopReason":"end_turn","pricingIdentity":{"authority":"api.anthropic.com","model":"claude-opus-4-5"}}"#; + insert_archived_event( + &conn, + "id", + "relay", + "eid-m3-pos", + 44200, + "agent1", + 100, + json, + 200, + ); + let row = AgentMetricIndexRow::from_payload(json, "eid-m3-pos", "agent1", 100, 200); + + // Verify from_payload extracted the M3 fields. + assert_eq!( + row.turn_cache_write_tokens, + Some(30), + "from_payload: turn_cache_write_tokens must parse cacheWriteTokens" + ); + assert_eq!( + row.cumulative_cache_write_tokens, + Some(90), + "from_payload: cumulative_cache_write_tokens must parse cumulative.cacheWriteTokens" + ); + assert_eq!( + row.pricing_authority.as_deref(), + Some("api.anthropic.com"), + "from_payload: pricing_authority must parse pricingIdentity.authority" + ); + assert_eq!( + row.pricing_model.as_deref(), + Some("claude-opus-4-5"), + "from_payload: pricing_model must parse pricingIdentity.model" + ); + assert!( + row.pricing_cache_class.is_none(), + "from_payload: pricing_cache_class must be None when absent" + ); + + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + let rows = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(rows.len(), 1); + let loaded = &rows[0]; + assert_eq!( + loaded.turn_cache_write_tokens, + Some(30), + "round-trip: turn_cache_write_tokens must survive insert + load" + ); + assert_eq!( + loaded.cumulative_cache_write_tokens, + Some(90), + "round-trip: cumulative_cache_write_tokens must survive insert + load" + ); + assert_eq!( + loaded.pricing_authority.as_deref(), + Some("api.anthropic.com"), + "round-trip: pricing_authority must survive insert + load" + ); + assert_eq!( + loaded.pricing_model.as_deref(), + Some("claude-opus-4-5"), + "round-trip: pricing_model must survive insert + load" + ); + assert!( + loaded.pricing_cache_class.is_none(), + "round-trip: pricing_cache_class must be None (absent in payload)" + ); +} + +/// M3 omission/explicit-zero: a payload with no cache-write and no pricingIdentity +/// must produce NULL for all five M3 columns — never inferred zeros. +#[test] +fn m3_insert_and_load_m3_columns_null_when_absent() { + let conn = in_memory(); + // Standard payload without cacheWriteTokens or pricingIdentity. + let json = &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, + "id", + "relay", + "eid-m3-null", + 44200, + "agent1", + 100, + json, + 200, + ); + let row = AgentMetricIndexRow::from_payload(json, "eid-m3-null", "agent1", 100, 200); + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + let rows = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(rows.len(), 1); + let loaded = &rows[0]; + assert_eq!( + loaded.turn_cache_write_tokens, None, + "M3: turn_cache_write_tokens must be None when absent in payload" + ); + assert_eq!( + loaded.cumulative_cache_write_tokens, None, + "M3: cumulative_cache_write_tokens must be None when absent in payload" + ); + assert_eq!( + loaded.pricing_authority, None, + "M3: pricing_authority must be None when pricingIdentity absent" + ); + assert_eq!( + loaded.pricing_model, None, + "M3: pricing_model must be None when pricingIdentity absent" + ); + assert_eq!( + loaded.pricing_cache_class, None, + "M3: pricing_cache_class must be None when pricingIdentity absent" + ); +} diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 42c6812674e..9f1458e96fa 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -17,8 +17,11 @@ //! validation (sig/id + kind + p-tag + agent tag + frame=telemetry + author //! == agent) is applied fail-closed. +mod agent_usage; +mod metric_store; mod pipeline; pub mod store; +mod store_migrations; use pipeline::{commit_archive, plan_archive, query_buckets}; @@ -116,9 +119,17 @@ pub struct MatchedScope { /// Result of a batch archive call. #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct ArchiveBatchResult { /// Events successfully written to the store (event + scope rows). pub persisted: u32, + /// Newly-indexed `agent_metric_index` rows (valid or invalid) written in + /// this call — the count the frontend uses to decide whether an + /// agent-usage query needs to be invalidated. Distinct from `persisted`: + /// a re-ingested duplicate can be `persisted` (event/scope rows upserted, + /// no-op) without incrementing this counter, since the index row for + /// that id was already inserted by whichever earlier batch first saw it. + pub persisted_agent_metrics: u32, /// Events dropped due to access denial or invalid payload (not an error). pub dropped: u32, } @@ -674,6 +685,96 @@ pub async fn read_archived_events( .await } +// ── get_agent_usage_series ─────────────────────────────────────────────────── + +/// Compute the locally archived NIP-AM usage series for one identity/relay. +/// +/// Synchronous SQLite core of [`get_agent_usage_series`], split out so tests +/// can drive it directly against an in-memory `Connection` without a Tauri +/// `AppState`. Backfills any unindexed kind-44200 rows, repairs orphaned +/// index rows (defense in depth alongside A6's GC-time cascade), validates +/// the request, loads the window + exact-key probe rows, and hands them to +/// the pure `agent_usage::compute_series`. +fn agent_usage_series( + conn: &Connection, + identity_pk: &str, + relay_url: &str, + request: &agent_usage::AgentUsageSeriesRequest, +) -> Result { + // Fail-closed request validation happens before any SQLite work. + let agent_pubkey = agent_usage::validate_request(request)?; + + metric_store::backfill_agent_metric_index(conn, identity_pk, relay_url)?; + metric_store::repair_orphaned_metric_index_rows(conn, identity_pk, relay_url)?; + + let collection_enabled = { + let kinds_json = + store::get_subscription_kinds(conn, identity_pk, relay_url, "owner_p", identity_pk)? + .unwrap_or_else(|| "[]".to_string()); + let kinds: Vec = serde_json::from_str(&kinds_json).unwrap_or_default(); + kinds.contains(&(KIND_AGENT_TURN_METRIC as u64)) + }; + + // A13: only meaningful when the request is scoped to one author. + let has_archived_evidence = match &agent_pubkey { + None => None, + Some(pk) => Some(metric_store::has_archived_evidence( + conn, + identity_pk, + relay_url, + pk, + )?), + }; + + let start = request.bucket_boundaries[0]; + let end = *request + .bucket_boundaries + .last() + .expect("validate_request already rejected fewer than 2 boundaries"); + + let window_rows = metric_store::load_window_valid_rows( + conn, + identity_pk, + relay_url, + start, + end, + agent_pubkey.as_deref(), + )?; + let invalid_report_count = metric_store::count_invalid_rows_in_window( + conn, + identity_pk, + relay_url, + start, + end, + agent_pubkey.as_deref(), + )?; + let probe_keys = agent_usage::window_probe_keys(&window_rows); + let probe_rows = + metric_store::load_rows_at_exact_keys(conn, identity_pk, relay_url, &probe_keys)?; + + Ok(agent_usage::compute_series( + &window_rows, + &probe_rows, + invalid_report_count, + &request.bucket_boundaries, + has_archived_evidence, + collection_enabled, + )) +} + +/// Compute the locally archived NIP-AM usage series for the active identity +/// + relay (Rev 3 frozen contract). See [`agent_usage_series`] for the logic. +#[tauri::command] +pub async fn get_agent_usage_series( + state: State<'_, AppState>, + request: agent_usage::AgentUsageSeriesRequest, +) -> Result { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + run_archive_db_task(move |conn| agent_usage_series(conn, &identity_pk, &relay_url, &request)) + .await +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs new file mode 100644 index 00000000000..2dc568d701c --- /dev/null +++ b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs @@ -0,0 +1,411 @@ +//! Kind-44200 (NIP-AM agent turn metric) archive and `get_agent_usage_series` +//! integration tests for `archive/mod.rs`. +//! +//! Kept in a sibling file so `mod_tests.rs` stays under the 1000-line gate; +//! `#[path]`-included from there so the shared fixtures (`in_memory`, +//! `add_sub`, `candidate`, `make_observer_frame`, `run_batch_sync_with_keys`) +//! stay private to `mod_tests`. + +use super::*; + +// ── Kind-44200 agent-turn-metric archive tests ─────────────────────────── + +fn make_turn_metric_event(owner_keys: &Keys, agent_keys: &Keys) -> Event { + use buzz_core_pkg::agent_turn_metric::{ + encrypt_agent_turn_metric, AgentTurnMetricPayload, TokenCounts, + }; + let owner_pk = owner_keys.public_key().to_hex(); + let payload = AgentTurnMetricPayload { + harness: "test-harness".to_string(), + model: Some("test-model".to_string()), + channel_id: None, + session_id: Some("sess-1".to_string()), + turn_id: Some("turn-1".to_string()), + turn_seq: Some(1), + timestamp: "2026-07-01T00:00:00Z".to_string(), + turn: Some(TokenCounts { + input_tokens: Some(100), + output_tokens: Some(50), + total_tokens: Some(150), + cost_usd: Some(0.001), + cache_read_tokens: None, + cache_write_tokens: None, + }), + cumulative: None, + delta_reliable: true, + stop_reason: None, + pricing_identity: None, + }; + let ciphertext = + encrypt_agent_turn_metric(agent_keys, &owner_keys.public_key(), &payload).unwrap(); + let tags = vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(44200), &ciphertext) + .tags(tags) + .sign_with_keys(agent_keys) + .unwrap() +} + +/// A kind-44200 event with `owner_p` scope must route to the persistent +/// (relay-query) path, NOT the ephemeral path. +#[test] +fn test_owner_p_44200_routes_to_persistent_path() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Subscription for kind 44200 under owner_p. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + // Must be in persistent buckets, NOT ephemeral list. + assert_eq!(plan.buckets.len(), 1, "kind-44200 must land in a bucket"); + assert_eq!( + plan.ephemeral.len(), + 0, + "kind-44200 must NOT be on the ephemeral path" + ); + assert_eq!( + plan.buckets[0].scope_type_str, "owner_p", + "bucket scope_type must be owner_p" + ); +} + +/// A kind-24200 event with `owner_p` scope must still route to ephemeral. +#[test] +fn test_owner_p_24200_still_routes_to_ephemeral() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + assert_eq!( + plan.buckets.len(), + 0, + "kind-24200 must NOT land in a bucket" + ); + assert_eq!( + plan.ephemeral.len(), + 1, + "kind-24200 must be on the ephemeral path" + ); +} + +/// Decrypt success: plaintext payload JSON is stored, not raw ciphertext. +#[test] +fn test_turn_metric_decrypt_success_stores_plaintext() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + + assert_eq!(result.persisted, 1, "event must be persisted"); + assert_eq!(result.dropped, 0, "no drops on successful decrypt"); + assert_eq!( + result.persisted_agent_metrics, 1, + "one newly-indexed agent_metric_index row on first ingest" + ); + + // The stored raw_json must be plaintext JSON, not NIP-44 ciphertext. + let raw_json: String = conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + // Plaintext JSON should be a valid object with "harness" key. + let parsed: serde_json::Value = + serde_json::from_str(&raw_json).expect("stored raw_json must be valid JSON"); + assert_eq!( + parsed["harness"], "test-harness", + "stored plaintext must decode to AgentTurnMetricPayload" + ); + // Sanity: must NOT be the original NIP-44 ciphertext (which is not JSON). + assert_ne!( + raw_json, ev.content, + "stored content must differ from original ciphertext" + ); +} + +/// Decrypt fail: event is dropped, nothing written to the store (fail-closed). +#[test] +fn test_turn_metric_decrypt_fail_drops_fail_closed() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let wrong_keys = Keys::generate(); // wrong owner key — decrypt will fail + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Register subscription under owner_pk so the event passes plan-phase, + // but use `wrong_keys` in commit so decrypt fails. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &wrong_keys, // wrong key → decrypt fails + ); + + assert_eq!( + result.persisted, 0, + "decrypt failure must not persist the event" + ); + assert_eq!(result.dropped, 1, "decrypt failure must count as dropped"); + + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "no rows must be written to archived_events on decrypt failure" + ); +} + +/// Re-ingesting a batch containing an already-archived kind-44200 event must +/// no-op the metric index insert: `persisted` still counts the (idempotent) +/// event/scope upsert, but `persisted_agent_metrics` must be 0 for the +/// duplicate — the row was already indexed by the first ingest (A5: this is +/// exactly the signal the frontend uses to skip a redundant query +/// invalidation). +#[test] +fn test_reingest_of_same_metric_event_does_not_double_count_persisted_agent_metrics() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand1 = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let first = run_batch_sync_with_keys( + vec![cand1], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + assert_eq!(first.persisted, 1); + assert_eq!(first.persisted_agent_metrics, 1); + + // Same event re-ingested in a second batch (e.g. relay redelivery). + let cand2 = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let second = run_batch_sync_with_keys( + vec![cand2], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + assert_eq!( + second.persisted, 1, + "re-ingest of a duplicate is still an accepted (idempotent) write" + ); + assert_eq!( + second.persisted_agent_metrics, 0, + "re-ingest must NOT double-count the metric index row" + ); + + let index_count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(index_count, 1, "exactly one index row must exist total"); +} + +// ── get_agent_usage_series integration ────────────────────────────────────── +// +// Exercises `agent_usage_series` (the sync core `get_agent_usage_series` +// delegates to) end to end: ingest a real encrypted turn-metric event +// through the full archive pipeline, then read it back through the command +// core, proving backfill/indexing, collection-enabled detection, and the +// pure accounting ladder are wired together correctly — not just each in +// isolation. + +/// A freshly ingested single turn-metric event surfaces in the series with +/// its direct (delta-reliable, no-baseline) token counts, and +/// `collectionEnabled` reflects the active owner_p/44200 subscription. +#[test] +fn test_agent_usage_series_surfaces_freshly_ingested_event() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let agent_pk = agent_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let batch = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + assert_eq!(batch.persisted_agent_metrics, 1, "event must be indexed"); + + // `make_turn_metric_event`'s payload timestamp is 2026-07-01T00:00:00Z. + const EVENT_DAY_START: i64 = 1_782_864_000; + let boundaries: Vec = (0..=7).map(|i| EVENT_DAY_START + i * 86_400).collect(); + let request = agent_usage::AgentUsageSeriesRequest { + bucket_boundaries: boundaries, + agent_pubkey: None, + }; + + let series = agent_usage_series(&conn, &owner_pk, relay_url, &request).unwrap(); + + assert!( + series.collection_enabled, + "owner_p subscription includes kind 44200" + ); + assert_eq!(series.coverage.report_count, 1); + assert_eq!(series.coverage.invalid_report_count, 0); + assert_eq!(series.agents.len(), 1, "exactly one agent reported usage"); + let agent = &series.agents[0]; + assert_eq!(agent.agent_pubkey, agent_pk); + // No baseline row exists, so the ladder falls back to the direct + // (delta-reliable) turn values from the payload: 100/50/150. + assert_eq!(agent.usage.input_tokens.value.as_deref(), Some("100")); + assert_eq!(agent.usage.output_tokens.value.as_deref(), Some("50")); + assert_eq!(agent.usage.total_tokens.value.as_deref(), Some("150")); + assert!(!agent.usage.input_tokens.incomplete); + assert_eq!( + series.has_archived_evidence, None, + "no agentPubkey filter was supplied" + ); +} + +/// Filtering by `agentPubkey` scopes both the returned series and +/// `hasArchivedEvidence` (A13) to that one author; an unrelated agent's +/// events must not leak into either. +#[test] +fn test_agent_usage_series_filters_by_agent_pubkey_and_sets_has_archived_evidence() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let target_agent = Keys::generate(); + let other_agent = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let target_pk = target_agent.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let target_ev = make_turn_metric_event(&owner_keys, &target_agent); + let other_ev = make_turn_metric_event(&owner_keys, &other_agent); + let cands = vec![ + candidate(&target_ev, ScopeType::OwnerP, &owner_pk), + candidate(&other_ev, ScopeType::OwnerP, &owner_pk), + ]; + let batch = run_batch_sync_with_keys( + cands, + &owner_pk, + relay_url, + &conn, + vec![target_ev.clone(), other_ev.clone()], + &owner_keys, + ); + assert_eq!(batch.persisted_agent_metrics, 2); + + const EVENT_DAY_START: i64 = 1_782_864_000; + let boundaries: Vec = (0..=7).map(|i| EVENT_DAY_START + i * 86_400).collect(); + let request = agent_usage::AgentUsageSeriesRequest { + bucket_boundaries: boundaries, + agent_pubkey: Some(target_pk.clone()), + }; + + let series = agent_usage_series(&conn, &owner_pk, relay_url, &request).unwrap(); + + assert_eq!( + series.agents.len(), + 1, + "only the filtered agent's usage must be returned" + ); + assert_eq!(series.agents[0].agent_pubkey, target_pk); + assert_eq!( + series.has_archived_evidence, + Some(true), + "A13: evidence exists for the filtered author" + ); +} + +/// An unindexed pre-existing kind-44200 row (simulating an event archived +/// by a prior build before `agent_metric_index` existed) is picked up by +/// the command's backfill step before the window is read. +#[test] +fn test_agent_usage_series_backfills_unindexed_row_before_reading() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + + // Insert directly into `archived_events`, bypassing `commit_archive`, so + // no `agent_metric_index` row is created — the exact state a fresh + // backfill must repair. + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let plaintext = r#"{"harness":"test-harness","model":"test-model","sessionId":"sess-1","turnId":"turn-1","turnSeq":1,"timestamp":"2026-07-01T00:00:00Z","turn":{"inputTokens":100,"outputTokens":50,"totalTokens":150,"costUsd":0.001},"deltaReliable":true}"#; + store::upsert_archived_event( + &conn, + &owner_pk, + relay_url, + &ev.id.to_hex(), + 44200, + &agent_keys.public_key().to_hex(), + ev.created_at.as_secs() as i64, + plaintext, + 0, + ) + .unwrap(); + + let index_count_before: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(index_count_before, 0, "no index row before backfill"); + + const EVENT_DAY_START: i64 = 1_782_864_000; + let boundaries: Vec = (0..=7).map(|i| EVENT_DAY_START + i * 86_400).collect(); + let request = agent_usage::AgentUsageSeriesRequest { + bucket_boundaries: boundaries, + agent_pubkey: None, + }; + + let series = agent_usage_series(&conn, &owner_pk, relay_url, &request).unwrap(); + + assert_eq!( + series.coverage.report_count, 1, + "backfill must index the pre-existing row before the window read" + ); +} diff --git a/desktop/src-tauri/src/archive/mod_tests.rs b/desktop/src-tauri/src/archive/mod_tests.rs index 288d2ab34c3..21587669268 100644 --- a/desktop/src-tauri/src/archive/mod_tests.rs +++ b/desktop/src-tauri/src/archive/mod_tests.rs @@ -621,182 +621,11 @@ fn test_commit_archive_rolls_back_when_scope_write_would_fail() { ); } -// ── Kind-44200 agent-turn-metric archive tests ─────────────────────────── - -fn make_turn_metric_event(owner_keys: &Keys, agent_keys: &Keys) -> Event { - use buzz_core_pkg::agent_turn_metric::{ - encrypt_agent_turn_metric, AgentTurnMetricPayload, TokenCounts, - }; - let owner_pk = owner_keys.public_key().to_hex(); - let payload = AgentTurnMetricPayload { - harness: "test-harness".to_string(), - model: Some("test-model".to_string()), - channel_id: None, - session_id: Some("sess-1".to_string()), - turn_id: Some("turn-1".to_string()), - turn_seq: Some(1), - timestamp: "2026-07-01T00:00:00Z".to_string(), - turn: Some(TokenCounts { - input_tokens: Some(100), - output_tokens: Some(50), - total_tokens: Some(150), - cost_usd: Some(0.001), - cache_read_tokens: None, - cache_write_tokens: None, - }), - cumulative: None, - delta_reliable: true, - stop_reason: None, - }; - let ciphertext = - encrypt_agent_turn_metric(agent_keys, &owner_keys.public_key(), &payload).unwrap(); - let tags = vec![ - Tag::parse(["p", &owner_pk]).unwrap(), - Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), - ]; - EventBuilder::new(Kind::Custom(44200), &ciphertext) - .tags(tags) - .sign_with_keys(agent_keys) - .unwrap() -} - -/// A kind-44200 event with `owner_p` scope must route to the persistent -/// (relay-query) path, NOT the ephemeral path. -#[test] -fn test_owner_p_44200_routes_to_persistent_path() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - // Subscription for kind 44200 under owner_p. - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); - - let ev = make_turn_metric_event(&owner_keys, &agent_keys); - let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); - - let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); - - // Must be in persistent buckets, NOT ephemeral list. - assert_eq!(plan.buckets.len(), 1, "kind-44200 must land in a bucket"); - assert_eq!( - plan.ephemeral.len(), - 0, - "kind-44200 must NOT be on the ephemeral path" - ); - assert_eq!( - plan.buckets[0].scope_type_str, "owner_p", - "bucket scope_type must be owner_p" - ); -} - -/// A kind-24200 event with `owner_p` scope must still route to ephemeral. -#[test] -fn test_owner_p_24200_still_routes_to_ephemeral() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); - - let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); - let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); - - let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); - - assert_eq!( - plan.buckets.len(), - 0, - "kind-24200 must NOT land in a bucket" - ); - assert_eq!( - plan.ephemeral.len(), - 1, - "kind-24200 must be on the ephemeral path" - ); -} - -/// Decrypt success: plaintext payload JSON is stored, not raw ciphertext. -#[test] -fn test_turn_metric_decrypt_success_stores_plaintext() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); - - let ev = make_turn_metric_event(&owner_keys, &agent_keys); - let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); - let result = run_batch_sync_with_keys( - vec![cand], - &owner_pk, - relay_url, - &conn, - vec![ev.clone()], - &owner_keys, - ); - - assert_eq!(result.persisted, 1, "event must be persisted"); - assert_eq!(result.dropped, 0, "no drops on successful decrypt"); - - // The stored raw_json must be plaintext JSON, not NIP-44 ciphertext. - let raw_json: String = conn - .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) - .unwrap(); - // Plaintext JSON should be a valid object with "harness" key. - let parsed: serde_json::Value = - serde_json::from_str(&raw_json).expect("stored raw_json must be valid JSON"); - assert_eq!( - parsed["harness"], "test-harness", - "stored plaintext must decode to AgentTurnMetricPayload" - ); - // Sanity: must NOT be the original NIP-44 ciphertext (which is not JSON). - assert_ne!( - raw_json, ev.content, - "stored content must differ from original ciphertext" - ); -} - -/// Decrypt fail: event is dropped, nothing written to the store (fail-closed). -#[test] -fn test_turn_metric_decrypt_fail_drops_fail_closed() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let wrong_keys = Keys::generate(); // wrong owner key — decrypt will fail - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - // Register subscription under owner_pk so the event passes plan-phase, - // but use `wrong_keys` in commit so decrypt fails. - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); - - let ev = make_turn_metric_event(&owner_keys, &agent_keys); - let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); - let result = run_batch_sync_with_keys( - vec![cand], - &owner_pk, - relay_url, - &conn, - vec![ev.clone()], - &wrong_keys, // wrong key → decrypt fails - ); - - assert_eq!( - result.persisted, 0, - "decrypt failure must not persist the event" - ); - assert_eq!(result.dropped, 1, "decrypt failure must count as dropped"); - - let event_count: i64 = conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!( - event_count, 0, - "no rows must be written to archived_events on decrypt failure" - ); -} +// Kind-44200 agent-turn-metric coverage lives in a sibling file to keep this +// one under the 1000-line gate; nested here (not in `mod.rs`) so it inherits +// the shared fixtures above through `use super::*`. +#[path = "mod_agent_metric_tests.rs"] +mod agent_metric; // ── Real-relay integration tests ────────────────────────────────────────── // diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs index 2bd149dcebf..98ff64dff48 100644 --- a/desktop/src-tauri/src/archive/pipeline.rs +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -268,6 +268,7 @@ pub(super) fn commit_archive( conn: &Connection, ) -> Result { let mut persisted: u32 = 0; + let mut persisted_agent_metrics: u32 = 0; let mut dropped: u32 = pre_dropped; // Collect writes; count drops first, then execute inside a single @@ -399,6 +400,36 @@ pub(super) fn commit_archive( &w.scope_value, now, )?; + + // Index kind-44200 rows in the SAME transaction as the canonical + // insert (Rev 2 F5): the plaintext payload was already decrypted + // above into `w.raw_json`, so this is parse-only, no re-decrypt. + // `insert_metric_index_row`'s own `ON CONFLICT DO NOTHING` makes + // a duplicate call for an already-indexed id (e.g. re-ingest of + // a row seen in an earlier batch) a safe no-op — its `bool` + // return tells us whether this call actually inserted a new + // index row, which is exactly what `persisted_agent_metrics` + // counts (A5: newly-indexed rows, valid or invalid, not raw + // write attempts). + if w.kind == super::KIND_AGENT_TURN_METRIC as i64 { + let index_row = super::metric_store::AgentMetricIndexRow::from_payload( + &w.raw_json, + &w.eid, + &w.pubkey, + w.created_at, + now, + ); + let index_inserted = super::metric_store::insert_metric_index_row( + &tx, + identity_pk, + relay_url, + &index_row, + )?; + if index_inserted { + persisted_agent_metrics += 1; + } + } + persisted += 1; } @@ -450,5 +481,9 @@ pub(super) fn commit_archive( .map_err(|e| format!("failed to commit archive transaction: {e}"))?; } - Ok(ArchiveBatchResult { persisted, dropped }) + Ok(ArchiveBatchResult { + persisted, + persisted_agent_metrics, + dropped, + }) } diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs index ae0ef92e4be..54cb9a4193b 100644 --- a/desktop/src-tauri/src/archive/store.rs +++ b/desktop/src-tauri/src/archive/store.rs @@ -13,6 +13,8 @@ use std::path::Path; use rusqlite::{params, Connection, OptionalExtension}; use std::time::{Duration, Instant}; +use super::store_migrations::apply_schema_migrations; + // ── Schema ───────────────────────────────────────────────────────────────── pub(super) const SCHEMA: &str = " @@ -75,6 +77,67 @@ CREATE TABLE IF NOT EXISTS archive_migrations ( name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL ); + +-- Parsed index of kind 44200 (NIP-AM agent turn metric) archive rows. +-- +-- Rebuildable from `archived_events.raw_json` — never the source of truth. +-- Every archived kind-44200 row gets exactly one row here, keyed by +-- (identity, relay, id), with `parse_status` 'valid' or 'invalid'. Token +-- counters are stored as fixed-width 20-digit zero-padded decimal TEXT +-- (order-preserving lexicographically) because SQLite INTEGER is signed +-- i64 and NIP-AM counters are full-range u64. `turn_seq` uses the same +-- encoding so it survives sequence values above i64::MAX. +CREATE TABLE IF NOT EXISTS agent_metric_index ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + agent_pubkey TEXT NOT NULL, + event_created_at INTEGER NOT NULL, + archived_at INTEGER NOT NULL, + reported_at INTEGER, + session_id TEXT, + turn_seq TEXT, + model TEXT, + delta_reliable INTEGER, + turn_input_tokens TEXT, + turn_output_tokens TEXT, + turn_total_tokens TEXT, + turn_cost_usd REAL, + turn_cache_read_tokens TEXT, + turn_cache_write_tokens TEXT, + cumulative_input_tokens TEXT, + cumulative_output_tokens TEXT, + cumulative_total_tokens TEXT, + cumulative_cost_usd REAL, + cumulative_cache_read_tokens TEXT, + cumulative_cache_write_tokens TEXT, + pricing_authority TEXT, + pricing_model TEXT, + pricing_cache_class TEXT, + harness TEXT, + parse_status TEXT NOT NULL CHECK (parse_status IN ('valid','invalid')), + PRIMARY KEY (identity_pubkey, relay_url, id) +); + +-- Backfill/anti-join source: scoped partial index so the per-read backfill +-- scan over archived_events only touches kind-44200 rows. +CREATE INDEX IF NOT EXISTS idx_archived_events_agent_metric + ON archived_events (identity_pubkey, relay_url, id) + WHERE kind = 44200; + +-- Predecessor/baseline lookup: exact-sequence probes (A11) and duplicate +-- cardinality checks key on this prefix. +CREATE INDEX IF NOT EXISTS idx_agent_metric_session + ON agent_metric_index (identity_pubkey, relay_url, agent_pubkey, session_id, turn_seq, id); + +-- Window scan by reported time. +CREATE INDEX IF NOT EXISTS idx_agent_metric_reported + ON agent_metric_index (identity_pubkey, relay_url, reported_at); + +-- Coarse-time scan for invalid-row coverage (invalid rows lack a trustworthy +-- reported_at, so their window membership is judged by event_created_at). +CREATE INDEX IF NOT EXISTS idx_agent_metric_created + ON agent_metric_index (identity_pubkey, relay_url, event_created_at, parse_status); "; // ── Open / init ───────────────────────────────────────────────────────────── @@ -100,6 +163,8 @@ pub fn open_archive_db(path: &Path) -> Result { conn.execute_batch(SCHEMA) .map_err(|e| format!("failed to initialize archive schema: {e}"))?; + apply_schema_migrations(&conn)?; + Ok(conn) } @@ -442,6 +507,8 @@ pub fn get_subscription_kinds( /// Upsert an event row (idempotent on the PK). /// /// Does nothing if the event is already archived (same identity/relay/id). +/// Returns `true` iff this call inserted a new row (`false` if the row +/// already existed and the `ON CONFLICT DO NOTHING` no-op'd). // Args mirror the archived_events columns; a params struct would just rename them. #[allow(clippy::too_many_arguments)] pub fn upsert_archived_event( @@ -454,25 +521,26 @@ pub fn upsert_archived_event( created_at: i64, raw_json: &str, archived_at: i64, -) -> Result<(), String> { - conn.execute( - "INSERT INTO archived_events - (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING", - params![ - identity_pubkey, - relay_url, - event_id, - kind, - pubkey, - created_at, - raw_json, - archived_at - ], - ) - .map_err(|e| format!("failed to upsert archived event: {e}"))?; - Ok(()) +) -> Result { + let affected = conn + .execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING", + params![ + identity_pubkey, + relay_url, + event_id, + kind, + pubkey, + created_at, + raw_json, + archived_at + ], + ) + .map_err(|e| format!("failed to upsert archived event: {e}"))?; + Ok(affected > 0) } /// Upsert a scope membership row for an event. @@ -771,17 +839,28 @@ pub fn read_archived_observer_events_for_channel( .map_err(|e| format!("read read_archived_observer_events_for_channel row: {e}")) } -/// GC: delete orphaned event rows whose last scope row was just removed. +/// GC: delete orphaned event rows whose last scope row was just removed, and +/// atomically cascade-delete any `agent_metric_index` rows whose canonical +/// `archived_events` row no longer exists. +/// +/// Both deletes run inside ONE SQLite transaction (not two autocommit +/// statements) so the derived index can never observe a canonical row as +/// gone while the index row it produced still exists — an index row must +/// never outlive the event it was parsed from (A6). /// -/// Called after any batch deletion of scope rows. Uses a LEFT JOIN so only -/// events with zero remaining scope rows are deleted. +/// Called after any batch deletion of scope rows. Uses a LEFT JOIN-equivalent +/// anti-join so only events with zero remaining scope rows are deleted. #[allow(dead_code)] // Used by P4 purge commands; not yet wired to a Tauri command. pub fn gc_orphaned_events( conn: &Connection, identity_pubkey: &str, relay_url: &str, ) -> Result { - let affected = conn + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("failed to begin gc_orphaned_events transaction: {e}"))?; + + let affected = tx .execute( "DELETE FROM archived_events WHERE identity_pubkey = ?1 @@ -794,11 +873,19 @@ pub fn gc_orphaned_events( params![identity_pubkey, relay_url], ) .map_err(|e| format!("failed to gc orphaned events: {e}"))?; + + super::metric_store::delete_orphaned_metric_index_rows(&tx, identity_pubkey, relay_url)?; + + tx.commit() + .map_err(|e| format!("failed to commit gc_orphaned_events transaction: {e}"))?; Ok(affected) } // ── Tests ─────────────────────────────────────────────────────────────────── +#[cfg(test)] +#[path = "store_migration_tests.rs"] +mod store_migration_tests; #[cfg(test)] #[path = "store_tests.rs"] mod store_tests; diff --git a/desktop/src-tauri/src/archive/store_migration_tests.rs b/desktop/src-tauri/src/archive/store_migration_tests.rs new file mode 100644 index 00000000000..6a40d7f4cd7 --- /dev/null +++ b/desktop/src-tauri/src/archive/store_migration_tests.rs @@ -0,0 +1,867 @@ +//! Migration tests for `archive/store.rs` — M1: harness column. +//! +//! Kept in a sibling file so `store_tests.rs` stays under the 1000-line gate; +//! `#[path]`-included from `store.rs`. + +use super::*; + +// ── Migration M1: harness column + open_archive_db reopen tests ────────────── + +/// Build a DB file that looks like a pre-harness archive: schema without the +/// `harness` column, a seeded `archived_events` row and a corresponding +/// `agent_metric_index` row with `harness = NULL`, and no `archive_migrations` +/// marker. Return the path to the temp file. +fn build_old_schema_db(path: &std::path::Path) { + // Old schema has no `harness TEXT` column and no `archive_migrations` table. + const OLD_SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS archived_events ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + kind INTEGER NOT NULL, pubkey TEXT NOT NULL, created_at INTEGER NOT NULL, + raw_json TEXT NOT NULL, archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) +); +CREATE TABLE IF NOT EXISTS archived_event_scopes ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + scope_type TEXT NOT NULL, scope_value TEXT NOT NULL, archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id, scope_type, scope_value) +); +CREATE TABLE IF NOT EXISTS save_subscriptions ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, kinds TEXT NOT NULL, created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, scope_type, scope_value) +); +CREATE TABLE IF NOT EXISTS observer_channel_index ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + channel_id TEXT, created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) +); +CREATE TABLE IF NOT EXISTS agent_metric_index ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + agent_pubkey TEXT NOT NULL, event_created_at INTEGER NOT NULL, + archived_at INTEGER NOT NULL, reported_at INTEGER, session_id TEXT, + turn_seq TEXT, model TEXT, delta_reliable INTEGER, + turn_input_tokens TEXT, turn_output_tokens TEXT, turn_total_tokens TEXT, + turn_cost_usd REAL, cumulative_input_tokens TEXT, cumulative_output_tokens TEXT, + cumulative_total_tokens TEXT, cumulative_cost_usd REAL, + parse_status TEXT NOT NULL CHECK (parse_status IN ('valid','invalid')), + PRIMARY KEY (identity_pubkey, relay_url, id) +); +"; + let conn = Connection::open(path).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(OLD_SCHEMA).unwrap(); + + // A valid payload that carries harness="goose". + let raw_json = r#"{"harness":"goose","model":"claude","channelId":null,"sessionId":"s1","turnId":null,"turnSeq":1,"timestamp":"2026-07-01T00:00:00Z","turn":{"inputTokens":10,"outputTokens":20,"totalTokens":30,"costUsd":0.01},"cumulative":{"inputTokens":100,"outputTokens":200,"totalTokens":300,"costUsd":0.1},"deltaReliable":true,"stopReason":"end_turn"}"#; + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES ('id', 'relay', 'eid1', 44200, 'agent1', 100, ?1, 200)", + rusqlite::params![raw_json], + ) + .unwrap(); + // Seed the index row with harness absent (old schema has no column). + conn.execute( + "INSERT INTO agent_metric_index + (identity_pubkey, relay_url, id, agent_pubkey, event_created_at, + archived_at, reported_at, session_id, turn_seq, model, + delta_reliable, turn_input_tokens, turn_output_tokens, + turn_total_tokens, turn_cost_usd, cumulative_input_tokens, + cumulative_output_tokens, cumulative_total_tokens, + cumulative_cost_usd, parse_status) + VALUES ('id','relay','eid1','agent1',100,200,1751414400000, + 's1','00000000000000000001','claude', + 1,'00000000000000000010','00000000000000000020', + '00000000000000000030',0.01, + '00000000000000000100','00000000000000000200', + '00000000000000000300',0.1,'valid')", + [], + ) + .unwrap(); +} + +/// Opening a pre-harness DB via `open_archive_db` must: +/// +/// 1. Add the `harness` column. +/// 2. Rebuild all index rows so harness is populated from `archived_events`. +/// 3. Record the migration marker in `archive_migrations`. +/// +/// This proves M1 fires on a real legacy file, not just a hand-rolled +/// DELETE+backfill as in the metric_store migration test. +#[test] +fn migration_m1_reopen_old_schema_db_populates_harness_and_records_marker() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + build_old_schema_db(&db_path); + + // Reopen via the real migration path. + let conn = open_archive_db(&db_path).expect("open_archive_db must succeed on legacy DB"); + + // Harness must be populated. + let harness: Option = conn + .query_row( + "SELECT harness FROM agent_metric_index WHERE id = 'eid1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + harness, + Some("goose".to_string()), + "M1: harness must be populated from raw_json after reopen" + ); + + // Marker must be recorded. + let marker_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations \ + WHERE name = 'add_harness_to_metric_index'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(marker_count, 1, "M1: migration marker must be recorded"); +} + +/// Re-opening the same DB a second time must be a no-op: marker already present, +/// harness already populated, no error. +#[test] +fn migration_m1_reopen_twice_is_idempotent() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + build_old_schema_db(&db_path); + + // First open: runs M1. + open_archive_db(&db_path).expect("first open must succeed"); + // Second open: M1 is already marked; must also succeed without error. + let conn2 = open_archive_db(&db_path).expect("second open must succeed (M1 is idempotent)"); + + let count: i64 = conn2 + .query_row( + "SELECT COUNT(*) FROM agent_metric_index WHERE harness = 'goose'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, 1, + "after idempotent second open, harness row must still be present" + ); +} + +/// If the migration is aborted after DELETE but before COMMIT (simulated by +/// manually applying each sub-step without committing), the DB must be in its +/// pre-migration state and the marker must be absent, so the next +/// `open_archive_db` can re-run M1 from scratch and complete successfully. +/// +/// We simulate the "crash before commit" scenario by: +/// 1. Building a pre-harness DB on disk. +/// 2. Opening a raw connection, running ALTER + DELETE but rolling back before +/// inserting the marker — leaving the DB in pre-migration state. +/// 3. Calling `open_archive_db` on the same file and asserting M1 completes. +#[test] +fn migration_m1_crashed_before_commit_reruns_fully_on_next_open() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + build_old_schema_db(&db_path); + + // Simulate a crash: start a transaction, ALTER + DELETE, then ROLLBACK + // (leaving DB exactly as-built — pre-migration, no marker). + { + let conn = Connection::open(&db_path).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + // ALTER inside the transaction. + conn.execute_batch("ALTER TABLE agent_metric_index ADD COLUMN harness TEXT") + .unwrap(); + // DELETE index rows (simulating the mid-migration state). + conn.execute_batch("DELETE FROM agent_metric_index") + .unwrap(); + // Rollback — no marker inserted, no new rows. + conn.execute_batch("ROLLBACK").unwrap(); + } + + // After rollback: marker must be absent and the original index row still there. + { + let verify = Connection::open(&db_path).unwrap(); + // archive_migrations table may not exist at all (old schema). If it does, + // the marker must not be present. + let marker_count: i64 = verify + .query_row( + "SELECT COUNT(*) FROM sqlite_master \ + WHERE type='table' AND name='archive_migrations'", + [], + |r| r.get(0), + ) + .unwrap(); + if marker_count > 0 { + let applied: i64 = verify + .query_row( + "SELECT COUNT(*) FROM archive_migrations \ + WHERE name = 'add_harness_to_metric_index'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(applied, 0, "marker must be absent after rollback"); + } + } + + // The next open must run M1 fully and succeed. + let conn = + open_archive_db(&db_path).expect("open_archive_db must succeed after simulated crash"); + + // Harness must be populated. + let harness: Option = conn + .query_row( + "SELECT harness FROM agent_metric_index WHERE id = 'eid1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + harness, + Some("goose".to_string()), + "M1 must re-run after simulated crash and populate harness" + ); + + // Marker must now be recorded. + let marker: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations \ + WHERE name = 'add_harness_to_metric_index'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + marker, 1, + "M1 marker must be recorded after successful re-run" + ); +} + +// ── Migration M2: cache_read_tokens columns ─────────────────────────────────── + +/// Opening a fresh DB (created by `open_archive_db`) must have both cache +/// columns present and the M2 marker recorded. +#[test] +fn migration_m2_fresh_db_has_cache_columns_and_marker() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let conn = open_archive_db(db_file.path()).expect("open_archive_db must succeed"); + + // Both columns must exist (PRAGMA table_info returns one row per column). + let columns: Vec = { + let mut stmt = conn + .prepare("PRAGMA table_info(agent_metric_index)") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert!( + columns.iter().any(|c| c == "turn_cache_read_tokens"), + "turn_cache_read_tokens must exist on a fresh DB" + ); + assert!( + columns.iter().any(|c| c == "cumulative_cache_read_tokens"), + "cumulative_cache_read_tokens must exist on a fresh DB" + ); + + let marker_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_read_tokens'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(marker_count, 1, "M2 marker must be recorded on fresh DB"); +} + +/// Opening a post-M1, pre-M2 DB (i.e., has `harness` column and M1 marker, +/// but no `turn_cache_read_tokens` column) must add both cache columns, +/// leaving all pre-existing rows with NULL for those columns, and record the +/// M2 marker. +#[test] +fn migration_m2_upgrades_post_m1_db_and_leaves_nulls() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + // Build a DB that looks like it ran M1 but not M2: the schema has + // `harness` but not the cache columns, and M1 marker is present. + { + let conn = Connection::open(&db_path).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + // Use M1-era schema: harness present, no cache columns, no archive_migrations table yet. + const M1_SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS archived_events ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + kind INTEGER NOT NULL, pubkey TEXT NOT NULL, created_at INTEGER NOT NULL, + raw_json TEXT NOT NULL, archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) +); +CREATE TABLE IF NOT EXISTS archived_event_scopes ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + scope_type TEXT NOT NULL, scope_value TEXT NOT NULL, archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id, scope_type, scope_value) +); +CREATE TABLE IF NOT EXISTS save_subscriptions ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, kinds TEXT NOT NULL, created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, scope_type, scope_value) +); +CREATE TABLE IF NOT EXISTS observer_channel_index ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + channel_id TEXT, created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) +); +CREATE TABLE IF NOT EXISTS agent_metric_index ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + agent_pubkey TEXT NOT NULL, event_created_at INTEGER NOT NULL, + archived_at INTEGER NOT NULL, reported_at INTEGER, session_id TEXT, + turn_seq TEXT, model TEXT, delta_reliable INTEGER, + turn_input_tokens TEXT, turn_output_tokens TEXT, turn_total_tokens TEXT, + turn_cost_usd REAL, cumulative_input_tokens TEXT, cumulative_output_tokens TEXT, + cumulative_total_tokens TEXT, cumulative_cost_usd REAL, + harness TEXT, + parse_status TEXT NOT NULL CHECK (parse_status IN ('valid','invalid')), + PRIMARY KEY (identity_pubkey, relay_url, id) +); +CREATE TABLE IF NOT EXISTS archive_migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL +); +"; + conn.execute_batch(M1_SCHEMA).unwrap(); + // Mark M1 as already run so `open_archive_db` skips it. + conn.execute( + "INSERT INTO archive_migrations (name, applied_at) VALUES ('add_harness_to_metric_index', 1)", + [], + ) + .unwrap(); + // Seed one existing index row — it should get NULL cache columns. + let raw_json = r#"{"harness":"goose","model":"claude","channelId":null,"sessionId":"s1","turnId":null,"turnSeq":1,"timestamp":"2026-07-01T00:00:00Z","turn":{"inputTokens":10,"outputTokens":20,"totalTokens":30,"costUsd":0.01},"cumulative":{"inputTokens":100,"outputTokens":200,"totalTokens":300,"costUsd":0.1},"deltaReliable":true,"stopReason":"end_turn"}"#; + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES ('id', 'relay', 'eid1', 44200, 'agent1', 100, ?1, 200)", + rusqlite::params![raw_json], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_metric_index + (identity_pubkey, relay_url, id, agent_pubkey, event_created_at, + archived_at, reported_at, session_id, turn_seq, model, + delta_reliable, turn_input_tokens, turn_output_tokens, + turn_total_tokens, turn_cost_usd, cumulative_input_tokens, + cumulative_output_tokens, cumulative_total_tokens, + cumulative_cost_usd, harness, parse_status) + VALUES ('id','relay','eid1','agent1',100,200,1751414400, + 's1','00000000000000000001','claude', + 1,'00000000000000000010','00000000000000000020', + '00000000000000000030',0.01, + '00000000000000000100','00000000000000000200', + '00000000000000000300',0.1,'goose','valid')", + [], + ) + .unwrap(); + } + + // Open via migration path — M1 is already marked, M2 should run. + let conn = open_archive_db(&db_path).expect("open_archive_db must succeed on post-M1 DB"); + + // Both cache columns must now exist. + let columns: Vec = { + let mut stmt = conn + .prepare("PRAGMA table_info(agent_metric_index)") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert!( + columns.iter().any(|c| c == "turn_cache_read_tokens"), + "M2: turn_cache_read_tokens must be present after upgrade" + ); + assert!( + columns.iter().any(|c| c == "cumulative_cache_read_tokens"), + "M2: cumulative_cache_read_tokens must be present after upgrade" + ); + + // Pre-existing row gets NULLs — not estimated, not backfilled. + let (turn_cr, cum_cr): (Option, Option) = conn + .query_row( + "SELECT turn_cache_read_tokens, cumulative_cache_read_tokens \ + FROM agent_metric_index WHERE id = 'eid1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert!( + turn_cr.is_none(), + "M2: pre-existing row turn_cache_read_tokens must be NULL" + ); + assert!( + cum_cr.is_none(), + "M2: pre-existing row cumulative_cache_read_tokens must be NULL" + ); + + // Marker must be recorded. + let marker_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_read_tokens'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(marker_count, 1, "M2: migration marker must be recorded"); +} + +/// Re-opening after M2 has run is a no-op — marker present, columns present, no error. +#[test] +fn migration_m2_reopen_twice_is_idempotent() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + // First open runs both M1 and M2. + open_archive_db(&db_path).expect("first open must succeed"); + // Second open must not error even though both columns already exist. + let conn2 = open_archive_db(&db_path).expect("second open must succeed (M2 is idempotent)"); + + let marker_count: i64 = conn2 + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_read_tokens'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + marker_count, 1, + "M2: marker must still be present after idempotent second open" + ); +} + +/// Partial-schema repair: only `turn_cache_read_tokens` present (no cumulative). +/// M2 must add the missing cumulative column and commit the marker. +#[test] +fn migration_m2_repairs_turn_only_partial_schema_and_records_marker() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + // Build a DB that has `turn_cache_read_tokens` but NOT `cumulative_cache_read_tokens` + // (simulates a crash between the two ALTER TABLE statements in old M2). + { + let conn = Connection::open(&db_path).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + // Use M1-era schema + add only the turn column manually. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS archived_events ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + kind INTEGER NOT NULL, pubkey TEXT NOT NULL, created_at INTEGER NOT NULL, + raw_json TEXT NOT NULL, archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) + ); + CREATE TABLE IF NOT EXISTS archived_event_scopes ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + scope_type TEXT NOT NULL, scope_value TEXT NOT NULL, archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id, scope_type, scope_value) + ); + CREATE TABLE IF NOT EXISTS save_subscriptions ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, kinds TEXT NOT NULL, created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, scope_type, scope_value) + ); + CREATE TABLE IF NOT EXISTS observer_channel_index ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + channel_id TEXT, created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) + ); + CREATE TABLE IF NOT EXISTS agent_metric_index ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + agent_pubkey TEXT NOT NULL, event_created_at INTEGER NOT NULL, + archived_at INTEGER NOT NULL, reported_at INTEGER, session_id TEXT, + turn_seq TEXT, model TEXT, delta_reliable INTEGER, + turn_input_tokens TEXT, turn_output_tokens TEXT, turn_total_tokens TEXT, + turn_cost_usd REAL, cumulative_input_tokens TEXT, cumulative_output_tokens TEXT, + cumulative_total_tokens TEXT, cumulative_cost_usd REAL, + harness TEXT, + parse_status TEXT NOT NULL CHECK (parse_status IN ('valid','invalid')), + turn_cache_read_tokens TEXT, + PRIMARY KEY (identity_pubkey, relay_url, id) + ); + CREATE TABLE IF NOT EXISTS archive_migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + );", + ) + .unwrap(); + // Mark M1 as done so open_archive_db skips it. + conn.execute( + "INSERT INTO archive_migrations (name, applied_at) VALUES ('add_harness_to_metric_index', 1)", + [], + ) + .unwrap(); + // Do NOT mark M2 — let open_archive_db run it. + } + + // Opening must run M2: it finds turn_cache_read_tokens already present and + // only adds the missing cumulative column, then commits the marker. + let conn = + open_archive_db(&db_path).expect("open_archive_db must repair turn-only partial M2 schema"); + + let columns: Vec = { + let mut stmt = conn + .prepare("PRAGMA table_info(agent_metric_index)") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert!( + columns.iter().any(|c| c == "turn_cache_read_tokens"), + "M2 partial repair: turn_cache_read_tokens must be present" + ); + assert!( + columns.iter().any(|c| c == "cumulative_cache_read_tokens"), + "M2 partial repair: cumulative_cache_read_tokens must have been added" + ); + + let marker_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_read_tokens'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + marker_count, 1, + "M2 partial repair: marker must be committed after full repair" + ); +} + +/// Partial-schema repair: only `cumulative_cache_read_tokens` present (no turn). +/// M2 must add the missing turn column and commit the marker. +#[test] +fn migration_m2_repairs_cumulative_only_partial_schema_and_records_marker() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + { + let conn = Connection::open(&db_path).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS archived_events ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + kind INTEGER NOT NULL, pubkey TEXT NOT NULL, created_at INTEGER NOT NULL, + raw_json TEXT NOT NULL, archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) + ); + CREATE TABLE IF NOT EXISTS archived_event_scopes ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + scope_type TEXT NOT NULL, scope_value TEXT NOT NULL, archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id, scope_type, scope_value) + ); + CREATE TABLE IF NOT EXISTS save_subscriptions ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, kinds TEXT NOT NULL, created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, scope_type, scope_value) + ); + CREATE TABLE IF NOT EXISTS observer_channel_index ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + channel_id TEXT, created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) + ); + CREATE TABLE IF NOT EXISTS agent_metric_index ( + identity_pubkey TEXT NOT NULL, relay_url TEXT NOT NULL, id TEXT NOT NULL, + agent_pubkey TEXT NOT NULL, event_created_at INTEGER NOT NULL, + archived_at INTEGER NOT NULL, reported_at INTEGER, session_id TEXT, + turn_seq TEXT, model TEXT, delta_reliable INTEGER, + turn_input_tokens TEXT, turn_output_tokens TEXT, turn_total_tokens TEXT, + turn_cost_usd REAL, cumulative_input_tokens TEXT, cumulative_output_tokens TEXT, + cumulative_total_tokens TEXT, cumulative_cost_usd REAL, + harness TEXT, + parse_status TEXT NOT NULL CHECK (parse_status IN ('valid','invalid')), + cumulative_cache_read_tokens TEXT, + PRIMARY KEY (identity_pubkey, relay_url, id) + ); + CREATE TABLE IF NOT EXISTS archive_migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO archive_migrations (name, applied_at) VALUES ('add_harness_to_metric_index', 1)", + [], + ) + .unwrap(); + } + + let conn = open_archive_db(&db_path) + .expect("open_archive_db must repair cumulative-only partial M2 schema"); + + let columns: Vec = { + let mut stmt = conn + .prepare("PRAGMA table_info(agent_metric_index)") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert!( + columns.iter().any(|c| c == "turn_cache_read_tokens"), + "M2 partial repair: turn_cache_read_tokens must have been added" + ); + assert!( + columns.iter().any(|c| c == "cumulative_cache_read_tokens"), + "M2 partial repair: cumulative_cache_read_tokens must be present" + ); + + let marker_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_read_tokens'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + marker_count, 1, + "M2 partial repair: marker must be committed after full repair" + ); +} + +/// A fresh DB must include the M3 columns (`turn_cache_write_tokens`, +/// `cumulative_cache_write_tokens`, `pricing_authority`, `pricing_model`, +/// `pricing_cache_class`) in the initial schema. +#[test] +fn migration_m3_fresh_db_has_all_m3_columns() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + let conn = open_archive_db(&db_path).expect("fresh open must succeed"); + + let columns: Vec = { + let mut stmt = conn + .prepare("PRAGMA table_info(agent_metric_index)") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + + for col in &[ + "turn_cache_write_tokens", + "cumulative_cache_write_tokens", + "pricing_authority", + "pricing_model", + "pricing_cache_class", + ] { + assert!( + columns.iter().any(|c| c == col), + "M3: {col} must exist on a fresh DB" + ); + } + + let marker_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_write_and_pricing'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(marker_count, 1, "M3: marker must be recorded on fresh DB"); +} + +/// Opening a post-M2, pre-M3 DB (has cache-read columns and M2 marker, but +/// no cache-write or pricing columns) must add all five M3 columns and +/// leave pre-existing rows as NULL in those columns. +#[test] +fn migration_m3_upgrades_post_m2_database() { + use rusqlite::Connection; + use tempfile::NamedTempFile; + + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + // Build a post-M2, pre-M3 DB by hand (bypassing the normal open path + // so the M3 migration has not yet run). + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch( + "PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS archive_migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + ); + CREATE TABLE agent_metric_index ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + agent_pubkey TEXT NOT NULL, + event_created_at INTEGER NOT NULL, + archived_at INTEGER NOT NULL, + reported_at INTEGER, + session_id TEXT, + turn_seq TEXT, + model TEXT, + delta_reliable INTEGER, + turn_input_tokens TEXT, + turn_output_tokens TEXT, + turn_total_tokens TEXT, + turn_cost_usd REAL, + turn_cache_read_tokens TEXT, + cumulative_input_tokens TEXT, + cumulative_output_tokens TEXT, + cumulative_total_tokens TEXT, + cumulative_cost_usd REAL, + cumulative_cache_read_tokens TEXT, + harness TEXT, + parse_status TEXT NOT NULL CHECK (parse_status IN ('valid','invalid')), + PRIMARY KEY (identity_pubkey, relay_url, id) + );", + ) + .unwrap(); + // Mark M1 and M2 as applied, but NOT M3. + conn.execute_batch( + "INSERT OR IGNORE INTO archive_migrations (name, applied_at) VALUES ('add_harness_to_metric_index', 0); + INSERT OR IGNORE INTO archive_migrations (name, applied_at) VALUES ('add_cache_read_tokens', 0);", + ) + .unwrap(); + // Insert a pre-migration row. + conn.execute( + "INSERT INTO agent_metric_index + (identity_pubkey, relay_url, id, agent_pubkey, event_created_at, + archived_at, reported_at, session_id, turn_seq, model, + delta_reliable, turn_input_tokens, turn_output_tokens, + turn_total_tokens, turn_cost_usd, turn_cache_read_tokens, + cumulative_input_tokens, cumulative_output_tokens, + cumulative_total_tokens, cumulative_cost_usd, + cumulative_cache_read_tokens, harness, parse_status) + VALUES ('id','relay','eid-m3','agent1',100,200,1751414400, + 's1','00000000000000000001','model1', + 1,'00000000000000000010','00000000000000000020', + '00000000000000000030',0.01,'00000000000000000050', + '00000000000000000100','00000000000000000200', + '00000000000000000300',0.1,'00000000000000000500', + 'buzz-agent','valid')", + [], + ) + .unwrap(); + } + + // Open via the production path — M3 should run. + let conn = open_archive_db(&db_path).expect("open_archive_db must succeed on post-M2 DB"); + + // All five M3 columns must now exist. + let columns: Vec = { + let mut stmt = conn + .prepare("PRAGMA table_info(agent_metric_index)") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + for col in &[ + "turn_cache_write_tokens", + "cumulative_cache_write_tokens", + "pricing_authority", + "pricing_model", + "pricing_cache_class", + ] { + assert!( + columns.iter().any(|c| c == col), + "M3: {col} must be present after upgrade" + ); + } + + // Pre-existing row gets NULLs for all new columns. + type M3NullRow = ( + Option, + Option, + Option, + Option, + Option, + ); + let (tcw, ccw, pa, pm, pcc): M3NullRow = conn + .query_row( + "SELECT turn_cache_write_tokens, cumulative_cache_write_tokens, \ + pricing_authority, pricing_model, pricing_cache_class \ + FROM agent_metric_index WHERE id = 'eid-m3'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), + ) + .unwrap(); + assert!( + tcw.is_none(), + "M3: pre-existing row turn_cache_write_tokens must be NULL" + ); + assert!( + ccw.is_none(), + "M3: pre-existing row cumulative_cache_write_tokens must be NULL" + ); + assert!( + pa.is_none(), + "M3: pre-existing row pricing_authority must be NULL" + ); + assert!( + pm.is_none(), + "M3: pre-existing row pricing_model must be NULL" + ); + assert!( + pcc.is_none(), + "M3: pre-existing row pricing_cache_class must be NULL" + ); + + // Marker must be recorded. + let marker_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_write_and_pricing'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(marker_count, 1, "M3: migration marker must be recorded"); +} + +/// Re-opening after M3 has run is a no-op. +#[test] +fn migration_m3_reopen_twice_is_idempotent() { + use tempfile::NamedTempFile; + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + open_archive_db(&db_path).expect("first open must succeed"); + let conn2 = open_archive_db(&db_path).expect("second open must succeed (M3 is idempotent)"); + + let marker_count: i64 = conn2 + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_write_and_pricing'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + marker_count, 1, + "M3: marker must still be present after idempotent second open" + ); +} diff --git a/desktop/src-tauri/src/archive/store_migrations.rs b/desktop/src-tauri/src/archive/store_migrations.rs new file mode 100644 index 00000000000..4e9391693ef --- /dev/null +++ b/desktop/src-tauri/src/archive/store_migrations.rs @@ -0,0 +1,325 @@ +//! One-shot schema migrations for the archive database. +//! +//! Applied by `store::open_archive_db` on every open and guarded by markers in +//! `archive_migrations`, so a migration that already ran is a no-op. +//! +//! Kept in a sibling file (not `store.rs`) to keep that file under the +//! 1000-line gate, per the existing `metric_store.rs` / `pipeline.rs` +//! precedent. + +use rusqlite::{params, Connection}; + +/// One-shot, idempotent schema migrations recorded in `archive_migrations`. +/// +/// Each migration is guarded by a `SELECT 1 FROM archive_migrations WHERE +/// name = '...'` check so it is safe to call on every open — a migration +/// that already ran is a no-op. +/// +/// Ordering: M2 (column additions) runs before M1 (index rebuild) so that +/// the M1 rebuild, which calls `insert_metric_index_row`, always operates +/// against a schema that includes the cache-read columns. +pub(super) fn apply_schema_migrations(conn: &Connection) -> Result<(), String> { + migrate_add_cache_read_tokens(conn)?; + migrate_add_cache_write_and_pricing(conn)?; + migrate_add_harness_to_metric_index(conn) +} + +/// M1: add `harness TEXT` column to `agent_metric_index` and rebuild index +/// rows so pre-existing rows gain harness populated from `archived_events`. +/// +/// The entire migration — column addition, full DELETE of stale index rows, +/// fresh re-insertion from `archived_events.raw_json`, and the +/// `archive_migrations` marker — is wrapped in a single `unchecked_transaction` +/// (BEGIN DEFERRED). A crash anywhere before COMMIT leaves the DB in its +/// pre-migration state and the marker absent, so the next open re-runs the +/// migration from scratch. +/// +/// The worklist is built from `archived_events` (the canonical store) rather +/// than from `agent_metric_index` so that a partially-rebuilt or entirely +/// empty index never causes us to forget which (identity, relay) scopes exist. +fn migrate_add_harness_to_metric_index(conn: &Connection) -> Result<(), String> { + let already_run: bool = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_harness_to_metric_index'", + [], + |r| r.get::<_, i64>(0), + ) + .map_err(|e| format!("migration M1: guard check: {e}"))? + > 0; + + if already_run { + return Ok(()); + } + + // All steps run inside one transaction so a crash before COMMIT leaves the + // DB fully pre-migration and the marker absent — the next open re-runs. + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("migration M1: begin transaction: {e}"))?; + + // 1. Add the `harness` column only when it is genuinely absent, so we + // propagate unexpected DDL errors instead of swallowing them. + let harness_exists: bool = { + let mut stmt = tx + .prepare("PRAGMA table_info(agent_metric_index)") + .map_err(|e| format!("migration M1: PRAGMA table_info prepare: {e}"))?; + let names: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| format!("migration M1: PRAGMA table_info query: {e}"))? + .collect::, _>>() + .map_err(|e| format!("migration M1: PRAGMA table_info read: {e}"))?; + names.iter().any(|n| n == "harness") + }; + if !harness_exists { + tx.execute_batch("ALTER TABLE agent_metric_index ADD COLUMN harness TEXT") + .map_err(|e| format!("migration M1: ALTER TABLE failed: {e}"))?; + } + + // 2. Collect all (identity, relay) scopes from `archived_events` — the + // canonical store — so the worklist is correct even if the index was + // partially rebuilt or empty before this migration runs. + let scopes: Vec<(String, String)> = { + let mut stmt = tx + .prepare( + "SELECT DISTINCT identity_pubkey, relay_url + FROM archived_events + WHERE kind = 44200", + ) + .map_err(|e| format!("migration M1: prepare scope query: {e}"))?; + let result = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| format!("migration M1: query scopes: {e}"))? + .collect::, _>>() + .map_err(|e| format!("migration M1: read scopes: {e}"))?; + result + }; + + if !scopes.is_empty() { + // 3. Delete all stale index rows; re-insert them with harness populated + // from the shared `from_payload` parser below. Both the DELETE and + // all inserts run inside the same outer transaction — no intermediate + // commit, so readers never observe an empty index. + tx.execute_batch("DELETE FROM agent_metric_index") + .map_err(|e| format!("migration M1: delete index rows: {e}"))?; + + for (identity, relay) in &scopes { + rebuild_metric_index_in_tx(&tx, identity, relay)?; + } + } + + // 4. Record migration as applied — written last so the marker is only + // present in a fully committed transaction. + tx.execute( + "INSERT OR IGNORE INTO archive_migrations (name, applied_at) \ + VALUES ('add_harness_to_metric_index', ?1)", + params![std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64], + ) + .map_err(|e| format!("migration M1: record marker: {e}"))?; + + tx.commit() + .map_err(|e| format!("migration M1: commit: {e}"))?; + + Ok(()) +} + +/// Re-insert all kind-44200 rows for `(identity, relay)` from +/// `archived_events.raw_json` through the shared `from_payload` parser. +/// +/// Unlike `metric_store::backfill_agent_metric_index`, this function runs +/// entirely inside the caller's transaction — no nested `BEGIN`/`COMMIT`. +/// It is used exclusively by M1 where the outer transaction provides atomicity. +fn rebuild_metric_index_in_tx( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result<(), String> { + let mut stmt = conn + .prepare( + "SELECT id, pubkey, created_at, archived_at, raw_json + FROM archived_events + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND kind = 44200", + ) + .map_err(|e| format!("migration M1: prepare rebuild select: {e}"))?; + + let rows: Vec<(String, String, i64, i64, String)> = stmt + .query_map(params![identity_pubkey, relay_url], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + )) + }) + .map_err(|e| format!("migration M1: query rebuild rows: {e}"))? + .collect::, _>>() + .map_err(|e| format!("migration M1: read rebuild row: {e}"))?; + drop(stmt); + + for (id, pubkey, created_at, archived_at, raw_json) in &rows { + let parsed = super::metric_store::AgentMetricIndexRow::from_payload( + raw_json, + id, + pubkey, + *created_at, + *archived_at, + ); + super::metric_store::insert_metric_index_row(conn, identity_pubkey, relay_url, &parsed) + .map_err(|e| format!("migration M1: insert rebuilt row: {e}"))?; + } + + Ok(()) +} + +/// M2: add `turn_cache_read_tokens TEXT` and `cumulative_cache_read_tokens TEXT` +/// columns to `agent_metric_index`. +/// +/// These columns persist the optional NIP-AM `cacheReadTokens` fields (turn + +/// cumulative). They are informational subsets of `inputTokens`, not additions +/// to it. All pre-migration rows receive `NULL` — fail-closed, never estimated. +/// +/// Each column is added independently so a crash between the two ALTERs leaves +/// the DB in a self-repairable partial state: the marker is only committed after +/// BOTH columns are present. +fn migrate_add_cache_read_tokens(conn: &Connection) -> Result<(), String> { + let already_run: bool = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_read_tokens'", + [], + |r| r.get::<_, i64>(0), + ) + .map_err(|e| format!("migration M2: guard check: {e}"))? + > 0; + + if already_run { + return Ok(()); + } + + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("migration M2: begin transaction: {e}"))?; + + // Query existing columns once; add each column only if absent (idempotent: + // a crash between the two ALTERs leaves a partial schema that self-repairs + // on the next open — exactly the M3 pattern). + let names: Vec = { + let mut stmt = tx + .prepare("PRAGMA table_info(agent_metric_index)") + .map_err(|e| format!("migration M2: PRAGMA table_info prepare: {e}"))?; + let ns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| format!("migration M2: PRAGMA table_info query: {e}"))? + .collect::, _>>() + .map_err(|e| format!("migration M2: PRAGMA table_info read: {e}"))?; + ns + }; + + let cols = [ + ("turn_cache_read_tokens", "TEXT"), + ("cumulative_cache_read_tokens", "TEXT"), + ]; + for (col, ty) in &cols { + if !names.iter().any(|n| n == col) { + tx.execute_batch(&format!( + "ALTER TABLE agent_metric_index ADD COLUMN {col} {ty}" + )) + .map_err(|e| format!("migration M2: ALTER TABLE ({col}) failed: {e}"))?; + } + } + + tx.execute( + "INSERT OR IGNORE INTO archive_migrations (name, applied_at) \ + VALUES ('add_cache_read_tokens', ?1)", + params![std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64], + ) + .map_err(|e| format!("migration M2: record marker: {e}"))?; + + tx.commit() + .map_err(|e| format!("migration M2: commit: {e}"))?; + + Ok(()) +} + +/// M3: add `turn_cache_write_tokens TEXT`, `cumulative_cache_write_tokens TEXT`, +/// `pricing_authority TEXT`, `pricing_model TEXT`, and `pricing_cache_class TEXT` +/// to `agent_metric_index`. +/// +/// Cache-write columns store per-session-turn and session-cumulative write tokens +/// (same encode/decode contract as the existing cache-read columns). The pricing +/// columns store the three components of `pricingIdentity` from NIP-AM § billing +/// identity, denormalized for index queries. +/// +/// New columns default to NULL for all pre-migration rows (they had no cache-write +/// reporting and no publisher-side identity proof). No rows are deleted: the columns +/// are additive and all pre-existing rows remain valid as "field not reported". +fn migrate_add_cache_write_and_pricing(conn: &Connection) -> Result<(), String> { + // Guard: skip if already applied. + let already_applied: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_cache_write_and_pricing'", + [], + |row| row.get(0), + ) + .map_err(|e| format!("migration M3: guard check: {e}"))?; + if already_applied > 0 { + return Ok(()); + } + + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("migration M3: begin transaction: {e}"))?; + + // Check which columns already exist (idempotent: ALTER TABLE is skipped for + // each column that already exists, e.g. from a partial previous run that + // committed the column but not the migration marker). + let names: Vec = { + let mut stmt = tx + .prepare("PRAGMA table_info(agent_metric_index)") + .map_err(|e| format!("migration M3: PRAGMA table_info prepare: {e}"))?; + let ns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| format!("migration M3: PRAGMA table_info query: {e}"))? + .collect::, _>>() + .map_err(|e| format!("migration M3: PRAGMA table_info read: {e}"))?; + ns + }; + + let cols = [ + ("turn_cache_write_tokens", "TEXT"), + ("cumulative_cache_write_tokens", "TEXT"), + ("pricing_authority", "TEXT"), + ("pricing_model", "TEXT"), + ("pricing_cache_class", "TEXT"), + ]; + for (col, ty) in &cols { + if !names.iter().any(|n| n == col) { + tx.execute_batch(&format!( + "ALTER TABLE agent_metric_index ADD COLUMN {col} {ty}" + )) + .map_err(|e| format!("migration M3: ALTER TABLE ({col}) failed: {e}"))?; + } + } + + tx.execute( + "INSERT OR IGNORE INTO archive_migrations (name, applied_at) \ + VALUES ('add_cache_write_and_pricing', ?1)", + rusqlite::params![std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64], + ) + .map_err(|e| format!("migration M3: record marker: {e}"))?; + + tx.commit() + .map_err(|e| format!("migration M3: commit: {e}"))?; + + Ok(()) +} diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs index 0a07811e7c2..bbd15391e75 100644 --- a/desktop/src-tauri/src/archive/store_tests.rs +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -285,9 +285,13 @@ fn test_remove_owner_p_kind_noop_when_kind_absent() { #[test] fn test_upsert_archived_event_is_idempotent() { let conn = in_memory(); - upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); - // Second call must not error or duplicate. - upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 201).unwrap(); + let first = + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + assert!(first, "first insert of a new id must report newly-inserted"); + // Second call must not error or duplicate, and must report false (no new row). + let second = + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 201).unwrap(); + assert!(!second, "duplicate insert must report NOT newly-inserted"); let count: i64 = conn .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) .unwrap(); diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4704582372d..183f27dba12 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use tauri::{AppHandle, State}; use super::agent_model_process::run_agent_models_command; +use super::managed_agent_definition::apply_model_provider_prompt_update; // The map-only lookup is reached solely from the base-URL helpers that exist for // their unit tests; discovery itself always goes through the process-env variant. #[cfg(test)] @@ -696,35 +697,6 @@ use databricks::{ }; use databricks::{discover_databricks_models, DatabricksAuthIntent}; -/// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch -/// to `record`, enforcing the linked-instance write guard: a definition-linked -/// record's model/provider/prompt are definition-authoritative (see -/// `effective_config::resolve_linked`), so writes to these three fields are -/// silently dropped for a linked instance rather than persisting a byte the -/// resolver will never read. Definition-less instances accept the patch -/// as-is. Extracted so the guard is exercised by both `update_managed_agent` -/// and its regression tests — a test that reimplements this check instead of -/// calling it can go green after the real guard is deleted. -fn apply_model_provider_prompt_update( - record: &mut crate::managed_agents::ManagedAgentRecord, - model: Option>, - provider: Option>, - system_prompt: Option>, -) { - if record.persona_id.is_some() { - return; - } - if let Some(model_update) = model { - record.model = model_update; - } - if let Some(provider_update) = provider { - record.provider = provider_update; - } - if let Some(prompt_update) = system_prompt { - record.system_prompt = prompt_update; - } -} - /// Update mutable fields on an existing managed agent record. /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, @@ -769,7 +741,7 @@ pub async fn update_managed_agent( input.model, input.provider, input.system_prompt, - ); + )?; if let Some(parallelism) = input.parallelism { record.parallelism = parallelism; } diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs index 63b4564e61d..4b6e512c059 100644 --- a/desktop/src-tauri/src/commands/agent_models_databricks.rs +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -1,7 +1,8 @@ //! Databricks v1/v2 model discovery and interactive reauthentication. -use std::collections::BTreeMap; -use std::sync::LazyLock; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{LazyLock, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; use crate::commands::agent_models_env::{ env_or_process_value, redaction_env_with_value, DiscoveryProvider, @@ -13,6 +14,77 @@ use crate::managed_agents::AgentModelsResponse; // callback listener/browser flow for the process-wide OAuth cache. static AUTH_GATE: LazyLock> = LazyLock::new(|| tokio::sync::Mutex::new(())); +// Hard cap on the interactive browser flow launched from a discovery surface. +// An abandoned SSO tab must fail discovery cleanly rather than wedge the +// dropdown forever. (`authenticate_databricks` has its own 60s callback wait; +// this outer bound also covers endpoint discovery and token exchange.) +const AUTH_FLOW_TIMEOUT: Duration = Duration::from_secs(150); + +// How long a failed/cancelled interactive sign-in suppresses re-launching the +// browser from passive surfaces. +pub(super) const AUTH_COOLDOWN: Duration = Duration::from_secs(5 * 60); + +/// Per-host record of a recently failed, cancelled, or timed-out interactive +/// sign-in. +/// +/// Passive discovery surfaces fire on every form-state change, so without this +/// a cancelled SSO page would re-pop the browser on the very next keystroke. +/// Entries expire so a genuine later retry still launches; the saved-model +/// picker bypasses the cooldown and a success clears it. +#[derive(Default)] +pub(super) struct AuthCooldown { + until: Mutex>, +} + +impl AuthCooldown { + fn map(&self) -> MutexGuard<'_, HashMap> { + // The critical sections below are panic-free map ops, so recover from a + // poisoned lock rather than wedge every future sign-in on one panic. + self.until + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub(super) fn is_active(&self, host: &str, now: Instant) -> bool { + let mut map = self.map(); + match map.get(host) { + Some(&expiry) if now < expiry => true, + Some(_) => { + map.remove(host); + false + } + None => false, + } + } + + pub(super) fn record(&self, host: &str, now: Instant) { + self.map().insert(host.to_string(), now + AUTH_COOLDOWN); + } + + pub(super) fn clear(&self, host: &str) { + self.map().remove(host); + } + + /// Whether the interactive browser flow may launch now under `auth_intent`. + /// Passive surfaces are suppressed while a per-host cooldown is active; the + /// explicit picker path always launches and clears any stale suppression. + pub(super) fn permits_launch( + &self, + auth_intent: DatabricksAuthIntent, + host: &str, + now: Instant, + ) -> bool { + if auth_intent.respects_cooldown() { + !self.is_active(host, now) + } else { + self.clear(host); + true + } + } +} + +static AUTH_COOLDOWNS: LazyLock = LazyLock::new(AuthCooldown::default); + pub(super) fn is_databricks_provider(provider: Option<&str>) -> bool { matches!( provider @@ -50,8 +122,14 @@ pub(super) enum DatabricksAuthIntent { } impl DatabricksAuthIntent { - fn allows_interactive_auth(self) -> bool { - matches!(self, Self::InteractiveModelPicker) + /// Passive draft discovery honors (and, on failure, writes) the per-host + /// cooldown so a cancelled SSO page does not re-pop on the next form + /// keystroke. The saved-model picker is an explicit user action, so it + /// bypasses the cooldown and clears it before launching. Both surfaces + /// launch the browser flow (Phase 2 goose-parity); this predicate is the + /// only behavioral difference between them. + fn respects_cooldown(self) -> bool { + matches!(self, Self::PassiveDraftDiscovery) } } @@ -60,11 +138,16 @@ pub(super) fn databricks_sign_in_required_error() -> String { .to_string() } -pub(super) fn should_start_interactive_auth( - api_key: &str, - auth_intent: DatabricksAuthIntent, -) -> bool { - api_key.is_empty() && auth_intent.allows_interactive_auth() +pub(super) fn databricks_sign_in_timed_out_error() -> String { + "Databricks sign-in timed out; open the model picker to retry, or run `buzz-agent auth databricks`" + .to_string() +} + +pub(super) fn should_start_interactive_auth(api_key: &str) -> bool { + // Phase 2: both discovery surfaces launch the browser flow when no static + // token is configured. Which surface is allowed to actually pop the browser + // (vs. respect a cooldown) is decided via `AuthCooldown::permits_launch`. + api_key.is_empty() } pub(super) async fn discover_databricks_models( @@ -93,22 +176,26 @@ pub(super) async fn discover_databricks_models( let entries = match buzz_agent_pkg::discover_databricks_models(&config).await { Ok(entries) => entries, - Err(buzz_agent_pkg::AgentError::LlmAuth(_)) - if should_start_interactive_auth(&api_key, auth_intent) => - { + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) if should_start_interactive_auth(&api_key) => { let _auth = AUTH_GATE.lock().await; match buzz_agent_pkg::discover_databricks_models(&config).await { + // A peer sign-in under the gate already succeeded. Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { - buzz_agent_pkg::authenticate_databricks(&host) - .await - .map_err(|error| { - format_redacted_error( - "Databricks sign-in failed", - &error, - &redaction_env, - ) - })?; + // Passive surfaces suppress the browser while a recent + // failure/cancel is cooling down; the explicit picker path + // always launches (and clears any stale cooldown). + if !AUTH_COOLDOWNS.permits_launch(auth_intent, &host, Instant::now()) { + return Err(databricks_sign_in_required_error()); + } + run_interactive_databricks_auth( + buzz_agent_pkg::authenticate_databricks(&host), + AUTH_FLOW_TIMEOUT, + &AUTH_COOLDOWNS, + &host, + &redaction_env, + ) + .await?; buzz_agent_pkg::discover_databricks_models(&config) .await .map_err(|error| { @@ -172,3 +259,43 @@ fn format_redacted_error( let message = crate::managed_agents::redact_env_values_in(&error.to_string(), redaction_env); format!("{context}: {message}") } + +/// Run the interactive browser OAuth flow under a hard timeout and maintain the +/// per-host cooldown. Success clears the cooldown; a failure, cancel, or +/// timeout records it so passive surfaces stop re-launching the browser on the +/// next form keystroke. `timeout` is injected (production passes +/// [`AUTH_FLOW_TIMEOUT`]) so the timeout/cooldown policy is unit-testable +/// without a live browser. +pub(super) async fn run_interactive_databricks_auth( + auth: Fut, + timeout: Duration, + cooldowns: &AuthCooldown, + host: &str, + redaction_env: &BTreeMap, +) -> Result<(), String> +where + Fut: std::future::Future>, +{ + match tokio::time::timeout(timeout, auth).await { + Ok(Ok(())) => { + cooldowns.clear(host); + Ok(()) + } + Ok(Err(error)) => { + cooldowns.record(host, Instant::now()); + Err(format_redacted_error( + "Databricks sign-in failed", + &error, + redaction_env, + )) + } + Err(_elapsed) => { + cooldowns.record(host, Instant::now()); + Err(databricks_sign_in_timed_out_error()) + } + } +} + +#[cfg(test)] +#[path = "agent_models_databricks_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_models_databricks_tests.rs b/desktop/src-tauri/src/commands/agent_models_databricks_tests.rs new file mode 100644 index 00000000000..cb530ec59bd --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_databricks_tests.rs @@ -0,0 +1,109 @@ +//! Cooldown and interactive-auth policy tests for Databricks discovery. +//! +//! Housed as a child of `agent_models_databricks` (not the shared +//! `agent_models_tests`) so the async timeout/cooldown cases sit next to the +//! code they exercise and reach its `pub(super)` items directly via +//! `use super::*` — and so the shared test file stays under its size ratchet. + +use super::*; + +#[test] +fn databricks_cooldown_suppresses_passive_relaunch_but_never_the_picker() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let now = Instant::now(); + + // A fresh host permits either surface to launch. + assert!(cooldowns.permits_launch(DatabricksAuthIntent::PassiveDraftDiscovery, host, now)); + assert!(cooldowns.permits_launch(DatabricksAuthIntent::InteractiveModelPicker, host, now)); + + // After a failed/cancelled attempt, passive discovery must NOT re-pop the + // browser while the window is active... + cooldowns.record(host, now); + assert!(!cooldowns.permits_launch(DatabricksAuthIntent::PassiveDraftDiscovery, host, now)); + + // ...but an explicit picker click always launches, and clears the window so + // a later passive read is unblocked too. + assert!(cooldowns.permits_launch(DatabricksAuthIntent::InteractiveModelPicker, host, now)); + assert!(cooldowns.permits_launch(DatabricksAuthIntent::PassiveDraftDiscovery, host, now)); +} + +#[test] +fn databricks_cooldown_expires_after_its_window_and_is_host_scoped() { + let cooldowns = AuthCooldown::default(); + let host = "https://a.cloud.databricks.com"; + let other = "https://b.cloud.databricks.com"; + let now = Instant::now(); + + cooldowns.record(host, now); + // A cooldown on one host never suppresses another. + assert!(!cooldowns.is_active(other, now)); + assert!(cooldowns.is_active(host, now)); + + // The window is closed the instant it elapses, so a genuine later retry + // launches again. + let after = now + AUTH_COOLDOWN; + assert!(!cooldowns.is_active(host, after)); +} + +#[tokio::test] +async fn databricks_interactive_auth_success_clears_a_prior_cooldown() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let redaction = BTreeMap::new(); + cooldowns.record(host, Instant::now()); + + let result = run_interactive_databricks_auth( + async { Ok(()) }, + Duration::from_secs(150), + &cooldowns, + host, + &redaction, + ) + .await; + + assert!(result.is_ok()); + assert!(!cooldowns.is_active(host, Instant::now())); +} + +#[tokio::test] +async fn databricks_interactive_auth_failure_records_a_cooldown() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let redaction = BTreeMap::new(); + + let result = run_interactive_databricks_auth( + async { Err(buzz_agent_pkg::AgentError::LlmAuth("closed the tab".into())) }, + Duration::from_secs(150), + &cooldowns, + host, + &redaction, + ) + .await; + + let error = result.expect_err("a failed sign-in must surface an error"); + assert!(error.contains("Databricks sign-in failed")); + assert!(cooldowns.is_active(host, Instant::now())); +} + +#[tokio::test(start_paused = true)] +async fn databricks_interactive_auth_timeout_records_cooldown_and_returns_timeout_copy() { + let cooldowns = AuthCooldown::default(); + let host = "https://example.cloud.databricks.com"; + let redaction = BTreeMap::new(); + + // An abandoned SSO tab: the flow never resolves. Under the paused clock the + // injected timeout fires deterministically without real waiting. + let result = run_interactive_databricks_auth( + std::future::pending::>(), + Duration::from_secs(150), + &cooldowns, + host, + &redaction, + ) + .await; + + let error = result.expect_err("a timed-out sign-in must surface an error"); + assert_eq!(error, databricks_sign_in_timed_out_error()); + assert!(cooldowns.is_active(host, Instant::now())); +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index e7d0e70fd0b..79dd7263c61 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -509,7 +509,8 @@ fn linked_instance_ignores_model_provider_prompt_writes() { Some(Some("explicit-model".to_string())), Some(Some("explicit-prov".to_string())), Some(Some("explicit-prompt".to_string())), - ); + ) + .unwrap(); assert!( record.model.is_none(), @@ -560,7 +561,8 @@ fn definition_less_instance_accepts_model_provider_prompt_writes() { Some(Some("new-model".to_string())), Some(Some("new-prov".to_string())), Some(Some("new-prompt".to_string())), - ); + ) + .unwrap(); assert_eq!(record.model.as_deref(), Some("new-model")); assert_eq!(record.provider.as_deref(), Some("new-prov")); @@ -577,19 +579,12 @@ fn is_databricks_provider_matches_both_variants() { } #[test] -fn databricks_interactive_auth_requires_explicit_intent_and_no_static_token() { - assert!(should_start_interactive_auth( - "", - DatabricksAuthIntent::InteractiveModelPicker - )); - assert!(!should_start_interactive_auth( - "", - DatabricksAuthIntent::PassiveDraftDiscovery - )); - assert!(!should_start_interactive_auth( - "static-token", - DatabricksAuthIntent::InteractiveModelPicker - )); +fn databricks_interactive_auth_launches_only_without_a_static_token() { + // Phase 2: both surfaces launch the browser flow when the token is empty; + // the surface distinction is now cooldown-only (asserted separately). A + // configured static token still short-circuits interactive auth entirely. + assert!(should_start_interactive_auth("")); + assert!(!should_start_interactive_auth("static-token")); } #[test] diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dd61fc9398a..26136719706 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1,6 +1,8 @@ use nostr::{Keys, ToBech32}; use tauri::{AppHandle, State}; +use super::managed_agent_definition::validate_create_definition; + use crate::{ app_state::AppState, managed_agents::{ @@ -115,19 +117,14 @@ pub(super) fn tombstone_managed_agent_pending( } } -/// Build and sign the NIP-IA `kind:9035` archive request enqueued when an -/// agent is deleted. Pure given the keys — unit-testable without an -/// `AppHandle`. Reuses the same wire builder as the GUI's Archive action -/// (`events::build_archive_identity_request`); the machine-readable reason is -/// `retired` (NIP-IA suggested code for a deliberately decommissioned key). -/// -/// The owner auth tag is minted locally from the same keys used to sign the -/// request, avoiding a network fetch while the managed-agent store lock is -/// held. The relay still independently verifies it against the agent's live -/// kind:0. +/// Build an owner-authenticated NIP-IA `kind:9035` archive request for a deleted agent. +/// Definition-linked agents carry the persona id in `content`, where it survives the +/// kind:30177 tombstone as owner-signed historical alias data. The request uses the +/// same builder as the GUI Archive action and the NIP-IA `retired` reason. pub(super) fn build_agent_archive_request( keys: &nostr::Keys, agent_pubkey: &str, + persona_id: Option<&str>, ) -> Result { let auth_tag = if keys .public_key() @@ -147,9 +144,13 @@ pub(super) fn build_agent_archive_request( .map_err(|_| "owner auth tag must have four elements".to_string())?, ) }; + let content = persona_id + .filter(|id| !id.trim().is_empty()) + .map(|id| serde_json::json!({ "persona_id": id }).to_string()) + .unwrap_or_default(); crate::events::build_archive_identity_request( agent_pubkey, - "", + &content, Some("retired"), None, auth_tag.as_ref(), @@ -158,22 +159,15 @@ pub(super) fn build_agent_archive_request( .map_err(|e| format!("failed to sign archive request: {e}")) } -/// Enqueue a NIP-IA `kind:9035` archive request for a deleted agent, retained -/// next to its kind:5 tombstone with `pending_sync = 1`. -/// -/// The tombstone removes the agent's 30177 record cross-device, but the -/// agent's `kind:0` and channel membership keep populating member pickers and -/// autocomplete on the relay until the identity is archived. Retaining the -/// request here gives archival the same offline durability as the tombstone; -/// the flush loop is the sole publisher and re-signs the request with a fresh -/// `created_at` at publish time, because the relay enforces a ±120s freshness -/// window on 9035s. -/// -/// Same contract as `tombstone_managed_agent_pending`: called inside the -/// `managed_agents_store_lock`-held delete body, never across an `.await`, -/// best-effort — a failure is logged and swallowed so it never blocks the -/// disk-authoritative delete. -pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) { +/// Durably enqueue the archive request next to the kind:5 tombstone. The flush +/// loop re-signs it with a relay-fresh timestamp. Best-effort and lock-scoped, +/// matching `tombstone_managed_agent_pending`. +pub(super) fn archive_managed_agent_pending( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, + persona_id: Option<&str>, +) { use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent}; use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST; use nostr::JsonUtil; @@ -181,7 +175,7 @@ pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, a let result = (|| -> Result<(), String> { let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey)?; + let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey, persona_id)?; let conn = open_retention_db(&scope.db_path)?; retain_event( &conn, @@ -568,15 +562,13 @@ pub async fn create_managed_agent( state: State<'_, AppState>, ) -> Result { let name = input.name.trim().to_string(); - if name.is_empty() { - return Err("agent name is required".to_string()); - } let requested_persona_id = input .persona_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); + validate_create_definition(&name, requested_persona_id.as_deref(), &input)?; if let Some(parallelism) = input.parallelism { if !(1..=32).contains(¶llelism) { return Err("parallelism must be between 1 and 32".to_string()); @@ -1321,6 +1313,10 @@ pub async fn delete_managed_agent( } } + let persona_id = records + .iter() + .find(|record| record.pubkey == pubkey) + .and_then(|record| record.persona_id.clone()); if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { stop_managed_agent_process(&app, record, &mut runtimes)?; } @@ -1341,7 +1337,7 @@ pub async fn delete_managed_agent( // NIP-IA: archive the deleted agent's identity on the relay so it // stops appearing in member pickers and autocomplete. Same // best-effort, inside-the-lock contract as the tombstone above. - archive_managed_agent_pending(&app, &state, &pubkey); + archive_managed_agent_pending(&app, &state, &pubkey, persona_id.as_deref()); } try_regenerate_nest(&app); Ok(()) diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 54a03e2babe..f550a72e0c3 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -98,8 +98,12 @@ fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() { let owner = nostr::Keys::generate(); let agent = nostr::Keys::generate(); - let event = build_agent_archive_request(&owner, &agent.public_key().to_hex()) - .expect("build archive request"); + let event = build_agent_archive_request( + &owner, + &agent.public_key().to_hex(), + Some("persona-reviewer"), + ) + .expect("build archive request"); let json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap(); let tags = json["tags"].as_array().unwrap(); @@ -107,6 +111,7 @@ fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() { assert_eq!(event.pubkey, owner.public_key()); assert!(event.verify_id()); assert!(event.verify_signature()); + assert_eq!(event.content, r#"{"persona_id":"persona-reviewer"}"#); assert!(tags.iter().any(|tag| { tag.as_array().is_some_and(|parts| { parts.first().and_then(serde_json::Value::as_str) == Some("p") diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59c80c48076..2688346ffd0 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -3,7 +3,7 @@ use tauri::State; use crate::{ app_state::AppState, events, - models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse}, + models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse, GetChannelsPayload}, nostr_convert, relay::{query_relay, relay_api_base_url_with_override, submit_event, submit_event_with_keys}, }; @@ -75,79 +75,189 @@ fn classify_pending_owner(state: &AppState, my_pubkey: &str, d_tag: Option<&str> d_tag.is_some_and(|d| state.is_pending_owned_channel(my_pubkey, d)) } -#[tauri::command] -pub async fn get_channels(state: State<'_, AppState>) -> Result, String> { - let _profile_start = std::time::Instant::now(); - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; +// ── FNV-1a hash for the not-modified short-circuit ─────────────────────────── + +/// FNV-1a 64-bit hash over arbitrary bytes. Used in preference to +/// `std::collections::hash_map::DefaultHasher` because the standard library +/// does not guarantee cross-invocation stability. +fn fnv1a_64(data: &[u8]) -> u64 { + const OFFSET: u64 = 14695981039346656037; + const PRIME: u64 = 1099511628211; + let mut hash = OFFSET; + for &byte in data { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(PRIME); + } + hash +} - // Step 1: find all kind:39002 (members) events that mention me, then - // pull the channel ids out of their `d` tags. - let member_events = query_relay_all( - &state, - serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}), - ) - .await?; +/// Stable projection of `ChannelInfo` for hashing. Excludes `last_message_at` +/// so routine message traffic does not invalidate the not-modified short-circuit +/// for the channel list. +#[derive(serde::Serialize)] +struct ChannelInfoForHash<'a> { + id: &'a str, + name: &'a str, + channel_type: &'a str, + visibility: &'a str, + description: &'a str, + topic: &'a Option, + purpose: &'a Option, + member_count: i64, + member_pubkeys: &'a Vec, + archived_at: &'a Option, + participants: &'a Vec, + participant_pubkeys: &'a Vec, + is_member: bool, + ttl_seconds: &'a Option, + ttl_deadline: &'a Option, +} - #[cfg(debug_assertions)] - let t_members = _profile_start.elapsed(); +/// Compute a stable 64-bit FNV-1a hash over the channel list, canonicalized +/// by sorting on channel id and excluding `last_message_at`. Returns a +/// 16-character lowercase hex string. +fn compute_channels_hash(channels: &[ChannelInfo]) -> String { + let mut sorted: Vec<&ChannelInfo> = channels.iter().collect(); + sorted.sort_by(|a, b| a.id.cmp(&b.id)); - let mut channel_ids: Vec = member_events + let projections: Vec> = sorted .iter() - .filter_map(|ev| { - ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "d" { - Some(s[1].clone()) - } else { - None - } - }) + .map(|c| ChannelInfoForHash { + id: &c.id, + name: &c.name, + channel_type: &c.channel_type, + visibility: &c.visibility, + description: &c.description, + topic: &c.topic, + purpose: &c.purpose, + member_count: c.member_count, + member_pubkeys: &c.member_pubkeys, + archived_at: &c.archived_at, + participants: &c.participants, + participant_pubkeys: &c.participant_pubkeys, + is_member: c.is_member, + ttl_seconds: &c.ttl_seconds, + ttl_deadline: &c.ttl_deadline, }) .collect(); - channel_ids.sort(); - channel_ids.dedup(); - - // The real kind:39002 membership has now resolved for these channels — - // drop them from the pending-owner overlay (see `AppState::pending_owned_channels`) - // so a channel this identity created no longer speaks through the overlay - // once genuine membership is observable, and a later leave correctly - // flips it back to `is_member=false`. - for id in &channel_ids { - state.clear_pending_owned_channel(&my_pubkey, id); - } - // Step 2: fetch channel metadata events (kind:39000) for member channels. - // kind:39000 is addressable: exactly one event per `d` tag, so a limit - // equal to the number of ids is both necessary and sufficient. Without - // an explicit limit, multi-value `#d` filters fall through to the relay's - // default LIMIT and can drop results when there are many channels. - let meta_events = if !channel_ids.is_empty() { - query_relay( - &state, - &[serde_json::json!({ - "kinds": [39000], - "#d": channel_ids, - "limit": channel_ids.len(), - })], - ) - .await? - } else { - Vec::new() - }; + let canonical = serde_json::to_string(&projections).unwrap_or_default(); + format!("{:016x}", fnv1a_64(canonical.as_bytes())) +} +// ── Core fetch implementation ───────────────────────────────────────────────── + +/// Fetch the full channel list from the relay. Called by both `get_channels` +/// (the Tauri command, which wraps the result with hash-based short-circuit +/// logic) and `ensure_starter_channels` (which needs the raw list directly). +/// +/// Relay round-trips run in two concurrent phases: +/// - Phase 1 (parallel): member-chain (kind:39002→kind:39000), open directory +/// (kind:39000 all-open), and hidden-DM snapshot (kind:30622). +/// - Phase 2 (parallel): member counts (kind:39002 batch) and last-message +/// timestamps (per-channel kind:9/40002). +async fn fetch_channels(state: &AppState) -> Result, String> { #[cfg(debug_assertions)] - let t_member_meta = _profile_start.elapsed(); + let _profile_start = std::time::Instant::now(); + + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; - // Step 3: fetch ALL open channel metadata so the channel browser can show - // discoverable channels the user hasn't joined yet. The relay's access - // control allows reading kind:39000 for open channels regardless of membership. - let open_meta_events = query_relay_all(&state, serde_json::json!({"kinds": [39000]})).await?; + // Phase 1 — concurrent: member-chain (steps 1→2), open directory (step 3), + // and hidden-DM snapshot (step 6). These three have no mutual dependencies. + let (member_chain_result, open_meta_result, hidden_dms) = tokio::join!( + // Steps 1+2: find the channels this identity belongs to, then fetch + // their metadata events. + async { + // Step 1: kind:39002 events listing my pubkey as a member. + let member_events = query_relay_all( + state, + serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}), + ) + .await?; + + let mut member_channel_ids: Vec = member_events + .iter() + .filter_map(|ev| { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }) + }) + .collect(); + member_channel_ids.sort(); + member_channel_ids.dedup(); + + // Real kind:39002 membership has landed — clear the pending-owner + // overlay so a subsequent leave correctly flips `is_member` back + // to false. See `AppState::pending_owned_channels`. + for id in &member_channel_ids { + state.clear_pending_owned_channel(&my_pubkey, id); + } + + // Step 2: fetch channel metadata events (kind:39000) for member channels. + // kind:39000 is addressable: exactly one event per `d` tag, so a limit + // equal to the number of ids is both necessary and sufficient. + let meta_events = if !member_channel_ids.is_empty() { + query_relay( + state, + &[serde_json::json!({ + "kinds": [39000], + "#d": &member_channel_ids, + "limit": member_channel_ids.len(), + })], + ) + .await? + } else { + Vec::new() + }; + + Ok::<_, String>(meta_events) + }, + // Step 3: fetch ALL open channel metadata so the channel browser can show + // discoverable channels the user hasn't joined yet. + query_relay_all(state, serde_json::json!({"kinds": [39000]})), + // Step 6: NIP-DV hidden-DM snapshot. Tolerant — a failure means no DMs + // are hidden rather than aborting the whole fetch. + async { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [buzz_core_pkg::kind::KIND_DM_VISIBILITY], + "#p": [&my_pubkey], + "limit": 1, + })], + ) + .await + .unwrap_or_default(); + events + .iter() + .max_by_key(|e| e.created_at.as_secs()) + .map(|e| { + e.tags + .iter() + .filter_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) + }) + .collect::>() + }) + .unwrap_or_default() + }, + ); #[cfg(debug_assertions)] - let t_open_meta = _profile_start.elapsed(); + let t_phase1 = _profile_start.elapsed(); + + let meta_events = member_chain_result?; + let open_meta_events = open_meta_result?; + // hidden_dms is already a resolved HashSet (tolerant path above) // Merge: member channels (marked as member) + open channels (not yet joined). let member_d_tags: std::collections::HashSet = meta_events @@ -187,58 +297,19 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result } // The overlay (`AppState::pending_owned_channels`) marks channels this // identity just created via `create_channel` whose kind:39002 owner - // membership hasn't propagated yet (#1761) — a fresh channel has no - // member event and would otherwise fall through to `is_member=false` - // here, disabling the owner's own composer until that snapshot lands. - // The overlay can only be populated by this process's own - // `create_channel` call (never by relay data) and is keyed by - // `(my_pubkey, d_tag)`, so it adds no trust-boundary risk and can - // never speak for a channel a different identity created; `channel_ids` - // above clears it once real membership is observed for `my_pubkey`. - let is_pending_owner = classify_pending_owner(&state, &my_pubkey, d_tag.as_deref()); + // membership hasn't propagated yet (#1761). + let is_pending_owner = classify_pending_owner(state, &my_pubkey, d_tag.as_deref()); if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(is_pending_owner)) { channels.push(info); } } - // Populate member_count by batch-fetching kind:39002 for every listed - // channel and counting unique p-tag pubkeys. The kind:40901 summary - // sidecar that channel_info_from_event prefers isn't emitted by the - // relay today, so without this step every channel reports 0 members - // in the channel browser (the active-channel top bar masks this with - // its own live members query). - let all_d_tags: Vec = channels.iter().map(|c| c.id.clone()).collect(); - if !all_d_tags.is_empty() { - let members_events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [39002], - "#d": all_d_tags, - "limit": all_d_tags.len(), - })], - ) - .await - .unwrap_or_default(); - - let membership = collect_members_by_channel(&members_events); - for channel in &mut channels { - if let Some(info) = membership.get(&channel.id) { - channel.member_count = info.count; - channel.member_pubkeys = info.pubkeys.clone(); - } - } - } - - #[cfg(debug_assertions)] - let t_member_counts = _profile_start.elapsed(); - - // Populate last_message_at by fetching the most recent human message per - // channel. Uses per-channel filters (single #h value each) so the relay can - // push the query to its indexed channel_id column. Multi-value #h is NOT - // SQL-pushed and would silently drop quieter channels under the global limit. - let channel_ids: Vec = channels.iter().map(|c| c.id.clone()).collect(); - if !channel_ids.is_empty() { - let filters: Vec = channel_ids + // Phase 2 — concurrent: member counts (step 4) and last-message timestamps + // (step 5). Both tolerate failures — empty defaults leave counts at 0 and + // timestamps at None rather than aborting. + let all_channel_ids: Vec = channels.iter().map(|c| c.id.clone()).collect(); + if !all_channel_ids.is_empty() { + let last_msg_filters: Vec = all_channel_ids .iter() .map(|id| { serde_json::json!({ @@ -249,11 +320,32 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result }) .collect(); - let message_events = query_relay(&state, &filters).await.unwrap_or_default(); + // Bind both filter arrays before the join so their lifetimes cover + // both branches of the concurrent pair. + let member_count_filters = [serde_json::json!({ + "kinds": [39002], + "#d": &all_channel_ids, + "limit": all_channel_ids.len(), + })]; + let (members_result, message_result) = tokio::join!( + // Step 4: batch-fetch kind:39002 for member counts. + query_relay(state, &member_count_filters), + // Step 5: per-channel last-message filter. Uses per-channel `#h` + // so the relay can push each query to its indexed channel_id column. + query_relay(state, &last_msg_filters), + ); + + let membership = collect_members_by_channel(&members_result.unwrap_or_default()); + for channel in &mut channels { + if let Some(info) = membership.get(&channel.id) { + channel.member_count = info.count; + channel.member_pubkeys = info.pubkeys.clone(); + } + } let mut last_message_by_channel: std::collections::HashMap = std::collections::HashMap::new(); - for ev in &message_events { + for ev in &message_result.unwrap_or_default() { if let Some(ch_id) = ev.tags.iter().find_map(|t| { let s = t.as_slice(); (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) @@ -269,7 +361,6 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result .or_insert(ts); } } - for channel in &mut channels { if let Some(&ts) = last_message_by_channel.get(&channel.id) { channel.last_message_at = Some(nostr_convert::timestamp_to_iso(ts)); @@ -277,63 +368,71 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result } } - #[cfg(debug_assertions)] - let t_last_message = _profile_start.elapsed(); - - // NIP-DV: drop DMs the viewer has hidden. The relay maintains a per-viewer - // parameterized-replaceable snapshot (kind:30622, d=my pubkey) whose `h` - // tags list currently-hidden DM channel ids. The snapshot also carries - // `p`=my pubkey so the relay's #p read-gate scopes it to me; we query by - // `#p` for that reason. Reading the latest one is the only way the client - // learns hide state, which the relay tracks privately. - let hidden_dms: std::collections::HashSet = { - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [buzz_core_pkg::kind::KIND_DM_VISIBILITY], - "#p": [&my_pubkey], - "limit": 1, - })], - ) - .await - .unwrap_or_default(); - events - .iter() - .max_by_key(|e| e.created_at.as_secs()) - .map(|e| { - e.tags - .iter() - .filter_map(|t| { - let s = t.as_slice(); - (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) - }) - .collect() - }) - .unwrap_or_default() - }; - if !hidden_dms.is_empty() { - channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id)); - } - #[cfg(debug_assertions)] { let total = _profile_start.elapsed(); eprintln!( - "buzz-desktop: get_channels profile channels={} members={:?} member_meta={:?} open_meta={:?} member_counts={:?} last_message={:?} hidden_dm={:?} total={:?}", + "buzz-desktop: get_channels profile channels={} phase1(member_chain+open_meta+hidden_dm)={:?} phase2(member_counts+last_msg)={:?} total={:?}", channels.len(), - t_members, - t_member_meta - t_members, - t_open_meta - t_member_meta, - t_member_counts - t_open_meta, - t_last_message - t_member_counts, - total - t_last_message, + t_phase1, + total - t_phase1, total, ); } + // NIP-DV: drop DMs the viewer has hidden. + if !hidden_dms.is_empty() { + channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id)); + } + Ok(channels) } +// ── Tauri commands ──────────────────────────────────────────────────────────── + +/// Return the full channel list for the active identity. +/// +/// `known_hash` is a previously returned `hash` value. When it matches the +/// computed stable hash (which excludes `last_message_at`), the response +/// carries `channels: null` so the multi-MB list is not serialized across IPC. +/// `last_messages` is always included because it is cheap and changes with +/// every new message. +#[tauri::command] +pub async fn get_channels( + known_hash: Option, + state: State<'_, AppState>, +) -> Result { + let channels = fetch_channels(&state).await?; + + let last_messages: std::collections::HashMap = channels + .iter() + .filter_map(|c| { + c.last_message_at + .as_ref() + .map(|ts| (c.id.clone(), ts.clone())) + }) + .collect(); + + let hash = compute_channels_hash(&channels); + + // Not-modified short-circuit: skip the multi-MB IPC payload when the + // caller's hash matches. `last_messages` still ships so the TS side can + // update sidebar timestamps without re-rendering the full list. + if known_hash.as_deref() == Some(hash.as_str()) { + return Ok(GetChannelsPayload { + hash, + channels: None, + last_messages, + }); + } + + Ok(GetChannelsPayload { + hash, + channels: Some(channels), + last_messages, + }) +} + struct ChannelMembership { count: i64, pubkeys: Vec, @@ -612,7 +711,7 @@ pub async fn create_channel( pub async fn ensure_starter_channels( state: State<'_, AppState>, ) -> Result, String> { - let mut existing_channels = get_channels(state.clone()).await?; + let mut existing_channels = fetch_channels(&state).await?; let relay_scope = relay_api_base_url_with_override(&state); let creator_keys = state.signing_keys()?; let creator_pubkey = creator_keys.public_key().to_hex(); @@ -671,7 +770,7 @@ pub async fn ensure_starter_channels( } if !has_all_starter_channels(&existing_channels) { - existing_channels = get_channels(state.clone()).await?; + existing_channels = fetch_channels(&state).await?; } if !has_all_starter_channels(&existing_channels) { diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index 5b65695a913..43da15703c8 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -2,6 +2,7 @@ // channels.rs under the per-file line cap. use super::*; +use crate::models::ChannelInfo; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; /// Build a signed event for testing with the given kind, content, and tags. @@ -266,6 +267,131 @@ fn duplicate_channel_rejection_is_ensure_success_only() { )); } +// ── compute_channels_hash ───────────────────────────────────────────────────── + +fn make_channel(id: &str, name: &str, last_message_at: Option) -> ChannelInfo { + ChannelInfo { + id: id.to_string(), + name: name.to_string(), + channel_type: "stream".to_string(), + visibility: "open".to_string(), + description: "".to_string(), + topic: None, + purpose: None, + member_count: 0, + member_pubkeys: Vec::new(), + last_message_at, + archived_at: None, + participants: Vec::new(), + participant_pubkeys: Vec::new(), + is_member: true, + ttl_seconds: None, + ttl_deadline: None, + } +} + +#[test] +fn hash_is_order_insensitive() { + let c1 = make_channel("aaa", "Alpha", None); + let c2 = make_channel("bbb", "Beta", None); + let c3 = make_channel("aaa", "Alpha", None); + let c4 = make_channel("bbb", "Beta", None); + + assert_eq!( + compute_channels_hash(&[c1, c2]), + compute_channels_hash(&[c4, c3]), + "hash must be insensitive to channel list ordering", + ); +} + +#[test] +fn hash_ignores_last_message_at() { + let c_none = make_channel("chan-1", "Alpha", None); + let c_some = make_channel("chan-1", "Alpha", Some("2026-01-01T00:00:00Z".to_string())); + + assert_eq!( + compute_channels_hash(&[c_none]), + compute_channels_hash(&[c_some]), + "hash must be insensitive to last_message_at", + ); +} + +#[test] +fn hash_changes_on_metadata_change() { + let c1 = make_channel("chan-1", "Alpha", None); + let c2 = make_channel("chan-1", "AlphaRenamed", None); + + assert_ne!( + compute_channels_hash(&[c1]), + compute_channels_hash(&[c2]), + "hash must change when channel name changes", + ); +} + +#[test] +fn hash_changes_on_membership_change() { + let mut c1 = make_channel("chan-1", "Alpha", None); + let mut c2 = make_channel("chan-1", "Alpha", None); + c1.member_pubkeys = vec![PK_A.to_string()]; + c2.member_pubkeys = vec![PK_A.to_string(), PK_B.to_string()]; + + assert_ne!( + compute_channels_hash(&[c1]), + compute_channels_hash(&[c2]), + "hash must change when member_pubkeys changes", + ); +} + +#[test] +fn not_modified_returns_none_when_hash_matches() { + let channels = vec![make_channel("chan-1", "General", None)]; + let hash = compute_channels_hash(&channels); + + // Mirror the get_channels command decision logic. + let known_hash = Some(hash.clone()); + let is_not_modified = known_hash.as_deref() == Some(hash.as_str()); + + assert!( + is_not_modified, + "identical hash must trigger the not-modified short-circuit", + ); +} + +#[test] +fn not_modified_does_not_trigger_on_hash_mismatch() { + let channels = vec![make_channel("chan-1", "General", None)]; + let hash = compute_channels_hash(&channels); + let known_hash = Some("0000000000000000".to_string()); + + let is_not_modified = known_hash.as_deref() == Some(hash.as_str()); + + assert!( + !is_not_modified, + "stale hash must NOT trigger the not-modified short-circuit", + ); +} + +#[test] +fn hash_is_stable_for_same_input() { + // Verifies that the FNV-1a output is deterministic across calls within + // the same process (unlike std DefaultHasher which uses random seeds). + let channels = vec![ + make_channel("aaa", "General", Some("2026-01-01T00:00:00Z".to_string())), + make_channel("bbb", "Random", None), + ]; + let first = compute_channels_hash(&channels); + let channels2 = vec![ + make_channel("aaa", "General", None), // last_message_at change is ignored + make_channel("bbb", "Random", None), + ]; + let second = compute_channels_hash(&channels2); + + assert_eq!( + first, second, + "hash must be deterministic and ignore last_message_at" + ); +} + #[test] fn starter_match_requires_open_unarchived_stream_by_normalized_name() { let spec = &STARTER_CHANNELS[0]; diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5ada1b38840..b781f8d9e68 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -1,9 +1,8 @@ use std::{io::Cursor, net::IpAddr, time::Duration}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use image::ImageDecoder; - use futures_util::StreamExt; +use image::ImageDecoder; use reqwest::{ header::{ACCEPT, CONTENT_TYPE, LOCATION, USER_AGENT}, redirect::Policy, @@ -11,8 +10,12 @@ use reqwest::{ use serde::Serialize; use url::Url; +#[path = "link_preview_image_retry.rs"] +mod image_retry; #[path = "link_preview_rate_limit.rs"] mod rate_limit; +#[path = "link_preview_youtube.rs"] +mod youtube; use rate_limit::{image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown}; @@ -67,6 +70,10 @@ async fn fetch_link_preview_metadata_inner( let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; validate_public_https_url(&url).await?; + if youtube::is_video_url(&url) { + return youtube::fetch_oembed_metadata(&url).await; + } + for redirect_count in 0..=MAX_REDIRECTS { let response = send_pinned_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; @@ -103,10 +110,13 @@ async fn fetch_link_preview_metadata_inner( Some(image_url) => Some( tokio::time::timeout( PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(image_url, false), + fetch_sanitized_image_with_retry(image_url, false), ) .await - .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), + .unwrap_or(Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: false, + })), ), None => None, } @@ -144,7 +154,7 @@ fn apply_image_result( metadata.image_domain = Some(domain); metadata.image_fetch_state = LinkPreviewImageFetchState::Image; } - Some(Err(ImageFetchError::Transient { retry_after })) => { + Some(Err(ImageFetchError::Transient { retry_after, .. })) => { metadata.image_fetch_state = LinkPreviewImageFetchState::TransientFailure; metadata.image_retry_after_ms = retry_after.and_then(|duration| u64::try_from(duration.as_millis()).ok()); @@ -313,10 +323,23 @@ fn extract_image_url(html: &str, page_url: &Url) -> Option { #[derive(Debug, PartialEq)] enum ImageFetchError { - Transient { retry_after: Option }, + Transient { + retry_after: Option, + retry_inline: bool, + }, Rejected, } +async fn fetch_sanitized_image_with_retry( + url: Url, + preserve_transparency: bool, +) -> Result<(String, String), ImageFetchError> { + image_retry::retry_transient_image_fetch(|| { + fetch_sanitized_image(url.clone(), preserve_transparency) + }) + .await +} + async fn fetch_sanitized_image( mut url: Url, preserve_transparency: bool, @@ -328,11 +351,15 @@ async fn fetch_sanitized_image( if let Some(retry_after) = image_host_cooldown_remaining(&url) { return Err(ImageFetchError::Transient { retry_after: Some(retry_after), + retry_inline: false, }); } let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp") .await - .map_err(|_| ImageFetchError::Transient { retry_after: None })?; + .map_err(|_| ImageFetchError::Transient { + retry_after: None, + retry_inline: true, + })?; if response.status().is_redirection() { if redirect_count == MAX_REDIRECTS { return Err(ImageFetchError::Rejected); @@ -359,7 +386,10 @@ async fn fetch_sanitized_image( if let Some(retry_after) = retry_after { set_image_host_cooldown(&url, retry_after); } - return Err(ImageFetchError::Transient { retry_after }); + return Err(ImageFetchError::Transient { + retry_after, + retry_inline: status != reqwest::StatusCode::TOO_MANY_REQUESTS, + }); } return Err(ImageFetchError::Rejected); } @@ -705,6 +735,7 @@ mod tests { &mut metadata, Some(Err(ImageFetchError::Transient { retry_after: Some(std::time::Duration::from_secs(15)), + retry_inline: false, })), ); assert_eq!( diff --git a/desktop/src-tauri/src/commands/link_preview_image_retry.rs b/desktop/src-tauri/src/commands/link_preview_image_retry.rs new file mode 100644 index 00000000000..f397c516367 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_image_retry.rs @@ -0,0 +1,75 @@ +use super::ImageFetchError; + +pub(super) async fn retry_transient_image_fetch( + mut fetch: F, +) -> Result<(String, String), ImageFetchError> +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let first = fetch().await; + if matches!( + first, + Err(ImageFetchError::Transient { + retry_inline: true, + .. + }) + ) { + return fetch().await; + } + first +} + +#[cfg(test)] +mod tests { + use super::retry_transient_image_fetch; + use crate::commands::link_preview::ImageFetchError; + use std::{cell::Cell, time::Duration}; + + #[tokio::test] + async fn retries_one_transient_failure_inline() { + let attempts = Cell::new(0); + let result = retry_transient_image_fetch(|| { + let attempt = attempts.get() + 1; + attempts.set(attempt); + async move { + if attempt == 1 { + Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: true, + }) + } else { + Ok(("image".to_string(), "example.com".to_string())) + } + } + }) + .await; + + assert!(result.is_ok()); + assert_eq!(attempts.get(), 2); + } + + #[tokio::test] + async fn does_not_retry_rate_limits_inline() { + let attempts = Cell::new(0); + let result = retry_transient_image_fetch(|| { + attempts.set(attempts.get() + 1); + async { + Err(ImageFetchError::Transient { + retry_after: Some(Duration::from_secs(60)), + retry_inline: false, + }) + } + }) + .await; + + assert_eq!( + result, + Err(ImageFetchError::Transient { + retry_after: Some(Duration::from_secs(60)), + retry_inline: false, + }) + ); + assert_eq!(attempts.get(), 1); + } +} diff --git a/desktop/src-tauri/src/commands/link_preview_youtube.rs b/desktop/src-tauri/src/commands/link_preview_youtube.rs new file mode 100644 index 00000000000..a0a5a753dcd --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_youtube.rs @@ -0,0 +1,364 @@ +use percent_encoding::percent_decode_str; +use reqwest::header::CONTENT_TYPE; +use serde::Deserialize; +use url::Url; + +use super::{ + apply_image_result, fetch_sanitized_image, normalize_metadata_description, + normalize_metadata_text, read_limited_bytes, send_pinned_request, ImageFetchError, + LinkPreviewImageFetchState, LinkPreviewMetadata, PREVIEW_FETCH_TIMEOUT, +}; + +const MAX_OEMBED_FETCH_BYTES: usize = 64 * 1024; +const OEMBED_ENDPOINT: &str = "https://www.youtube.com/oembed"; + +#[derive(Deserialize)] +struct OEmbedResponse { + title: String, + author_name: Option, + provider_name: Option, + thumbnail_url: Option, +} + +pub(super) fn is_video_url(url: &Url) -> bool { + let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else { + return false; + }; + match host.as_str() { + "youtu.be" | "www.youtu.be" => url + .path_segments() + .and_then(|mut segments| segments.next()) + .is_some_and(|segment| !segment.is_empty()), + "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com" => { + (url.path() == "/watch" + && url + .query_pairs() + .any(|(key, value)| key == "v" && !value.is_empty())) + || ["shorts", "live", "embed"].iter().any(|prefix| { + url.path_segments().is_some_and(|mut segments| { + segments.next() == Some(prefix) + && segments.next().is_some_and(|segment| !segment.is_empty()) + }) + }) + } + _ => false, + } +} + +pub(super) async fn fetch_oembed_metadata( + video_url: &Url, +) -> Result, String> { + let oembed_url = oembed_url(video_url)?; + let response = send_pinned_request(&oembed_url, "application/json").await?; + let Some((mut metadata, thumbnail_url)) = parse_oembed_response(response).await? else { + return Ok(None); + }; + let image_result = match thumbnail_url { + Some(thumbnail_url) => Some( + tokio::time::timeout( + PREVIEW_FETCH_TIMEOUT, + fetch_sanitized_image(thumbnail_url, false), + ) + .await + .unwrap_or(Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: false, + })), + ), + None => None, + }; + apply_image_result(&mut metadata, image_result); + Ok(Some(metadata)) +} + +fn oembed_url(video_url: &Url) -> Result { + let mut canonical_video_url = video_url.clone(); + if matches!( + video_url.host_str(), + Some("youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com") + ) { + let mut segments = video_url.path_segments(); + if segments.as_mut().and_then(|segments| segments.next()) == Some("embed") { + let encoded_video_id = segments + .and_then(|mut segments| segments.next()) + .ok_or_else(|| "YouTube embed URL has no video ID".to_string())?; + let video_id = percent_decode_str(encoded_video_id) + .decode_utf8() + .map_err(|_| "YouTube embed URL has an invalid video ID".to_string())?; + if !video_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) { + return Err("YouTube embed URL has an invalid video ID".to_string()); + } + canonical_video_url.set_path("/watch"); + canonical_video_url.set_query(None); + canonical_video_url + .query_pairs_mut() + .append_pair("v", &video_id); + canonical_video_url.set_fragment(None); + } + } + + let mut oembed_url = Url::parse(OEMBED_ENDPOINT) + .map_err(|error| format!("invalid YouTube oEmbed endpoint: {error}"))?; + oembed_url + .query_pairs_mut() + .append_pair("format", "json") + .append_pair("url", canonical_video_url.as_str()); + Ok(oembed_url) +} + +async fn parse_oembed_response( + response: reqwest::Response, +) -> Result)>, String> { + if !response.status().is_success() || !is_json_response(&response) { + return Ok(None); + } + let body = read_limited_bytes(response, MAX_OEMBED_FETCH_BYTES).await?; + let response: OEmbedResponse = match serde_json::from_slice(&body) { + Ok(response) => response, + Err(_) => return Ok(None), + }; + Ok(oembed_metadata(response)) +} + +fn oembed_metadata(response: OEmbedResponse) -> Option<(LinkPreviewMetadata, Option)> { + let title = normalize_metadata_text(&response.title)?; + let thumbnail_url = response + .thumbnail_url + .as_deref() + .and_then(|thumbnail| Url::parse(thumbnail).ok()); + let metadata = LinkPreviewMetadata { + title, + site_name: response + .provider_name + .as_deref() + .and_then(normalize_metadata_text) + .or_else(|| Some("YouTube".to_string())), + description: response + .author_name + .as_deref() + .and_then(normalize_metadata_description), + image_data_url: None, + image_domain: None, + image_fetch_state: LinkPreviewImageFetchState::None, + image_retry_after_ms: None, + favicon_data_url: None, + }; + Some((metadata, thumbnail_url)) +} + +fn is_json_response(response: &reqwest::Response) -> bool { + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Response, routing::get, Router}; + + async fn test_response(router: Router, path: &str) -> reqwest::Response { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + reqwest::get(format!("http://{address}{path}")) + .await + .unwrap() + } + + #[test] + fn recognizes_supported_video_urls_only() { + for href in [ + "https://www.youtube.com/watch?v=hLFs9JtMaRg", + "https://m.youtube.com/watch?v=hLFs9JtMaRg&feature=share", + "https://music.youtube.com/watch?v=hLFs9JtMaRg", + "https://youtu.be/hLFs9JtMaRg?t=10", + "https://www.youtube.com/shorts/hLFs9JtMaRg", + "https://www.youtube.com/live/hLFs9JtMaRg", + "https://www.youtube.com/embed/hLFs9JtMaRg", + ] { + assert!(is_video_url(&Url::parse(href).unwrap()), "{href}"); + } + for href in [ + "https://www.youtube.com/", + "https://www.youtube.com/watch", + "https://www.youtube.com/watch?v=", + "https://www.youtube.com/@buzz", + "https://youtube.com.evil.example/watch?v=hLFs9JtMaRg", + "https://notyoutube.com/watch?v=hLFs9JtMaRg", + ] { + assert!(!is_video_url(&Url::parse(href).unwrap()), "{href}"); + } + } + + #[test] + fn canonicalizes_embed_url_for_oembed() { + for (video_url, expected_video_id) in [ + ( + "https://www.youtube.com/embed/dQw4w9WgXcQ?start=10#player", + "dQw4w9WgXcQ", + ), + ("https://www.youtube.com/embed/%64Qw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://www.youtube.com/embed/dQw4w9WgX%63Q", "dQw4w9WgXcQ"), + ] { + let oembed_url = oembed_url(&Url::parse(video_url).unwrap()).unwrap(); + let params = oembed_url + .query_pairs() + .collect::>(); + assert_eq!( + params.get("format").map(|value| value.as_ref()), + Some("json") + ); + let provider_video_url = + Url::parse(params.get("url").expect("oEmbed URL parameter")).unwrap(); + assert_eq!(provider_video_url.path(), "/watch"); + assert_eq!( + provider_video_url + .query_pairs() + .find(|(key, _)| key == "v") + .map(|(_, value)| value.into_owned()), + Some(expected_video_id.to_string()) + ); + } + } + + #[test] + fn rejects_invalid_encoded_embed_video_ids() { + for href in [ + "https://www.youtube.com/embed/video%2Fid", + "https://www.youtube.com/embed/video%5Cid", + "https://www.youtube.com/embed/video%00id", + "https://www.youtube.com/embed/video%25id", + "https://www.youtube.com/embed/video%252Fid", + "https://www.youtube.com/embed/video%FFid", + ] { + assert!(oembed_url(&Url::parse(href).unwrap()).is_err(), "{href}"); + } + } + + #[tokio::test] + async fn response_requires_successful_bounded_json() { + let valid_json = + r#"{"title":"Video title","author_name":"Creator","provider_name":"YouTube"}"#; + let response = test_response( + Router::new().route( + "/valid", + get(move || async move { + Response::builder() + .header("content-type", "application/json; charset=UTF-8") + .body(Body::from(valid_json)) + .unwrap() + }), + ), + "/valid", + ) + .await; + let (metadata, _) = parse_oembed_response(response).await.unwrap().unwrap(); + assert_eq!(metadata.title, "Video title"); + + for response in [ + test_response( + Router::new().route( + "/not-found", + get(|| async { + Response::builder() + .status(404) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap() + }), + ), + "/not-found", + ) + .await, + test_response( + Router::new().route( + "/html", + get(|| async { + Response::builder() + .header("content-type", "text/html") + .body(Body::from("Not JSON")) + .unwrap() + }), + ), + "/html", + ) + .await, + ] { + assert_eq!(parse_oembed_response(response).await.unwrap(), None); + } + + let oversized = vec![b' '; MAX_OEMBED_FETCH_BYTES + 1]; + let response = test_response( + Router::new().route( + "/oversized", + get(move || { + let oversized = oversized.clone(); + async move { + Response::builder() + .header("content-type", "application/json") + .body(Body::from(oversized)) + .unwrap() + } + }), + ), + "/oversized", + ) + .await; + assert!(parse_oembed_response(response).await.is_err()); + } + + #[test] + fn converts_response_to_bounded_preview_metadata() { + let (metadata, thumbnail_url) = oembed_metadata(OEmbedResponse { + title: " Video title ".to_string(), + author_name: Some("Buzz Creator".to_string()), + provider_name: Some("YouTube".to_string()), + thumbnail_url: Some("https://i.ytimg.com/vi/example/hqdefault.jpg".to_string()), + }) + .unwrap(); + assert_eq!(metadata.title, "Video title"); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(metadata.description.as_deref(), Some("Buzz Creator")); + assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); + assert_eq!( + thumbnail_url.unwrap().as_str(), + "https://i.ytimg.com/vi/example/hqdefault.jpg" + ); + } + + #[test] + fn rejects_titleless_response_and_ignores_invalid_thumbnail() { + assert!(oembed_metadata(OEmbedResponse { + title: " ".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: None, + }) + .is_none()); + + let (metadata, thumbnail_url) = oembed_metadata(OEmbedResponse { + title: "Video title".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: Some("not a URL".to_string()), + }) + .unwrap(); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(thumbnail_url, None); + } +} diff --git a/desktop/src-tauri/src/commands/managed_agent_definition.rs b/desktop/src-tauri/src/commands/managed_agent_definition.rs new file mode 100644 index 00000000000..32753807486 --- /dev/null +++ b/desktop/src-tauri/src/commands/managed_agent_definition.rs @@ -0,0 +1,124 @@ +//! Managed-agent definition validation at local mutation boundaries. + +use crate::managed_agents::{CreateManagedAgentRequest, ManagedAgentRecord}; + +pub(super) fn validate_create_definition( + name: &str, + persona_id: Option<&str>, + input: &CreateManagedAgentRequest, +) -> Result<(), String> { + validate_definition_fields(name, persona_id, input.system_prompt.as_deref()) +} + +fn validate_definition_fields( + name: &str, + persona_id: Option<&str>, + system_prompt: Option<&str>, +) -> Result<(), String> { + crate::managed_agents::validate_managed_agent_definition_text(name, persona_id, system_prompt) + .map_err(|error| format!("Managed agent definition is unsafe: {error}")) +} + +/// Apply definition-owned update fields, then validate the complete +/// prospective definition before the caller can persist it. +pub(super) fn apply_model_provider_prompt_update( + record: &mut ManagedAgentRecord, + model: Option>, + provider: Option>, + system_prompt: Option>, +) -> Result<(), String> { + if record.persona_id.is_none() { + if let Some(model_update) = model { + record.model = model_update; + } + if let Some(provider_update) = provider { + record.provider = provider_update; + } + if let Some(prompt_update) = system_prompt { + record.system_prompt = prompt_update; + } + } + + validate_definition_fields( + &record.name, + record.persona_id.as_deref(), + record.system_prompt.as_deref(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn standalone_record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "standalone1", + "name": "standalone-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "safe prompt", + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + })) + .expect("standalone agent record") + } + + fn create_request(system_prompt: &str) -> CreateManagedAgentRequest { + serde_json::from_value(serde_json::json!({ + "name": "Reviewer", + "systemPrompt": system_prompt + })) + .expect("create request") + } + + #[test] + fn create_rejects_invisible_definition_less_name_or_prompt() { + for (name, prompt, code) in [ + ("Review\u{200B}er", "Review code.", "U+200B"), + ("Reviewer", "Review\u{202E} code.", "U+202E"), + ] { + let input = create_request(prompt); + let error = validate_create_definition(name, None, &input) + .expect_err("create must reject unsafe definition text"); + assert!(error.contains(code), "unexpected error: {error}"); + } + } + + #[test] + fn create_accepts_visible_multiline_definition_less_prompt() { + let input = create_request("Review changes.\n\tCall out security risks."); + validate_create_definition("Reviewer 🐝", None, &input) + .expect("visible multiline instructions should remain valid"); + } + + #[test] + fn update_rejects_invisible_definition_less_name_or_prompt() { + let mut unsafe_prompt = standalone_record(); + let error = apply_model_provider_prompt_update( + &mut unsafe_prompt, + None, + None, + Some(Some("Review\u{200B} code.".to_string())), + ) + .expect_err("definition-less prompt update must reject invisible text"); + assert!(error.contains("U+200B"), "unexpected error: {error}"); + + let mut unsafe_name = standalone_record(); + unsafe_name.name = "Review\u{202E}er".to_string(); + let error = apply_model_provider_prompt_update(&mut unsafe_name, None, None, None) + .expect_err("definition-less name update must reject formatting controls"); + assert!(error.contains("U+202E"), "unexpected error: {error}"); + } +} diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 070381f55e8..8da845c07d4 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -115,11 +115,10 @@ fn fd_real_path(_file: &std::fs::File) -> Result { /// MIME types blocked from upload — mirrors the server's generic-file deny-list. /// -/// Active-content XSS carriers and native executables. Everything else (images, -/// video, documents, archives, audio, text, data) is accepted; un-sniffable -/// files fall back to `application/octet-stream` and are served as downloads. +/// Active-content XSS carriers (JS, SVG) and native executables. Other types, +/// including HTML, are accepted as downloads; un-sniffable files fall back to +/// `application/octet-stream`. XHTML remains blocked in lockstep with the relay. const BLOCKED_MIME: &[&str] = &[ - "text/html", "application/xhtml+xml", "image/svg+xml", "application/javascript", @@ -895,9 +894,29 @@ mod tests { } #[test] - fn test_detect_and_validate_mime_rejects_html() { + fn test_detect_and_validate_mime_accepts_html_as_inert_download() { let html = b""; - assert!(detect_and_validate_mime(html).is_err()); + assert_eq!(detect_and_validate_mime(html).unwrap(), "text/html"); + } + + #[test] + fn test_detect_and_validate_mime_still_rejects_executable() { + let elf = [b"\x7fELF".as_slice(), &[0u8; 60]].concat(); + assert!(detect_and_validate_mime(&elf).is_err()); + } + + #[test] + fn test_blocked_mime_keeps_active_content_and_executables() { + for kept in [ + "image/svg+xml", + "application/xhtml+xml", + "application/javascript", + "text/javascript", + "application/x-executable", + "application/x-mach-binary", + ] { + assert!(BLOCKED_MIME.contains(&kept), "{kept} must stay blocked"); + } } #[test] diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs index a74ccd4dfe3..97081571623 100644 --- a/desktop/src-tauri/src/commands/media_raw.rs +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -27,7 +27,18 @@ pub async fn upload_media_bytes( app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { - upload_media_bytes_inner(data, filename, progress_id, app, state, None).await + let cancellation = begin_media_upload(progress_id.as_deref()); + let result = upload_media_bytes_inner( + data, + filename, + progress_id.clone(), + app, + state, + cancellation.as_ref(), + ) + .await; + finish_media_upload(progress_id.as_deref()); + result } fn decode_raw_upload_header(value: &str) -> Result { @@ -56,6 +67,12 @@ pub fn cancel_media_upload(progress_id: String) { cancel_registered_media_upload(&progress_id); } +/// Release the renderer's ownership after its upload promise settles. +#[tauri::command] +pub fn release_media_upload(progress_id: String) { + finish_media_upload(Some(&progress_id)); +} + /// Upload raw IPC bytes without expanding a large browser File into JSON. #[tauri::command] pub async fn upload_media_bytes_raw( diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs index 850afe1b123..5ed3f786521 100644 --- a/desktop/src-tauri/src/commands/media_upload_progress.rs +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -8,23 +8,45 @@ use tokio_util::sync::CancellationToken; use crate::{app_state::AppState, relay::classify_request_error}; -static MEDIA_UPLOAD_CANCELLATIONS: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); +#[derive(Default)] +struct MediaUploadCancellations { + tokens: HashMap, +} + +impl MediaUploadCancellations { + fn begin(&mut self, progress_id: &str) -> CancellationToken { + if let Some(cancel) = self.tokens.get(progress_id).cloned() { + return cancel; + } + let cancel = CancellationToken::new(); + self.tokens.insert(progress_id.to_string(), cancel.clone()); + cancel + } + + fn cancel(&mut self, progress_id: &str) { + let cancel = self.tokens.entry(progress_id.to_string()).or_default(); + cancel.cancel(); + } + + fn finish(&mut self, progress_id: &str) { + self.tokens.remove(progress_id); + } +} + +static MEDIA_UPLOAD_CANCELLATIONS: LazyLock> = + LazyLock::new(|| Mutex::new(MediaUploadCancellations::default())); pub(super) fn begin_media_upload(progress_id: Option<&str>) -> Option { let progress_id = progress_id?; - let cancel = CancellationToken::new(); - if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { - uploads.insert(progress_id.to_string(), cancel.clone()); - } - Some(cancel) + MEDIA_UPLOAD_CANCELLATIONS + .lock() + .ok() + .map(|mut uploads| uploads.begin(progress_id)) } pub(super) fn cancel_media_upload(progress_id: &str) { - if let Ok(uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { - if let Some(cancel) = uploads.get(progress_id) { - cancel.cancel(); - } + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.cancel(progress_id); } } @@ -33,7 +55,7 @@ pub(super) fn finish_media_upload(progress_id: Option<&str>) { return; }; if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { - uploads.remove(progress_id); + uploads.finish(progress_id); } } @@ -124,3 +146,85 @@ pub(super) fn emit_media_upload_phase( serde_json::json!({ "id": id, "phase": phase }), ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_before_begin_is_retained() { + let progress_id = format!("cancel-before-begin-{}", uuid::Uuid::new_v4()); + + cancel_media_upload(&progress_id); + let cancellation = begin_media_upload(Some(&progress_id)).expect("cancellation token"); + + assert!(cancellation.is_cancelled()); + finish_media_upload(Some(&progress_id)); + } + + #[test] + fn cancellation_after_begin_reaches_registered_token() { + let progress_id = format!("cancel-after-begin-{}", uuid::Uuid::new_v4()); + let cancellation = begin_media_upload(Some(&progress_id)).expect("cancellation token"); + + cancel_media_upload(&progress_id); + + assert!(cancellation.is_cancelled()); + finish_media_upload(Some(&progress_id)); + } + + #[test] + fn late_cancellation_after_native_finish_is_removed_on_release() { + let mut uploads = MediaUploadCancellations::default(); + let id = "late-cancel"; + + uploads.begin(id); + uploads.finish(id); + uploads.cancel(id); + assert!(uploads.tokens.contains_key(id)); + + uploads.finish(id); + assert!(!uploads.tokens.contains_key(id)); + } + + #[test] + fn repeated_concurrent_cycles_leave_no_registry_entries() { + let mut uploads = MediaUploadCancellations::default(); + let ids = (0..256) + .map(|index| format!("cycle-{index}")) + .collect::>(); + + for id in &ids { + uploads.begin(id); + } + for id in &ids { + uploads.cancel(id); + } + for id in &ids { + uploads.finish(id); + } + + assert!(uploads.tokens.is_empty()); + } + + #[test] + fn dispatched_cancellations_are_not_evicted_before_begin() { + let mut uploads = MediaUploadCancellations::default(); + let ids = (0..129) + .map(|index| format!("dispatched-{index}")) + .collect::>(); + + for id in &ids { + uploads.cancel(id); + } + + let oldest = uploads.begin(&ids[0]); + assert!(oldest.is_cancelled()); + assert_eq!(uploads.tokens.len(), ids.len()); + + for id in &ids { + uploads.finish(id); + } + assert!(uploads.tokens.is_empty()); + } +} diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 528ca387679..7356cd7fc0c 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use sha2::{Digest, Sha256}; use tauri::{AppHandle, Manager, State}; +use super::mesh_readiness::wait_for_mesh_inference; use crate::{app_state::AppState, mesh_llm, relay}; #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] @@ -530,142 +531,6 @@ pub async fn mesh_start_node( Ok(status) } -/// Mesh can bind its HTTP ingress and advertise a model shortly before the -/// router has installed a usable target. Probe the exact chat path agents use -/// so startup cannot race that gap (`single target None unavailable`). -/// Which startup stage a mesh client is stuck at when it never becomes -/// inference-ready. The two live-observed failure modes are physically -/// distinct and want different user copy: -/// -/// * `CatalogNeverSynced` — the local client node came up and connected to -/// the host at the control level (ping/RTT fine), but the served model -/// never appeared in the local `/v1/models` catalog. That catalog is -/// populated by the peer gossip exchange; when the gossip bi-stream can't -/// establish across the network (observed as iroh -/// `MultipathNotNegotiated` / unreachable direct path), the catalog stays -/// empty forever and every request is rejected "model not available". -/// Root cause is the network path between this machine and the host. -/// * `RoutingNeverCompleted` — the model *did* sync into the catalog, but -/// inference requests never completed (routing/transport to the host -/// failing per-request). The host is discoverable and advertised but not -/// actually serving us. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MeshReadinessFailure { - CatalogNeverSynced, - RoutingNeverCompleted, -} - -/// Pure classifier: given whether the served model was ever observed in the -/// local `/v1/models` catalog during the wait, decide which stage failed. -/// Split out so the diagnosis is unit-testable without a live mesh. -fn classify_mesh_readiness_failure(model_ever_visible: bool) -> MeshReadinessFailure { - if model_ever_visible { - MeshReadinessFailure::RoutingNeverCompleted - } else { - MeshReadinessFailure::CatalogNeverSynced - } -} - -/// Actionable, non-technical copy for a readiness failure. `last_detail` is the -/// last raw transport/HTTP error, appended for support triage. -fn mesh_readiness_failure_message( - failure: MeshReadinessFailure, - model_id: &str, - last_detail: &str, -) -> String { - match failure { - MeshReadinessFailure::CatalogNeverSynced => format!( - "Buzz shared compute connected to the serving member but could not sync \ - the model list for \"{model_id}\" — this is a network path problem \ - between this machine and the host (the compute node is reachable for \ - pings but the model-sync stream did not establish). Try again, or have \ - the host and this machine on a more direct network. (last: {last_detail})" - ), - MeshReadinessFailure::RoutingNeverCompleted => format!( - "Buzz shared compute found \"{model_id}\" on a serving member but inference \ - requests did not complete — the host is discoverable but not currently \ - reachable for requests. Try again shortly. (last: {last_detail})" - ), - } -} - -/// Poll the local mesh OpenAI ingress until a real inference for `model_id` -/// succeeds, or a deadline elapses. On failure, returns a stage-specific, -/// actionable message (see [`MeshReadinessFailure`]) rather than a raw -/// `HTTP 429`, so the UI can tell "still warming up" apart from "can't reach -/// the host". -async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|error| format!("failed to build mesh readiness client: {error}"))?; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); - let models_url = format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL); - let chat_url = format!( - "{}/chat/completions", - crate::managed_agents::RELAY_MESH_API_BASE_URL - ); - let mut last_error = "mesh inference is not ready".to_string(); - // Track whether the served model ever reached the local catalog — the - // signal that splits "catalog never synced" from "routing never completed". - let mut model_ever_visible = false; - - while tokio::time::Instant::now() < deadline { - // Refresh catalog visibility. "auto" delegates model choice to the - // router, so any advertised model counts as the catalog having synced. - if let Ok(response) = client - .get(&models_url) - .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) - .send() - .await - { - if let Ok(body) = response.json::().await { - if let Some(data) = body.get("data").and_then(|d| d.as_array()) { - let wanted = model_id.trim().replace("@main", ""); - let visible = !data.is_empty() - && (model_id == crate::mesh_llm::AUTO_MODEL_ID - || data.iter().any(|m| { - m.get("id") - .and_then(|id| id.as_str()) - .map(|id| id.replace("@main", "") == wanted) - .unwrap_or(false) - })); - model_ever_visible |= visible; - } - } - } - - match client - .post(&chat_url) - .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) - .json(&serde_json::json!({ - "model": model_id, - "messages": [{"role": "user", "content": "Reply OK"}], - "max_tokens": 1, - "stream": false - })) - .send() - .await - { - Ok(response) if response.status().is_success() => return Ok(()), - Ok(response) => { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - last_error = format!("HTTP {status}: {body}"); - } - Err(error) => last_error = error.to_string(), - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - - let failure = classify_mesh_readiness_failure(model_ever_visible); - Err(mesh_readiness_failure_message( - failure, - model_id, - &last_error, - )) -} - pub(crate) async fn ensure_client_node_for_model( state: &AppState, model_id: impl AsRef, diff --git a/desktop/src-tauri/src/commands/mesh_llm_tests.rs b/desktop/src-tauri/src/commands/mesh_llm_tests.rs index 26eb1f5fbae..c4e1ae2e425 100644 --- a/desktop/src-tauri/src/commands/mesh_llm_tests.rs +++ b/desktop/src-tauri/src/commands/mesh_llm_tests.rs @@ -178,42 +178,6 @@ fn role_switch_checkpoint_starts_exactly_once_after_restart() { assert_eq!(consumed.relay_url, config.relay_url); } -#[test] -fn readiness_failure_is_catalog_sync_when_model_never_visible() { - assert_eq!( - classify_mesh_readiness_failure(false), - MeshReadinessFailure::CatalogNeverSynced - ); -} - -#[test] -fn readiness_failure_is_routing_when_model_was_visible() { - assert_eq!( - classify_mesh_readiness_failure(true), - MeshReadinessFailure::RoutingNeverCompleted - ); -} - -#[test] -fn readiness_messages_are_distinct_and_actionable() { - let catalog = mesh_readiness_failure_message( - MeshReadinessFailure::CatalogNeverSynced, - "auto", - "HTTP 429", - ); - let routing = mesh_readiness_failure_message( - MeshReadinessFailure::RoutingNeverCompleted, - "auto", - "HTTP 503", - ); - // Distinct diagnoses, each names the model and carries the raw detail. - assert_ne!(catalog, routing); - assert!(catalog.contains("network path")); - assert!(catalog.contains("HTTP 429")); - assert!(routing.contains("did not complete")); - assert!(routing.contains("HTTP 503")); -} - #[test] fn mesh_status_cursor_uses_relay_composite_tiebreak() { let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status") diff --git a/desktop/src-tauri/src/commands/mesh_readiness.rs b/desktop/src-tauri/src/commands/mesh_readiness.rs new file mode 100644 index 00000000000..023695a6f9c --- /dev/null +++ b/desktop/src-tauri/src/commands/mesh_readiness.rs @@ -0,0 +1,251 @@ +//! Startup readiness for Buzz shared compute. +//! +//! Mesh can bind its HTTP ingress and advertise a model shortly before the +//! router has installed a usable target. These helpers probe the exact chat +//! path agents use, so startup cannot race that gap +//! (`single target None unavailable`), and classify a timeout into copy that +//! names the actual stage rather than a raw `HTTP 429`. + +use super::CmdResult; + +/// Which startup stage a mesh client is stuck at when it never becomes +/// inference-ready. The two live-observed failure modes are physically +/// distinct and want different user copy: +/// +/// * `CatalogNeverSynced` — the local client node came up and connected to +/// the host at the control level (ping/RTT fine), but the served model +/// never appeared in the local `/v1/models` catalog. That catalog is +/// populated by the peer gossip exchange; when the gossip bi-stream can't +/// establish across the network (observed as iroh +/// `MultipathNotNegotiated` / unreachable direct path), the catalog stays +/// empty forever and every request is rejected "model not available". +/// Root cause is the network path between this machine and the host. +/// * `RoutingNeverCompleted` — the model *did* sync into the catalog, but +/// inference requests never completed (routing/transport to the host +/// failing per-request). The host is discoverable and advertised but not +/// actually serving us. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeshReadinessFailure { + CatalogNeverSynced, + RoutingNeverCompleted, +} + +/// Pure classifier: given whether the served model was ever observed in the +/// local `/v1/models` catalog during the wait, decide which stage failed. +/// Split out so the diagnosis is unit-testable without a live mesh. +/// Whether the catalog has synced enough to count, for the model actually being +/// requested (a wire name — see `relay_mesh_wire_model`). +/// +/// The virtual `mesh` model delegates the choice to the router, so any +/// advertised model proves the catalog synced. It has to work that way: MeshLLM +/// only advertises `mesh` itself once two non-virtual models are reachable +/// (`should_advertise_virtual_mesh`), so requiring it by name would leave a +/// single-worker mesh looking permanently unsynced and misreport a slow model +/// load as a network path problem. +fn mesh_catalog_shows_model(advertised: &[String], wire_model: &str) -> bool { + if advertised.is_empty() { + return false; + } + if wire_model == crate::managed_agents::RELAY_MESH_VIRTUAL_MODEL_ID { + return true; + } + let wanted = wire_model.trim().replace("@main", ""); + advertised + .iter() + .any(|id| id.replace("@main", "") == wanted) +} + +fn classify_mesh_readiness_failure(model_ever_visible: bool) -> MeshReadinessFailure { + if model_ever_visible { + MeshReadinessFailure::RoutingNeverCompleted + } else { + MeshReadinessFailure::CatalogNeverSynced + } +} + +/// Actionable, non-technical copy for a readiness failure. `last_detail` is the +/// last raw transport/HTTP error, appended for support triage. +fn mesh_readiness_failure_message( + failure: MeshReadinessFailure, + model_id: &str, + last_detail: &str, +) -> String { + match failure { + MeshReadinessFailure::CatalogNeverSynced => format!( + "Buzz shared compute connected to the serving member but could not sync \ + the model list for \"{model_id}\" — this is a network path problem \ + between this machine and the host (the compute node is reachable for \ + pings but the model-sync stream did not establish). Try again, or have \ + the host and this machine on a more direct network. (last: {last_detail})" + ), + MeshReadinessFailure::RoutingNeverCompleted => format!( + "Buzz shared compute found \"{model_id}\" on a serving member but inference \ + requests did not complete — the host is discoverable but not currently \ + reachable for requests. Try again shortly. (last: {last_detail})" + ), + } +} + +/// Poll the local mesh OpenAI ingress until a real inference for `model_id` +/// succeeds, or a deadline elapses. On failure, returns a stage-specific, +/// actionable message (see [`MeshReadinessFailure`]) rather than a raw +/// `HTTP 429`, so the UI can tell "still warming up" apart from "can't reach +/// the host". +pub(crate) async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> { + // Probe the name that will actually be requested. Callers pass a stored + // value, which for shared-compute `auto` is not a model the mesh + // advertises: probing it would validate a route no agent uses, and could + // fail readiness while the real route works. Named models pass through + // unchanged, so this is safe for the serve-side callers too. + let requested_model = model_id; + let model_id = crate::managed_agents::relay_mesh_wire_model(model_id); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| format!("failed to build mesh readiness client: {error}"))?; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); + let models_url = format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL); + let chat_url = format!( + "{}/chat/completions", + crate::managed_agents::RELAY_MESH_API_BASE_URL + ); + let mut last_error = "mesh inference is not ready".to_string(); + // Track whether the served model ever reached the local catalog — the + // signal that splits "catalog never synced" from "routing never completed". + let mut model_ever_visible = false; + + while tokio::time::Instant::now() < deadline { + // Refresh catalog visibility. The virtual `mesh` model delegates the + // choice to the router, so any advertised model counts as the catalog + // having synced — and it must, because MeshLLM only advertises `mesh` + // itself once two non-virtual models are reachable + // (`should_advertise_virtual_mesh`). Requiring it by name would leave a + // single-worker mesh looking permanently unsynced. + if let Ok(response) = client + .get(&models_url) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .send() + .await + { + if let Ok(body) = response.json::().await { + if let Some(data) = body.get("data").and_then(|d| d.as_array()) { + let advertised: Vec = data + .iter() + .filter_map(|m| m.get("id").and_then(|id| id.as_str())) + .map(str::to_owned) + .collect(); + model_ever_visible |= mesh_catalog_shows_model(&advertised, model_id); + } + } + } + + match client + .post(&chat_url) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .json(&serde_json::json!({ + "model": model_id, + "messages": [{"role": "user", "content": "Reply OK"}], + "max_tokens": 1, + "stream": false + })) + .send() + .await + { + Ok(response) if response.status().is_success() => return Ok(()), + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + last_error = format!("HTTP {status}: {body}"); + } + Err(error) => last_error = error.to_string(), + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + + let failure = classify_mesh_readiness_failure(model_ever_visible); + Err(mesh_readiness_failure_message( + failure, + requested_model, + &last_error, + )) +} +#[cfg(test)] +mod tests { + use super::*; + + /// The regression this guards: MeshLLM only advertises the virtual `mesh` model + /// once two non-virtual models are reachable, so a single-worker mesh never + /// lists it by name. Keying visibility on the literal name would report a lone + /// host that is still loading weights as a network path problem. + #[test] + fn virtual_mesh_counts_any_advertised_model_as_a_synced_catalog() { + let one_worker = vec!["unsloth/gemma-4-E4B-it-GGUF:Q4_K_M".to_string()]; + assert!(mesh_catalog_shows_model( + &one_worker, + crate::managed_agents::RELAY_MESH_VIRTUAL_MODEL_ID + )); + } + + #[test] + fn an_empty_catalog_is_never_synced_even_for_the_virtual_model() { + assert!(!mesh_catalog_shows_model( + &[], + crate::managed_agents::RELAY_MESH_VIRTUAL_MODEL_ID + )); + } + + #[test] + fn a_named_model_must_actually_be_advertised() { + let advertised = vec!["unsloth/gemma-4-E4B-it-GGUF:Q4_K_M".to_string()]; + assert!(mesh_catalog_shows_model( + &advertised, + "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" + )); + assert!(!mesh_catalog_shows_model(&advertised, "some/other-model")); + } + + #[test] + fn a_named_model_ignores_the_main_revision_suffix() { + let advertised = vec!["unsloth/gemma-4-E4B-it-GGUF:Q4_K_M@main".to_string()]; + assert!(mesh_catalog_shows_model( + &advertised, + "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" + )); + } + + #[test] + fn readiness_failure_is_catalog_sync_when_model_never_visible() { + assert_eq!( + classify_mesh_readiness_failure(false), + MeshReadinessFailure::CatalogNeverSynced + ); + } + + #[test] + fn readiness_failure_is_routing_when_model_was_visible() { + assert_eq!( + classify_mesh_readiness_failure(true), + MeshReadinessFailure::RoutingNeverCompleted + ); + } + + #[test] + fn readiness_messages_are_distinct_and_actionable() { + let catalog = mesh_readiness_failure_message( + MeshReadinessFailure::CatalogNeverSynced, + "auto", + "HTTP 429", + ); + let routing = mesh_readiness_failure_message( + MeshReadinessFailure::RoutingNeverCompleted, + "auto", + "HTTP 503", + ); + // Distinct diagnoses, each names the model and carries the raw detail. + assert_ne!(catalog, routing); + assert!(catalog.contains("network path")); + assert!(catalog.contains("HTTP 429")); + assert!(routing.contains("did not complete")); + assert!(routing.contains("HTTP 503")); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 7b4b9b785f1..168be1ecf60 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -215,7 +215,7 @@ pub async fn search_messages( until: Option, state: State<'_, AppState>, ) -> Result { - let cap = limit.unwrap_or(20).min(100); + let cap = search_messages_limit(limit); let filter = build_search_messages_filter( &q, cap, @@ -229,6 +229,10 @@ pub async fn search_messages( Ok(nostr_convert::search_response_from_events(&events)) } +fn search_messages_limit(limit: Option) -> u32 { + limit.unwrap_or(20).min(500) +} + /// Fetch the full reply subtree under a thread root, server-side. /// /// Unlike the channel timeline (which the desktop assembles from its local @@ -486,6 +490,7 @@ pub async fn send_channel_message( emoji_tags: Option>>, mention_tags: Option>>, link_preview_tags: Option>>, + sent_from_thread_tag: Option>, mention_pubkeys: Option>, kind: Option, state: State<'_, AppState>, @@ -500,6 +505,9 @@ pub async fn send_channel_message( let link_previews = link_preview_tags.unwrap_or_default(); let relay_base = crate::relay::relay_api_base_url_with_override(&state); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); + if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE { + return Err("sent-from-thread provenance requires a stream message".into()); + } let mut resolved_root: Option = None; @@ -544,6 +552,7 @@ pub async fn send_channel_message( &emoji, &mention_refs_only, &link_previews, + sent_from_thread_tag.as_deref(), &relay_base, )? } @@ -712,6 +721,7 @@ fn build_managed_agent_channel_message( &[], &[], &[], + None, &crate::relay::relay_api_base_url(), client_tags, ) @@ -890,6 +900,10 @@ pub struct EditMessageInput { // tag, so a typo-fix edit never re-wakes existing mentions. #[serde(default)] mention_pubkeys: Vec, + // Full stable mention identity set selected in the edited composer. `None` + // means a partial edit that must preserve the existing snapshot; `Some`, + // including an empty set, authoritatively replaces it. + mention_tags: Option>>, #[serde(default)] suppress_link_previews: bool, } @@ -914,9 +928,12 @@ pub async fn edit_message( channel_uuid, target_eid, trimmed, - &input.media_tags, - &input.emoji_tags, - &mention_refs, + events::MessageEditTags { + media: &input.media_tags, + custom_emoji: &input.emoji_tags, + mentions: &mention_refs, + mention_refs: input.mention_tags.as_deref(), + }, input.suppress_link_previews, )?; submit_event(builder, &state).await?; diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index a907a3dff1d..c0ad03d936b 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -1,5 +1,12 @@ use super::*; +#[test] +fn search_messages_limit_allows_discussion_discovery_page() { + assert_eq!(search_messages_limit(None), 20); + assert_eq!(search_messages_limit(Some(500)), 500); + assert_eq!(search_messages_limit(Some(1_000)), 500); +} + #[test] fn marker_author_scope_validates_scope_and_required_pubkey() { assert_eq!( diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 322834630a3..761bee9cd32 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -25,6 +25,7 @@ mod identity_archive; mod join_policy; mod legacy_storage; mod link_preview; +mod managed_agent_definition; pub(crate) mod media; mod media_animated; mod media_download; @@ -35,6 +36,8 @@ mod media_transcode; mod media_upload_progress; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; +#[cfg(feature = "mesh-llm")] +pub(crate) mod mesh_readiness; mod messages; mod notifications; mod observer_archive; @@ -49,6 +52,7 @@ mod project_git_diff; mod project_git_exec; mod project_git_merge_error; mod project_git_push; +mod project_git_recipient_notes; mod project_git_workflow; mod project_repo_paths; mod project_terminal; @@ -103,6 +107,7 @@ pub use profile::*; pub use project_git::*; pub use project_git_branches::*; pub use project_git_diff::*; +pub use project_git_recipient_notes::*; pub use project_git_workflow::*; pub use project_terminal::*; pub use qr_download::*; diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index c00de1c6da1..944013029b8 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -7,8 +7,8 @@ use uuid::Uuid; use crate::{ app_state::AppState, managed_agents::{ - apply_persona_behavior, load_personas, save_personas, try_regenerate_nest, AgentDefinition, - CatalogSource, CreatePersonaRequest, + apply_persona_behavior, load_personas, save_personas, try_regenerate_nest, + validate_agent_definition_text, AgentDefinition, CatalogSource, CreatePersonaRequest, }, util::now_iso, }; @@ -25,7 +25,10 @@ pub async fn create_persona( let state = app.state::(); let display_name = trim_required(&input.display_name, "Display name")?; // System prompt optional: core memory is auto-injected. Empty is valid. - let system_prompt = input.system_prompt.trim().to_string(); + // Preserve it byte-for-byte: shared/import review surfaces show this + // exact string before the ACP harness executes it. + let system_prompt = input.system_prompt.clone(); + validate_agent_definition_text(&display_name, &system_prompt)?; let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d6..cbb23143533 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -102,12 +102,21 @@ fn reconcile_inbound_persona_event_blocking( // The d-tag identifies the record within its kind. Persona derives it from // the parsed record (`persona_d_tag`); team/agent carry it as the event's - // d-tag directly. The persona is parsed once here and reused in the apply - // branch below — team/agent content is parsed in-branch since their d-tag - // comes from the event tag, not the content. + // d-tag directly. Definition-bearing content is parsed and validated once + // here, before retention, then reused in the apply branch below. This keeps + // an unsafe event out of both the retention database and the local store. let inbound_persona = (kind == KIND_PERSONA) .then(|| persona_from_event(&event)) .transpose()?; + if let Some(persona) = &inbound_persona { + validate_inbound_persona_definition(persona)?; + } + let inbound_managed_agent = (kind == KIND_MANAGED_AGENT) + .then(|| managed_agent_content_from_event(&event)) + .transpose()?; + if let Some(managed_agent) = &inbound_managed_agent { + validate_inbound_managed_agent_definition(managed_agent)?; + } let d_tag = match &inbound_persona { Some(persona) => persona_d_tag(persona), None => event_d_tag(&event)?, @@ -164,11 +173,10 @@ fn reconcile_inbound_persona_event_blocking( } KIND_MANAGED_AGENT => { let mut agents = load_managed_agents(&app)?; - apply_inbound_managed_agent( - &mut agents, - &d_tag, - managed_agent_content_from_event(&event)?, - ); + let managed_agent = inbound_managed_agent.ok_or_else(|| { + "managed-agent content was not parsed before retention".to_string() + })?; + apply_inbound_managed_agent(&mut agents, &d_tag, managed_agent); save_managed_agents(&app, &agents)?; } _ => unreachable!("kind gated above"), @@ -182,6 +190,25 @@ fn reconcile_inbound_persona_event_blocking( Ok(()) } +fn validate_inbound_persona_definition(persona: &AgentDefinition) -> Result<(), String> { + crate::managed_agents::validate_agent_definition_text( + &persona.display_name, + &persona.system_prompt, + ) + .map_err(|error| format!("Inbound persona definition is unsafe: {error}")) +} + +fn validate_inbound_managed_agent_definition( + managed_agent: &ManagedAgentEventContent, +) -> Result<(), String> { + crate::managed_agents::validate_managed_agent_definition_text( + &managed_agent.name, + managed_agent.persona_id.as_deref(), + managed_agent.system_prompt.as_deref(), + ) + .map_err(|error| format!("Inbound managed-agent definition is unsafe: {error}")) +} + /// Parse an inbound wire event and enforce the signature gate. Everything /// downstream trusts `event.pubkey` (ownership routing, tombstone scoping, /// behavioral-quad application), so a forged pubkey must die here — the diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432d..e65973f1493 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -4,7 +4,7 @@ use super::*; use std::collections::BTreeMap; -const UUID: &str = "11111111-2222-3333-4444-555555555555"; +const UUID: &str = "11111111-2222-3333-4444-555555555555"; // sadscan:disable sq.pii.cc.visa -- fixed test UUID /// A local in-app persona: `source_team_persona_slug` is None, so its d-tag /// IS its UUID id. Carries env_vars + source_team that must survive a patch. @@ -673,3 +673,63 @@ fn inbound_gate_accepts_validly_signed_event() { let parsed = parse_verified_inbound_event(&event.as_json()).unwrap(); assert_eq!(parsed.pubkey, keys.public_key()); } + +#[test] +fn inbound_persona_rejects_invisible_definition_text() { + let mut inbound = inbound_for("unsafe", "Remote"); + inbound.system_prompt = "Review\u{200B} code.".to_string(); + + let error = validate_inbound_persona_definition(&inbound) + .expect_err("relay sync must reject invisible instructions"); + + assert!(error.contains("U+200B")); +} + +fn inbound_managed_agent_content( + name: &str, + persona_id: Option<&str>, + system_prompt: Option<&str>, +) -> crate::managed_agents::agent_events::ManagedAgentEventContent { + crate::managed_agents::agent_events::ManagedAgentEventContent { + name: name.to_string(), + persona_id: persona_id.map(str::to_string), + system_prompt: system_prompt.map(str::to_string), + model: None, + provider: None, + persona_source_version: None, + parallelism: 1, + respond_to: crate::managed_agents::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + } +} + +#[test] +fn inbound_definition_less_agent_rejects_invisible_prompt() { + let inbound = inbound_managed_agent_content("Remote Agent", None, Some("Review\u{200B} code.")); + + let error = validate_inbound_managed_agent_definition(&inbound) + .expect_err("definition-less sync must reject invisible instructions"); + + assert!(error.contains("U+200B")); +} + +#[test] +fn inbound_managed_agent_rejects_bidirectional_name() { + let inbound = inbound_managed_agent_content("Remote\u{202E} Agent", None, None); + + let error = validate_inbound_managed_agent_definition(&inbound) + .expect_err("managed-agent sync must reject bidirectional names"); + + assert!(error.contains("U+202E")); +} + +#[test] +fn inbound_definition_less_agent_accepts_visible_multiline_prompt() { + let inbound = inbound_managed_agent_content( + "Remote Agent", + None, + Some("Review code.\n\tCall out security risks."), + ); + + assert!(validate_inbound_managed_agent_definition(&inbound).is_ok()); +} diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 0cd7ad03247..3be24d04131 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -237,7 +237,7 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { // Remove nsec from keyring after the record is gone. delete_agent_key(pk); super::agents::tombstone_managed_agent_pending(&app, &state, pk); - super::agents::archive_managed_agent_pending(&app, &state, pk); + super::agents::archive_managed_agent_pending(&app, &state, pk, Some(&id)); } tombstone_persona_pending(&app, &state, &d_tag); diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index cab5fababcd..89f2d1519ec 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -165,6 +165,12 @@ pub(super) fn prepare_persona_publication_at( let mut scoped_persona = persona.clone(); scoped_persona.shared = shared_override.unwrap_or_else(|| retained_persona_is_shared(existing.as_ref())); + if scoped_persona.shared { + crate::managed_agents::validate_agent_definition_text( + &scoped_persona.display_name, + &scoped_persona.system_prompt, + )?; + } let event = build_persona_event(&scoped_persona)? .custom_created_at(monotonic_created_at( existing.as_ref().map(|row| row.created_at), @@ -396,4 +402,18 @@ mod tests { .expect_err("a directory cannot be opened as the retention database"); assert!(error.contains("failed to open retention db")); } + + #[test] + fn shared_publication_rejects_invisible_definition_text() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let db_path = dir.path().join("retention.sqlite3"); + let mut unsafe_persona = persona(); + unsafe_persona.system_prompt = "Review\u{200B} the catalog.".to_string(); + + let error = prepare_persona_publication_at(&db_path, &keys, &unsafe_persona, Some(true)) + .expect_err("sharing must reject an invisible instruction character"); + + assert!(error.contains("U+200B")); + } } diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index ed2472d54ea..b3830e62b52 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -9,7 +9,7 @@ use crate::{ managed_agents::{ apply_persona_behavior, effective_agent_command, load_managed_agents, load_personas, managed_agent_avatar_url, save_managed_agents, save_personas, try_regenerate_nest, - AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest, + validate_agent_definition_text, AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest, }, util::now_iso, }; @@ -91,6 +91,7 @@ pub(super) async fn update_persona_with( let state = app.state::(); let display_name = trim_required(&input.display_name, "Display name")?; let system_prompt = input.system_prompt.clone(); + validate_agent_definition_text(&display_name, &system_prompt)?; let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); diff --git a/desktop/src-tauri/src/commands/project_git_recipient_notes.rs b/desktop/src-tauri/src/commands/project_git_recipient_notes.rs new file mode 100644 index 00000000000..4695749fed7 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_recipient_notes.rs @@ -0,0 +1,430 @@ +//! Labeled recipient notes for the Projects workflow: kind:1 comments whose +//! `p` tags name recipients on a root event. Pull-request review requests +//! (`t: review-request`) and issue assignments (`t: assignment`) share this +//! shape so clients can parse them with one code path. + +use super::project_git_workflow::{ + normalize_event_id, project_owner_identity, validate_repo_address, +}; +use crate::app_state::AppState; +use crate::relay::submit_signed_event_with_keys; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use serde::Deserialize; +use tauri::{AppHandle, State}; + +/// Repository-scoped metadata for an agent-signed review request. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPullRequestReviewRequestInput { + target_owner: String, + repo_address: String, + pull_request_id: String, + reviewers: Vec, + reviewer_label: String, +} + +/// Repository-scoped metadata for an agent-signed issue assignee operation. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectIssueAssigneeOperationInput { + target_owner: String, + repo_address: String, + issue_id: String, + assignees: Vec, + assignee_label: String, + created_at: u64, +} + +#[derive(Clone, Copy)] +enum IssueAssigneeOperation { + Assign, + Unassign, +} + +impl IssueAssigneeOperation { + fn label(self) -> &'static str { + match self { + Self::Assign => "assignment", + Self::Unassign => "unassignment", + } + } + + fn content(self, assignee_label: &str) -> String { + match self { + Self::Assign => format!("Assigned this issue to {assignee_label}"), + Self::Unassign => format!("Unassigned {assignee_label} from this issue"), + } + } +} + +/// Parameters for [`build_labeled_recipient_note_event`]. +struct LabeledRecipientNote<'a> { + repo_address: &'a str, + root_id: &'a str, + root_id_error: &'a str, + recipients: &'a [String], + recipient_noun: &'a str, + label: &'a str, + content: String, + created_at: Option, +} + +/// Shared builder for labeled kind:1 notes tagging recipients (`p`) on a +/// root event — the convention used by both PR review requests +/// (`t: review-request`) and issue assignments (`t: assignment`). +fn build_labeled_recipient_note_event( + keys: &Keys, + note: LabeledRecipientNote<'_>, +) -> Result { + let LabeledRecipientNote { + repo_address, + root_id, + root_id_error, + recipients, + recipient_noun, + label, + content, + created_at, + } = note; + let owner = keys.public_key().to_hex(); + validate_repo_address(repo_address, &owner)?; + let root_id = normalize_event_id(root_id).ok_or_else(|| root_id_error.to_string())?; + if recipients.is_empty() || recipients.len() > 50 { + return Err(format!("Select between 1 and 50 {recipient_noun}s.")); + } + let mut recipients = recipients + .iter() + .map(|recipient| { + normalize_event_id(recipient).ok_or_else(|| format!("Invalid {recipient_noun} pubkey.")) + }) + .collect::, _>>()?; + recipients.sort(); + recipients.dedup(); + + let mut raw_tags = vec![ + vec!["e".to_string(), root_id, String::new(), "root".to_string()], + vec!["a".to_string(), repo_address.to_string()], + ]; + raw_tags.extend( + recipients + .into_iter() + .map(|recipient| vec!["p".to_string(), recipient]), + ); + raw_tags.push(vec!["t".to_string(), label.to_string()]); + let tags = raw_tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|error| format!("build {label} tags: {error}"))?; + let mut builder = EventBuilder::new(Kind::TextNote, content).tags(tags); + if let Some(created_at) = created_at { + builder = builder.custom_created_at(Timestamp::from_secs(created_at)); + } + builder + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign {label} note: {error}")) +} + +fn build_review_request_event( + keys: &Keys, + repo_address: &str, + pull_request_id: &str, + reviewers: &[String], + reviewer_label: &str, +) -> Result { + let reviewer_label = reviewer_label.trim(); + if reviewer_label.is_empty() || reviewer_label.chars().count() > 128 { + return Err("Reviewer label must be between 1 and 128 characters.".to_string()); + } + build_labeled_recipient_note_event( + keys, + LabeledRecipientNote { + repo_address, + root_id: pull_request_id, + root_id_error: "Invalid pull request event ID.", + recipients: reviewers, + recipient_noun: "reviewer", + label: "review-request", + content: format!("Requested a review from {reviewer_label}"), + created_at: None, + }, + ) +} + +#[cfg(test)] +fn build_issue_assignment_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + assignees: &[String], + assignee_label: &str, + created_at: Option, +) -> Result { + build_issue_assignee_operation_event( + keys, + repo_address, + issue_id, + assignees, + assignee_label, + created_at, + IssueAssigneeOperation::Assign, + ) +} + +#[cfg(test)] +fn build_issue_unassignment_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + assignees: &[String], + assignee_label: &str, + created_at: Option, +) -> Result { + build_issue_assignee_operation_event( + keys, + repo_address, + issue_id, + assignees, + assignee_label, + created_at, + IssueAssigneeOperation::Unassign, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_issue_assignee_operation_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + assignees: &[String], + assignee_label: &str, + created_at: Option, + operation: IssueAssigneeOperation, +) -> Result { + let assignee_label = assignee_label.trim(); + if assignee_label.is_empty() || assignee_label.chars().count() > 128 { + return Err("Assignee label must be between 1 and 128 characters.".to_string()); + } + build_labeled_recipient_note_event( + keys, + LabeledRecipientNote { + repo_address, + root_id: issue_id, + root_id_error: "Invalid issue event ID.", + recipients: assignees, + recipient_noun: "assignee", + label: operation.label(), + content: operation.content(assignee_label), + created_at, + }, + ) +} + +#[tauri::command] +pub async fn sign_project_pull_request_review_request( + input: ProjectPullRequestReviewRequestInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let event = Event::from_json(build_review_request_event( + &identity.keys, + &input.repo_address, + &input.pull_request_id, + &input.reviewers, + &input.reviewer_label, + )?) + .map_err(|error| format!("parse signed review request: {error}"))?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + +#[tauri::command] +pub async fn sign_project_issue_assignment( + input: ProjectIssueAssigneeOperationInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + sign_project_issue_assignee_operation(input, IssueAssigneeOperation::Assign, app, state).await +} + +#[tauri::command] +pub async fn sign_project_issue_unassignment( + input: ProjectIssueAssigneeOperationInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + sign_project_issue_assignee_operation(input, IssueAssigneeOperation::Unassign, app, state).await +} + +async fn sign_project_issue_assignee_operation( + input: ProjectIssueAssigneeOperationInput, + operation: IssueAssigneeOperation, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let event = Event::from_json(build_issue_assignee_operation_event( + &identity.keys, + &input.repo_address, + &input.issue_id, + &input.assignees, + &input.assignee_label, + Some(input.created_at), + operation, + )?) + .map_err(|error| format!("parse signed issue {}: {error}", operation.label()))?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + build_issue_assignment_event, build_issue_unassignment_event, build_review_request_event, + }; + use nostr::{Event, JsonUtil, Keys}; + + #[test] + fn issue_assignment_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let assignee = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_issue_assignment_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&assignee), + "Bob", + None, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind, nostr::Kind::TextNote); + assert_eq!(event.content, "Assigned this issue to Bob"); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", assignee.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "assignment"])); + assert!(event.verify().is_ok()); + } + + #[test] + fn issue_assignment_rejects_invalid_metadata() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let repo_address = format!("30617:{owner}:buzz"); + + assert!(build_issue_assignment_event( + &keys, + &repo_address, + &"d".repeat(64), + &[], + "Bob", + None, + ) + .is_err()); + assert!(build_issue_assignment_event( + &keys, + &repo_address, + &"d".repeat(64), + &["b".repeat(64)], + " ", + None, + ) + .is_err()); + assert!(build_issue_assignment_event( + &keys, + &repo_address, + "not-an-event-id", + &["b".repeat(64)], + "Bob", + None, + ) + .is_err()); + } + + #[test] + fn issue_unassignment_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let assignee = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_issue_unassignment_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&assignee), + "Bob", + Some(123), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.content, "Unassigned Bob from this issue"); + assert_eq!(event.created_at.as_secs(), 123); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", assignee.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "unassignment"])); + assert!(event.verify().is_ok()); + } + + #[test] + fn review_request_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let reviewer = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_review_request_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&reviewer), + "Bob", + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind, nostr::Kind::TextNote); + assert_eq!(event.content, "Requested a review from Bob"); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", reviewer.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "review-request"])); + assert!(event.verify().is_ok()); + } +} diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 9e06852762b..2784068c7ca 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -59,17 +59,6 @@ pub struct ProjectPullRequestMergeInput { expected_commit: String, } -/// Repository-scoped metadata for an agent-signed review request. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectPullRequestReviewRequestInput { - target_owner: String, - repo_address: String, - pull_request_id: String, - reviewers: Vec, - reviewer_label: String, -} - /// Repository-scoped metadata for an agent-signed lifecycle status. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -90,21 +79,41 @@ pub struct ProjectPullRequestMergedStatusInput { status_event: String, } +/// A project or repository announcement signed by its direct or managed owner. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectOwnerAnnouncementInput { + target_owner: String, + kind: u16, + content: String, + created_at: Option, + tags: Vec>, +} + +/// Signed announcement plus any relay publication failure for recovery. +#[derive(Serialize)] +pub struct ProjectOwnerAnnouncementResult { + /// Serialized signed Nostr event. + event: String, + /// Relay error when signing succeeded but publication did not. + publication_error: Option, +} + fn normalize_commit(value: &str) -> Option { clean_commit(Some(value.trim().to_ascii_lowercase())) } -fn normalize_event_id(value: &str) -> Option { +pub(crate) fn normalize_event_id(value: &str) -> Option { let value = value.trim().to_ascii_lowercase(); (value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())).then_some(value) } -struct ProjectOwnerIdentity { - keys: Keys, - auth_tag: Option, +pub(crate) struct ProjectOwnerIdentity { + pub(crate) keys: Keys, + pub(crate) auth_tag: Option, } -fn project_owner_identity( +pub(crate) fn project_owner_identity( app: &AppHandle, state: &AppState, target_owner: &str, @@ -126,7 +135,7 @@ fn project_owner_identity( .iter() .find(|record| record.pubkey.eq_ignore_ascii_case(target_owner)) .ok_or_else(|| { - "Only the repository owner or the owner of its managed agent can merge pull requests." + "Only the owner identity or the owner of its managed agent can perform this action." .to_string() })?; if let Some(error) = spawn_key_refusal(record) { @@ -143,7 +152,7 @@ fn project_owner_identity( }) } -fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> { +pub(crate) fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> { let prefix = format!("30617:{owner}:"); if repo_address.strip_prefix(&prefix).is_none_or(str::is_empty) { return Err("Repository address does not match the repository owner.".to_string()); @@ -151,6 +160,67 @@ fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> Ok(()) } +fn validate_project_owner_announcement( + input: &ProjectOwnerAnnouncementInput, +) -> Result<(), String> { + if !matches!(input.kind, 30_617 | 30_621) { + return Err("Only project and repository announcements can be signed here.".to_string()); + } + let has_valid_d_tag = input.tags.iter().any(|tag| { + tag.first().is_some_and(|value| value == "d") + && tag.get(1).is_some_and(|value| !value.trim().is_empty()) + }); + if !has_valid_d_tag { + return Err("Project and repository announcements require a non-empty d tag.".to_string()); + } + if let Some(created_at) = input.created_at { + // Mirror the ACP publish path (`build_project_owner_announcement_events`): + // these are addressable events where the latest created_at wins, so a + // far-future timestamp would wedge the head until that time. Reject + // anything more than 5 minutes ahead. + if created_at > Timestamp::now().as_secs().saturating_add(300) { + return Err("Announcement timestamp is too far in the future.".to_string()); + } + } + Ok(()) +} + +/// Sign and publish an addressable project event as a direct or managed owner. +#[tauri::command] +pub async fn publish_project_owner_announcement( + input: ProjectOwnerAnnouncementInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + validate_project_owner_announcement(&input)?; + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid project owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let nostr_tags = input + .tags + .into_iter() + .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}"))) + .collect::, _>>()?; + let mut builder = EventBuilder::new(Kind::Custom(input.kind), input.content).tags(nostr_tags); + if let Some(created_at) = input.created_at { + builder = builder.custom_created_at(Timestamp::from(created_at)); + } + let event = builder + .sign_with_keys(&identity.keys) + .map_err(|error| format!("sign failed: {error}"))?; + let publication_error = + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await + .err(); + + Ok(ProjectOwnerAnnouncementResult { + event: event.as_json(), + publication_error, + }) +} + fn validate_merge_status_metadata( repo_address: &str, owner: &str, @@ -243,63 +313,6 @@ fn build_pull_request_status_event( .map_err(|error| format!("sign pull request status: {error}")) } -fn build_review_request_event( - keys: &Keys, - repo_address: &str, - pull_request_id: &str, - reviewers: &[String], - reviewer_label: &str, -) -> Result { - let owner = keys.public_key().to_hex(); - validate_repo_address(repo_address, &owner)?; - let pull_request_id = normalize_event_id(pull_request_id) - .ok_or_else(|| "Invalid pull request event ID.".to_string())?; - if reviewers.is_empty() || reviewers.len() > 50 { - return Err("Select between 1 and 50 reviewers.".to_string()); - } - let mut reviewers = reviewers - .iter() - .map(|reviewer| { - normalize_event_id(reviewer).ok_or_else(|| "Invalid reviewer pubkey.".to_string()) - }) - .collect::, _>>()?; - reviewers.sort(); - reviewers.dedup(); - let reviewer_label = reviewer_label.trim(); - if reviewer_label.is_empty() || reviewer_label.chars().count() > 128 { - return Err("Reviewer label must be between 1 and 128 characters.".to_string()); - } - - let mut raw_tags = vec![ - vec![ - "e".to_string(), - pull_request_id, - String::new(), - "root".to_string(), - ], - vec!["a".to_string(), repo_address.to_string()], - ]; - raw_tags.extend( - reviewers - .into_iter() - .map(|reviewer| vec!["p".to_string(), reviewer]), - ); - raw_tags.push(vec!["t".to_string(), "review-request".to_string()]); - let tags = raw_tags - .into_iter() - .map(Tag::parse) - .collect::, _>>() - .map_err(|error| format!("build review request tags: {error}"))?; - EventBuilder::new( - Kind::TextNote, - format!("Requested a review from {reviewer_label}"), - ) - .tags(tags) - .sign_with_keys(keys) - .map(|event| event.as_json()) - .map_err(|error| format!("sign pull request review request: {error}")) -} - fn same_repository(left: &str, right: &str) -> bool { left.trim() .trim_end_matches('/') @@ -452,30 +465,6 @@ pub async fn sign_project_pull_request_status( Ok(()) } -#[tauri::command] -pub async fn sign_project_pull_request_review_request( - input: ProjectPullRequestReviewRequestInput, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - let target_owner = input.target_owner.trim().to_ascii_lowercase(); - if normalize_event_id(&target_owner).is_none() { - return Err("Invalid target repository owner.".to_string()); - } - let identity = project_owner_identity(&app, &state, &target_owner)?; - let event = Event::from_json(build_review_request_event( - &identity.keys, - &input.repo_address, - &input.pull_request_id, - &input.reviewers, - &input.reviewer_label, - )?) - .map_err(|error| format!("parse signed review request: {error}"))?; - submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) - .await?; - Ok(()) -} - #[tauri::command] pub async fn publish_project_pull_request_merged_status( input: ProjectPullRequestMergedStatusInput, @@ -683,8 +672,8 @@ pub async fn merge_project_pull_request( mod tests { use super::{ align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event, - build_review_request_event, normalize_commit, same_repository, - validate_merge_status_metadata, + normalize_commit, same_repository, validate_merge_status_metadata, + validate_project_owner_announcement, ProjectOwnerAnnouncementInput, }; use crate::commands::project_git_exec::{build_test_git_auth_config, run_git}; use nostr::{Event, JsonUtil, Keys, Timestamp}; @@ -717,6 +706,62 @@ mod tests { assert_eq!(normalize_commit(&"z".repeat(40)), None); } + #[test] + fn project_owner_announcement_is_limited_to_addressable_project_kinds() { + let valid = ProjectOwnerAnnouncementInput { + target_owner: "a".repeat(64), + kind: 30_621, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }; + assert!(validate_project_owner_announcement(&valid).is_ok()); + + let invalid_kind = ProjectOwnerAnnouncementInput { kind: 1, ..valid }; + assert_eq!( + validate_project_owner_announcement(&invalid_kind), + Err("Only project and repository announcements can be signed here.".to_string()) + ); + } + + #[test] + fn project_owner_announcement_requires_an_address() { + let input = ProjectOwnerAnnouncementInput { + target_owner: "a".repeat(64), + kind: 30_617, + content: String::new(), + created_at: None, + tags: vec![vec!["name".to_string(), "buzz".to_string()]], + }; + assert_eq!( + validate_project_owner_announcement(&input), + Err("Project and repository announcements require a non-empty d tag.".to_string()) + ); + } + + #[test] + fn project_owner_announcement_rejects_far_future_timestamps() { + // Mirrors the ACP path's +300s cap: an addressable head stamped far in + // the future could not be superseded until that time. + let base = ProjectOwnerAnnouncementInput { + target_owner: "a".repeat(64), + kind: 30_621, + content: String::new(), + created_at: Some(Timestamp::now().as_secs() + 200), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }; + assert!(validate_project_owner_announcement(&base).is_ok()); + + let far_future = ProjectOwnerAnnouncementInput { + created_at: Some(Timestamp::now().as_secs() + 301), + ..base + }; + assert_eq!( + validate_project_owner_announcement(&far_future), + Err("Announcement timestamp is too far in the future.".to_string()) + ); + } + #[test] fn repository_comparison_normalizes_git_suffix_and_trailing_slash() { assert!(same_repository( @@ -850,36 +895,4 @@ mod tests { ) .is_err()); } - - #[test] - fn review_request_is_signed_by_repository_owner() { - let keys = Keys::generate(); - let owner = keys.public_key().to_hex(); - let reviewer = "b".repeat(64); - let repo_address = format!("30617:{owner}:buzz"); - let event = Event::from_json( - build_review_request_event( - &keys, - &repo_address, - &"d".repeat(64), - std::slice::from_ref(&reviewer), - "Bob", - ) - .unwrap(), - ) - .unwrap(); - - assert_eq!(event.pubkey, keys.public_key()); - assert_eq!(event.kind, nostr::Kind::TextNote); - assert_eq!(event.content, "Requested a review from Bob"); - assert!(event - .tags - .iter() - .any(|tag| tag.as_slice() == ["p", reviewer.as_str()])); - assert!(event - .tags - .iter() - .any(|tag| tag.as_slice() == ["t", "review-request"])); - assert!(event.verify().is_ok()); - } } diff --git a/desktop/src-tauri/src/commands/window_vibrancy.rs b/desktop/src-tauri/src/commands/window_vibrancy.rs index 5eb1f16b8b9..39dcef3c6e5 100644 --- a/desktop/src-tauri/src/commands/window_vibrancy.rs +++ b/desktop/src-tauri/src/commands/window_vibrancy.rs @@ -1,15 +1,29 @@ //! Runtime macOS window vibrancy (blur-behind) toggle. //! +//! **Invariant:** the main window is created opaque (`tauri.conf.json` +//! `transparent: false`) and the NSWindow is never made transparent at runtime. +//! Behind-window vibrancy renders correctly inside opaque windows — this is +//! exactly how Finder and Notes render vibrant sidebars — so glass only requires +//! runtime webview-canvas transparency, which this command sets on the enable +//! path. When glass is disabled the webview canvas may remain non-drawing +//! (wry's `drawsBackground` flag is one-way at runtime), but that is harmless: +//! glass-off CSS paints the full background opaque and the always-opaque NSWindow +//! is beneath it. +//! +//! Why not `transparent: true`? A creation-time transparent window causes tao to +//! call `NSWindow.setOpaque(false)` and `setBackgroundColor(clearColor)`. The +//! runtime `Window::set_background_color(None)` then resolves `None` to +//! `clearColor` instead of the opaque system default — and there is no runtime +//! `setOpaque(true)` path through tauri — leaving the compositor blending the +//! whole window even with glass off. +//! //! Vibrancy applies an `NSVisualEffectView` behind the webview so the desktop -//! (and windows behind Buzz) blur through wherever the app's CSS is +//! (and windows behind Buzz) blurs through wherever the WKWebView canvas is //! transparent. It is a native, macOS-only effect: there is no "intensity" //! setting at the OS level, only a set of material presets. The frontend tunes -//! perceived intensity by changing CSS surface opacity while this command -//! handles the native material. -//! -//! This is fully reversible at runtime: enabling applies the chosen material, -//! disabling clears it. On non-macOS platforms the command is a no-op so the -//! shared frontend can call it unconditionally. +//! perceived intensity by adjusting CSS surface opacity while this command +//! handles the native material. On non-macOS platforms the command is a no-op +//! so the shared frontend can call it unconditionally. #[cfg(target_os = "macos")] use tauri::Manager; @@ -35,6 +49,16 @@ pub fn set_window_vibrancy( .ok_or_else(|| "main window not found".to_string())?; if !enabled { + // The NSWindow layer is permanently opaque, so no window-layer + // reset is needed here. Skipping `set_background_color(None)` at + // the webview layer also avoids tauri mapping `None` to opaque + // white, which would still force `drawsBackground=false` on the + // WKWebView (counterproductive). After a glass session the webview + // canvas may stay non-drawing — wry's `drawsBackground` flag is + // one-way at runtime — but that is harmless: glass-off CSS paints + // the full background opaque and the always-opaque NSWindow is + // beneath it. If `clear_vibrancy` fails, the opaque CSS already + // covers everything, so no see-through state is reachable. clear_vibrancy(&window).map_err(|e| e.to_string())?; return Ok(()); } @@ -58,7 +82,22 @@ pub fn set_window_vibrancy( // clear is a no-op (returns `false`) when none is present. let _ = clear_vibrancy(&window); + // Install the blur layer first: a failure of the canvas write leaves + // the window with vibrancy behind an opaque webview, not a see-through + // one. Either mixed state self-corrects on the next toggle. apply_vibrancy(&window, material, None, None).map_err(|e| e.to_string())?; + + // Make only the WKWebView canvas transparent so native vibrancy shows + // through; the NSWindow layer stays opaque by design. Targeting the + // webview layer directly (via `AsRef`) avoids the + // `WebviewWindow::set_background_color` path, which also writes the + // NSWindow layer. Must follow `apply_vibrancy` so the blur layer is + // present before the canvas becomes see-through. + let webview: &tauri::Webview<_> = window.as_ref(); + webview + .set_background_color(Some(tauri::window::Color(0, 0, 0, 0))) + .map_err(|e| e.to_string())?; + Ok(()) } diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index 1d5f309fb5c..25e02980fa7 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -5,7 +5,7 @@ use tauri::State; use crate::{ app_state::AppState, events, - relay::{parse_command_response, query_relay, submit_event}, + relay::{get_relay_json, parse_command_response, query_relay, submit_event}, }; // ── Wire shapes (snake_case, consumed by tauriWorkflows.ts) ────────────────── @@ -47,6 +47,41 @@ pub struct WorkflowSaveWire { pub webhook_secret: Option, } +#[derive(Debug, Clone, serde::Deserialize, Serialize, PartialEq)] +pub struct WorkflowRunCursorWire { + pub before: String, + pub before_id: String, +} + +#[derive(Debug, Clone, serde::Deserialize, Serialize, PartialEq)] +pub struct WorkflowRunsWire { + pub runs: Vec, + pub next: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, Serialize, PartialEq)] +pub struct WorkflowApprovalsWire { + pub approvals: Vec, +} + +/// Canonical trigger acknowledgement consumed by the Desktop client. +/// +/// The relay currently returns only `run_id`; the workflow id is the command +/// input and a newly-created run always begins pending. Keeping that adaptation +/// here prevents the frontend from guessing fields or confusing the trigger +/// event id with the persisted run id. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct WorkflowTriggerWire { + pub run_id: String, + pub workflow_id: String, + pub status: String, +} + +#[derive(Debug, serde::Deserialize)] +struct WorkflowTriggerAck { + run_id: String, +} + // ── Reads ──────────────────────────────────────────────────────────────────── #[tauri::command] @@ -121,26 +156,16 @@ pub async fn get_workflow( pub async fn get_workflow_runs( workflow_id: String, limit: Option, - _state: State<'_, AppState>, -) -> Result, String> { - // TODO(workflow-runs): Run reconstruction is a clearly-scoped follow-up. - // The authoritative run record the frontend's `WorkflowRun` shape needs - // (status / current_step / execution_trace / error_message) lives in the - // relay DB and is not exposed to the desktop client as a single queryable - // record. If the relay starts emitting lifecycle events (46001–46007, …), - // folding that stream into `WorkflowRun` would be another viable design. - // The important bit for this command is that raw lifecycle events are not - // the `RawWorkflowRun` contract. - // - // Until then we return a bare empty array — NOT a raw-event wrapper. The - // frontend wrapper (`getWorkflowRuns`) does `raw.map(fromRawWorkflowRun)`, - // so it must receive an array; the wrapped `{ runs: [...] }` shape would - // make `.map()` throw and crash the detail panel (the same TypeError class - // as the original page bug). Raw lifecycle events also don't carry the - // `id`/`workflow_id`/`status`/… fields `RawWorkflowRun` expects, so an - // empty list is the honest, safe placeholder. - let _ = (workflow_id, limit); - Ok(Vec::new()) + state: State<'_, AppState>, +) -> Result { + let workflow_id = + uuid::Uuid::parse_str(&workflow_id).map_err(|_| "invalid workflow id".to_string())?; + let limit = limit.unwrap_or(20).clamp(1, 100); + get_relay_json( + &state, + &format!("/workflows/{workflow_id}/runs?limit={limit}"), + ) + .await } // ── Writes ─────────────────────────────────────────────────────────────────── @@ -242,10 +267,10 @@ pub async fn delete_workflow( pub async fn trigger_workflow( workflow_id: String, state: State<'_, AppState>, -) -> Result { +) -> Result { let builder = events::build_workflow_trigger(&workflow_id)?; let result = submit_event(builder, &state).await?; - Ok(serde_json::json!({ "event_id": result.event_id })) + trigger_wire_from_message(workflow_id, &result.message) } // ── Approvals ──────────────────────────────────────────────────────────────── @@ -254,15 +279,17 @@ pub async fn trigger_workflow( pub async fn get_run_approvals( workflow_id: String, run_id: String, - _state: State<'_, AppState>, -) -> Result, String> { - // TODO(workflow-runs): Like runs (see `get_workflow_runs`), reconstructing - // approvals into the frontend's `WorkflowApproval` shape from lifecycle - // events (46010/46011/46012) is a clearly-scoped follow-up tracked under - // TODO(workflow-runs). Return a bare empty array so the frontend's - // `getRunApprovals` (`raw.map(fromRawApproval)`) is safe. - let _ = (workflow_id, run_id); - Ok(Vec::new()) + state: State<'_, AppState>, +) -> Result { + let workflow_id = + uuid::Uuid::parse_str(&workflow_id).map_err(|_| "invalid workflow id".to_string())?; + let run_id = + uuid::Uuid::parse_str(&run_id).map_err(|_| "invalid workflow run id".to_string())?; + get_relay_json( + &state, + &format!("/workflows/{workflow_id}/runs/{run_id}/approvals"), + ) + .await } #[tauri::command] @@ -289,6 +316,21 @@ pub async fn deny_approval( // ── Helpers (pure, unit-tested in workflows_tests.rs) ───────────────────────── +fn trigger_wire_from_message( + workflow_id: String, + message: &str, +) -> Result { + let ack: WorkflowTriggerAck = parse_command_response(message)?; + if ack.run_id.trim().is_empty() { + return Err("workflow trigger response contained an empty run_id".to_string()); + } + Ok(WorkflowTriggerWire { + run_id: ack.run_id, + workflow_id, + status: "pending".to_string(), + }) +} + fn current_pubkey_hex(state: &AppState) -> Result { let keys = state.keys.lock().map_err(|e| e.to_string())?; Ok(keys.public_key().to_hex()) diff --git a/desktop/src-tauri/src/commands/workflows_tests.rs b/desktop/src-tauri/src/commands/workflows_tests.rs index f07f4b0f421..647cc687064 100644 --- a/desktop/src-tauri/src/commands/workflows_tests.rs +++ b/desktop/src-tauri/src/commands/workflows_tests.rs @@ -189,21 +189,41 @@ fn workflow_wire_serializes_with_snake_case_keys() { } #[test] -fn runs_and_approvals_serialize_to_bare_empty_array() { - // Regression guard for the crash class this fix closed. The frontend - // wrappers `getWorkflowRuns` / `getRunApprovals` do `raw.map(...)`, so the - // Rust side MUST return a bare JSON array. A wrapped `{ runs: [...] }` / - // `{ approvals: [...] }` shape would make `.map()` throw and crash the - // detail panel — the same TypeError class as the original page bug. - // - // The commands take `State`, so we can't invoke them directly in - // a unit test; instead we pin the exact value they return (`Vec::new()` of - // their `Vec` element type) and assert its serialized shape. - let runs: Vec = Vec::new(); - let approvals: Vec = Vec::new(); - assert_eq!(serde_json::to_string(&runs).expect("serialize runs"), "[]"); +fn trigger_response_uses_persisted_run_id_contract() { + let wire = trigger_wire_from_message( + WF.to_string(), + "response:{\"run_id\":\"33333333-3333-3333-3333-333333333333\"}", + ) + .expect("parse trigger response"); + + assert_eq!(wire.run_id, "33333333-3333-3333-3333-333333333333"); + assert_eq!(wire.workflow_id, WF); + assert_eq!(wire.status, "pending"); + let value = serde_json::to_value(wire).expect("serialize trigger response"); + assert!(value.get("event_id").is_none()); +} + +#[test] +fn trigger_response_rejects_missing_or_empty_run_id() { + assert!(trigger_wire_from_message(WF.to_string(), "response:{}").is_err()); + assert!(trigger_wire_from_message(WF.to_string(), "response:{\"run_id\":\" \"}",).is_err()); +} + +#[test] +fn run_reads_serialize_to_backend_envelopes() { + let runs = WorkflowRunsWire { + runs: Vec::new(), + next: None, + }; + let approvals = WorkflowApprovalsWire { + approvals: Vec::new(), + }; + assert_eq!( + serde_json::to_value(runs).expect("serialize runs"), + serde_json::json!({ "runs": [], "next": null }) + ); assert_eq!( - serde_json::to_string(&approvals).expect("serialize approvals"), - "[]" + serde_json::to_value(approvals).expect("serialize approvals"), + serde_json::json!({ "approvals": [] }) ); } diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc367..9b4a01b1c70 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -20,6 +20,79 @@ pub(crate) struct PendingCommunityDeepLink { #[derive(Default)] pub(crate) struct PendingCommunityDeepLinks(Mutex>); +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingNavigationDeepLink { + id: String, + kind: String, + channel_id: String, + message_id: Option, + thread_root_id: Option, +} + +#[derive(Default)] +pub(crate) struct PendingNavigationDeepLinks(Mutex>); + +impl PendingNavigationDeepLinks { + fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque> { + self.0.lock().unwrap_or_else(|poisoned| { + eprintln!("buzz-desktop: recovering poisoned pending navigation deep-link queue"); + poisoned.into_inner() + }) + } + + fn enqueue(&self, pending: PendingNavigationDeepLink) { + let mut queue = self.lock(); + if queue.iter().any(|item| { + item.kind == pending.kind + && item.channel_id == pending.channel_id + && item.message_id == pending.message_id + && item.thread_root_id == pending.thread_root_id + }) { + return; + } + queue.push_back(pending); + } + + fn clear(&self) { + self.lock().clear(); + } + + fn first(&self) -> Option { + self.lock().front().cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self.lock(); + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + +#[tauri::command] +pub(crate) fn clear_pending_navigation_deep_links(pending: State<'_, PendingNavigationDeepLinks>) { + pending.clear(); +} + +#[tauri::command] +pub(crate) fn take_pending_navigation_deep_link( + pending: State<'_, PendingNavigationDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_navigation_deep_link( + id: String, + pending: State<'_, PendingNavigationDeepLinks>, +) -> bool { + pending.acknowledge(&id) +} + impl PendingCommunityDeepLinks { fn enqueue(&self, pending: PendingCommunityDeepLink) { let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); @@ -54,6 +127,49 @@ impl PendingCommunityDeepLinks { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingEntityDeepLink { + id: String, + href: String, +} + +#[derive(Default)] +pub(crate) struct PendingEntityDeepLinks(Mutex>); + +impl PendingEntityDeepLinks { + fn enqueue(&self, href: String) -> PendingEntityDeepLink { + let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); + if let Some(existing) = queue.iter().find(|item| item.href == href) { + return existing.clone(); + } + let pending = PendingEntityDeepLink { + id: uuid::Uuid::new_v4().to_string(), + href, + }; + queue.push_back(pending.clone()); + pending + } + + fn first(&self) -> Option { + self.0 + .lock() + .expect("pending deep-link queue poisoned") + .front() + .cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + #[tauri::command] pub(crate) fn take_pending_community_deep_link( pending: State<'_, PendingCommunityDeepLinks>, @@ -69,6 +185,21 @@ pub(crate) fn acknowledge_pending_community_deep_link( pending.acknowledge(&id) } +#[tauri::command] +pub(crate) fn take_pending_entity_deep_link( + pending: State<'_, PendingEntityDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_entity_deep_link( + id: String, + pending: State<'_, PendingEntityDeepLinks>, +) -> bool { + pending.acknowledge(&id) +} + fn queue_community_deep_link( app: &tauri::AppHandle, kind: &str, @@ -88,6 +219,24 @@ fn queue_community_deep_link( }); } +fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serde_json::Value) { + let Some(channel_id) = payload["channelId"].as_str() else { + return; + }; + app.state::() + .enqueue(PendingNavigationDeepLink { + id: uuid::Uuid::new_v4().to_string(), + kind: kind.to_owned(), + channel_id: channel_id.to_owned(), + message_id: payload["messageId"].as_str().map(str::to_owned), + thread_root_id: payload["threadRootId"].as_str().map(str::to_owned), + }); +} + +fn queue_entity_deep_link(app: &tauri::AppHandle, href: String) -> PendingEntityDeepLink { + app.state::().enqueue(href) +} + fn activate_main_window(app: &tauri::AppHandle) { let Some(window) = app.get_webview_window("main") else { return; @@ -104,6 +253,58 @@ fn activate_main_window(app: &tauri::AppHandle) { } } +fn parse_channel_deep_link(url: &Url) -> Option { + if url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + return None; + } + let mut segments = url.path_segments()?; + let channel_id = segments.next()?; + let message_id = segments.next(); + if segments.next().is_some() { + return None; + } + let channel_id = uuid::Uuid::parse_str(channel_id).ok()?.to_string(); + if message_id.is_some_and(|value| { + value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + return None; + } + Some(match message_id { + Some(message_id) => serde_json::json!({ + "channelId": channel_id, + "messageId": message_id.to_ascii_lowercase(), + }), + None => serde_json::json!({ "channelId": channel_id }), + }) +} + +#[cfg(desktop)] +pub(crate) fn install_deep_link_handlers(app: &mut tauri::App) { + use tauri_plugin_deep_link::DeepLinkExt; + + let dl_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + handle_deep_link_url(&dl_handle, url.as_str()); + } + }); + + #[cfg(any(target_os = "windows", target_os = "linux"))] + match app.deep_link().get_current() { + Ok(Some(urls)) => { + for url in urls { + handle_deep_link_url(app.handle(), url.as_str()); + } + } + Ok(None) => {} + Err(error) => eprintln!("buzz-desktop: failed to read launch deep link: {error}"), + } +} + /// Parse the query string of a `buzz://message?…` URL into the JSON /// payload emitted on `deep-link-message`. Returns `None` when a required /// param (`channel`, `id`) is missing or empty — mirroring the validation @@ -163,6 +364,90 @@ fn parse_join_deep_link(url: &Url) -> Option { })) } +/// Hosts of the `buzz://` git-entity links built by +/// `desktop/src/shared/lib/entityLink.ts` and `crates/buzz-cli/src/links.rs`. +const ENTITY_LINK_HOSTS: [&str; 4] = ["repo", "project", "pr", "issue"]; + +fn is_hex64(value: &str) -> bool { + value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Mirrors `isValidDtag` in `entityLink.ts` — the link format addresses a +/// narrower d-tag charset than Nostr allows. +fn is_linkable_dtag(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + && !value.starts_with('.') + && !value.contains("..") +} + +/// Validate a `buzz://repo|project|pr|issue?…` link and return it verbatim +/// for the frontend, which re-parses it with `parseEntityLink` before +/// navigating. Validating here too keeps a malformed link from raising and +/// focusing the window for a navigation that would then be declined. +/// +/// Workspace tabs addressable by `buzz://repo|project` links — mirrors +/// `ENTITY_LINK_TABS` in `entityLink.ts`. +const ENTITY_LINK_TABS: [&str; 6] = [ + "files", + "commits", + "issues", + "prs", + "contributors", + "channels", +]; + +/// The canonical-form rules match `parseEntityLink`: no path segments, no +/// fragment, and no parameters beyond `owner`/`d` (plus `id` for event +/// links and the optional `tab` for coordinate links), so a future +/// extension of the format is declined by old builds rather than silently +/// misread. +fn parse_entity_deep_link(url: &Url) -> Option<()> { + let host = url.host_str()?; + if !ENTITY_LINK_HOSTS.contains(&host) { + return None; + } + if !matches!(url.path(), "" | "/") || url.fragment().is_some() { + return None; + } + + let needs_event_id = host == "pr" || host == "issue"; + let allows_tab = host == "repo" || host == "project"; + let (mut owner, mut dtag, mut id, mut tab) = (None, None, None, None); + for (key, value) in url.query_pairs() { + let slot = match key.as_ref() { + "owner" => &mut owner, + "d" => &mut dtag, + "id" if needs_event_id => &mut id, + "tab" if allows_tab => &mut tab, + _ => return None, + }; + if slot.is_some() { + return None; + } + *slot = Some(value.into_owned()); + } + + if !owner.is_some_and(|owner| is_hex64(&owner)) { + return None; + } + if !dtag.is_some_and(|dtag| is_linkable_dtag(&dtag)) { + return None; + } + if needs_event_id && !id.is_some_and(|id| is_hex64(&id)) { + return None; + } + if let Some(tab) = tab { + if !ENTITY_LINK_TABS.contains(&tab.as_str()) { + return None; + } + } + Some(()) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct AddCommunityDeepLinkPayload { @@ -295,6 +580,7 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result` — emits `deep-link-connect` to the frontend +/// - `buzz://repo|project|pr|issue?…` — emits `deep-link-entity` to the frontend pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let url = match Url::parse(url_str) { Ok(u) => u, @@ -350,6 +636,20 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { ); let _ = app.emit("deep-link-add-community", payload); } + Some("channel") => { + let Some(payload) = parse_channel_deep_link(&url) else { + eprintln!("buzz-desktop: channel deep link missing/invalid channel: {url_str}"); + return; + }; + activate_main_window(app); + if payload["messageId"].is_string() { + queue_navigation_deep_link(app, "message", &payload); + let _ = app.emit("deep-link-message", payload); + } else { + queue_navigation_deep_link(app, "channel", &payload); + let _ = app.emit("deep-link-channel", payload); + } + } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // @@ -364,8 +664,23 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { return; }; activate_main_window(app); + queue_navigation_deep_link(app, "message", &payload); let _ = app.emit("deep-link-message", payload); } + Some("repo" | "project" | "pr" | "issue") => { + // `buzz://repo|project?owner=&d=` and + // `buzz://pr|issue?id=&owner=&d=` — the + // share links copied from the Projects UI. The frontend owns + // routing (`useEntityDeepLinks`), so the validated URL is + // forwarded unchanged. + if parse_entity_deep_link(&url).is_none() { + eprintln!("buzz-desktop: malformed entity deep link: {url_str}"); + return; + } + activate_main_window(app); + let pending = queue_entity_deep_link(app, url_str.to_owned()); + let _ = app.emit("deep-link-entity", pending); + } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { Ok(payload) => { activate_main_window(app); @@ -385,327 +700,5 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } #[cfg(test)] -mod tests { - use url::Url; - - use super::{ - parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, - parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, - }; - - fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { - PendingCommunityDeepLink { - id: id.to_owned(), - kind: if code.is_some() { "join" } else { "connect" }.to_owned(), - relay_url: relay_url.to_owned(), - code: code.map(str::to_owned), - policy_receipt: None, - name: None, - } - } - - #[test] - fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { - let mut link = pending("join", "wss://relay.example", Some("invite")); - link.policy_receipt = Some("relay-signed-receipt".to_owned()); - - let payload = serde_json::to_value(link).unwrap(); - assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); - } - - #[test] - fn pending_community_links_are_fifo_and_acknowledged_in_order() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("second", "wss://two.example", Some("two"))); - assert_eq!(queue.first().unwrap().id, "first"); - assert!(!queue.acknowledge("second")); - assert!(queue.acknowledge("first")); - assert_eq!(queue.first().unwrap().id, "second"); - } - - #[test] - fn pending_community_links_dedupe_exact_intents() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); - assert!(queue.acknowledge("first")); - assert!(queue.first().is_none()); - } - - fn valid_nostr_bind_url() -> Url { - Url::parse( - "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", - ) - .unwrap() - } - - #[test] - fn parse_add_community_deep_link_extracts_relay_and_name() { - let url = Url::parse( - "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", - ) - .unwrap(); - let payload = parse_add_community_deep_link(&url).unwrap(); - assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); - assert_eq!(payload.name.as_deref(), Some("Acme Team")); - } - - #[test] - fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { - for raw in [ - "buzz://add-community?relay=wss%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) - .unwrap() - .name - .is_none()); - } - } - - #[test] - fn parse_add_community_deep_link_rejects_invalid_relays() { - for raw in [ - "buzz://add-community", - "buzz://add-community?relay=", - "buzz://add-community?relay=not-a-url", - "buzz://add-community?relay=https%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2F", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); - } - } - - #[test] - fn parse_message_deep_link_extracts_required_params() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_message_deep_link_accepts_buzz_scheme() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - } - - #[test] - fn parse_message_deep_link_includes_thread_root() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["threadRootId"], "root1"); - } - - #[test] - fn parse_message_deep_link_rejects_missing_id() { - let url = Url::parse("buzz://message?channel=abc").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_channel() { - // Regression: `channel=&id=foo` previously produced channelId: "". - let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_id() { - let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_treats_empty_thread_as_absent() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_relay_and_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["relayUrl"], "wss://relay.example"); - assert_eq!(payload["code"], "abc.def"); - assert!(payload["policyReceipt"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_policy_receipt() { - let url = Url::parse( - "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", - ) - .unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["policyReceipt"], "receipt.value"); - } - - #[test] - fn parse_join_deep_link_rejects_missing_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_empty_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_missing_relay() { - let url = Url::parse("buzz://join?code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_non_websocket_relay() { - let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_valid_url() { - let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); - assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); - assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); - assert_eq!(payload.verification_code, "123456"); - assert_eq!(payload.audience, "buzz:nostr-identity"); - assert_eq!(payload.action, "bind_nostr_identity"); - assert_eq!(payload.protocol, "buzz-nostr-identity"); - assert_eq!(payload.version, "1"); - assert_eq!(payload.origin, "https://example.com"); - assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); - assert_eq!(payload.return_mode, "clipboard"); - assert_eq!(payload.callback_url, None); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz?mockSession=1") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - - assert_eq!(payload.return_mode, "browser_fragment_v1"); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); - - assert_eq!( - parse_nostr_bind_deep_link(&url).unwrap_err(), - "browser_fragment_v1 requires callback_url" - ); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_http_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { - let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_empty_nonce() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_short_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_long_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_action() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_audience() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_https_origin() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_path() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); - } -} +#[path = "deep_link_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs new file mode 100644 index 00000000000..eaddbb7a4c3 --- /dev/null +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -0,0 +1,569 @@ +use url::Url; + +use super::{ + parse_add_community_deep_link, parse_channel_deep_link, parse_entity_deep_link, + parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, + PendingCommunityDeepLink, PendingCommunityDeepLinks, PendingEntityDeepLinks, + PendingNavigationDeepLink, PendingNavigationDeepLinks, ENTITY_LINK_TABS, +}; + +fn entity_link_golden() -> serde_json::Value { + serde_json::from_str(include_str!("../../../test-fixtures/entity-links.json")) + .expect("valid entity-links golden fixture") +} + +#[test] +fn parse_entity_deep_link_accepts_every_share_link_shape() { + let golden = entity_link_golden(); + let owner = golden["owner"].as_str().unwrap(); + let dtag = golden["dtag"].as_str().unwrap(); + for raw in golden["links"] + .as_object() + .unwrap() + .values() + .map(|value| value.as_str().unwrap().to_owned()) + .chain(golden["tabs"].as_array().unwrap().iter().map(|tab| { + format!( + "buzz://repo?owner={owner}&d={dtag}&tab={}", + tab.as_str().unwrap() + ) + })) + { + assert!( + parse_entity_deep_link(&Url::parse(&raw).unwrap()).is_some(), + "{raw}" + ); + } + let expected_tabs = golden["tabs"] + .as_array() + .unwrap() + .iter() + .map(|tab| tab.as_str().unwrap()) + .collect::>(); + assert_eq!(ENTITY_LINK_TABS.as_slice(), expected_tabs); +} + +#[test] +fn parse_entity_deep_link_rejects_malformed_and_non_canonical_links() { + let golden = entity_link_golden(); + let owner = golden["owner"].as_str().unwrap(); + let event_id = golden["eventId"].as_str().unwrap(); + for raw in [ + // Missing or malformed identifiers. + format!("buzz://repo?owner={owner}"), + "buzz://repo?owner=nope&d=buzz-world".to_owned(), + format!("buzz://repo?owner={owner}&d=.hidden"), + format!("buzz://repo?owner={owner}&d=has%20space"), + format!("buzz://pr?owner={owner}&d=buzz-world"), + format!("buzz://pr?id=short&owner={owner}&d=buzz-world"), + // Coordinate links take no event id. + format!("buzz://repo?id={event_id}&owner={owner}&d=buzz-world"), + // Non-canonical: unknown param, duplicate param, path, fragment. + format!("buzz://repo?owner={owner}&d=buzz-world&relay=wss%3A%2F%2Fx.example"), + format!("buzz://repo?owner={owner}&owner={owner}&d=buzz-world"), + // Unknown tab value, duplicate tab, and tab on an event link. + format!("buzz://repo?owner={owner}&d=buzz-world&tab=overview"), + format!("buzz://repo?owner={owner}&d=buzz-world&tab=prs&tab=prs"), + format!("buzz://pr?id={event_id}&owner={owner}&d=buzz-world&tab=prs"), + format!("buzz://repo/extra?owner={owner}&d=buzz-world"), + format!("buzz://repo?owner={owner}&d=buzz-world#top"), + // Not an entity host. + format!("buzz://message?owner={owner}&d=buzz-world"), + ] { + assert!( + parse_entity_deep_link(&Url::parse(&raw).unwrap()).is_none(), + "{raw}" + ); + } +} + +fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { + PendingCommunityDeepLink { + id: id.to_owned(), + kind: if code.is_some() { "join" } else { "connect" }.to_owned(), + relay_url: relay_url.to_owned(), + code: code.map(str::to_owned), + policy_receipt: None, + name: None, + } +} + +fn pending_navigation( + id: &str, + kind: &str, + channel_id: &str, + message_id: Option<&str>, + thread_root_id: Option<&str>, +) -> PendingNavigationDeepLink { + PendingNavigationDeepLink { + id: id.to_owned(), + kind: kind.to_owned(), + channel_id: channel_id.to_owned(), + message_id: message_id.map(str::to_owned), + thread_root_id: thread_root_id.map(str::to_owned), + } +} + +#[test] +fn pending_navigation_links_are_fifo_acknowledged_and_deduplicated() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation( + "first", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "duplicate", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "second", + "message", + "channel-1", + Some("message-1"), + Some("root-1"), + )); + + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); + assert!(queue.acknowledge("second")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_navigation_links_can_be_cleared() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation( + "first", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "second", + "message", + "channel-1", + Some("message-1"), + None, + )); + + queue.clear(); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_navigation_queue_recovers_after_mutex_poisoning() { + let queue = std::sync::Arc::new(PendingNavigationDeepLinks::default()); + let poisoner = std::sync::Arc::clone(&queue); + assert!(std::thread::spawn(move || { + let _guard = poisoner.0.lock().unwrap(); + panic!("poison queue for recovery regression"); + }) + .join() + .is_err()); + + queue.enqueue(pending_navigation( + "after-poison", + "channel", + "channel-1", + None, + None, + )); + assert_eq!(queue.first().unwrap().id, "after-poison"); + assert!(queue.acknowledge("after-poison")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { + let mut link = pending("join", "wss://relay.example", Some("invite")); + link.policy_receipt = Some("relay-signed-receipt".to_owned()); + + let payload = serde_json::to_value(link).unwrap(); + assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); +} + +#[test] +fn pending_community_links_are_fifo_and_acknowledged_in_order() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("second", "wss://two.example", Some("two"))); + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); +} + +#[test] +fn pending_community_links_dedupe_exact_intents() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); + assert!(queue.acknowledge("first")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_entity_links_survive_until_acknowledged_in_order() { + let queue = PendingEntityDeepLinks::default(); + let first = queue.enqueue("buzz://project?owner=aa&d=first".to_owned()); + let second = queue.enqueue("buzz://project?owner=aa&d=second".to_owned()); + + assert_eq!(queue.first(), Some(first.clone())); + assert!(!queue.acknowledge(&second.id)); + assert!(queue.acknowledge(&first.id)); + assert_eq!(queue.first(), Some(second)); +} + +#[test] +fn pending_entity_links_dedupe_launch_and_open_callbacks() { + let queue = PendingEntityDeepLinks::default(); + let href = "buzz://project?owner=aa&d=buzz".to_owned(); + let first = queue.enqueue(href.clone()); + let duplicate = queue.enqueue(href); + + assert_eq!(duplicate.id, first.id); + assert!(queue.acknowledge(&first.id)); + assert!(queue.first().is_none()); +} + +fn valid_nostr_bind_url() -> Url { + Url::parse( + "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", + ) + .unwrap() +} + +#[test] +fn parse_add_community_deep_link_extracts_relay_and_name() { + let url = Url::parse( + "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", + ) + .unwrap(); + let payload = parse_add_community_deep_link(&url).unwrap(); + assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); + assert_eq!(payload.name.as_deref(), Some("Acme Team")); +} + +#[test] +fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { + for raw in [ + "buzz://add-community?relay=wss%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) + .unwrap() + .name + .is_none()); + } +} + +#[test] +fn parse_add_community_deep_link_rejects_invalid_relays() { + for raw in [ + "buzz://add-community", + "buzz://add-community?relay=", + "buzz://add-community?relay=not-a-url", + "buzz://add-community?relay=https%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2F", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); + } +} + +#[test] +fn parse_channel_deep_link_accepts_one_path_segment() { + let url = Url::parse("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32").unwrap(); + let payload = parse_channel_deep_link(&url).unwrap(); + assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32"); +} + +#[test] +fn parse_channel_deep_link_accepts_message_path() { + let message_id = "8455293f0123456789abcdef0123456789abcdef0123456789abcdef01234567"; + let url = Url::parse(&format!( + "buzz://channel/a372f080-5961-4535-b1a3-edffface377d/{message_id}" + )) + .unwrap(); + let payload = parse_channel_deep_link(&url).unwrap(); + assert_eq!(payload["channelId"], "a372f080-5961-4535-b1a3-edffface377d"); + assert_eq!(payload["messageId"], message_id); +} + +#[test] +fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() { + for (raw, expected) in [ + ( + "buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + ), + ( + "buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32", + "580ca78b-9dae-46f3-8854-bd671853ba32", + ), + ] { + let payload = parse_channel_deep_link(&Url::parse(raw).unwrap()).unwrap(); + assert_eq!(payload["channelId"], expected); + } +} + +#[test] +fn parse_channel_deep_link_rejects_malformed_forms() { + for raw in [ + "buzz://channel", + "buzz://channel/", + "buzz://channel/one/two", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/not-hex", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/extra", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/", + "buzz://channel/one?extra=true", + "buzz://channel/one#fragment", + "buzz://:pass@channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "buzz://channel/not-a-uuid", + "buzz://channel/%2F", + "buzz://channel/%00", + ] { + assert!(parse_channel_deep_link(&Url::parse(raw).unwrap()).is_none()); + } +} + +#[test] +fn parse_message_deep_link_extracts_required_params() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_message_deep_link_accepts_buzz_scheme() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); +} + +#[test] +fn parse_message_deep_link_includes_thread_root() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["threadRootId"], "root1"); +} + +#[test] +fn parse_message_deep_link_rejects_missing_id() { + let url = Url::parse("buzz://message?channel=abc").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_channel() { + // Regression: `channel=&id=foo` previously produced channelId: "". + let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_id() { + let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_treats_empty_thread_as_absent() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_relay_and_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["relayUrl"], "wss://relay.example"); + assert_eq!(payload["code"], "abc.def"); + assert!(payload["policyReceipt"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_policy_receipt() { + let url = Url::parse( + "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", + ) + .unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["policyReceipt"], "receipt.value"); +} + +#[test] +fn parse_join_deep_link_rejects_missing_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_empty_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_missing_relay() { + let url = Url::parse("buzz://join?code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_non_websocket_relay() { + let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_valid_url() { + let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); + assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); + assert_eq!(payload.verification_code, "123456"); + assert_eq!(payload.audience, "buzz:nostr-identity"); + assert_eq!(payload.action, "bind_nostr_identity"); + assert_eq!(payload.protocol, "buzz-nostr-identity"); + assert_eq!(payload.version, "1"); + assert_eq!(payload.origin, "https://example.com"); + assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); + assert_eq!(payload.return_mode, "clipboard"); + assert_eq!(payload.callback_url, None); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz?mockSession=1") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + + assert_eq!(payload.return_mode, "browser_fragment_v1"); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); + + assert_eq!( + parse_nostr_bind_deep_link(&url).unwrap_err(), + "browser_fragment_v1 requires callback_url" + ); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_http_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { + let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_empty_nonce() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_short_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_long_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_action() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_audience() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_https_origin() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_path() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); +} diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 23cb2ba220c..1513742beaf 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -165,6 +165,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) .unwrap(); @@ -181,6 +182,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) .unwrap(); diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index b7937419bf1..df814afb36f 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -11,6 +11,12 @@ use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; + +mod message_tags; + +use message_tags::{ + append_client_tags, append_sent_from_thread_tag, emoji_tags, imeta_tags, mention_reference_tags, +}; // ── Constants ──────────────────────────────────────────────────────────────── /// Maximum content size — matches buzz-sdk (64 KiB). @@ -74,56 +80,6 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { Ok(tags) } -fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Result<(), String> { - for mention in mentions { - if mention.first().map(String::as_str) != Some("mention") { - return Err(format!( - "mention reference tags must use 'mention' prefix (got {:?})", - mention.first() - )); - } - let Some(pubkey) = mention.get(1) else { - return Err("mention reference tag missing pubkey".into()); - }; - check_pubkey(pubkey)?; - tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?); - } - Ok(()) -} - -/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" -/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). -fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for mt in media_tags { - if mt.first().map(String::as_str) != Some("imeta") { - return Err(format!( - "media tags must use 'imeta' prefix (got {:?})", - mt.first() - )); - } - let parts: Vec<&str> = mt.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid imeta tag: {e}"))?); - } - Ok(()) -} - -/// Validate and append NIP-30 custom-emoji tags. Mirrors `imeta_tags`: rejects -/// any tag whose first element is not "emoji" so this path can't be used to -/// smuggle forged "h"/"e"/"p" tags. Each tag is `["emoji", shortcode, url]`. -fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for et in emoji_tags { - if et.first().map(String::as_str) != Some("emoji") { - return Err(format!( - "emoji tags must use 'emoji' prefix (got {:?})", - et.first() - )); - } - let parts: Vec<&str> = et.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?); - } - Ok(()) -} - /// Validate a hex pubkey is exactly 64 hex characters. fn check_pubkey(pubkey: &str) -> Result<(), String> { if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { @@ -302,6 +258,7 @@ pub fn build_message( custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], link_preview_tags: &[Vec], + sent_from_thread_tag: Option<&[String]>, relay_base: &str, ) -> Result { build_message_with_client_tags( @@ -313,6 +270,7 @@ pub fn build_message( custom_emoji_tags, mention_ref_tags, link_preview_tags, + sent_from_thread_tag, relay_base, &[], ) @@ -333,9 +291,13 @@ pub fn build_message_with_client_tags( custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], link_preview_tags: &[Vec], + sent_from_thread_tag: Option<&[String]>, relay_base: &str, client_tags: &[Vec], ) -> Result { + if sent_from_thread_tag.is_some() && thread_ref.is_some() { + return Err("sent-from-thread provenance requires a top-level message".into()); + } check_content(content)?; let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; if let Some(tr) = thread_ref { @@ -346,27 +308,11 @@ pub fn build_message_with_client_tags( emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?; + append_sent_from_thread_tag(sent_from_thread_tag, &mut tags)?; append_client_tags(client_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } -fn append_client_tags(client_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for client_tag in client_tags { - if client_tag.first().map(String::as_str) != Some("client") { - return Err(format!( - "client tags must use 'client' prefix (got {:?})", - client_tag.first() - )); - } - if client_tag.len() < 2 { - return Err("client tag missing marker".into()); - } - let parts: Vec<&str> = client_tag.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid client tag: {e}"))?); - } - Ok(()) -} - /// Kind 45001 — forum post. pub fn build_forum_post( channel_id: Uuid, @@ -401,15 +347,20 @@ pub fn build_forum_comment( Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } +pub struct MessageEditTags<'a> { + pub media: &'a [Vec], + pub custom_emoji: &'a [Vec], + pub mentions: &'a [&'a str], + pub mention_refs: Option<&'a [Vec]>, +} + /// Kind 40003 — edit a message with full content, media, emoji, mentions, /// and optional monotonic link-preview suppression. pub fn build_message_edit( channel_id: Uuid, target_event_id: EventId, content: &str, - media_tags: &[Vec], - custom_emoji_tags: &[Vec], - mentions: &[&str], + edit_tags: MessageEditTags<'_>, suppress_link_previews: bool, ) -> Result { check_content(content)?; @@ -417,9 +368,13 @@ pub fn build_message_edit( tag(vec!["h", &channel_id.to_string()])?, tag(vec!["e", &target_event_id.to_hex()])?, ]; - tags.extend(mention_tags(mentions)?); - imeta_tags(media_tags, &mut tags)?; - emoji_tags(custom_emoji_tags, &mut tags)?; + tags.extend(mention_tags(edit_tags.mentions)?); + imeta_tags(edit_tags.media, &mut tags)?; + emoji_tags(edit_tags.custom_emoji, &mut tags)?; + if let Some(mention_refs) = edit_tags.mention_refs { + mention_reference_tags(mention_refs, &mut tags)?; + tags.push(tag(vec!["buzz:mention-snapshot"])?); + } if suppress_link_previews { tags.push(tag(vec!["link-preview", "none"])?); } @@ -930,25 +885,35 @@ mod tests { assert_eq!(event.pubkey.to_hex(), TARGET_HEX); } - // ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── - // - // The composer diffs the edited body's mentions against the original and - // hands `build_message_edit` only the *newly added* pubkeys. These tests - // pin the builder's contract given that contract: emit a `p` per added - // mention (deduped, lowercased), and none when the added set is empty - // (typo-fix edit) — so an unchanged mention set re-wakes nobody. - const CH_ID: &str = "11111111-1111-4111-8111-111111111111"; const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; fn edit_tags(mentions: &[&str]) -> Vec> { + edit_tags_with_refs(mentions, Some(&[])) + } + + fn edit_tags_with_refs( + mentions: &[&str], + mention_refs: Option<&[Vec]>, + ) -> Vec> { let channel = Uuid::parse_str(CH_ID).unwrap(); let target = EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") .unwrap(); - let builder = - build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap(); + let builder = build_message_edit( + channel, + target, + "hi @alice", + MessageEditTags { + media: &[], + custom_emoji: &[], + mentions, + mention_refs, + }, + false, + ) + .unwrap(); let secret = nostr::SecretKey::from_hex( "0000000000000000000000000000000000000000000000000000000000000003", ) @@ -962,7 +927,6 @@ mod tests { let tags = edit_tags(&[ALICE_HEX]); assert_eq!(tags[0][0], "h"); assert_eq!(tags[1][0], "e"); - // The `p` tag rides right after the `e` tag (insertion order). assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); } @@ -979,6 +943,42 @@ mod tests { ); } + #[test] + fn edit_emits_full_mention_reference_snapshot() { + let tags = edit_tags_with_refs(&[], Some(&[vec!["mention".into(), ALICE_HEX.into()]])); + assert!( + tags.iter().any(|tag| tag == &["mention", ALICE_HEX]), + "stable mention reference must be present: {tags:?}" + ); + assert!( + tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]), + "snapshot marker must be present: {tags:?}" + ); + } + + #[test] + fn empty_edit_mention_snapshot_is_explicit() { + let tags = edit_tags_with_refs(&[], Some(&[])); + assert!( + tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]), + "empty snapshot must still clear stale references: {tags:?}" + ); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("mention"))); + } + + #[test] + fn partial_edit_omits_mention_snapshot() { + let tags = edit_tags_with_refs(&[], None); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("mention"))); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("buzz:mention-snapshot"))); + } + #[test] fn edit_mentions_are_deduped_and_lowercased() { let alice_upper = ALICE_HEX.to_ascii_uppercase(); diff --git a/desktop/src-tauri/src/events/message_tags.rs b/desktop/src-tauri/src/events/message_tags.rs new file mode 100644 index 00000000000..c43a8874def --- /dev/null +++ b/desktop/src-tauri/src/events/message_tags.rs @@ -0,0 +1,140 @@ +use nostr::{EventId, Tag}; + +use super::check_pubkey; + +const MAX_THREAD_ROOT_EXCERPT_CHARS: usize = 64; +const SENT_FROM_THREAD_TAG: &str = "buzz:sent-from-thread"; + +pub(super) fn mention_reference_tags( + mentions: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + for mention in mentions { + if mention.first().map(String::as_str) != Some("mention") { + return Err(format!( + "mention reference tags must use 'mention' prefix (got {:?})", + mention.first() + )); + } + let Some(pubkey) = mention.get(1) else { + return Err("mention reference tag missing pubkey".into()); + }; + check_pubkey(pubkey)?; + tags.push( + Tag::parse(vec!["mention", &pubkey.to_ascii_lowercase()]) + .map_err(|error| format!("invalid mention reference tag: {error}"))?, + ); + } + Ok(()) +} + +pub(super) fn append_sent_from_thread_tag( + source_tag: Option<&[String]>, + tags: &mut Vec, +) -> Result<(), String> { + let Some(source_tag) = source_tag else { + return Ok(()); + }; + if !matches!(source_tag.len(), 2 | 3) + || source_tag.first().map(String::as_str) != Some(SENT_FROM_THREAD_TAG) + { + return Err("invalid sent-from-thread tag shape".into()); + } + + EventId::from_hex(source_tag[1].trim()) + .map_err(|_| "sent-from-thread tag has invalid root event ID")?; + + if let Some(excerpt) = source_tag.get(2) { + if excerpt.trim().is_empty() + || excerpt.chars().count() > MAX_THREAD_ROOT_EXCERPT_CHARS + || excerpt.chars().any(char::is_control) + { + return Err("sent-from-thread tag has invalid root excerpt".into()); + } + } + + let parts: Vec<&str> = source_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid sent-from-thread tag: {e}"))?); + Ok(()) +} + +/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" +/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). +pub(super) fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { + for media_tag in media_tags { + if media_tag.first().map(String::as_str) != Some("imeta") { + return Err(format!( + "media tags must use 'imeta' prefix (got {:?})", + media_tag.first() + )); + } + let parts: Vec<&str> = media_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid imeta tag: {e}"))?); + } + Ok(()) +} + +/// Validate and append NIP-30 custom-emoji tags. Mirrors `imeta_tags`: rejects +/// any tag whose first element is not "emoji" so this path can't be used to +/// smuggle forged "h"/"e"/"p" tags. Each tag is `["emoji", shortcode, url]`. +pub(super) fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { + for emoji_tag in emoji_tags { + if emoji_tag.first().map(String::as_str) != Some("emoji") { + return Err(format!( + "emoji tags must use 'emoji' prefix (got {:?})", + emoji_tag.first() + )); + } + let parts: Vec<&str> = emoji_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?); + } + Ok(()) +} + +pub(super) fn append_client_tags( + client_tags: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + for client_tag in client_tags { + if client_tag.first().map(String::as_str) != Some("client") { + return Err(format!( + "client tags must use 'client' prefix (got {:?})", + client_tag.first() + )); + } + if client_tag.len() < 2 { + return Err("client tag missing marker".into()); + } + let parts: Vec<&str> = client_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid client tag: {e}"))?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_HEX: &str = "d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1"; + + #[test] + fn message_accepts_only_valid_sent_from_thread_provenance() { + let source_tag = vec![ + SENT_FROM_THREAD_TAG.to_string(), + ROOT_HEX.to_string(), + "Root message excerpt".to_string(), + ]; + let mut tags = Vec::new(); + append_sent_from_thread_tag(Some(&source_tag), &mut tags).unwrap(); + assert_eq!(tags[0].as_slice(), source_tag); + + let forged_channel_tag = vec!["h".to_string(), "channel-id".to_string()]; + assert!(append_sent_from_thread_tag(Some(&forged_channel_tag), &mut Vec::new()).is_err()); + + let invalid_root_tag = vec![ + SENT_FROM_THREAD_TAG.to_string(), + "not-an-event-id".to_string(), + ]; + assert!(append_sent_from_thread_tag(Some(&invalid_root_tag), &mut Vec::new()).is_err()); + } +} diff --git a/desktop/src-tauri/src/huddle/agent_tts_routing.rs b/desktop/src-tauri/src/huddle/agent_tts_routing.rs index 2ee3ec0d41a..87a56c0dbbc 100644 --- a/desktop/src-tauri/src/huddle/agent_tts_routing.rs +++ b/desktop/src-tauri/src/huddle/agent_tts_routing.rs @@ -25,8 +25,9 @@ pub(super) fn classify_agent_tts_runtime( } /// 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; +/// This high safety cap keeps unexpectedly large events bounded while allowing +/// normal long-form huddle replies to play in full. +pub(super) const MAX_TTS_TEXT_LEN: usize = 8_096; pub(super) fn normalize_agent_tts_text(text: String) -> String { if text.chars().count() > MAX_TTS_TEXT_LEN { diff --git a/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs index cb550d7005b..c9ebabe6b62 100644 --- a/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs +++ b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs @@ -47,6 +47,7 @@ fn disabled_is_the_only_intentional_runtime_no_op() { #[test] fn assistant_text_truncation_is_unicode_safe_before_voice_routing() { + assert_eq!(MAX_TTS_TEXT_LEN, 8_096); let input = "🦀".repeat(MAX_TTS_TEXT_LEN + 1); let output = normalize_agent_tts_text(input); assert_eq!( diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 41a348d8889..2b5b601de49 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -29,40 +29,20 @@ use super::{pipeline::start_auto_enabled_transcription, HuddlePhase}; // ── Constants ───────────────────────────────────────────────────────────────── -/// Voice-mode guidelines posted as kind:48106 (huddle guidelines) to the -/// ephemeral channel at huddle start. Agents see them via EOSE replay. -/// Instructs agents on voice-mode etiquette: TTS constraints, brevity, -/// self-selection, and sentence-at-a-time delivery. +/// Voice-mode instructions posted as kind:48106 to the ephemeral channel at +/// huddle start. Agents load this event into the channel session system prompt. /// -/// Why sentence-at-a-time: the desktop speaks each agent message as it -/// arrives (queued, in order), so an agent that sends its first sentence -/// immediately — then the rest as separate messages — cuts time-to-first- -/// audio from "full reply generated" to "first sentence generated". This is -/// the prompt-level equivalent of token streaming, with no harness changes. -/// -/// Build voice-mode guidelines with the parent channel ID so agents know -/// where "the main channel" is. +/// Keep this deliberately short: the invariant that matters is that a directly +/// addressed user receives an immediate spoken response before any other work. pub fn voice_mode_guidelines(parent_channel_id: &str) -> String { format!( "\ You are in a live voice huddle attached to channel {parent_channel_id}. -Your text is read aloud via TTS, message by message, in the order sent. - -Latency matters most: reply IMMEDIATELY — do not compose your full reply -before sending anything. The moment your first sentence is formed, send it -as its own `buzz messages send` tool call: it is what breaks the silence. -Then send each following sentence the same way — one sentence per separate -`buzz messages send` call. Never hold a finished sentence back to bundle it -with the next one. - -- If not addressed or relevant: do nothing. Do not respond. -- Keep the whole reply short — a few sentences at most. Start with the answer, no preamble. -- No markdown, code blocks, lists, or structured data — say it naturally. -- To share code or detailed data: say \"I'll post that in the main channel\" and do so. -- When you need a tool, say one short sentence first (e.g. \"Let me check.\"), then run it, then summarize the key finding verbally. -- If a new human message arrives mid-reply, you were interrupted: drop your unsent sentences and respond to the new message instead. -- In multi-agent huddles, identify yourself only when needed. -- Use your Buzz tools proactively when asked." +Only messages sent with `buzz messages send` to this huddle channel are spoken aloud, in the order sent; everything else you produce is silent. +When a user addresses you, your FIRST tool call must send a brief spoken reply to this channel, before any file read, search, or other tool call. The usual rule against bare acknowledgments does not apply here; the pickup is the feedback that you heard them. +Then work, sending each useful sentence as its own message the moment it is ready—a few sentences per answer, not a monologue. +Speak plainly without markdown; post code or long detail to the attached channel instead. +If you are not addressed, stay silent." ) } @@ -317,7 +297,18 @@ fn contains_member(members: &[(String, Option)], pubkey: &str) -> bool { #[cfg(test)] mod tests { - use super::contains_member; + use super::{contains_member, voice_mode_guidelines}; + + #[test] + fn voice_mode_guidelines_pin_spoken_reply_as_first_tool_call() { + let guidelines = voice_mode_guidelines("parent-channel"); + assert_eq!(guidelines.lines().count(), 6); + assert!(guidelines.contains("Only messages sent with `buzz messages send`")); + assert!(guidelines.contains("your FIRST tool call must send a brief spoken reply")); + assert!(guidelines.contains("before any file read, search, or other tool call")); + assert!(guidelines.contains("rule against bare acknowledgments does not apply here")); + assert!(guidelines.contains("parent-channel")); + } #[test] fn existing_parent_membership_is_preserved_regardless_of_role() { diff --git a/desktop/src-tauri/src/huddle/commands.rs b/desktop/src-tauri/src/huddle/commands.rs index 993d8e54eba..e4f25a93fcb 100644 --- a/desktop/src-tauri/src/huddle/commands.rs +++ b/desktop/src-tauri/src/huddle/commands.rs @@ -7,7 +7,9 @@ use uuid::Uuid; use crate::{app_state::AppState, events, relay::submit_event}; -use super::{relay_api::validate_pubkey_hex, HuddlePhase}; +use super::pipeline::start_auto_enabled_transcription; +use super::relay_api::MAX_HUDDLE_AGENTS; +use super::{agents, relay_api::validate_pubkey_hex, HuddlePhase}; /// Update the clickable microphone control independently from the PTT shortcut. #[tauri::command] @@ -130,3 +132,85 @@ pub async fn remove_agent_from_huddle( Ok(()) } + +/// Add an agent to the active huddle. +/// +/// Steps: +/// 1. Validates the huddle is in the Connected or Active phase. +/// 2. Adds the agent to both the ephemeral and parent channels (kind:9000). +/// 3. Only appends the agent pubkey to `agent_pubkeys` if the ephemeral add +/// succeeded — failed adds (policy rejection) are NOT p-tagged. +/// +/// Returns a structured `AgentAddResult` so the frontend can surface +/// parent-channel errors without treating them as hard failures. +/// +/// The running ACP process for this agent auto-subscribes when it receives +/// the kind:9000 membership notification — no separate process spawn needed. +#[tauri::command] +pub async fn add_agent_to_huddle( + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result { + validate_pubkey_hex(&agent_pubkey)?; + + 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()); + } + + // Enforce agent cap on incremental adds too. + let current_agent_count = hs + .agent_pubkeys + .lock() + .unwrap_or_else(|e| e.into_inner()) + .len(); + if current_agent_count >= MAX_HUDDLE_AGENTS { + return Err(format!( + "agent limit reached: {} (max {})", + current_agent_count, MAX_HUDDLE_AGENTS + )); + } + + let eph = hs + .ephemeral_channel_id + .clone() + .ok_or("no ephemeral channel")?; + let parent = hs.parent_channel_id.clone().ok_or("no parent channel")?; + (eph, parent, hs.huddle_generation) + }; + + let eph_uuid = Uuid::parse_str(&eph_id).map_err(|e| e.to_string())?; + let parent_uuid = Uuid::parse_str(&parent_id).map_err(|e| e.to_string())?; + + // 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 — 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. + 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/latency_bench.rs b/desktop/src-tauri/src/huddle/latency_bench.rs new file mode 100644 index 00000000000..710854b5337 --- /dev/null +++ b/desktop/src-tauri/src/huddle/latency_bench.rs @@ -0,0 +1,324 @@ +//! Ad-hoc baseline latency bench for the STT -> fake LLM -> TTS pipeline. +//! +//! Drives the REAL production machinery: +//! - `SttPipeline::new` (rubato 48k->16k, earshot VAD, 300 ms silence flush, +//! Parakeet TDT-CTC 110M int8 via sherpa-onnx, 1 thread) +//! - `TtsPipeline::new_with_voice` (warmup synth, chunker, synth_chunk, +//! rodio persistent Player, 20 ms lead-in) +//! +//! with a fake LLM in place of the relay/agent leg. +//! +//! Audio is fed in real-time 100 ms batches (mirroring the AudioWorklet +//! cadence) so VAD endpointing behaves exactly like production. +//! +//! Timestamps captured per turn: +//! t_speech_end last voiced sample delivered to push_audio (wall clock, +//! derived from the WAV's last voiced sample + feed pacing) +//! t_transcript text_rx yields the transcript +//! t_speak fake-LLM reply handed to TtsPipeline::speak +//! t_first_audio tts_active rising edge = first player.append accepted +//! +//! Run: +//! BUZZ_BENCH_WAV=<48k f32 mono wav> cargo test --release -p buzz-desktop \ +//! --lib huddle::latency_bench -- --ignored --nocapture + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::{Duration, Instant}; + +use super::stt::SttPipeline; +use super::tts::TtsPipeline; + +/// Read a mono 32-bit-float WAV (as produced by `afconvert -d LEF32@48000`). +/// Minimal parser: walks RIFF chunks, asserts fmt = IEEE float mono 48 kHz. +fn read_wav_f32_48k(path: &str) -> Vec { + let bytes = std::fs::read(path).expect("read wav"); + assert_eq!(&bytes[0..4], b"RIFF"); + assert_eq!(&bytes[8..12], b"WAVE"); + let mut pos = 12usize; + let mut fmt_ok = false; + let mut data: Option<(usize, usize)> = None; + while pos + 8 <= bytes.len() { + let id = &bytes[pos..pos + 4]; + let len = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()) as usize; + let body = pos + 8; + match id { + b"fmt " => { + let format = u16::from_le_bytes(bytes[body..body + 2].try_into().unwrap()); + let channels = u16::from_le_bytes(bytes[body + 2..body + 4].try_into().unwrap()); + let rate = u32::from_le_bytes(bytes[body + 4..body + 8].try_into().unwrap()); + let bits = u16::from_le_bytes(bytes[body + 14..body + 16].try_into().unwrap()); + assert_eq!(format, 3, "expected IEEE float wav"); + assert_eq!(channels, 1, "expected mono"); + assert_eq!(rate, 48_000, "expected 48 kHz"); + assert_eq!(bits, 32); + fmt_ok = true; + } + b"data" => data = Some((body, len)), + _ => {} + } + pos = body + len + (len & 1); + } + assert!(fmt_ok, "fmt chunk missing"); + let (off, len) = data.expect("data chunk missing"); + bytes[off..off + len] + .chunks_exact(4) + .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) + .collect() +} + +/// Index (in samples) one past the last sample whose |amplitude| exceeds the +/// threshold — "when the user stopped speaking" on the feed timeline. +fn last_voiced_sample(samples: &[f32], threshold: f32) -> usize { + samples + .iter() + .rposition(|s| s.abs() > threshold) + .map(|i| i + 1) + .unwrap_or(0) +} + +/// Poll a tokio mpsc receiver from sync context for up to `timeout`. +/// 1 ms poll keeps timestamp error negligible against ~100 ms scales. +fn tokio_recv_with_timeout( + rx: &mut tokio::sync::mpsc::Receiver, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Ok(t) = rx.try_recv() { + return Some(t); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(1)); + } +} + +struct TurnResult { + label: &'static str, + transcript: String, + stt_ms: f64, + llm_ms: f64, + tts_ms: f64, + e2e_ms: f64, +} + +#[test] +#[ignore = "ad-hoc latency baseline; needs models in ~/.buzz/models and an audio output device"] +fn baseline_stt_fake_llm_tts_first_audio() { + let home = dirs::home_dir().expect("home"); + let stt_dir = home.join(".buzz/models/parakeet-tdt-ctc-110m-en"); + let tts_dir = home.join(".buzz/models/pocket-tts"); + assert!( + stt_dir.join("model.int8.onnx").exists(), + "parakeet model missing" + ); + assert!(tts_dir.join("bundle.json").exists(), "pocket model missing"); + + let wav_path = std::env::var("BUZZ_BENCH_WAV").expect("set BUZZ_BENCH_WAV"); + let samples_48k = read_wav_f32_48k(&wav_path); + let speech_end_sample = last_voiced_sample(&samples_48k, 0.015); + let audio_dur_s = samples_48k.len() as f64 / 48_000.0; + let speech_end_s = speech_end_sample as f64 / 48_000.0; + eprintln!( + "bench: utterance {wav_path}: {audio_dur_s:.2} s total, speech ends at {speech_end_s:.2} s" + ); + + let llm_delay_ms: u64 = std::env::var("BUZZ_BENCH_LLM_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + // ── Bring up the real pipelines, exactly as maybe_start_* do ──────────── + let tts_active = Arc::new(AtomicBool::new(false)); + let tts_cancel = Arc::new(AtomicBool::new(false)); + + let t = Instant::now(); + let tts = TtsPipeline::new_with_voice( + tts_dir, + Arc::clone(&tts_active), + Arc::clone(&tts_cancel), + "eve", + None, // default output device + None, // no Tauri app handle + ) + .expect("tts pipeline"); + eprintln!( + "bench: TTS pipeline ready (engine load + warmup + audio prime) in {:.0} ms", + t.elapsed().as_secs_f64() * 1e3 + ); + + let t = Instant::now(); + let (stt, mut text_rx) = SttPipeline::new(stt_dir, None, None).expect("stt pipeline"); + // Recognizer loads inside the worker thread; give it time, then verify + // liveness via a first throwaway feed below. + std::thread::sleep(Duration::from_secs(2)); + assert!(!stt.is_finished(), "stt worker died during init"); + eprintln!( + "bench: STT pipeline spawned ({:.0} ms incl. settle sleep)", + t.elapsed().as_secs_f64() * 1e3 + ); + + // Fake LLM replies: short / medium / long, cycled across turns. + let replies: [(&'static str, &'static str); 3] = [ + ("reply_short", "Let me check."), + ("reply_medium", "Got it. The relay deploy finished about two minutes ago and all checks passed."), + ("reply_long", "Here's where things stand. The relay deploy finished cleanly and every health check is green. Two pods restarted during rollout, which is expected, and message latency is back to normal."), + ]; + let turns: usize = std::env::var("BUZZ_BENCH_TURNS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(6); + + // 100 ms batches at 48 kHz, matching the AudioWorklet push cadence. + const BATCH: usize = 4_800; + let mut results: Vec = Vec::new(); + + let stt = Arc::new(stt); + for turn in 0..turns { + let (label, reply) = replies[turn % replies.len()]; + + // Feed the utterance in real time from a separate thread (the + // AudioWorklet role), then trailing silence so the 300 ms VAD flush + // fires. The main thread meanwhile timestamps transcript arrival — + // recv must NOT be serialized behind the silence feed, or the + // measurement floor becomes the feed loop instead of the STT path. + let feeder_stt = Arc::clone(&stt); + let feeder_samples = samples_48k.clone(); + let feed_start = Instant::now(); + let feeder = std::thread::spawn(move || { + let mut cursor = 0usize; + while cursor < feeder_samples.len() { + let end = (cursor + BATCH).min(feeder_samples.len()); + let bytes: Vec = feeder_samples[cursor..end] + .iter() + .flat_map(|s| s.to_le_bytes()) + .collect(); + feeder_stt.push_audio(bytes).expect("push"); + cursor = end; + // Pace to real time. + let target = feed_start + Duration::from_millis((cursor / 48) as u64); + let now = Instant::now(); + if target > now { + std::thread::sleep(target - now); + } + } + // Trailing silence: 1 s guarantees the 300 ms flush window closes. + let silence = vec![0u8; BATCH * 4]; + for _ in 0..10 { + feeder_stt + .push_audio(silence.clone()) + .expect("push silence"); + std::thread::sleep(Duration::from_millis(100)); + } + }); + let t_speech_end = feed_start + Duration::from_secs_f64(speech_end_s); + + // Transcript arrival. An utterance with an intra-sentence pause can + // VAD-split into multiple segments; keep the LAST one delivered so the + // turn aligns with the true end of speech. The extra "is another + // segment coming?" wait below is a HARNESS artifact (prod forwards + // every segment immediately) and is excluded from all timings. + let mut transcript = text_rx + .blocking_recv() + .expect("stt channel closed before transcript"); + let mut t_transcript = Instant::now(); + let mut segments = 1usize; + loop { + match text_rx.try_recv() { + Ok(t) => { + transcript = t; + t_transcript = Instant::now(); + segments += 1; + } + Err(_) => { + if feeder.is_finished() { + // Feed done (incl. 1 s trailing silence): any final + // segment has already flushed and decoded. One short + // grace poll covers a decode still in flight. + match tokio_recv_with_timeout(&mut text_rx, Duration::from_millis(500)) { + Some(t) => { + transcript = t; + t_transcript = Instant::now(); + segments += 1; + } + None => break, + } + } else { + // Feeder still delivering audio — a later segment may + // arrive any time until the feed (plus flush window) + // completes. Keep waiting; do NOT break early or the + // tail segment leaks into the next turn. + if let Some(t) = + tokio_recv_with_timeout(&mut text_rx, Duration::from_millis(100)) + { + transcript = t; + t_transcript = Instant::now(); + segments += 1; + } + } + } + } + } + feeder.join().expect("feeder"); + + // Fake LLM. Applied AFTER the harness-only segment wait; llm_ms is the + // configured delay, so the harness wait never leaks into any timing. + if llm_delay_ms > 0 { + std::thread::sleep(Duration::from_millis(llm_delay_ms)); + } + let t_speak = Instant::now(); + tts.speak(reply.to_string()).expect("speak"); + + // First audio: tts_active rising edge == first accepted player append. + let deadline = Instant::now() + Duration::from_secs(30); + while !tts_active.load(Ordering::Acquire) { + assert!(Instant::now() < deadline, "no first audio within 30 s"); + std::thread::sleep(Duration::from_micros(500)); + } + let t_first_audio = Instant::now(); + + let stt_ms = (t_transcript - t_speech_end).as_secs_f64() * 1e3; + // llm_ms is exactly the configured fake-LLM delay; tts is measured + // from speak() to first accepted append. e2e composes the three real + // legs so the harness-only segment wait (between t_transcript and the + // fake-LLM sleep) never inflates the pipeline number. + let llm_ms = llm_delay_ms as f64; + let tts_ms = (t_first_audio - t_speak).as_secs_f64() * 1e3; + let e2e_ms = stt_ms + llm_ms + tts_ms; + eprintln!( + "bench turn {turn} [{label}]: stt={stt_ms:.0}ms llm={llm_ms:.0}ms tts_first_audio={tts_ms:.0}ms e2e={e2e_ms:.0}ms segments={segments} transcript={transcript:?}" + ); + results.push(TurnResult { + label, + transcript, + stt_ms, + llm_ms, + tts_ms, + e2e_ms, + }); + + // Wait for playback to drain + prod cooldown before the next turn. + while tts_active.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(20)); + } + std::thread::sleep(Duration::from_millis(500)); + } + + // Summary JSON for the write-up. + println!("["); + for (i, r) in results.iter().enumerate() { + let comma = if i + 1 < results.len() { "," } else { "" }; + println!( + " {{\"turn\":{i},\"label\":\"{}\",\"stt_ms\":{:.1},\"llm_ms\":{:.1},\"tts_first_audio_ms\":{:.1},\"e2e_ms\":{:.1},\"transcript\":{:?}}}{comma}", + r.label, r.stt_ms, r.llm_ms, r.tts_ms, r.e2e_ms, r.transcript + ); + } + println!("]"); + + stt.shutdown(); + tts.shutdown(); +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index fcf29d688b9..1feb2073b09 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -29,6 +29,8 @@ pub mod agents; pub mod audio_output; mod commands; pub mod jitter; +#[cfg(test)] +mod latency_bench; pub mod models; pub mod pipeline; pub mod playout; @@ -69,7 +71,8 @@ pub(super) fn drain_until_shutdown( // ── Re-exports ──────────────────────────────────────────────────────────────── pub use commands::{ - interrupt_huddle_speech, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, + add_agent_to_huddle, interrupt_huddle_speech, remove_agent_from_huddle, + set_huddle_manual_mic_unmuted, }; pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode}; pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; @@ -91,7 +94,7 @@ use agent_tts_routing::{ 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, + post_connect_setup, PostConnectOutcome, }; use relay_api::{ count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex, @@ -915,85 +918,3 @@ pub async fn speak_agent_message( eprintln!("buzz-desktop: tts stage=queue status=failed reason=closed route_id={route_id}") }) } - -/// Add an agent to the active huddle. -/// -/// Steps: -/// 1. Validates the huddle is in the Connected or Active phase. -/// 2. Adds the agent to both the ephemeral and parent channels (kind:9000). -/// 3. Only appends the agent pubkey to `agent_pubkeys` if the ephemeral add -/// succeeded — failed adds (policy rejection) are NOT p-tagged. -/// -/// Returns a structured `AgentAddResult` so the frontend can surface -/// parent-channel errors without treating them as hard failures. -/// -/// The running ACP process for this agent auto-subscribes when it receives -/// the kind:9000 membership notification — no separate process spawn needed. -#[tauri::command] -pub async fn add_agent_to_huddle( - agent_pubkey: String, - state: State<'_, AppState>, -) -> Result { - validate_pubkey_hex(&agent_pubkey)?; - - 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()); - } - - // Enforce agent cap on incremental adds too. - let current_agent_count = hs - .agent_pubkeys - .lock() - .unwrap_or_else(|e| e.into_inner()) - .len(); - if current_agent_count >= MAX_HUDDLE_AGENTS { - return Err(format!( - "agent limit reached: {} (max {})", - current_agent_count, MAX_HUDDLE_AGENTS - )); - } - - let eph = hs - .ephemeral_channel_id - .clone() - .ok_or("no ephemeral channel")?; - let parent = hs.parent_channel_id.clone().ok_or("no parent channel")?; - (eph, parent, hs.huddle_generation) - }; - - let eph_uuid = Uuid::parse_str(&eph_id).map_err(|e| e.to_string())?; - let parent_uuid = Uuid::parse_str(&parent_id).map_err(|e| e.to_string())?; - - // 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 — 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. - 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/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 4d4e840104e..afa7aed8e05 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -277,10 +277,6 @@ pub(crate) async fn post_connect_setup( /// /// Returns `Ok(true)` if the pipeline was started, `Ok(false)` if models are /// not ready (voice-only mode), or `Err` on a real failure. -/// -/// Creates the shared `tts_active` flag and passes it to the STT pipeline -/// for barge-in / echo gating. The same flag is later passed to the TTS -/// pipeline so it can signal when audio is playing. pub(crate) async fn maybe_start_stt_pipeline( state: &AppState, ephemeral_channel_id: &str, @@ -309,7 +305,6 @@ pub(crate) async fn maybe_start_stt_pipeline( // 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, agent_pubkeys_arc, session_gen, expected_generation, @@ -345,7 +340,6 @@ pub(crate) async fn maybe_start_stt_pipeline( None }; ( - Arc::clone(&hs.tts_active), Arc::clone(&hs.agent_pubkeys), Arc::clone(&hs.session_generation), hs.session_generation.load(Ordering::Acquire), @@ -359,12 +353,7 @@ pub(crate) async fn maybe_start_stt_pipeline( drop(old_stt); let constructed = tokio::task::spawn_blocking(move || { - stt::SttPipeline::new( - model_dir, - tts_active, - ptt_active_for_stt, - manual_mic_unmuted_for_stt, - ) + stt::SttPipeline::new(model_dir, ptt_active_for_stt, manual_mic_unmuted_for_stt) }) .await; let (pipeline, text_rx) = match constructed { @@ -668,6 +657,7 @@ pub(crate) fn spawn_transcription_task( &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) { Ok(b) => b, diff --git a/desktop/src-tauri/src/huddle/preprocessing.rs b/desktop/src-tauri/src/huddle/preprocessing.rs index ce85e3145e3..8eeddc2bea0 100644 --- a/desktop/src-tauri/src/huddle/preprocessing.rs +++ b/desktop/src-tauri/src/huddle/preprocessing.rs @@ -12,87 +12,6 @@ //! → numbers → words → "forty two" //! → collapse whitespace → clean string //! ``` -//! -//! Also provides `split_sentences` — the single sentence-boundary splitter used -//! by both the TTS batching pipeline and the Supertonic text chunker. - -use regex::Regex; -use std::sync::LazyLock; - -// ── Sentence splitting ──────────────────────────────────────────────────────── - -/// Regex: a sentence-ending punctuation mark followed by whitespace. -static RE_SENTENCE_BOUNDARY: LazyLock = LazyLock::new(|| Regex::new(r"([.!?])\s+").unwrap()); - -/// Common abbreviations that end with a period but are NOT sentence boundaries. -const ABBREVIATIONS: &[&str] = &[ - "Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.", "Jr.", "St.", "Ave.", "Rd.", "Blvd.", "Dept.", - "Inc.", "Ltd.", "Co.", "Corp.", "etc.", "vs.", "i.e.", "e.g.", "Ph.D.", -]; - -/// Split text into sentence-sized chunks. -/// -/// Combines regex-based boundary detection with: -/// - Abbreviation awareness (`Dr.`, `Mr.`, etc. don't split) -/// - Digit-before-period check (avoids splitting `1.` `2.` numbered lists) -/// - `\n` and `—` treated as sentence breaks -/// -/// Returns non-empty, trimmed strings. -pub fn split_sentences(text: &str) -> Vec { - // First, split on newlines and em-dashes to get coarse segments. - let coarse: Vec<&str> = text.split(['\n', '—']).collect(); - - let mut sentences = Vec::new(); - - for segment in coarse { - let segment = segment.trim(); - if segment.is_empty() { - continue; - } - // Within each segment, split on sentence-ending punctuation. - let matches: Vec<_> = RE_SENTENCE_BOUNDARY.find_iter(segment).collect(); - if matches.is_empty() { - sentences.push(segment.to_string()); - continue; - } - - let mut last_end = 0usize; - for m in &matches { - let before = &segment[last_end..m.start()]; - let punc_char = &segment[m.start()..m.start() + 1]; - - // Skip if this looks like an abbreviation. - let combined = format!("{}{}", before.trim(), punc_char); - let is_abbrev = ABBREVIATIONS.iter().any(|a| combined.ends_with(a)); - - // Skip if the character before the period is a digit (numbered list). - let is_digit_period = punc_char == "." - && !before.is_empty() - && before.ends_with(|c: char| c.is_ascii_digit()); - - if !is_abbrev && !is_digit_period { - let piece = segment[last_end..m.end()].trim(); - if !piece.is_empty() { - sentences.push(piece.to_string()); - } - last_end = m.end(); - } - } - - if last_end < segment.len() { - let tail = segment[last_end..].trim(); - if !tail.is_empty() { - sentences.push(tail.to_string()); - } - } - } - - if sentences.is_empty() { - vec![text.to_string()] - } else { - sentences - } -} // ── Public API ──────────────────────────────────────────────────────────────── @@ -602,49 +521,6 @@ mod tests { assert_eq!(out, "hello world"); } - #[test] - fn split_sentences_basic() { - let result = split_sentences("Hello world. How are you? I'm fine!"); - assert_eq!(result, vec!["Hello world.", "How are you?", "I'm fine!"]); - } - - #[test] - fn split_sentences_newline_break() { - let result = split_sentences("First line.\nSecond line."); - assert_eq!(result, vec!["First line.", "Second line."]); - } - - #[test] - fn split_sentences_em_dash_break() { - let result = split_sentences("Start here—then continue."); - assert_eq!(result, vec!["Start here", "then continue."]); - } - - #[test] - fn split_sentences_abbreviations() { - let result = split_sentences("Dr. Smith went home. He was tired."); - assert_eq!(result, vec!["Dr. Smith went home.", "He was tired."]); - } - - #[test] - fn split_sentences_numbered_list() { - let result = split_sentences("1. First item. 2. Second item."); - // "1." and "2." should NOT cause a split (digit before period). - assert_eq!(result, vec!["1. First item.", "2. Second item."]); - } - - #[test] - fn split_sentences_single() { - let result = split_sentences("Just one sentence"); - assert_eq!(result, vec!["Just one sentence"]); - } - - #[test] - fn split_sentences_empty() { - let result = split_sentences(""); - assert_eq!(result, vec![""]); - } - #[test] fn filters_trivial_responses() { assert_eq!(preprocess_for_tts("."), ""); diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 7acf5fe633b..c615ff19c2e 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -137,6 +137,8 @@ pub struct HuddleState { pub ptt_active: Arc, /// True while the clickable microphone control is manually unmuted. /// In PTT mode, either this flag or `ptt_active` opens the STT gate. + /// Defaults to muted so push-to-talk actually gates the microphone + /// until the user explicitly opens it. #[serde(skip)] pub manual_mic_unmuted: Arc, } @@ -226,7 +228,7 @@ impl Default for HuddleState { session_generation: Arc::new(AtomicU64::new(0)), voice_input_mode: VoiceInputMode::default(), ptt_active: Arc::new(AtomicBool::new(false)), - manual_mic_unmuted: Arc::new(AtomicBool::new(true)), + manual_mic_unmuted: Arc::new(AtomicBool::new(false)), } } } @@ -339,10 +341,10 @@ mod tests { } #[test] - fn defaults_to_push_to_talk_with_an_open_microphone() { + fn defaults_to_push_to_talk_with_a_muted_microphone() { let state = HuddleState::default(); assert_eq!(state.voice_input_mode, super::VoiceInputMode::PushToTalk); - assert!(state.manual_mic_unmuted.load(Ordering::Acquire)); + assert!(!state.manual_mic_unmuted.load(Ordering::Acquire)); } #[test] diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 70a80886402..19a28b150b3 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -61,15 +61,11 @@ pub struct SttPipeline { impl SttPipeline { /// Spawn the pipeline thread. /// - /// `tts_active` is a shared flag set by the TTS pipeline while audio is - /// playing. The STT worker uses it to: - /// - discard accumulated speech so local playback cannot feed back into STT - /// - apply a cooldown after TTS stops before re-enabling STT - /// - /// Open-mic VAD cannot distinguish a nearby human from the app's own native - /// TTS playback because it has no acoustic echo reference. Local mic frames - /// therefore never cancel TTS. Push-to-talk and remote participant speech - /// remain explicit, reliable barge-in paths. + /// Mic input is transcribed even while agent TTS is playing: the huddle UI + /// already tells users to wear headphones, so speaker bleed is accepted in + /// exchange for never dropping human speech that overlaps agent audio. + /// Local mic frames still never cancel TTS — push-to-talk and remote + /// participant speech remain the explicit barge-in paths. /// /// `ptt_active` and `manual_mic_unmuted` are present when the PTT shortcut /// is enabled. The pipeline accepts speech while either input path is open; @@ -86,7 +82,6 @@ impl SttPipeline { /// thread on every `recv_timeout` call). pub fn new( model_dir: PathBuf, - tts_active: Arc, ptt_active: Option>, manual_mic_unmuted: Option>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { @@ -105,7 +100,6 @@ impl SttPipeline { audio_rx, text_tx, shutdown_worker, - tts_active, ptt_active_worker, manual_mic_unmuted_worker, ) @@ -166,6 +160,11 @@ impl Drop for SttPipeline { /// How many 16 kHz samples of silence before we flush to STT. /// 300 ms × 16 000 Hz / 256 samples-per-frame ≈ 19 frames. /// Previous value (28 frames / 450 ms) felt sluggish in conversation. +/// +/// This window is a turn-taking quality knob, not a latency lever: an earlier +/// env override (`BUZZ_STT_FLUSH_MS`) let it be lowered to 150 ms, which split +/// natural mid-sentence pauses into separate messages and confused the +/// listening agents. Reverted — the window is fixed at the production value. const SILENCE_FLUSH_FRAMES: usize = 19; /// earshot requires exactly 256 samples per frame at 16 kHz. @@ -183,12 +182,6 @@ const MIN_VOICED_FRAMES: usize = 12; /// How long the worker waits on the audio channel before checking the shutdown flag. const RECV_TIMEOUT: Duration = Duration::from_millis(50); -/// 150 ms cooldown after TTS stops before STT re-enables. -/// Prevents the tail of TTS audio from being transcribed as speech. -/// This remains shorter than the previous 200 ms gate that ate the first word, -/// but is long enough for speaker/AEC tail audio to leave the microphone path. -const TTS_COOLDOWN: Duration = Duration::from_millis(150); - /// Number of ONNX Runtime intra-op threads used by the offline recognizer. /// /// Held at 1 (conservative) until we have a local A/B on real huddle audio. @@ -200,12 +193,31 @@ const TTS_COOLDOWN: Duration = Duration::from_millis(150); /// shows it's safe on the minimum-spec target. const STT_NUM_THREADS: i32 = 1; +/// EXPERIMENTAL (latency bench): override recognizer intra-op threads via +/// `BUZZ_STT_THREADS`. Default preserves the production single thread. +fn stt_num_threads() -> i32 { + std::env::var("BUZZ_STT_THREADS") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&n| n >= 1) + .unwrap_or(STT_NUM_THREADS) +} + +/// EXPERIMENTAL (latency bench): `BUZZ_STT_SPECULATIVE=1` starts the Parakeet +/// decode at the FIRST silent VAD frame instead of after the full flush +/// window, overlapping the ~150-250 ms decode with the silence wait. If +/// speech resumes, the speculative result is discarded. When silence holds +/// to the flush threshold the transcript is emitted immediately, so the STT +/// leg collapses to ~max(flush window, decode time). +fn stt_speculative_decode() -> bool { + std::env::var("BUZZ_STT_SPECULATIVE").is_ok_and(|v| v == "1") +} + fn stt_worker( model_dir: PathBuf, audio_rx: Receiver>, text_tx: tokio_mpsc::Sender, shutdown: Arc, - tts_active: Arc, ptt_active: Option>, manual_mic_unmuted: Option>, ) { @@ -248,7 +260,7 @@ fn stt_worker( let mut cfg = OfflineRecognizerConfig::default(); cfg.model_config.nemo_ctc.model = Some(model_path.to_string_lossy().into_owned()); cfg.model_config.tokens = Some(tokens_path.to_string_lossy().into_owned()); - cfg.model_config.num_threads = STT_NUM_THREADS; + cfg.model_config.num_threads = stt_num_threads(); // Explicit — defaults are not part of the API contract, and noisy debug // logging in release builds would be expensive on every VAD chunk. cfg.model_config.debug = false; @@ -275,11 +287,14 @@ fn stt_worker( let mut in_speech = false; // Number of frames earshot classified as voiced in the current segment. let mut voiced_frames = 0; - // Timestamp when TTS last stopped — used for the playback-tail cooldown. - let mut tts_stopped_at: Option = None; + // Silence flush window (frames) — fixed at the production value. + let flush_frames = SILENCE_FLUSH_FRAMES; + // EXPERIMENTAL: speculative decode result + the voiced-frame count it was + // computed at. Valid only while no new voiced frame has arrived since. + let speculative_enabled = stt_speculative_decode(); + let mut speculative: Option<(String, usize)> = None; // ── 5. Main loop ────────────────────────────────────────────────────────── - let mut tts_was_active = false; let mut transmit_was_active = ptt_active .as_ref() .is_some_and(|ptt| ptt.load(Ordering::Acquire)) @@ -292,14 +307,6 @@ fn stt_worker( break; } - // Track TTS transitions to set the cooldown timer. - let tts_now = tts_active.load(Ordering::Acquire); - if tts_was_active && !tts_now { - // TTS just stopped — record the timestamp for the cooldown window. - tts_stopped_at = Some(std::time::Instant::now()); - } - tts_was_active = tts_now; - // Track the combined manual/PTT transmission edge. When both paths // close, the worklet stops sending frames, so flush here rather than // waiting for silence that will never arrive. @@ -348,10 +355,10 @@ fn stt_worker( &mut silence_frames, &mut in_speech, &mut voiced_frames, + flush_frames, + (speculative_enabled, &mut speculative), &recognizer, &text_tx, - &tts_active, - &mut tts_stopped_at, ptt_active.as_ref(), manual_mic_unmuted.as_ref(), ); @@ -391,14 +398,16 @@ fn resample_chunk(resampler: &mut rubato::Fft, chunk_48k: &[f32]) -> Vec), recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, - tts_active: &Arc, - tts_stopped_at: &mut Option, ptt_active: Option<&Arc>, manual_mic_unmuted: Option<&Arc>, ) { + let (speculative_enabled, speculative) = speculative; leftover.extend_from_slice(samples); while leftover.len() >= VAD_FRAME_SAMPLES { @@ -424,54 +434,26 @@ fn process_16k_samples( let is_speech = prob > VAD_THRESHOLD; let manually_open = manual_mic_unmuted.is_some_and(|manual| manual.load(Ordering::Acquire)); + let ptt_held = ptt_active.is_some_and(|ptt| ptt.load(Ordering::Acquire)); // Shortcut-enabled mode accepts input from either the held shortcut or // a manually open microphone. - let is_speech = if let Some(ptt) = ptt_active { - is_speech && (ptt.load(Ordering::Acquire) || manually_open) + let is_speech = if ptt_active.is_some() { + is_speech && (ptt_held || manually_open) } else { is_speech }; - - let tts_playing = tts_active.load(Ordering::Acquire); - - // While TTS is playing, discard local mic input. The native TTS output - // is not available as an echo-cancellation reference to this worker, so - // VAD cannot reliably tell speaker feedback from a human interruption. - // Push-to-talk and remote participant audio provide the intentional - // cancellation paths instead. - if tts_playing { - *in_speech = false; - speech_buf.clear(); - *silence_frames = 0; - *voiced_frames = 0; - continue; - } - - // TTS not playing — check cooldown window. - if let Some(stopped) = *tts_stopped_at { - if stopped.elapsed() < TTS_COOLDOWN { - // Still in cooldown — discard but keep tracking speech state. - if !is_speech { - *in_speech = false; - } - speech_buf.clear(); - *silence_frames = 0; - *voiced_frames = 0; - continue; - } else { - // Cooldown expired — clear the timer and reset all segment state. - *tts_stopped_at = None; - *in_speech = false; - *silence_frames = 0; - *voiced_frames = 0; - } - } + // A held shortcut means "I am not done talking": silence never ends + // the utterance while it is held. VAD pause flushing applies in pure + // VAD mode, or with a manually open mic once the shortcut is up. + let vad_flush_allowed = vad_flush_allowed(ptt_active.is_some(), manually_open, ptt_held); if is_speech { *silence_frames = 0; *in_speech = true; *voiced_frames += 1; speech_buf.extend_from_slice(&frame); + // New voiced audio invalidates any speculative decode. + speculative.take(); // OOM guard: flush and reset if the buffer exceeds 30 s of audio. if speech_buf.len() >= MAX_SPEECH_SAMPLES { @@ -486,11 +468,29 @@ fn process_16k_samples( speech_buf.extend_from_slice(&frame); *silence_frames += 1; - // A manually open microphone behaves like normal VAD. A - // shortcut-only transmission stays grouped until key release. - if (ptt_active.is_none() || manually_open) && *silence_frames >= SILENCE_FLUSH_FRAMES { - // End of utterance — transcribe. - flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); + // EXPERIMENTAL: kick the Parakeet decode at the first silent + // frame so it overlaps the flush window. speech_buf keeps + // accumulating silence afterwards, but trailing silence does not + // change the transcript; any resumed speech invalidates the + // speculative result above. + if speculative_enabled + && speculative.is_none() + && vad_flush_allowed + && has_enough_voiced_audio(*voiced_frames) + { + speculative.replace((decode_speech(recognizer, speech_buf), *voiced_frames)); + } + + // A manually open microphone behaves like normal VAD. A held + // shortcut keeps the utterance grouped until key release. + if vad_flush_allowed && *silence_frames >= flush_frames { + // End of utterance — transcribe (or emit the speculative decode). + match speculative.take() { + Some((text, decoded_at)) if decoded_at == *voiced_frames => { + send_transcript(text, text_tx); + } + _ => flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx), + } speech_buf.clear(); *silence_frames = 0; *in_speech = false; @@ -514,16 +514,22 @@ fn flush_to_stt( if speech_buf.is_empty() || !has_enough_voiced_audio(voiced_frames) { return; } + send_transcript(decode_speech(recognizer, speech_buf), text_tx); +} +/// Run the Parakeet decode on a speech buffer and return the trimmed text. +fn decode_speech(recognizer: &sherpa_onnx::OfflineRecognizer, speech_buf: &[f32]) -> String { let stream = recognizer.create_stream(); stream.accept_waveform(16_000, speech_buf); recognizer.decode(&stream); - let text = stream + stream .get_result() .map(|r| r.text.trim().to_string()) - .unwrap_or_default(); + .unwrap_or_default() +} +fn send_transcript(text: String, text_tx: &tokio_mpsc::Sender) { if !text.is_empty() { if let Err(e) = text_tx.blocking_send(text) { eprintln!("buzz-desktop: STT text channel closed: {e}"); @@ -535,6 +541,17 @@ fn has_enough_voiced_audio(voiced_frames: usize) -> bool { voiced_frames >= MIN_VOICED_FRAMES } +/// Whether a silence run may end the current utterance and flush it to STT. +/// +/// Pure VAD mode (no shortcut configured) always allows pause flushing. When +/// the push-to-talk shortcut is configured, a held shortcut is an explicit +/// "I am not done talking" signal, so silence never flushes while it is held +/// — even if the microphone is also manually open. A manually open mic with +/// the shortcut up behaves like normal VAD. +fn vad_flush_allowed(ptt_mode: bool, manually_open: bool, ptt_held: bool) -> bool { + !ptt_mode || (manually_open && !ptt_held) +} + /// Convert raw bytes (f32 LE) to f32 samples. /// Caller should ensure `bytes.len() % 4 == 0`; extra bytes are silently truncated. /// @@ -553,7 +570,7 @@ use super::drain_until_shutdown; #[cfg(test)] mod tests { - use super::{has_enough_voiced_audio, MIN_VOICED_FRAMES}; + use super::{has_enough_voiced_audio, vad_flush_allowed, MIN_VOICED_FRAMES}; #[test] fn short_vad_blips_do_not_reach_the_recognizer() { @@ -561,4 +578,19 @@ mod tests { assert!(!has_enough_voiced_audio(MIN_VOICED_FRAMES - 1)); assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); } + + #[test] + fn held_push_to_talk_never_silence_flushes() { + // Pure VAD mode: silence always ends the utterance. + assert!(vad_flush_allowed(false, false, false)); + // Shortcut configured, nothing transmitting: nothing to flush anyway, + // but the pause path stays closed. + assert!(!vad_flush_allowed(true, false, false)); + // Shortcut held: "I am not done talking" — never flush on silence, + // regardless of the manual mic state. + assert!(!vad_flush_allowed(true, false, true)); + assert!(!vad_flush_allowed(true, true, true)); + // Manually open mic with the shortcut up: normal VAD behavior. + assert!(vad_flush_allowed(true, true, false)); + } } diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 6a56f85444c..aca2339a3c4 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -7,9 +7,9 @@ //! → bounded sync_channel (TEXT_QUEUE_DEPTH = 8) //! → tts_worker thread (owns 1 Pocket TTS engine + 1 persistent Player) //! 1. Preprocess text -//! 2. Split into sentences -//! 3. Synthesize each sentence individually → f32 PCM -//! 4. Clamp to full scale + fade out each sentence +//! 2. Split into tokenizer-safe natural units, prioritizing sentence one +//! 3. Synthesize each unit → f32 PCM +//! 4. Clamp to full scale + fade out each unit //! 5. Append each buffer to the persistent rodio Player (gapless) //! 6. While audio is draining, keep pulling queued text items and //! synthesizing ahead — playback of item N overlaps synthesis of @@ -41,7 +41,7 @@ use std::{ sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{self, SyncSender}, - Arc, Mutex, MutexGuard, PoisonError, + Arc, Mutex, }, thread, time::{Duration, Instant}, @@ -50,7 +50,7 @@ use std::{ 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}; +use super::preprocessing::preprocess_for_tts; #[path = "tts_voice_transition.rs"] mod voice_transition; @@ -69,6 +69,9 @@ mod pipeline_controls; #[path = "tts_speaker_cancellation.rs"] mod speaker_cancellation; use speaker_cancellation::*; +#[path = "tts_streaming.rs"] +mod streaming; +use streaming::*; // ── Constants ───────────────────────────────────────────────────────────────── @@ -99,38 +102,11 @@ const SYNTH_STEPS: usize = 1; /// 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 -/// sentence chunk, so the OS audio device / rodio mixer has a fully-quiet -/// ramp-up window before the real onset hits. -/// -/// This used to be applied only before the first sentence of a whole response. -/// That still left later sentence chunks vulnerable to first-syllable clipping -/// when their first phoneme was soft (notably `I'm` / `I've`) and rodio crossed -/// from an explicit silence buffer straight into non-zero speech. 20 ms ≈ 480 -/// samples is enough to cover a CoreAudio buffer turnover without being audible -/// as latency. At sentence boundaries this lead-in is budgeted out of the -/// existing inter-sentence pause, so it does not lengthen multi-sentence gaps. +/// Length of the zero-sample cushion prepended when playback is idle, so the +/// OS audio device / rodio mixer has a fully-quiet ramp-up window before the +/// real onset hits. Continuously queued chunks receive no synthetic padding. const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize; -/// Approximate character budget for one synthesis chunk. -/// -/// Upstream pocket-tts groups sentences into chunks of up to -/// `MAX_TOKEN_PER_CHUNK = 50` tokenizer tokens (`default_parameters.py`) — -/// typically multi-sentence chunks — because every `generate()` call is an -/// independent generation with a cold FlowLM start, and each chunk boundary -/// is an exposed prosody seam (kyutai-labs/pocket-tts #151; the Kyutai team -/// names chunk stitching as the reliability lever). Our previous -/// sentence-per-call path created ~2–4× more seams than upstream. -/// -/// 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, @@ -450,7 +426,9 @@ fn tts_worker( // `tts_active` lifecycle: set on the first append while idle, cleared // whenever the player has fully drained — either in the idle timeout // arm or on item receipt before synthesis begins. - let silence_buf_len = (INTER_SENTENCE_SILENCE * SAMPLE_RATE as f32) as usize; + // EXPERIMENTAL (latency bench): `Some(emit_frames)` = stream PCM deltas + // out of Pocket as they are generated (see tts_streaming.rs). + let tts_streaming = streaming_emit_frames(); // `first_append` = "no audio queued since the player last went idle". // Flipped by `build_sentence_append_buffer` on the first real append; the // idle branch below uses it to decide when to drop `tts_active` and to @@ -705,17 +683,20 @@ fn tts_worker( 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. 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); + // Let Pocket's tokenizer-aware splitter isolate the first sentence for + // minimum time-to-first-audio, then pack later sentences into the + // largest natural units within the model's exact 50-token limit. Once + // each unit is appended, generation of the next proceeds while rodio + // plays the already-queued audio. + let chunks = match engine.split_text_for_playback(&text) { + Ok(chunks) => chunks, + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=failed reason=chunking route_id={route_id}" + ); + continue; + } + }; if chunks.is_empty() { eprintln!( "buzz-desktop: tts stage=synthesis status=empty reason=no_chunks route_id={route_id}" @@ -747,6 +728,41 @@ fn tts_worker( continue; } + // EXPERIMENTAL (latency bench): streaming synthesis path — see + // tts_streaming.rs for the mechanics and exactness constraints. + if let Some(emit_frames) = tts_streaming { + let outcome = synthesize_streaming( + &engine, + text, + &style, + emit_frames, + (&cancel, &voice_cancel, &shutdown), + StreamingPlayback { + player: &player, + first_append: &mut first_append, + route_id, + }, + &mut |prepared| { + if !append_audio( + prepared, + route_id, + speaker_pubkey.as_deref(), + speaker_generation, + ) { + return false; + } + appended_audio = true; + last_route_id = route_id; + true + }, + ); + if let Some(outcome) = outcome { + synthesis_outcome = outcome; + break 'playback_chunks; + } + continue; + } + let model_chunks = match engine.split_text_into_chunks(text) { Ok(model_chunks) => model_chunks, Err(_) => { @@ -811,7 +827,6 @@ fn tts_worker( samples, chunk_index, &mut first_append, - silence_buf_len, player.empty(), ) { if !append_audio( @@ -842,9 +857,7 @@ fn tts_worker( } } } - if let Some(prepared) = - playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) - { + if let Some(prepared) = playback_audio.finish(&mut first_append, player.empty()) { if !append_audio( prepared, route_id, @@ -882,90 +895,6 @@ fn tts_worker( tts_active.store(false, Ordering::Release); } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Check for cancel or shutdown. Returns `true` if the caller should break/continue. -/// On cancel: drains the text queue and clears the cancel flag. -/// -/// `player` pairs the Player with the `player_ops` mutex shared with the -/// barge-in monitor thread; the cancel/shutdown clear runs under that lock so -/// it is serialized with the monitor's stale-branch re-check (see the monitor -/// block in `tts_worker`). -fn handle_cancel_or_shutdown( - cancel_signals: CancelSignals<'_>, - shutdown: &AtomicBool, - tts_active: &AtomicBool, - 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(); - } - tts_active.store(false, Ordering::Release); - return true; - } - 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 - // (rodio 0.22 `clear()` ends with `self.pause()`). With one - // persistent Player for the worker's lifetime, the un-pause is - // mandatory: without `play()`, every append after a barge-in - // would queue silently forever. - p.clear(); - p.play(); - // 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. - } - tts_active.store(false, Ordering::Release); - return true; - } - false -} - -/// Acquire the `player_ops` lock, recovering from poison. -/// -/// The data under the mutex is `()` — it only serializes Player mutations — -/// so a panicked holder leaves nothing inconsistent to observe and recovery -/// is always safe. Without this, a worker panic would wedge the monitor (or -/// vice versa) on `unwrap()`. -fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { - ops.lock().unwrap_or_else(PoisonError::into_inner) -} - // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/huddle/tts_audio.rs b/desktop/src-tauri/src/huddle/tts_audio.rs index 58300b7497e..80bf0c4661c 100644 --- a/desktop/src-tauri/src/huddle/tts_audio.rs +++ b/desktop/src-tauri/src/huddle/tts_audio.rs @@ -10,15 +10,11 @@ pub(super) struct PreparedModelAudio { /// 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, - } + Self { pending: None } } pub(super) fn push( @@ -26,36 +22,26 @@ impl PlaybackChunkAudio { 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; + let prepared = prepare_model_audio(previous, first_append, playback_idle, false); 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, + playback_idle, true, )) } @@ -64,7 +50,6 @@ impl PlaybackChunkAudio { 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 { @@ -74,13 +59,7 @@ fn prepare_model_audio( apply_fade_out(&mut audio); } PreparedModelAudio { - buffer: build_sentence_append_buffer( - first_append, - audio, - silence_buf_len, - starts_playback_chunk, - ends_playback_chunk, - ), + buffer: build_sentence_append_buffer(first_append, audio, starts_playback_chunk), sample_count, chunk_index, } @@ -103,9 +82,7 @@ pub(super) fn apply_fade_out(samples: &mut [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; @@ -116,117 +93,73 @@ pub(super) fn build_sentence_append_buffer( } 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); + let mut buffer = Vec::with_capacity(lead_in_len + audio.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() { + fn model_units_are_queued_contiguously_without_injected_silence() { 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) + .push(vec![0.4; 16], 0, &mut first_append, false) .is_none()); let first = chunk - .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .push(vec![0.5; 16], 1, &mut first_append, 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); + assert_eq!(first.buffer, vec![0.4; 16]); let last = chunk - .finish(&mut first_append, silence, false) + .finish(&mut first_append, false) .expect("last ready model unit"); - assert_eq!(last.buffer.len(), 16 + 100); - assert_eq!(last.buffer.last(), Some(&0.0)); + assert_eq!(last.buffer.len(), 16); + assert_eq!(last.sample_count, 16); } #[test] - fn empty_edge_units_do_not_steal_lead_in_or_trailing_boundary() { + fn empty_edge_units_do_not_steal_audio_boundaries() { 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) + .push(Vec::new(), 0, &mut first_append, false) .is_none()); assert!(chunk - .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .push(vec![0.5; 16], 1, &mut first_append, false) .is_none()); assert!(chunk - .push(Vec::new(), 2, &mut first_append, silence, false) + .push(Vec::new(), 2, &mut first_append, false) .is_none()); let only = chunk - .finish(&mut first_append, silence, false) + .finish(&mut first_append, 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)); + assert_eq!(only.buffer.len(), 16); } #[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) + .push(vec![0.4; 16], 0, &mut first_append, 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); + .push(vec![0.5; 16], 1, &mut first_append, false) + .expect("first ready model unit"); + assert_eq!(first.buffer.len(), 16); let after_underrun = chunk - .push(vec![0.6; 16], 2, &mut first_append, silence, true) - .expect("model unit after underrun"); + .push(vec![0.6; 16], 2, &mut first_append, true) + .expect("second ready model unit"); assert_eq!(after_underrun.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); assert!(after_underrun.buffer[..SENTENCE_LEAD_IN_SAMPLES] .iter() diff --git a/desktop/src-tauri/src/huddle/tts_streaming.rs b/desktop/src-tauri/src/huddle/tts_streaming.rs new file mode 100644 index 00000000000..2bb401c43f5 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_streaming.rs @@ -0,0 +1,104 @@ +//! EXPERIMENTAL (latency bench): streaming synthesis path for the TTS worker. +//! +//! `BUZZ_TTS_STREAMING=1` streams PCM deltas out of Pocket as they are +//! generated instead of waiting for the full first-chunk synthesis. +//! `BUZZ_TTS_EMIT_FRAMES` tunes the delta size in Flow LM frames (80 ms of +//! audio each). Default 12 = the Mimi decoder's native chunk, which keeps +//! streamed audio bit-identical to the batch path; smaller deltas are faster +//! to first audio but diverge (~23 dB SNR vs batch — decoder intra-chunk +//! lookahead). + +use super::*; + +use crate::huddle::pocket::{PocketTts, VoiceStyle}; + +/// Read the streaming env overrides once per worker: `Some(emit_frames)` +/// when `BUZZ_TTS_STREAMING=1`, `None` for the production batch path. +pub(super) fn streaming_emit_frames() -> Option { + std::env::var("BUZZ_TTS_STREAMING") + .is_ok_and(|v| v == "1") + .then(|| { + std::env::var("BUZZ_TTS_EMIT_FRAMES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(12) + }) +} + +/// Playback context threaded through one streamed chunk. +pub(super) struct StreamingPlayback<'a> { + pub(super) player: &'a rodio::Player, + pub(super) first_append: &'a mut bool, + pub(super) route_id: u64, +} + +/// Synthesize one text chunk through `synth_chunk_streaming`, appending PCM +/// deltas to the player as they are generated so first audio lands after +/// ~`emit_frames` of generation instead of after the whole first-chunk +/// synthesis. Delta boundary decoration reuses `PlaybackChunkAudio`: lead-in +/// on the first delta, fade-out only on the final one. +/// +/// `signals` = (cancel, voice_cancel, shutdown); `append_audio` returns +/// `false` to abort (its own cancellation checks and logging apply). Returns +/// `None` on success or `Some(outcome)` — the worker's `synthesis_outcome` +/// label — when the chunk was cancelled or failed. +pub(super) fn synthesize_streaming( + engine: &PocketTts, + text: &str, + style: &VoiceStyle, + emit_frames: usize, + signals: (&AtomicBool, &AtomicBool, &AtomicBool), + playback: StreamingPlayback<'_>, + append_audio: &mut dyn FnMut(PreparedModelAudio) -> bool, +) -> Option<&'static str> { + let (cancel, voice_cancel, shutdown) = signals; + let StreamingPlayback { + player, + first_append, + route_id, + } = playback; + let mut playback_audio = PlaybackChunkAudio::new(); + let mut delta_index = 0usize; + let stream_result = engine.synth_chunk_streaming(text, style, emit_frames, &mut |samples| { + if cancel.load(Ordering::Acquire) + || voice_cancel.load(Ordering::Acquire) + || shutdown.load(Ordering::Acquire) + { + return false; + } + let chunk_index = delta_index; + delta_index += 1; + if let Some(prepared) = + playback_audio.push(samples, chunk_index, first_append, player.empty()) + { + if !append_audio(prepared) { + return false; + } + } + true + }); + match stream_result { + Ok(true) => { + if let Some(prepared) = playback_audio.finish(first_append, player.empty()) { + if !append_audio(prepared) { + *first_append = true; + return Some("cancelled"); + } + } + None + } + Ok(false) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason=stream_callback route_id={route_id}" + ); + *first_append = true; + Some("cancelled") + } + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=failed reason=inference route_id={route_id}" + ); + Some("failed") + } + } +} diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 1dee4de90cc..50e4d17ced5 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -785,98 +785,63 @@ fn apply_fade_out_single_sample() { // ── build_sentence_append_buffer tests ─────────────────────────────────── -/// REGRESSION: every chunk needs an onset cushion; synthesized chunks -/// can start with speech energy within the first millisecond. -#[test] -fn lead_in_pad_is_present_for_every_sentence_chunk() { - const SENTENCE_AUDIO_LEN: usize = 1000; - const SILENCE_BUF_LEN: usize = 2400; // 100 ms at 24 kHz, like production - const N_SENTENCES: usize = 5; - - let mut first = true; - - for _ in 0..N_SENTENCES { - let buf = build_sentence_append_buffer( - &mut first, - vec![0.5_f32; SENTENCE_AUDIO_LEN], - SILENCE_BUF_LEN, - true, - true, - ); - - assert_eq!(buf.len(), SENTENCE_AUDIO_LEN + SILENCE_BUF_LEN); - assert!( - buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0), - "lead-in pad must be pure silence" - ); - assert!( - buf[SENTENCE_LEAD_IN_SAMPLES..SENTENCE_LEAD_IN_SAMPLES + SENTENCE_AUDIO_LEN] - .iter() - .all(|&s| s == 0.5), - "sentence audio must immediately follow the lead-in" - ); - assert!( - buf[SENTENCE_LEAD_IN_SAMPLES + SENTENCE_AUDIO_LEN..] - .iter() - .all(|&s| s == 0.0), - "trailing gap must be pure silence" - ); - } - - assert!(!first, "first_append flag must be cleared after first call"); -} - -/// `first_append` still flips on the first call for `tts_active` gating. +/// `first_append` still flips on the first append for `tts_active` gating. #[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, true, true); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], false); + assert_eq!(buf, vec![0.5; 100]); 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, true, true); - assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); - assert!(!first); } -/// Leading silence is exactly the lead-in; no pre-audio gap is double-counted. +/// Playback chunks are contiguous: Pocket's generated pause is not extended +/// with a fixed inter-sentence silence budget. #[test] -fn first_sentence_leading_silence_is_exactly_lead_in() { +fn sentence_append_buffer_does_not_inject_silence() { let mut first = true; - 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); + let first_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], false); + let second_buf = build_sentence_append_buffer(&mut first, vec![0.25; 100], false); + + assert_eq!(first_buf, vec![0.5; 100]); + assert_eq!(second_buf, vec![0.25; 100]); } -/// Tail silence plus the next lead-in preserves the 100 ms sentence gap. +/// If generation falls behind playback, retain the onset cushion that protects +/// the first phoneme while the output path wakes back up. #[test] -fn sentence_gap_budget_is_preserved() { +fn idle_playback_gets_an_onset_cushion() { 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, true, true); - let second_buf = - build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, true); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], true); - let first_tail = &first_buf[SENTENCE_LEAD_IN_SAMPLES + 100..]; - let second_lead = &second_buf[..SENTENCE_LEAD_IN_SAMPLES]; - assert_eq!(first_tail.len(), silence_buf_len - SENTENCE_LEAD_IN_SAMPLES); - assert_eq!(second_lead.len(), SENTENCE_LEAD_IN_SAMPLES); - assert_eq!(first_tail.len() + second_lead.len(), silence_buf_len); + assert_eq!(buf.len(), SENTENCE_LEAD_IN_SAMPLES + 100); + assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); + assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5); } -/// Regression guard: one contiguous rodio source per synthesized sentence. #[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, true, true); +fn tts_worker_uses_distinct_playback_and_model_splitters() { + let source = include_str!("tts.rs"); + let playback_calls = source.matches("engine.split_text_for_playback(").count(); + let model_calls = source.matches("engine.split_text_into_chunks(").count(); - assert_eq!(buf.len(), 2400 + 100); - assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); + assert_eq!( + (playback_calls, model_calls), + (1, 1), + "the worker must isolate sentence one only in the outer playback split" + ); + + // Counts alone are order-blind: swapping the two call sites keeps them at + // (1, 1) while the outer split stops isolating sentence one, which delays + // first audio by a whole generation. Pin the ORDER too. + let playback_at = source + .find("engine.split_text_for_playback(") + .expect("outer playback split exists"); + let model_at = source + .find("engine.split_text_into_chunks(") + .expect("inner model split exists"); assert!( - buf[SENTENCE_LEAD_IN_SAMPLES..SENTENCE_LEAD_IN_SAMPLES + 100] - .iter() - .all(|&s| s == 0.5) + playback_at < model_at, + "the playback split must be the OUTER pass; swapping the two delays first audio" ); } @@ -907,79 +872,3 @@ fn clamp_to_full_scale_empty_buffer() { let out = clamp_to_full_scale(Vec::new()); assert!(out.is_empty()); } - -// ── group_sentences_into_chunks tests ───────────────────────────────────── - -fn s(v: &[&str]) -> Vec { - v.iter().map(|x| x.to_string()).collect() -} - -/// The first sentence always stands alone — it bounds time-to-first-audio. -/// Even when the whole message would fit in one chunk, sentence one must -/// not wait on synthesis of the rest. -#[test] -fn chunk_grouping_first_sentence_is_always_alone() { - let chunks = group_sentences_into_chunks(&s(&["Hi there.", "Short.", "Tiny."]), 200); - assert_eq!(chunks[0], "Hi there."); - assert_eq!(chunks.len(), 2); - assert_eq!(chunks[1], "Short. Tiny."); -} - -/// Sentences after the first pack greedily up to the char budget, then -/// spill into a new chunk. Fewer generate() calls = fewer prosody seams. -#[test] -fn chunk_grouping_packs_up_to_budget_then_spills() { - let a = "A".repeat(50) + "."; - let b = "B".repeat(50) + "."; - let c = "C".repeat(50) + "."; - let d = "D".repeat(50) + "."; - // Budget of 110: b+c fits (51+1+51 = 103), adding d (103+1+51) does not. - let chunks = group_sentences_into_chunks(&s(&[&a, &b, &c, &d]), 110); - assert_eq!(chunks.len(), 3, "chunks: {chunks:?}"); - assert_eq!(chunks[0], a); - assert_eq!(chunks[1], format!("{b} {c}")); - assert_eq!(chunks[2], d); -} - -/// 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() + "."; - assert!(long.len() > 200); - let chunks = group_sentences_into_chunks(&s(&["First.", &long]), 200); - assert_eq!(chunks, vec!["First.".to_string(), long]); -} - -/// Single-sentence messages — the common huddle case, since agents are -/// prompted to send one sentence per message — are unaffected by grouping. -#[test] -fn chunk_grouping_single_sentence_unchanged() { - let chunks = group_sentences_into_chunks(&s(&["Just one sentence here."]), 200); - assert_eq!(chunks, vec!["Just one sentence here.".to_string()]); -} - -/// Empty and whitespace-only entries are dropped, and never produce -/// empty chunks (which would synthesize as garbage). -#[test] -fn chunk_grouping_skips_blank_sentences() { - let chunks = group_sentences_into_chunks(&s(&["", " ", "Real sentence.", " ", "Two."]), 200); - assert_eq!(chunks[0], "Real sentence."); - assert_eq!(chunks.len(), 2); - assert_eq!(chunks[1], "Two."); -} - -/// Empty input produces no chunks (the worker loop then synthesizes nothing). -#[test] -fn chunk_grouping_empty_input() { - assert!(group_sentences_into_chunks(&[], 200).is_empty()); -} - -/// Chunks joined with a single space preserve each sentence's terminal -/// punctuation — the model sees natural multi-sentence prose, matching the -/// shape upstream's ~50-token chunker produces. -#[test] -fn chunk_grouping_preserves_punctuation_at_joins() { - let chunks = group_sentences_into_chunks(&s(&["Lead.", "Really?", "Yes!", "Good."]), 200); - assert_eq!(chunks[1], "Really? Yes! Good."); -} diff --git a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs index b9249c9afc4..404f8a8153f 100644 --- a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs +++ b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs @@ -2,7 +2,7 @@ use super::*; /// The onset cushion covers 20 ms at the production sample rate. #[test] -fn sentence_lead_in_is_sane() { +fn chunk_lead_in_is_sane() { assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); } @@ -11,14 +11,11 @@ fn sentence_lead_in_is_sane() { #[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); + let first_unit = build_sentence_append_buffer(&mut first, vec![0.5; 100], false); + let last_unit = build_sentence_append_buffer(&mut first, vec![0.25; 100], false); - assert_eq!(first_unit.len(), SENTENCE_LEAD_IN_SAMPLES + 100); + assert_eq!(first_unit.len(), 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); + assert_eq!(first_unit.len() + last_unit.len(), 200); } diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index a60d3506ffa..99b165bfe81 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -5,7 +5,7 @@ use std::{ sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{self, SyncSender}, - Arc, Mutex, + Arc, Mutex, MutexGuard, PoisonError, }, }; @@ -64,7 +64,7 @@ impl PlaybackProbe { } pub(super) fn set_synthesis_in_flight(&self, in_flight: bool) { - let _ops = super::lock_player_ops(&self.player_ops); + let _ops = lock_player_ops(&self.player_ops); self.synthesis_in_flight.store(in_flight, Ordering::Release); } @@ -202,7 +202,7 @@ pub(super) fn request_active_speaker_cancel( let Some(player) = playback_probe.player() else { return false; }; - let _ops = super::lock_player_ops(&playback_probe.player_ops); + let _ops = lock_player_ops(&playback_probe.player_ops); let playback_live = !player.empty() || playback_probe.synthesis_in_flight.load(Ordering::Acquire); request_active_speaker_cancel_while_locked( @@ -472,6 +472,88 @@ fn log_cancelled_route(route_id: u64, reason: &str) { eprintln!("buzz-desktop: tts stage=queue status=dropped reason={reason} route_id={route_id}"); } +/// Check for cancel or shutdown. Returns `true` if the caller should break/continue. +/// On cancel: drains the text queue and clears the cancel flag. +/// +/// `player` pairs the Player with the `player_ops` mutex shared with the +/// barge-in monitor thread; the cancel/shutdown clear runs under that lock so +/// it is serialized with the monitor's stale-branch re-check (see the monitor +/// block in `tts_worker`). +pub(super) fn handle_cancel_or_shutdown( + cancel_signals: CancelSignals<'_>, + shutdown: &AtomicBool, + tts_active: &AtomicBool, + 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(); + } + tts_active.store(false, Ordering::Release); + return true; + } + 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 + // (rodio 0.22 `clear()` ends with `self.pause()`). With one + // persistent Player for the worker's lifetime, the un-pause is + // mandatory: without `play()`, every append after a barge-in + // would queue silently forever. + p.clear(); + p.play(); + // 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. + } + tts_active.store(false, Ordering::Release); + return true; + } + false +} + +/// Acquire the `player_ops` lock, recovering from poison. +/// +/// The data under the mutex is `()` — it only serializes Player mutations — +/// so a panicked holder leaves nothing inconsistent to observe and recovery +/// is always safe. Without this, a worker panic would wedge the monitor (or +/// vice versa) on `unwrap()`. +pub(super) fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { + ops.lock().unwrap_or_else(PoisonError::into_inner) +} + #[cfg(test)] mod speaker_generation_tests { use super::*; diff --git a/desktop/src-tauri/src/initial_window.rs b/desktop/src-tauri/src/initial_window.rs index b1245515125..f6d88259b2e 100644 --- a/desktop/src-tauri/src/initial_window.rs +++ b/desktop/src-tauri/src/initial_window.rs @@ -15,9 +15,16 @@ pub(crate) fn reveal_initial_window(window: &tauri::Window #[cfg(target_os = "macos")] pub(crate) fn set_initial_window_backing(window: &tauri::Window) { - // The window remains transparent at runtime for vibrancy. Use an opaque - // native backing only across the first visible frames so the previous app - // cannot show through before WebKit has submitted its first surface. + // Both this write and the deferred clear target the Window (NSWindow) + // backing color only; they never touch the webview canvas or the + // NSVisualEffectView, so they are not load-bearing for glass. Glass state + // — the effect view and webview-canvas transparency — is managed entirely + // by `set_window_vibrancy`, which the ThemeProvider calls after mount. The + // 250ms-delayed clear cannot clobber a persisted-glass-on cold boot + // regardless of ordering with that call. + // + // Write an opaque dark backing so the previous app cannot show through + // before WebKit submits its first composited surface. if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { eprintln!("buzz-desktop: failed to set initial window backing: {error}"); } @@ -26,6 +33,10 @@ pub(crate) fn set_initial_window_backing(window: &tauri::Wind #[cfg(target_os = "macos")] pub(crate) async fn clear_initial_window_backing(window: &tauri::Window) { tokio::time::sleep(std::time::Duration::from_millis(250)).await; + // Restore the default system window background so fast-resize gutter + // flashes match the platform theme rather than the hardcoded dark color + // written at reveal. Targets the Window (NSWindow) layer only; webview + // canvas and glass state are unaffected. if let Err(error) = window.set_background_color(None) { eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 66abfdf8ea5..04f42d085fd 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -51,8 +51,11 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; use commands::*; use deep_link::{ - acknowledge_pending_community_deep_link, handle_deep_link_url, - take_pending_community_deep_link, PendingCommunityDeepLinks, + acknowledge_pending_community_deep_link, acknowledge_pending_entity_deep_link, + acknowledge_pending_navigation_deep_link, clear_pending_navigation_deep_links, + handle_deep_link_url, take_pending_community_deep_link, take_pending_entity_deep_link, + take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingEntityDeepLinks, + PendingNavigationDeepLinks, }; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, @@ -194,8 +197,7 @@ pub fn run() { .plugin(tauri_plugin_process::init()); // The global-shortcut plugin is omitted from test builds: linking it into - // the lib-test binary makes it fail to load on Windows - // (STATUS_ENTRYPOINT_NOT_FOUND) before any test runs. + // the lib-test binary makes it fail to load on Windows (STATUS_ENTRYPOINT_NOT_FOUND) before any test runs. #[cfg(not(test))] let builder = builder.plugin({ use tauri_plugin_global_shortcut::ShortcutState; @@ -245,13 +247,7 @@ pub fn run() { .store(true, std::sync::atomic::Ordering::Release); } } - // Emit ptt-state=true to the frontend. - // The React side plays the press audio cue on this event - // (Web Audio API via HuddleContext). Rust-side rodio audio - // was considered but rejected: the rodio OutputStream must - // outlive the handler and sharing it across the shortcut - // closure adds lifecycle complexity for marginal gain. - // The React implementation is sufficient and simpler. + // React plays the press cue, avoiding a shared long-lived rodio stream. let _ = app.emit("ptt-state", true); } ShortcutState::Released => { @@ -291,7 +287,6 @@ pub fn run() { } else { builder.plugin(tauri_plugin_updater::Builder::new().build()) }; - let builder = app_menu::install(builder); #[cfg(target_os = "linux")] let builder = builder.manage(notification_sound::NotificationSoundState::default()); @@ -307,6 +302,8 @@ pub fn run() { .manage(build_app_state()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) + .manage(PendingNavigationDeepLinks::default()) + .manage(PendingEntityDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) @@ -518,15 +515,7 @@ pub fn run() { // and on cold start. The single-instance plugin handles forwarding // from duplicate launches on Windows/Linux. #[cfg(desktop)] - { - use tauri_plugin_deep_link::DeepLinkExt; - let dl_handle = app.handle().clone(); - app.deep_link().on_open_url(move |event| { - for url in event.urls() { - handle_deep_link_url(&dl_handle, url.as_str()); - } - }); - } + deep_link::install_deep_link_handlers(app); // Defer launch-time agent restoration until `apply_workspace` has // installed the active workspace relay and identity. Starting here @@ -619,6 +608,11 @@ pub fn run() { terminal_runtime::terminal_focus, take_pending_community_deep_link, acknowledge_pending_community_deep_link, + take_pending_navigation_deep_link, + acknowledge_pending_navigation_deep_link, + clear_pending_navigation_deep_links, + take_pending_entity_deep_link, + acknowledge_pending_entity_deep_link, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, @@ -659,8 +653,11 @@ pub fn run() { delete_project_remote_branch, push_project_local_repository, pull_project_local_repository, + publish_project_owner_announcement, sign_project_pull_request_status, sign_project_pull_request_review_request, + sign_project_issue_assignment, + sign_project_issue_unassignment, publish_project_pull_request_merged_status, merge_project_pull_request, open_project_terminal, @@ -740,6 +737,7 @@ pub fn run() { upload_media_bytes, upload_media_bytes_raw, cancel_media_upload, + release_media_upload, download_image, save_png_data_url, download_file, @@ -911,6 +909,7 @@ pub fn run() { archive::read_archived_observer_events_for_channel, archive::index_observer_channel_id, archive::read_unindexed_observer_rows, + archive::get_agent_usage_series, is_auto_update_supported, set_window_vibrancy, #[cfg(target_os = "macos")] diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index 05979e76cbf..59b300d9d17 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -8,6 +8,25 @@ use std::collections::BTreeMap; use base64::Engine as _; +/// Seconds a woken lazy harness stays warm before it releases its worker +/// subprocesses back to the empty-slot state (via `BUZZ_ACP_IDLE_POOL_SLEEP`). +/// The next accepted event re-wakes it through the same lazy path. Matches the +/// harness's own 15-minute per-turn idle window so a warm pool survives a +/// normal back-and-forth but a truly quiet harness stops paying for workers. +const IDLE_POOL_SLEEP_SECS: &str = "900"; + +/// Value for `BUZZ_ACP_IDLE_POOL_SLEEP`. Idle re-sleep is only meaningful for +/// lazy harnesses (the harness ignores it otherwise); gate to `lazy` here so +/// the env reads inert (`"0"` = disabled) for eager harnesses. This is a +/// desktop-owned lifetime policy (reserved key), not user-tunable. +pub(super) fn idle_pool_sleep_env(lazy: bool) -> &'static str { + if lazy { + IDLE_POOL_SLEEP_SECS + } else { + "0" + } +} + /// Return the baked-in build-time env pairs as a map. /// /// Internal builds (buzz-releases) bake provider/model defaults and arbitrary diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d8..416b0c76c9d 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -111,6 +111,12 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont /// Returns an unsigned `EventBuilder` — the caller signs and submits. The /// `d_tag` is the agent's pubkey. pub fn build_agent_event(record: &ManagedAgentRecord) -> Result { + super::validate_managed_agent_definition_text( + &record.name, + record.persona_id.as_deref(), + record.system_prompt.as_deref(), + ) + .map_err(|error| format!("Managed agent definition is unsafe to publish: {error}"))?; let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize managed-agent content: {e}"))?; let tags = @@ -227,6 +233,31 @@ mod tests { assert_eq!(event.kind.as_u16() as u32, KIND_MANAGED_AGENT); } + #[test] + fn publication_rejects_unsafe_definition_less_name_and_prompt() { + let mut unsafe_name = sample_agent(); + unsafe_name.persona_id = None; + unsafe_name.name = "Review\u{200B}er".to_string(); + let error = build_agent_event(&unsafe_name) + .expect_err("publication must reject an invisible agent name"); + assert!(error.contains("U+200B"), "unexpected error: {error}"); + + let mut unsafe_prompt = sample_agent(); + unsafe_prompt.persona_id = None; + unsafe_prompt.system_prompt = Some("Review\u{202E} code.".to_string()); + let error = build_agent_event(&unsafe_prompt) + .expect_err("publication must reject bidi formatting in instructions"); + assert!(error.contains("U+202E"), "unexpected error: {error}"); + } + + #[test] + fn publication_ignores_inert_linked_record_prompt() { + let mut linked = sample_agent(); + linked.system_prompt = Some("stale\u{200B} prompt".to_string()); + build_agent_event(&linked) + .expect("linked record prompt is omitted in favor of the validated persona"); + } + #[test] fn d_tag_is_agent_pubkey() { let builder = build_agent_event(&sample_agent()).unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 7c08e7095f6..5b51c522551 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -403,6 +403,15 @@ pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> if snapshot.profile.display_name.trim().is_empty() { return Err("Snapshot profile.displayName is empty".to_string()); } + super::validate_agent_definition_text( + &snapshot.profile.display_name, + snapshot + .definition + .system_prompt + .as_deref() + .unwrap_or_default(), + ) + .map_err(|error| format!("Snapshot definition is unsafe: {error}"))?; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/definition_validation.rs b/desktop/src-tauri/src/managed_agents/definition_validation.rs new file mode 100644 index 00000000000..92445604d2e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/definition_validation.rs @@ -0,0 +1,270 @@ +//! Validation for human-reviewed agent definition text. +//! +//! Shared definitions are executable configuration: `system_prompt` is shown +//! to a person, then delivered verbatim to an ACP harness. Characters that +//! consume input bytes without a visible glyph break that review invariant and +//! are rejected rather than silently stripped. + +use regex::Regex; +use std::sync::LazyLock; + +const MAX_DISPLAY_NAME_CHARS: usize = 128; +const MAX_SYSTEM_PROMPT_BYTES: usize = 64 * 1024; +const EMOJI_VARIATION_SELECTOR: char = '\u{FE0F}'; +const ZERO_WIDTH_JOINER: char = '\u{200D}'; + +static EXTENDED_PICTOGRAPHIC: LazyLock> = + LazyLock::new(|| Regex::new(r"^\p{Extended_Pictographic}$").ok()); + +/// Validate the human-visible fields of an agent definition. +pub(crate) fn validate_agent_definition_text( + display_name: &str, + system_prompt: &str, +) -> Result<(), String> { + if display_name.trim().is_empty() { + return Err("Display name is required".to_string()); + } + let display_name_chars = display_name.chars().count(); + if display_name_chars > MAX_DISPLAY_NAME_CHARS { + return Err(format!( + "Display name is too long ({display_name_chars} characters, max {MAX_DISPLAY_NAME_CHARS})" + )); + } + if system_prompt.len() > MAX_SYSTEM_PROMPT_BYTES { + return Err(format!( + "Agent instructions are too long ({} bytes, max {MAX_SYSTEM_PROMPT_BYTES})", + system_prompt.len() + )); + } + + validate_visible_text(display_name, "Display name", false)?; + validate_visible_text(system_prompt, "Agent instructions", true) +} + +/// Validate the human-reviewed definition text carried by a managed agent. +/// +/// Definition-linked agents resolve their executable prompt through the +/// separately validated persona, so only their instance name is checked here. +/// Definition-less agents carry their executable prompt directly and must +/// validate both fields at every local, inbound, and publication boundary. +pub(crate) fn validate_managed_agent_definition_text( + name: &str, + persona_id: Option<&str>, + system_prompt: Option<&str>, +) -> Result<(), String> { + let executable_prompt = if persona_id.is_none() { + system_prompt.unwrap_or_default() + } else { + "" + }; + validate_agent_definition_text(name, executable_prompt) +} + +fn validate_visible_text( + value: &str, + label: &str, + allow_layout_controls: bool, +) -> Result<(), String> { + let characters = value.chars().collect::>(); + for (index, &character) in characters.iter().enumerate() { + let allowed_layout_control = allow_layout_controls && matches!(character, '\n' | '\t'); + let allowed_emoji_format = is_allowed_emoji_format(&characters, index); + if (!allowed_layout_control && character.is_control()) + || (is_default_ignorable(character) && !allowed_emoji_format) + { + return Err(format!( + "{label} contains prohibited invisible or formatting character U+{:04X}", + character as u32 + )); + } + } + Ok(()) +} + +fn is_allowed_emoji_format(characters: &[char], index: usize) -> bool { + match characters[index] { + EMOJI_VARIATION_SELECTOR => index + .checked_sub(1) + .and_then(|previous| characters.get(previous)) + .is_some_and(|&character| is_emoji_variation_base(character)), + ZERO_WIDTH_JOINER => { + has_preceding_emoji_base(characters, index) + && characters + .get(index + 1) + .is_some_and(|&character| is_extended_pictographic(character)) + } + _ => false, + } +} + +fn has_preceding_emoji_base(characters: &[char], index: usize) -> bool { + let mut previous = index.checked_sub(1); + while let Some(previous_index) = previous { + let character = characters[previous_index]; + if character != EMOJI_VARIATION_SELECTOR && !is_emoji_modifier(character) { + return is_extended_pictographic(character); + } + previous = previous_index.checked_sub(1); + } + false +} + +fn is_emoji_variation_base(character: char) -> bool { + matches!(character, '#' | '*' | '0'..='9') || is_extended_pictographic(character) +} + +fn is_emoji_modifier(character: char) -> bool { + matches!(character as u32, 0x1F3FB..=0x1F3FF) +} + +fn is_extended_pictographic(character: char) -> bool { + let mut encoded = [0; 4]; + let character = character.encode_utf8(&mut encoded); + EXTENDED_PICTOGRAPHIC + .as_ref() + .is_some_and(|pattern| pattern.is_match(character)) +} + +/// Unicode `Default_Ignorable_Code_Point` ranges (DerivedCoreProperties). +/// +/// Joiners and variation selectors remain in this set. The validation pass +/// makes a narrow contextual exception for rendered emoji composition while +/// rejecting detached instances and every other default-ignorable character. +fn is_default_ignorable(character: char) -> bool { + matches!( + character as u32, + 0x00AD + | 0x034F + | 0x061C + | 0x115F..=0x1160 + | 0x17B4..=0x17B5 + | 0x180B..=0x180F + | 0x200B..=0x200F + | 0x202A..=0x202E + | 0x2060..=0x206F + | 0x3164 + | 0xFE00..=0xFE0F + | 0xFEFF + | 0xFFA0 + | 0xFFF0..=0xFFF8 + | 0x1BCA0..=0x1BCA3 + | 0x1D173..=0x1D17A + | 0xE0000..=0xE0FFF + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_plain_multiline_instructions() { + assert!(validate_agent_definition_text( + "Code Reviewer 🐝", + "Review changes.\n\tCall out security risks." + ) + .is_ok()); + } + + #[test] + fn accepts_rendered_emoji_sequences_in_names_and_prompts() { + for emoji in ["❤️", "☕️", "👩‍💻", "🧑🏽‍💻", "👨‍👩‍👧‍👦", "1️⃣"] + { + assert!(validate_agent_definition_text( + &format!("Reviewer {emoji}"), + &format!("Review changes {emoji}") + ) + .is_ok()); + } + } + + #[test] + fn rejects_default_ignorable_characters_in_name_or_prompt() { + for character in [ + '\u{00AD}', + '\u{034F}', + '\u{200B}', + '\u{202E}', + '\u{2060}', + '\u{2066}', + '\u{3164}', + '\u{E007F}', + ] { + let name = format!("Review{character}er"); + let prompt = format!("Review code.{character}"); + assert!(validate_agent_definition_text(&name, "Review code.").is_err()); + assert!(validate_agent_definition_text("Reviewer", &prompt).is_err()); + } + } + + #[test] + fn rejects_detached_or_text_embedded_emoji_formatting() { + for value in [ + "Review\u{FE0F}er", + "Review\u{200D}er", + "Review code.\u{200D}", + ] { + assert!(validate_agent_definition_text(value, "Review code.").is_err()); + assert!(validate_agent_definition_text("Reviewer", value).is_err()); + } + } + + #[test] + fn rejects_emoji_tag_sequences() { + let tagged_flag = "\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}"; + assert!( + validate_agent_definition_text(&format!("Reviewer {tagged_flag}"), "Review code.") + .is_err() + ); + assert!( + validate_agent_definition_text("Reviewer", &format!("Review code. {tagged_flag}")) + .is_err() + ); + } + + #[test] + fn rejects_non_layout_control_characters() { + for character in ['\0', '\r', '\u{0007}', '\u{0085}'] { + let prompt = format!("Review{character}code"); + assert!(validate_agent_definition_text("Reviewer", &prompt).is_err()); + } + } + + #[test] + fn enforces_display_name_and_prompt_bounds() { + assert!(validate_agent_definition_text(&"a".repeat(129), "prompt").is_err()); + assert!(validate_agent_definition_text("Reviewer", &"a".repeat(64 * 1024 + 1)).is_err()); + } + + #[test] + fn definition_less_managed_agent_validates_its_own_name_and_prompt() { + assert!(validate_managed_agent_definition_text( + "Review\u{200B}er", + None, + Some("Review code."), + ) + .is_err()); + assert!(validate_managed_agent_definition_text( + "Reviewer", + None, + Some("Review\u{200B} code."), + ) + .is_err()); + assert!(validate_managed_agent_definition_text( + "Reviewer 🐝", + None, + Some("Review changes.\n\tCall out risks."), + ) + .is_ok()); + } + + #[test] + fn definition_linked_managed_agent_ignores_inert_record_prompt() { + assert!(validate_managed_agent_definition_text( + "Reviewer", + Some("custom:reviewer"), + Some("stale\u{200B} prompt"), + ) + .is_ok()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index 34cdfede2c2..f3de11ad242 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -164,7 +164,11 @@ fn reserved_keys_include_respond_to_gate() { #[test] fn reserved_keys_include_remote_lifetime_policy() { - for key in ["BUZZ_ACP_EXIT_AFTER_INACTIVITY", "BUZZ_ACP_NO_PRESENCE"] { + for key in [ + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_IDLE_POOL_SLEEP", + "BUZZ_ACP_NO_PRESENCE", + ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); let agent = map(&[(key, "0")]); assert!(merged_user_env(&BTreeMap::new(), &agent).is_empty()); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index fe90ce430fd..c6ccd3709c0 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -11,6 +11,7 @@ pub(crate) use agent_env::{ mod backend; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; +mod definition_validation; mod discovery; pub(crate) mod effective_config; mod env_vars; @@ -51,6 +52,9 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { } pub use backend::*; +pub(crate) use definition_validation::{ + validate_agent_definition_text, validate_managed_agent_definition_text, +}; pub use discovery::*; pub use env_vars::*; #[cfg(windows)] diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 5c246feedc5..3858212bbba 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -1,9 +1,33 @@ pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1"; pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh"; +/// Stored value for "let the mesh decide", kept as the user-facing word. pub const RELAY_MESH_AUTO_MODEL_ID: &str = "auto"; +/// MeshLLM's virtual model. It resolves per request: a Mixture-of-Agents +/// committee when two or more workers are reachable, and otherwise degrades to +/// a single served model rather than erroring +/// (`moa_gateway::degrade_to_single_model`). That degradation is a pre-flight +/// capacity decision, so a committee that forms and *then* loses a worker still +/// surfaces as a failed turn — MoA repairs partial results internally +/// (`repair_tool_result_answer`) before it gets that far. Buzz translates the +/// stored `auto` here rather than teaching buzz-agent anything about meshes. #[cfg(feature = "mesh-llm")] -pub const RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV: &str = "BUZZ_AGENT_PREFER_MESH_FOR_AUTO"; +pub const RELAY_MESH_VIRTUAL_MODEL_ID: &str = "mesh"; + +/// The wire name for a stored shared-compute model: `auto` (and a blank legacy +/// value) means "let the mesh decide" and becomes MeshLLM's virtual `mesh` +/// model; anything else is a model the user named and is passed through. +/// +/// The single place this mapping happens. Every consumer that has to name a +/// model to the mesh — the LLM transport env and the ACP harness — goes through +/// here, so they cannot disagree. +#[cfg(feature = "mesh-llm")] +pub fn relay_mesh_wire_model(stored: &str) -> &str { + match stored.trim() { + "" | RELAY_MESH_AUTO_MODEL_ID => RELAY_MESH_VIRTUAL_MODEL_ID, + named => named, + } +} /// Translate the native Buzz shared compute provider into the OpenAI-compatible /// transport understood by buzz-agent. These are derived runtime details, not @@ -17,11 +41,7 @@ pub fn apply_relay_mesh_env( if provider.map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { return; } - let model = model - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) - .to_string(); + let model = relay_mesh_wire_model(model.unwrap_or(RELAY_MESH_AUTO_MODEL_ID)).to_string(); env.insert("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()); env.insert("BUZZ_AGENT_MODEL".to_string(), model.clone()); env.insert( @@ -34,14 +54,6 @@ pub fn apply_relay_mesh_env( RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), ); env.insert("OPENAI_COMPAT_API".to_string(), "chat".to_string()); - // Buzz owns the meaning of relay-mesh `auto`: buzz-agent dynamically uses - // mesh-llm's virtual Mixture-of-Agents model whenever the live catalog says - // at least two distinct models are available, and otherwise keeps the - // router's normal single-model `auto` behavior. - env.insert( - RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV.to_string(), - "1".to_string(), - ); // Keep the requested response inside smaller local-model context windows. // These are defaults, not policy: the effective agent/persona/global env // may deliberately choose a smaller cap or a different effort. This function @@ -128,10 +140,76 @@ mod tests { // stops gemma tool-calling; enabling thinking makes Qwen3 burn ~4x the // output budget). assert_eq!(env.get("BUZZ_AGENT_THINKING_EFFORT"), None); + } + + /// Stored `auto` is translated here, so buzz-agent receives a plain model + /// name and needs no knowledge of the mesh. MeshLLM decides per request + /// whether `mesh` becomes a committee or a single served model. + #[test] + fn stored_auto_becomes_the_virtual_mesh_model_on_the_wire() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + assert_eq!( - env.get(RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV) - .map(String::as_str), - Some("1") + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some(RELAY_MESH_VIRTUAL_MODEL_ID) + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some(RELAY_MESH_VIRTUAL_MODEL_ID) + ); + } + + /// A blank stored model is the legacy encoding of the same intent. + #[test] + fn blank_stored_model_becomes_the_virtual_mesh_model() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env(&mut env, Some(RELAY_MESH_PROVIDER_ID), Some(" ")); + + assert_eq!( + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some(RELAY_MESH_VIRTUAL_MODEL_ID) + ); + } + + /// Every consumer that names a model to the mesh goes through one helper, + /// so the LLM transport and the ACP harness cannot be told different things. + #[test] + fn wire_model_maps_auto_and_blank_but_passes_named_through() { + assert_eq!( + relay_mesh_wire_model(RELAY_MESH_AUTO_MODEL_ID), + RELAY_MESH_VIRTUAL_MODEL_ID + ); + assert_eq!(relay_mesh_wire_model(""), RELAY_MESH_VIRTUAL_MODEL_ID); + assert_eq!(relay_mesh_wire_model(" "), RELAY_MESH_VIRTUAL_MODEL_ID); + assert_eq!( + relay_mesh_wire_model("unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"), + "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" + ); + } + + /// A named model is sent verbatim: picking one is an explicit choice to + /// bypass mesh routing, and must not be rewritten. + #[test] + fn a_named_model_is_sent_verbatim() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some("unsloth/Qwen3-8B-GGUF:Q4_K_M"), + ); + + assert_eq!( + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some("unsloth/Qwen3-8B-GGUF:Q4_K_M") + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some("unsloth/Qwen3-8B-GGUF:Q4_K_M") ); } diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index 8698d3a51d1..afaaa2b4eb3 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -59,6 +59,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // Remote lifetime/presence policy: user env must not disable the // desktop/provider-owned bounds while the saved record still promises them. "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + // Desktop-owned pool lifetime policy: user env must not disable or reset + // the idle worker-reclamation window while the desktop launcher sets it. + "BUZZ_ACP_IDLE_POOL_SLEEP", "BUZZ_ACP_NO_PRESENCE", // Readiness handoff: desktop is the ONLY readiness source. A saved or // ambient env var must not be able to forge setup mode (NotReady) on a diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ec804869c42..b1c342e9955 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use tauri::AppHandle; -use super::agent_env::build_buzz_agent_provider_defaults; +use super::agent_env::{build_buzz_agent_provider_defaults, idle_pool_sleep_env}; use crate::{ managed_agents::{ @@ -531,6 +531,7 @@ pub fn spawn_agent_child( command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); + command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy)); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -713,7 +714,19 @@ pub fn spawn_agent_child( } else { command.env_remove("BUZZ_ACP_SYSTEM_PROMPT"); } - if let Some(model) = effective_model.as_deref() { + // Shared compute stores `auto`, but the wire name is MeshLLM's virtual + // `mesh` model. Translate here too, so the harness and the LLM client are + // told the same thing: `BUZZ_ACP_MODEL=auto` would name a model the mesh + // never advertises, leaving buzz-acp to warn and fall back on every new + // session while `BUZZ_AGENT_MODEL` said `mesh`. + #[cfg(feature = "mesh-llm")] + let acp_model = match (&mesh_model_id, effective_model.as_deref()) { + (Some(mesh_model_id), _) => Some(super::relay_mesh_wire_model(mesh_model_id).to_string()), + (None, model) => model.map(str::to_owned), + }; + #[cfg(not(feature = "mesh-llm"))] + let acp_model = effective_model.as_deref().map(str::to_owned); + if let Some(model) = acp_model.as_deref() { command.env("BUZZ_ACP_MODEL", model); } else { command.env_remove("BUZZ_ACP_MODEL"); diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 3f04d3d7a1e..768b2ad7db3 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -358,6 +358,21 @@ fn default_true() -> bool { true } +/// Response payload for `get_channels`. When the caller supplies a hash that +/// matches the computed stable hash, `channels` is `None` so the multi-MB +/// channel list is not serialized across IPC. `last_messages` is always +/// included — it is cheap and changes frequently (every new message). +#[derive(Serialize)] +pub struct GetChannelsPayload { + pub hash: String, + /// `None` on a not-modified response (hash matched); `Some` with the full + /// sorted list otherwise. + pub channels: Option>, + /// Map of channel id → ISO-8601 timestamp of its most recent message. + /// Empty for channels with no messages. + pub last_messages: std::collections::HashMap, +} + // ── Social / Contact list ─────────────────────────────────────────────────── #[derive(Serialize, Deserialize)] diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 7b636a4a822..685f83b7999 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -532,6 +532,9 @@ pub struct AgentProfileInfo { // ── Signed-event submission ───────────────────────────────────────────────── +mod get; +pub use get::get_relay_json; + mod submit; pub use submit::{ submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse, diff --git a/desktop/src-tauri/src/relay/get.rs b/desktop/src-tauri/src/relay/get.rs new file mode 100644 index 00000000000..7d0855f463f --- /dev/null +++ b/desktop/src-tauri/src/relay/get.rs @@ -0,0 +1,37 @@ +use reqwest::Method; +use serde::de::DeserializeOwned; + +use crate::app_state::AppState; + +use super::{ + build_nip98_auth_header, classify_request_error, parse_json_response, + relay_api_base_url_with_override, relay_error_message, +}; + +/// Execute an authenticated GET against the active relay and decode its JSON body. +pub async fn get_relay_json( + state: &AppState, + path_with_query: &str, +) -> Result { + if !path_with_query.starts_with('/') { + return Err("relay GET path must begin with '/'".to_string()); + } + crate::relay_admission::wait_for_rate_limit().await; + let url = format!( + "{}{}", + relay_api_base_url_with_override(state), + path_with_query + ); + let auth = build_nip98_auth_header(&Method::GET, &url, &[], state)?; + let response = state + .http_client + .get(&url) + .header("Authorization", auth) + .send() + .await + .map_err(|error| classify_request_error(&error))?; + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + parse_json_response(response).await +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 3f332ddbf1a..2f85c5d5172 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.8", + "version": "0.5.14", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { @@ -21,7 +21,7 @@ "height": 600, "maximized": true, "visible": false, - "transparent": true, + "transparent": false, "titleBarStyle": "Overlay", "hiddenTitle": true, "dragDropEnabled": false, diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx index 29dcd26cdfb..3e32697e9ec 100644 --- a/desktop/src/app/AppHuddleShell.tsx +++ b/desktop/src/app/AppHuddleShell.tsx @@ -1,7 +1,8 @@ -import type * as React from "react"; +import * as React from "react"; import { AppHuddleBar } from "@/app/AppHuddleBar"; import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; -import { HuddleProvider } from "@/features/huddle"; +import { HuddleProvider, useHuddle } from "@/features/huddle"; +import { HUDDLE_SHORTCUT_EVENT } from "@/shared/lib/keyboard-shortcuts"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { cn } from "@/shared/lib/cn"; @@ -19,6 +20,28 @@ type AppHuddleShellProps = { onVisibilityChange: (visible: boolean) => void; }; +type HuddleShortcutHandlerProps = { + children: React.ReactNode; +}; + +function HuddleShortcutHandler({ children }: HuddleShortcutHandlerProps) { + const { activeEphemeralChannelId, leaveHuddle } = useHuddle(); + + React.useEffect(() => { + if (!activeEphemeralChannelId) return; + + function handleHuddleShortcut() { + void leaveHuddle(); + } + + window.addEventListener(HUDDLE_SHORTCUT_EVENT, handleHuddleShortcut); + return () => + window.removeEventListener(HUDDLE_SHORTCUT_EVENT, handleHuddleShortcut); + }, [activeEphemeralChannelId, leaveHuddle]); + + return children; +} + export function AppHuddleShell({ children, currentPubkey, @@ -42,42 +65,44 @@ export function AppHuddleShell({ onShowHuddleInMainApp={isRoom ? undefined : onShowHuddleInMainApp} onViewHuddleChannel={isRoom ? undefined : onViewHuddleChannel} > - -
+ +