Files
buzz/crates/buzz-cli/Cargo.toml
Will Pfleger 0808ab485c fix(cli): enrich template cardinality error with per-candidate presence and profile hints (#4825)
## Problem

`buzz channels create --template` fails with a bare-pubkey
duplicate-instance error when a persona has more than one live instance.
When duplicate instances share the same display name and avatar, the
error gives the operator no signal to identify which instance is stale.

## What changed

**`crates/buzz-cli/src/commands/channels.rs`** (boundary) + one
visibility line in **`crates/buzz-cli/src/commands/users.rs`** (`fn
presence_subject` → `pub(crate)`), plus a relay behavior change in
**`crates/buzz-relay/src/api/bridge.rs`** and a supporting test in
**`crates/buzz-pubsub/src/presence.rs`**.

### Relay: presence-lookup failure is now surfaced as a failure

`synthesize_presence` previously collapsed a Redis `get_presence_bulk`
error into `unwrap_or_default()` → HTTP 200 `[]`, making a backend
outage indistinguishable from an authoritative all-offline snapshot. It
now returns `Option<Result<Vec<Value>, (StatusCode, Json<Value>)>>`:
`None` means the request is not a presence query (fall through),
`Some(Ok)` is a real snapshot (an empty vec is an authoritative
all-offline result), and `Some(Err)` is a backend failure surfaced as a
non-2xx response. The event tag-parse and sign paths were converted from
`.ok()?` fall-throughs to explicit `Some(Err(...))` for the same reason
— a silent fall-through would have reintroduced the fake-empty-success
anti-pattern. The function takes `pubsub` and `relay_keypair` directly
rather than the whole `AppState`, which keeps the Redis-error seam
unit-testable without live infrastructure; its single production caller
was updated accordingly, and no other caller consumes
`synthesize_presence`.

### Hint fetch architecture

**`assemble_roster_resolution<F, Fut>`** — post-fetch stage extracted
from `build_roster_resolution` as an injectable-fetcher async generic.
It contains: archive-filter-based duplicate detection, the conditional
`fetch_hints` call, and `finalize_roster_resolution` delegation.
Accepting the fetcher as a closure makes the wiring directly testable
without a relay.

`build_roster_resolution` reduces to: gather slugs → `tokio::join!(scan,
archive)` → delegate with the real `fetch_candidate_hints` closure.

**Zero hint queries on the happy path**: `assemble_roster_resolution`
only invokes the fetcher when duplicate live instances exist after
archive filtering. Untrusted archive snapshot (`Err`) conservatively
treats all found instances as live.

When duplicates exist, `fetch_candidate_hints` runs the presence
(kind:40902) and profile (kind:0) queries concurrently, bounding **each
independently** through `join_bounded_queries`: a per-query 3-second
timeout maps to `Err`, so a lookup that completes is never discarded
because its sibling hung. On total failure or timeout, bare pubkeys are
printed promptly.

### Trusted-snapshot boundary — `hints_from_results`

The relay drops the Redis presence key when an identity goes offline, so
the bulk presence snapshot never returns an event for an offline
instance — precisely the stale duplicate an operator needs flagged.
`hints_from_results` therefore treats a *trusted* presence snapshot as a
**complete snapshot** for the requested pubkeys: it seeds all of them
`offline`, then overlays returned statuses.

A response is trusted only when `trusted_presence_snapshot` validates
**every** array element as the actual snapshot contract: each must parse
as a complete signed `nostr::Event` of the presence-update kind carrying
exactly one `p` tag whose subject is one of the requested pubkeys.
Requiring the *sole* `p`-tag subject — the exact value `build_hint_map`
later reads via `presence_subject` — is what stops a mixed-tag event
(`[["p","<unrequested>"],["p","<requested>"]]`) from passing the gate
yet overlaying a different subject downstream. Any element that is not
such an event — a vacuous object like
`{"pubkey":"…","content":"online"}`, `[{}]`, `[null]`, an event of the
wrong kind, one for an unrequested subject, or one carrying more than
one `p` tag — makes presence enrichment untrusted: nothing is seeded
`offline`, so absence is never falsely inferred as offline. A failed,
timed-out, or non-array presence response is likewise untrusted. In
every untrusted case the successful profile sibling still contributes
its hints.

### Response-to-map conversion — `build_hint_map`

Sync production function taking the offline seed plus raw
presence/profile event slices. Relay-signed presence events carry the
agent pubkey in the `p` tag (not the event author); `build_hint_map`
calls `presence_subject` from `users.rs` (now `pub(crate)`) to resolve
the correct key.

### Hint display — `format_candidate`

Appends `[online/offline, profile updated YYYY-MM-DD]` (or subset) to
each candidate pubkey in the error. `profile_updated_at` names the field
correctly: kind:0 is replaceable state that desktop republishes on
rename and profile reconciliation, so `created_at` reflects the last
profile update, not provisioning time. Date formatted via
`chrono::DateTime::from_timestamp`; out-of-range timestamps omit the
date. Missing entries fall back to bare pubkeys.

### Cardinality rule

`apply_cardinality_rule` remains pure and relay-free; zero/one/many
semantics unchanged.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-27 14:40:53 -04:00

96 lines
3.1 KiB
TOML

[package]
name = "buzz-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Agent-first CLI for Buzz relay"
[lib]
name = "buzz_cli"
path = "src/lib.rs"
[[bin]]
name = "buzz"
path = "src/main.rs"
[dependencies]
# CLI argument parsing — derive macros + env var support (BUZZ_API_TOKEN auto-wired)
clap = { version = "4", features = ["derive", "env"] }
# HTTP client — async REST calls to the relay
reqwest = { workspace = true, features = ["json"] }
# Async runtime — tokio macros + multi-thread for reqwest
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
# Serialization — JSON body building and response passthrough
serde = { workspace = true }
serde_json = { workspace = true }
# Structured error types with exit code mapping
thiserror = { workspace = true }
# Nostr event signing — used in `buzz auth`, auto-mint, and signed event writes
nostr = { workspace = true }
# UUID parsing for validate_uuid + event building
uuid = { workspace = true }
# RFC3339 observer timestamps for owner-reviewed agent draft requests
chrono = { workspace = true }
# Typed event builders for all write operations
buzz-sdk = { workspace = true }
buzz-core = { workspace = true }
# Base64 encoding — NIP-98 event serialization for Authorization header
base64 = "0.22"
# SHA-256 — NIP-98 payload hash tag, Blossom file hash, mem patch base-hash
sha2 = "0.11"
# Unified-diff parser and strict applier — `mem patch`
diffy = "0.5"
# Hex encoding — SHA-256 hash output for Blossom uploads
hex = { workspace = true }
# Byte buffers returned by authenticated media downloads
bytes = "1"
# MIME type detection via magic bytes — file upload validation
infer = "0.19"
# URL parsing — extract server domain for Blossom auth tag
url = { workspace = true }
# Persona pack parsing, validation, and resolution
buzz-persona = { path = "../buzz-persona" }
# Platform app-data dir resolution — locates the desktop app's
# channel-templates.json store for `channels create --template`
dirs = "6"
# WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay)
buzz-ws-client = { path = "../buzz-ws-client" }
# Explicit rustls dep with ring provider — required to install the process-level
# CryptoProvider at startup. Without this the standalone `buzz` binary panics when
# a multi-package release build (buzz-acp + buzz-dev-mcp + buzz-cli in one cargo
# invocation) unifies both ring and aws-lc-rs features, leaving rustls unable to
# auto-select a provider. See crates/buzz-acp/Cargo.toml for the same dependency.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# Random number generation — full jitter for exponential backoff in with_retry
rand = { workspace = true }
[dev-dependencies]
# Scratch files for channel-templates.json fixtures in tests
tempfile = "3"
# Minimal HTTP test server for retry/policy integration tests
axum = { workspace = true }
# `test-util` enables paused-time control for deterministic timeout tests
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }